From f069bcbe1857c7eeeeaa5a07cea303d2c0dabb27 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Sat, 4 Jul 2026 08:34:04 -0700 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20bb.sdk.files=20=E2=80=94=20host=20f?= =?UTF-8?q?ile=20read/write=20API=20with=20CAS=20saves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New host.write_file daemon command (25MB cap, optional rootPath containment resolved through symlinks, expectedSha256 compare-and-swap returning a conflict outcome with the current hash). File reads now include sha256 so editors can do safe read-modify-write. Server routes POST /files/read|write|list fill defaults (primary host, utf8, createParents=false) at the boundary; new files SDK area exposes it to plugins as bb.sdk.files. Co-Authored-By: Claude Fable 5 --- apps/host-daemon/src/command-dispatch.ts | 2 + .../src/command-handlers/file-read.ts | 10 + .../src/command-handlers/file-write.test.ts | 273 +++++++++++++ .../src/command-handlers/file-write.ts | 177 +++++++++ apps/server/src/routes/files.ts | 91 ++++- .../bb-plugin-authoring/SKILL.md | 26 ++ .../test/files/host-file-routes.test.ts | 148 +++++++ apps/server/test/hosts/online-rpc.test.ts | 2 + .../public/public-projects-local-host.test.ts | 1 + .../test/public/public-thread-data.test.ts | 6 + packages/host-daemon-contract/src/commands.ts | 56 +++ packages/host-daemon-contract/src/session.ts | 1 + .../test/contract.test.ts | 15 + packages/sdk/src/areas/files.ts | 67 ++++ packages/sdk/src/core.ts | 3 + packages/server-contract/src/api-types.ts | 1 + packages/server-contract/src/api/files.ts | 55 +++ packages/server-contract/src/public-api.ts | 36 ++ plans/notes-plugin-api-spec.html | 371 ++++++++++++++++++ plans/plugin-system-design.md | 7 + 20 files changed, 1345 insertions(+), 3 deletions(-) create mode 100644 apps/host-daemon/src/command-handlers/file-write.test.ts create mode 100644 apps/host-daemon/src/command-handlers/file-write.ts create mode 100644 apps/server/test/files/host-file-routes.test.ts create mode 100644 packages/sdk/src/areas/files.ts create mode 100644 packages/server-contract/src/api/files.ts create mode 100644 plans/notes-plugin-api-spec.html diff --git a/apps/host-daemon/src/command-dispatch.ts b/apps/host-daemon/src/command-dispatch.ts index dc58c5cfd..272f59794 100644 --- a/apps/host-daemon/src/command-dispatch.ts +++ b/apps/host-daemon/src/command-dispatch.ts @@ -30,6 +30,7 @@ import { readHostFileMetadata, readHostRelativeFile, } from "./command-handlers/host-files.js"; +import { writeHostFile } from "./command-handlers/file-write.js"; import { resolveInteractiveRequest } from "./command-handlers/interactive.js"; import { pickHostFolder } from "./command-handlers/native-folder-picker.js"; import { runScript } from "./command-handlers/run-script.js"; @@ -324,6 +325,7 @@ const onlineRpcHandlers: OnlineRpcHandlerMap = { "host.file_metadata": readHostFileMetadata, "host.read_file": readHostFile, "host.read_file_relative": readHostRelativeFile, + "host.write_file": writeHostFile, "provider.list_models": async (command, options) => (options.listModels ?? defaultListModels)({ providerId: command.providerId, diff --git a/apps/host-daemon/src/command-handlers/file-read.ts b/apps/host-daemon/src/command-handlers/file-read.ts index e80e26bde..7f14f5b41 100644 --- a/apps/host-daemon/src/command-handlers/file-read.ts +++ b/apps/host-daemon/src/command-handlers/file-read.ts @@ -1,4 +1,5 @@ import { isUtf8 } from "node:buffer"; +import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import mimeTypes from "mime-types"; @@ -16,12 +17,17 @@ export const NON_IMAGE_FILE_SIZE_LIMIT_BYTES = 25 * 1024 * 1024; type FileContentEncoding = "base64" | "utf8"; +export function sha256Hex(contents: Buffer): string { + return createHash("sha256").update(contents).digest("hex"); +} + export interface ReadFileForTransportResult { content: string; contentEncoding: FileContentEncoding; mimeType?: string; modifiedAtMs?: number; path: string; + sha256: string; sizeBytes: number; } @@ -274,6 +280,7 @@ export async function readFileFromGitRef( content: "", contentEncoding: "utf8", ...(mimeType ? { mimeType } : {}), + sha256: sha256Hex(Buffer.alloc(0)), sizeBytes: 0, }; } @@ -287,6 +294,7 @@ export async function readFileFromGitRef( : blob.contents.toString("base64"), contentEncoding, ...(mimeType ? { mimeType } : {}), + sha256: sha256Hex(blob.contents), sizeBytes: blob.sizeBytes, }; } @@ -327,6 +335,7 @@ export async function readFileForTransport( contentEncoding, ...(mimeType ? { mimeType } : {}), modifiedAtMs: stat.mtimeMs, + sha256: sha256Hex(fileContents), sizeBytes: stat.size, }; } @@ -373,6 +382,7 @@ export async function readRootRelativeFileForTransport( contentEncoding, ...(mimeType ? { mimeType } : {}), modifiedAtMs: stat.mtimeMs, + sha256: sha256Hex(fileContents), sizeBytes: stat.size, }; } diff --git a/apps/host-daemon/src/command-handlers/file-write.test.ts b/apps/host-daemon/src/command-handlers/file-write.test.ts new file mode 100644 index 000000000..323dac61b --- /dev/null +++ b/apps/host-daemon/src/command-handlers/file-write.test.ts @@ -0,0 +1,273 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + CommandDispatchError, + type CommandOf, + isExpectedCommandDispatchError, +} from "../command-dispatch-support.js"; +import { writeHostFile } from "./file-write.js"; +import { readHostFile } from "./host-files.js"; + +const tempDirs: string[] = []; + +async function makeTempDir(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map((dir) => + fs.rm(dir, { recursive: true, force: true }), + ), + ); +}); + +function sha256(content: string): string { + return createHash("sha256").update(content, "utf8").digest("hex"); +} + +function writeCommand( + overrides: Partial> & + Pick, "path">, +): CommandOf<"host.write_file"> { + return { + type: "host.write_file", + content: "hello", + contentEncoding: "utf8", + createParents: false, + ...overrides, + }; +} + +async function captureWriteError( + command: CommandOf<"host.write_file">, +): Promise { + try { + await writeHostFile(command); + } catch (error) { + return error; + } + throw new Error("Expected writeHostFile to fail"); +} + +describe("writeHostFile", () => { + it("writes a new file unconditionally and returns its hash", async () => { + const dir = await makeTempDir("bb-file-write-"); + const target = path.join(dir, "note.md"); + + const result = await writeHostFile( + writeCommand({ path: target, content: "# Hello" }), + ); + + expect(result).toEqual({ + outcome: "written", + sha256: sha256("# Hello"), + sizeBytes: 7, + }); + await expect(fs.readFile(target, "utf8")).resolves.toBe("# Hello"); + }); + + it("overwrites an existing file when the expected hash matches", async () => { + const dir = await makeTempDir("bb-file-write-"); + const target = path.join(dir, "note.md"); + await fs.writeFile(target, "old"); + + const result = await writeHostFile( + writeCommand({ + path: target, + content: "new", + expectedSha256: sha256("old"), + }), + ); + + expect(result).toEqual({ + outcome: "written", + sha256: sha256("new"), + sizeBytes: 3, + }); + await expect(fs.readFile(target, "utf8")).resolves.toBe("new"); + }); + + it("returns a conflict with the current hash when the expected hash is stale", async () => { + const dir = await makeTempDir("bb-file-write-"); + const target = path.join(dir, "note.md"); + await fs.writeFile(target, "current"); + + const result = await writeHostFile( + writeCommand({ + path: target, + content: "mine", + expectedSha256: sha256("stale"), + }), + ); + + expect(result).toEqual({ + outcome: "conflict", + currentSha256: sha256("current"), + }); + await expect(fs.readFile(target, "utf8")).resolves.toBe("current"); + }); + + it("returns a null-hash conflict when the expected file is missing", async () => { + const dir = await makeTempDir("bb-file-write-"); + + const result = await writeHostFile( + writeCommand({ + path: path.join(dir, "gone.md"), + expectedSha256: sha256("anything"), + }), + ); + + expect(result).toEqual({ outcome: "conflict", currentSha256: null }); + }); + + it("treats expectedSha256 null as create-only", async () => { + const dir = await makeTempDir("bb-file-write-"); + const target = path.join(dir, "note.md"); + + const created = await writeHostFile( + writeCommand({ path: target, expectedSha256: null }), + ); + expect(created).toMatchObject({ outcome: "written" }); + + const conflicted = await writeHostFile( + writeCommand({ path: target, expectedSha256: null }), + ); + expect(conflicted).toEqual({ + outcome: "conflict", + currentSha256: sha256("hello"), + }); + }); + + it("decodes base64 content", async () => { + const dir = await makeTempDir("bb-file-write-"); + const target = path.join(dir, "logo.bin"); + + const result = await writeHostFile( + writeCommand({ + path: target, + content: Buffer.from([0, 1, 2, 255]).toString("base64"), + contentEncoding: "base64", + }), + ); + + expect(result).toMatchObject({ outcome: "written", sizeBytes: 4 }); + expect(Uint8Array.from(await fs.readFile(target))).toEqual( + Uint8Array.from([0, 1, 2, 255]), + ); + }); + + it("fails with ENOENT when the parent is missing and createParents is false", async () => { + const dir = await makeTempDir("bb-file-write-"); + + const error = await captureWriteError( + writeCommand({ path: path.join(dir, "nested", "note.md") }), + ); + + expect(isExpectedCommandDispatchError(error)).toBe(true); + expect((error as CommandDispatchError).code).toBe("ENOENT"); + }); + + it("creates missing parents when createParents is true", async () => { + const dir = await makeTempDir("bb-file-write-"); + const target = path.join(dir, "a", "b", "note.md"); + + const result = await writeHostFile( + writeCommand({ path: target, createParents: true }), + ); + + expect(result).toMatchObject({ outcome: "written" }); + await expect(fs.readFile(target, "utf8")).resolves.toBe("hello"); + }); + + it("rejects writes that escape the declared root via symlinks", async () => { + const rootDir = await makeTempDir("bb-file-write-root-"); + const outsideDir = await makeTempDir("bb-file-write-outside-"); + await fs.symlink(outsideDir, path.join(rootDir, "link"), "dir"); + + const error = await captureWriteError( + writeCommand({ + path: path.join(rootDir, "link", "escape.md"), + rootPath: rootDir, + }), + ); + + expect(error).toBeInstanceOf(CommandDispatchError); + expect((error as CommandDispatchError).code).toBe("invalid_path"); + await expect( + fs.readFile(path.join(outsideDir, "escape.md"), "utf8"), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("rejects lexical escapes from the declared root", async () => { + const rootDir = await makeTempDir("bb-file-write-root-"); + const outsideDir = await makeTempDir("bb-file-write-outside-"); + + const error = await captureWriteError( + writeCommand({ + path: path.join(outsideDir, "escape.md"), + rootPath: rootDir, + }), + ); + + expect(error).toBeInstanceOf(CommandDispatchError); + expect((error as CommandDispatchError).code).toBe("invalid_path"); + }); + + it("allows contained writes under the declared root", async () => { + const rootDir = await makeTempDir("bb-file-write-root-"); + + const result = await writeHostFile( + writeCommand({ + path: path.join(rootDir, "notes", "note.md"), + rootPath: rootDir, + createParents: true, + }), + ); + + expect(result).toMatchObject({ outcome: "written" }); + }); + + it("rejects directory targets", async () => { + const dir = await makeTempDir("bb-file-write-"); + + const error = await captureWriteError(writeCommand({ path: dir })); + + expect(error).toBeInstanceOf(CommandDispatchError); + expect((error as CommandDispatchError).code).toBe("invalid_path"); + }); + + it("rejects relative paths", async () => { + const error = await captureWriteError( + writeCommand({ path: "relative/note.md" }), + ); + + expect(error).toBeInstanceOf(CommandDispatchError); + expect((error as CommandDispatchError).code).toBe("invalid_path"); + }); + + it("round-trips with readHostFile for compare-and-swap saves", async () => { + const dir = await makeTempDir("bb-file-write-"); + const target = path.join(dir, "note.md"); + await fs.writeFile(target, "v1"); + + const read = await readHostFile({ type: "host.read_file", path: target }); + const result = await writeHostFile( + writeCommand({ + path: target, + content: "v2", + expectedSha256: read.sha256, + }), + ); + + expect(result).toMatchObject({ outcome: "written" }); + const reread = await readHostFile({ type: "host.read_file", path: target }); + expect(reread.content).toBe("v2"); + expect(reread.sha256).toBe(sha256("v2")); + }); +}); diff --git a/apps/host-daemon/src/command-handlers/file-write.ts b/apps/host-daemon/src/command-handlers/file-write.ts new file mode 100644 index 000000000..dc22b6210 --- /dev/null +++ b/apps/host-daemon/src/command-handlers/file-write.ts @@ -0,0 +1,177 @@ +import { Buffer } from "node:buffer"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { HostDaemonOnlineRpcResult } from "@bb/host-daemon-contract"; +import { + CommandDispatchError, + ExpectedCommandDispatchError, +} from "../command-dispatch-support.js"; +import type { CommandOf } from "../command-dispatch-support.js"; +import { isFsErrorWithCode } from "../fs-errors.js"; +import { NON_IMAGE_FILE_SIZE_LIMIT_BYTES, sha256Hex } from "./file-read.js"; +import { resolveNonSymlinkDirectoryPath } from "./root-path.js"; + +interface ResolvedWriteTarget { + /** Real (symlink-resolved) path to write, existing or not. */ + writePath: string; + /** True when the write target's direct parent directory is missing. */ + parentMissing: boolean; +} + +function isPathWithinRoot(candidatePath: string, rootPath: string): boolean { + const relativePath = path.relative(rootPath, candidatePath); + return ( + relativePath === "" || + (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)) + ); +} + +function createMissingTargetError( + resultPath: string, +): ExpectedCommandDispatchError { + return new ExpectedCommandDispatchError( + "ENOENT", + `Path does not exist: ${resultPath}`, + ); +} + +/** + * Resolve the write target through symlinks even though it may not exist yet: + * realpath the nearest existing ancestor and re-append the missing segments. + * Containment (when a root is declared) is checked against this resolved + * path, so a symlinked directory inside the root cannot smuggle a write + * outside it. + */ +async function resolveWriteTarget( + resolvedPath: string, + resultPath: string, +): Promise { + try { + return { writePath: await fs.realpath(resolvedPath), parentMissing: false }; + } catch (error) { + if (!isFsErrorWithCode(error, "ENOENT")) { + throw error; + } + } + + const missingSegments = [path.basename(resolvedPath)]; + let ancestor = path.dirname(resolvedPath); + for (;;) { + try { + const realAncestor = await fs.realpath(ancestor); + return { + writePath: path.join(realAncestor, ...missingSegments), + parentMissing: missingSegments.length > 1, + }; + } catch (error) { + if (!isFsErrorWithCode(error, "ENOENT")) { + throw error; + } + } + const parent = path.dirname(ancestor); + if (parent === ancestor) { + throw createMissingTargetError(resultPath); + } + missingSegments.unshift(path.basename(ancestor)); + ancestor = parent; + } +} + +export async function writeHostFile( + command: CommandOf<"host.write_file">, +): Promise> { + if (!path.isAbsolute(command.path)) { + throw new CommandDispatchError("invalid_path", "Path must be absolute"); + } + if (command.rootPath !== undefined && !path.isAbsolute(command.rootPath)) { + throw new CommandDispatchError("invalid_path", "rootPath must be absolute"); + } + + const contents = Buffer.from(command.content, command.contentEncoding); + if (contents.length > NON_IMAGE_FILE_SIZE_LIMIT_BYTES) { + throw new CommandDispatchError( + "file_too_large", + `File size ${contents.length} bytes exceeds the ${Math.floor(NON_IMAGE_FILE_SIZE_LIMIT_BYTES / (1024 * 1024))} MB limit`, + ); + } + + const resolvedPath = path.resolve(command.path); + const target = await resolveWriteTarget(resolvedPath, command.path); + + if (command.rootPath !== undefined) { + let realRootPath: string; + try { + realRootPath = await resolveNonSymlinkDirectoryPath({ + description: "Root path", + path: command.rootPath, + }); + } catch (error) { + if (isFsErrorWithCode(error, "ENOENT")) { + throw createMissingTargetError(command.path); + } + throw error; + } + if (!isPathWithinRoot(target.writePath, realRootPath)) { + throw new CommandDispatchError( + "invalid_path", + `Path "${command.path}" escapes write root`, + ); + } + } + + if (target.parentMissing && !command.createParents) { + throw createMissingTargetError(path.dirname(command.path)); + } + + let currentContents: Buffer | null = null; + try { + const stat = await fs.stat(target.writePath); + if (stat.isDirectory()) { + throw new CommandDispatchError( + "invalid_path", + "Path is a directory, not a file", + ); + } + currentContents = await fs.readFile(target.writePath); + } catch (error) { + if (!isFsErrorWithCode(error, "ENOENT")) { + throw error; + } + } + const currentSha256 = + currentContents === null ? null : sha256Hex(currentContents); + + if ( + command.expectedSha256 !== undefined && + command.expectedSha256 !== currentSha256 + ) { + return { outcome: "conflict", currentSha256 }; + } + + if (command.createParents) { + await fs.mkdir(path.dirname(target.writePath), { recursive: true }); + } + try { + await fs.writeFile(target.writePath, contents); + } catch (error) { + if (isFsErrorWithCode(error, "ENOENT")) { + throw createMissingTargetError(path.dirname(command.path)); + } + if ( + isFsErrorWithCode(error, "ENOTDIR") || + isFsErrorWithCode(error, "EISDIR") + ) { + throw new CommandDispatchError( + "invalid_path", + `Cannot write file at ${command.path}`, + ); + } + throw error; + } + + return { + outcome: "written", + sha256: sha256Hex(contents), + sizeBytes: contents.length, + }; +} diff --git a/apps/server/src/routes/files.ts b/apps/server/src/routes/files.ts index be4252d17..cb49423cc 100644 --- a/apps/server/src/routes/files.ts +++ b/apps/server/src/routes/files.ts @@ -9,13 +9,22 @@ import { import { COMMAND_TIMEOUT_MS } from "../constants.js"; import { ApiError } from "../errors.js"; import type { AppDeps, LoggedWorkSessionDeps } from "../types.js"; -import { callHostRetryableOnlineRpc } from "../services/hosts/online-rpc.js"; +import { + callHostOnlineRpc, + callHostRetryableOnlineRpc, +} from "../services/hosts/online-rpc.js"; import { createDaemonFileContentResponse, type DaemonFileReadResult, remapDaemonFileRouteError, } from "../services/hosts/daemon-file-response.js"; -import { requirePublicThreadEnvironment } from "../services/lib/entity-lookup.js"; +import { requirePrimaryHostId } from "../services/hosts/primary-host.js"; +import { + requireNonDestroyedHostWithStatus, + requirePublicThreadEnvironment, +} from "../services/lib/entity-lookup.js"; + +const HOST_FILE_LIST_LIMIT_DEFAULT = 1000; const HTML_PREVIEW_MAX_BYTES = 5 * 1024 * 1024; const HTML_PREVIEW_CONTENT_TYPE = "text/html; charset=utf-8"; @@ -114,7 +123,7 @@ async function serveRawFilesystemHtmlFile( } export function registerFileRoutes(app: Hono, deps: AppDeps): void { - const { get } = typedRoutes(app, { + const { get, post } = typedRoutes(app, { onValidationError: (msg) => new ApiError(400, "invalid_request", msg), }); const routes = publicApiRoutes.threads; @@ -122,4 +131,80 @@ export function registerFileRoutes(app: Hono, deps: AppDeps): void { get(routes.rawFile, async (context, query) => serveRawFilesystemHtmlFile(deps, context.req.param("id"), query.path), ); + + // Host file primitives (plugin design §4.1): read/write/list against a + // connected host. Omitted hostId resolves to the primary (local) host here, + // once, at the product boundary — daemon commands always get explicit values. + const fileRoutes = publicApiRoutes.files; + + const resolveHostId = (hostId: string | undefined): string => { + const resolved = hostId ?? requirePrimaryHostId(deps); + requireNonDestroyedHostWithStatus(deps, resolved); + return resolved; + }; + + post(fileRoutes.read, async (context, payload) => { + const hostId = resolveHostId(payload.hostId); + try { + const result = await callHostRetryableOnlineRpc(deps, { + hostId, + timeoutMs: COMMAND_TIMEOUT_MS, + command: { + type: "host.read_file", + path: payload.path, + ...(payload.rootPath !== undefined + ? { rootPath: payload.rootPath } + : {}), + }, + }); + return context.json(result); + } catch (error) { + return remapDaemonFileRouteError(error); + } + }); + + post(fileRoutes.write, async (context, payload) => { + const hostId = resolveHostId(payload.hostId); + try { + const result = await callHostOnlineRpc(deps, { + hostId, + timeoutMs: COMMAND_TIMEOUT_MS, + command: { + type: "host.write_file", + path: payload.path, + content: payload.content, + contentEncoding: payload.contentEncoding ?? "utf8", + createParents: payload.createParents ?? false, + ...(payload.rootPath !== undefined + ? { rootPath: payload.rootPath } + : {}), + ...(payload.expectedSha256 !== undefined + ? { expectedSha256: payload.expectedSha256 } + : {}), + }, + }); + return context.json(result); + } catch (error) { + return remapDaemonFileRouteError(error); + } + }); + + post(fileRoutes.list, async (context, payload) => { + const hostId = resolveHostId(payload.hostId); + try { + const result = await callHostRetryableOnlineRpc(deps, { + hostId, + timeoutMs: COMMAND_TIMEOUT_MS, + command: { + type: "host.list_files", + path: payload.path, + limit: payload.limit ?? HOST_FILE_LIST_LIMIT_DEFAULT, + ...(payload.query !== undefined ? { query: payload.query } : {}), + }, + }); + return context.json(result); + } catch (error) { + return remapDaemonFileRouteError(error); + } + }); } diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md index 6985c6776..1545a0cda 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md @@ -165,6 +165,32 @@ inputs) — never both. Attribution is auto-filled: `origin: "plugin"` and threadId, mode: "auto", input: [...] })` starts a turn on an idle thread or queues/steers a running one. +`bb.sdk.files` reads and writes files on a connected host (not just the +server machine — this is the right primitive when the user's files may live +on another host, and its `rootPath` confinement + compare-and-swap guard make +it the right save path even locally): + +```ts +const file = await bb.sdk.files.read({ path: "/home/me/notes/todo.md" }); +// → { content, contentEncoding, sha256, sizeBytes, modifiedAtMs?, ... } + +const saved = await bb.sdk.files.write({ + path: "/home/me/notes/todo.md", + rootPath: "/home/me/notes", // optional: confine writes beneath this root + content: "# Todo\n", + expectedSha256: file.sha256, // CAS guard; omit for unconditional, null for create-only +}); +if (saved.outcome === "conflict") { + // File changed since the read (saved.currentSha256, null = deleted) — + // re-read and merge instead of clobbering. +} +``` + +`hostId` is optional everywhere (defaults to the primary/local host). +`bb.sdk.files.list({ path, query?, limit? })` is a recursive fuzzy file +listing under a directory. Writes cap at 25 MB and return +`{ outcome: "written", sha256, sizeBytes }`. + ### bb.on — thread lifecycle events ```ts diff --git a/apps/server/test/files/host-file-routes.test.ts b/apps/server/test/files/host-file-routes.test.ts new file mode 100644 index 000000000..b10e22498 --- /dev/null +++ b/apps/server/test/files/host-file-routes.test.ts @@ -0,0 +1,148 @@ +import type { HostDaemonOnlineRpcRequestMessage } from "@bb/host-daemon-contract"; +import { describe, expect, it } from "vitest"; +import { registerHostRpcResponder } from "../helpers/host-rpc.js"; +import { readJson } from "../helpers/json.js"; +import { seedHostSession, seedPrimaryHost } from "../helpers/seed.js"; +import { withTestHarness } from "../helpers/test-app.js"; + +const WRITTEN_RESULT = { + outcome: "written", + sha256: "a".repeat(64), + sizeBytes: 5, +} as const; + +const READ_RESULT = { + path: "/home/me/notes/note.md", + content: "# Hi", + contentEncoding: "utf8", + mimeType: "text/markdown", + modifiedAtMs: 1234, + sha256: "b".repeat(64), + sizeBytes: 4, +} as const; + +function postJson(path: string, body: unknown): [string, RequestInit] { + return [ + path, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ]; +} + +describe("host file routes", () => { + it("fills write defaults and resolves the primary host at the boundary", async () => { + await withTestHarness(async (harness) => { + const { host, session } = seedHostSession(harness.deps); + seedPrimaryHost(harness.deps, host.id); + const requests: HostDaemonOnlineRpcRequestMessage[] = []; + registerHostRpcResponder(harness, { + hostId: host.id, + sessionId: session.id, + handle: (request) => { + requests.push(request); + if (request.command.type !== "host.write_file") { + throw new Error(`Unexpected RPC command ${request.command.type}`); + } + return { ok: true, result: WRITTEN_RESULT }; + }, + }); + + const response = await harness.app.request( + ...postJson("/api/v1/files/write", { + path: "/home/me/notes/note.md", + content: "hello", + }), + ); + + expect(response.status).toBe(200); + expect(await readJson(response)).toEqual(WRITTEN_RESULT); + expect(requests).toHaveLength(1); + expect(requests[0]?.command).toEqual({ + type: "host.write_file", + path: "/home/me/notes/note.md", + content: "hello", + contentEncoding: "utf8", + createParents: false, + }); + }); + }); + + it("passes the create-only null guard through to the daemon", async () => { + await withTestHarness(async (harness) => { + const { host, session } = seedHostSession(harness.deps); + const commands: unknown[] = []; + registerHostRpcResponder(harness, { + hostId: host.id, + sessionId: session.id, + handle: (request) => { + commands.push(request.command); + return { ok: true, result: { outcome: "conflict", currentSha256: null } }; + }, + }); + + const response = await harness.app.request( + ...postJson("/api/v1/files/write", { + hostId: host.id, + path: "/home/me/notes/new.md", + content: "hello", + expectedSha256: null, + createParents: true, + }), + ); + + expect(response.status).toBe(200); + expect(await readJson(response)).toEqual({ + outcome: "conflict", + currentSha256: null, + }); + expect(commands[0]).toMatchObject({ + expectedSha256: null, + createParents: true, + }); + }); + }); + + it("serves reads and remaps daemon ENOENT to 404", async () => { + await withTestHarness(async (harness) => { + const { host, session } = seedHostSession(harness.deps); + registerHostRpcResponder(harness, { + hostId: host.id, + sessionId: session.id, + handle: (request) => { + if (request.command.type !== "host.read_file") { + throw new Error(`Unexpected RPC command ${request.command.type}`); + } + if (request.command.path === "/home/me/notes/note.md") { + return { ok: true, result: READ_RESULT }; + } + return { + ok: false, + errorCode: "ENOENT", + errorMessage: "Path does not exist", + }; + }, + }); + + const okResponse = await harness.app.request( + ...postJson("/api/v1/files/read", { + hostId: host.id, + path: "/home/me/notes/note.md", + rootPath: "/home/me/notes", + }), + ); + expect(okResponse.status).toBe(200); + expect(await readJson(okResponse)).toEqual(READ_RESULT); + + const missingResponse = await harness.app.request( + ...postJson("/api/v1/files/read", { + hostId: host.id, + path: "/home/me/notes/missing.md", + }), + ); + expect(missingResponse.status).toBe(404); + }); + }); +}); diff --git a/apps/server/test/hosts/online-rpc.test.ts b/apps/server/test/hosts/online-rpc.test.ts index 49a486e7b..25f596889 100644 --- a/apps/server/test/hosts/online-rpc.test.ts +++ b/apps/server/test/hosts/online-rpc.test.ts @@ -260,6 +260,7 @@ describe("host online RPC retry semantics", () => { contentEncoding: "utf8", mimeType: "text/html", sizeBytes: 15, + sha256: "0".repeat(64), }, }, }); @@ -328,6 +329,7 @@ describe("host online RPC retry semantics", () => { mimeType: "text/html", modifiedAtMs: 1234, sizeBytes: 15, + sha256: "0".repeat(64), }, }, }); diff --git a/apps/server/test/public/public-projects-local-host.test.ts b/apps/server/test/public/public-projects-local-host.test.ts index d67083fa4..214e267a7 100644 --- a/apps/server/test/public/public-projects-local-host.test.ts +++ b/apps/server/test/public/public-projects-local-host.test.ts @@ -128,6 +128,7 @@ describe("public project local host routes", () => { contentEncoding: "utf8", mimeType: "application/typescript", sizeBytes: 18, + sha256: "0".repeat(64), }); const fileResponse = await filePromise; diff --git a/apps/server/test/public/public-thread-data.test.ts b/apps/server/test/public/public-thread-data.test.ts index 402a4e5dd..301d628e7 100644 --- a/apps/server/test/public/public-thread-data.test.ts +++ b/apps/server/test/public/public-thread-data.test.ts @@ -4017,6 +4017,7 @@ describe("public thread data routes", () => { contentEncoding: "base64", mimeType: "image/png", sizeBytes: pngBytes.byteLength, + sha256: "0".repeat(64), }); const fileResponse = await filePromise; expect(fileResponse.status).toBe(200); @@ -4067,6 +4068,7 @@ describe("public thread data routes", () => { contentEncoding: "utf8", mimeType: "text/html", sizeBytes: Buffer.byteLength(html), + sha256: "0".repeat(64), }); const fileResponse = await filePromise; @@ -4124,6 +4126,7 @@ describe("public thread data routes", () => { contentEncoding: "utf8", mimeType: "text/html", sizeBytes: Buffer.byteLength(html), + sha256: "0".repeat(64), }); const fileResponse = await filePromise; @@ -4175,6 +4178,7 @@ describe("public thread data routes", () => { contentEncoding: "utf8", mimeType: "text/html", sizeBytes: Buffer.byteLength(html), + sha256: "0".repeat(64), }); const fileResponse = await filePromise; @@ -4268,6 +4272,7 @@ describe("public thread data routes", () => { contentEncoding: "utf8", mimeType: "text/html", sizeBytes: 5 * 1024 * 1024 + 1, + sha256: "0".repeat(64), }); const fileResponse = await filePromise; @@ -4335,6 +4340,7 @@ describe("public thread data routes", () => { contentEncoding: "utf8", mimeType: "text/markdown", sizeBytes: fileBytes.byteLength, + sha256: "0".repeat(64), }, { hostId: host.id }, ); diff --git a/packages/host-daemon-contract/src/commands.ts b/packages/host-daemon-contract/src/commands.ts index c5e57bb8f..dc9bdc6d0 100644 --- a/packages/host-daemon-contract/src/commands.ts +++ b/packages/host-daemon-contract/src/commands.ts @@ -400,6 +400,32 @@ const hostFileMetadataCommandSchema = z }) .strict(); +/** + * Write a file at an absolute host path. Mirrors `host.read_file`'s + * containment contract: when `rootPath` is provided, the daemon enforces that + * the resolved target stays under that declared absolute root (following + * symlinks on the nearest existing ancestor). + * + * `expectedSha256` is the optimistic-concurrency guard for read-modify-write + * callers (editors saving over files agents may also touch): + * - omitted → unconditional write + * - a hash → write only when the current content hashes to it + * - null → write only when the file does not exist yet (create) + * A failed guard is the `conflict` result, not an error, so the caller gets + * the current hash to re-read against. + */ +const hostWriteFileCommandSchema = z + .object({ + type: z.literal("host.write_file"), + path: z.string().min(1), + rootPath: z.string().min(1).optional(), + content: z.string(), + contentEncoding: z.enum(["utf8", "base64"]), + createParents: z.boolean(), + expectedSha256: z.string().nullable().optional(), + }) + .strict(); + const hostListFilesCommandSchema = z.object({ type: z.literal("host.list_files"), path: z.string().min(1), @@ -770,8 +796,29 @@ const fileReadResultSchema = z.object({ mimeType: z.string().optional(), sizeBytes: z.number().int().nonnegative(), modifiedAtMs: z.number().nonnegative().optional(), + // Hash of the returned bytes, so editors can do compare-and-swap saves via + // `host.write_file`'s `expectedSha256`. + sha256: z.string(), }); +const fileWriteResultSchema = z.discriminatedUnion("outcome", [ + z + .object({ + outcome: z.literal("written"), + sha256: z.string(), + sizeBytes: z.number().int().nonnegative(), + }) + .strict(), + z + .object({ + outcome: z.literal("conflict"), + // Hash of the content currently on disk; null when the file does not + // exist (the caller expected it to). + currentSha256: z.string().nullable(), + }) + .strict(), +]); + const fileMetadataResultSchema = z.object({ path: z.string(), modifiedAtMs: z.number().nonnegative(), @@ -1276,6 +1323,15 @@ export const hostDaemonCommandRegistry = { flushEventsBeforeResult: false, envLane: null, }), + "host.write_file": defineHostDaemonCommandDescriptor({ + type: "host.write_file", + schema: hostWriteFileCommandSchema, + resultSchema: fileWriteResultSchema, + transport: "onlineRpc", + retryable: false, + flushEventsBeforeResult: false, + envLane: null, + }), "provider.list_models": defineHostDaemonCommandDescriptor({ type: "provider.list_models", schema: providerListModelsCommandSchema, diff --git a/packages/host-daemon-contract/src/session.ts b/packages/host-daemon-contract/src/session.ts index 4c680499a..bc36ae01c 100644 --- a/packages/host-daemon-contract/src/session.ts +++ b/packages/host-daemon-contract/src/session.ts @@ -307,6 +307,7 @@ const hostDaemonOnlineRpcResponseSuccessSchema = z.discriminatedUnion( onlineRpcResponseSuccessSchemaFor("host.list_branches"), onlineRpcResponseSuccessSchemaFor("host.read_file"), onlineRpcResponseSuccessSchemaFor("host.read_file_relative"), + onlineRpcResponseSuccessSchemaFor("host.write_file"), onlineRpcResponseSuccessSchemaFor("provider.list_models"), onlineRpcResponseSuccessSchemaFor("known_acp_agents.status"), onlineRpcResponseSuccessSchemaFor("provider.usage"), diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts index 607bd858f..71c686084 100644 --- a/packages/host-daemon-contract/test/contract.test.ts +++ b/packages/host-daemon-contract/test/contract.test.ts @@ -231,6 +231,7 @@ const ONLINE_RPC_RESPONSE_RESULT_FIXTURES: OnlineRpcResponseResultFixtures = { contentEncoding: "utf8", mimeType: "text/html", sizeBytes: 15, + sha256: "a".repeat(64), }, "host.read_file_relative": { path: "assets/logo.png", @@ -238,6 +239,12 @@ const ONLINE_RPC_RESPONSE_RESULT_FIXTURES: OnlineRpcResponseResultFixtures = { contentEncoding: "base64", mimeType: "image/png", sizeBytes: 8, + sha256: "b".repeat(64), + }, + "host.write_file": { + outcome: "written", + sha256: "c".repeat(64), + sizeBytes: 12, }, "provider.list_models": { models: [ @@ -581,6 +588,8 @@ const INTENTIONAL_OPTIONAL_HOST_DAEMON_FIELDS: Record = { "dynamic ACP model selection omits selectFlag when the agent cannot pin a model at launch.", "hostDaemonCommandSchema.checkout": "environment.provision only includes checkout instructions for unmanaged workspaces that requested a branch mutation.", + "hostDaemonOnlineRpcCommandSchema.expectedSha256": + "host.write_file may omit expectedSha256 for unconditional writes; a hash is the compare-and-swap guard and null means create-only.", "hostDaemonOnlineRpcCommandSchema.mergeBaseBranch": "workspace.status may omit mergeBaseBranch when the caller only needs working-tree state.", "hostDaemonOnlineRpcCommandSchema.acpLaunchSpec": @@ -2209,6 +2218,7 @@ describe("host-daemon command schemas", () => { contentEncoding: "utf8", mimeType: "text/markdown", sizeBytes: 13, + sha256: "d".repeat(64), }), ).toMatchObject({ path: "/tmp/bb-data/thread-storage/thread-123/notes.md", @@ -2223,6 +2233,7 @@ describe("host-daemon command schemas", () => { contentEncoding: "base64", mimeType: "image/png", sizeBytes: 8, + sha256: "f".repeat(64), }), ).toMatchObject({ path: "assets/logo.png", @@ -2789,6 +2800,7 @@ describe("host-daemon session schemas", () => { mimeType: "text/markdown", modifiedAtMs: 1234.5, sizeBytes: 13, + sha256: "e".repeat(64), }, }), ).toEqual({ @@ -2803,6 +2815,7 @@ describe("host-daemon session schemas", () => { mimeType: "text/markdown", modifiedAtMs: 1234.5, sizeBytes: 13, + sha256: "e".repeat(64), }, }); @@ -2819,6 +2832,7 @@ describe("host-daemon session schemas", () => { mimeType: "image/png", modifiedAtMs: 1234.5, sizeBytes: 8, + sha256: "f".repeat(64), }, }), ).toEqual({ @@ -2833,6 +2847,7 @@ describe("host-daemon session schemas", () => { mimeType: "image/png", modifiedAtMs: 1234.5, sizeBytes: 8, + sha256: "f".repeat(64), }, }); diff --git a/packages/sdk/src/areas/files.ts b/packages/sdk/src/areas/files.ts new file mode 100644 index 000000000..866230b41 --- /dev/null +++ b/packages/sdk/src/areas/files.ts @@ -0,0 +1,67 @@ +import type { CreateSdkAreaArgs, PublicApiOutput } from "./common.js"; + +/** + * Host file primitives. `hostId` may be omitted to target the server's + * primary (local) host. `rootPath`, when set, confines the target beneath + * that absolute root on the host (symlink-safe). + */ +export interface FileReadArgs { + hostId?: string; + path: string; + rootPath?: string; +} + +export interface FileWriteArgs { + hostId?: string; + path: string; + rootPath?: string; + content: string; + /** Defaults to "utf8". */ + contentEncoding?: "utf8" | "base64"; + /** Defaults to false. */ + createParents?: boolean; + /** + * Optimistic-concurrency guard: omitted → unconditional write; a hash → + * write only when the current content hashes to it (use `read().sha256`); + * null → create-only. A failed guard resolves to the `conflict` outcome. + */ + expectedSha256?: string | null; +} + +export interface FileListArgs { + hostId?: string; + path: string; + query?: string; + limit?: number; +} + +export type FileReadResult = PublicApiOutput<"/files/read", "$post">; +export type FileWriteResult = PublicApiOutput<"/files/write", "$post">; +export type FileListResult = PublicApiOutput<"/files/list", "$post">; + +export interface FilesArea { + read(args: FileReadArgs): Promise; + write(args: FileWriteArgs): Promise; + list(args: FileListArgs): Promise; +} + +export function createFilesArea(args: CreateSdkAreaArgs): FilesArea { + const { transport } = args; + return { + async read(input) { + return transport.readJson( + transport.api.v1.files.read.$post({ json: input }), + ); + }, + async write(input) { + return transport.readJson( + transport.api.v1.files.write.$post({ json: input }), + ); + }, + async list(input) { + return transport.readJson( + transport.api.v1.files.list.$post({ json: input }), + ); + }, + }; +} diff --git a/packages/sdk/src/core.ts b/packages/sdk/src/core.ts index 4568aa1d7..e0aafb0b1 100644 --- a/packages/sdk/src/core.ts +++ b/packages/sdk/src/core.ts @@ -1,6 +1,7 @@ import type { BbSdkContext, BbSdkTransport } from "./transport.js"; import { createAutomationsArea } from "./areas/automations.js"; import { createEnvironmentsArea } from "./areas/environments.js"; +import { createFilesArea } from "./areas/files.js"; import { createGuideArea } from "./areas/guide.js"; import { createHostsArea } from "./areas/hosts.js"; import { createProjectsArea } from "./areas/projects.js"; @@ -19,6 +20,7 @@ export interface CreateBbSdkArgs { export interface BbSdk extends BbRealtime { automations: ReturnType; environments: ReturnType; + files: ReturnType; guide: ReturnType; hosts: ReturnType; projects: ReturnType; @@ -37,6 +39,7 @@ export function createBbSdk(args: CreateBbSdkArgs): BbSdk { return { automations: createAutomationsArea(sdkContext), environments: createEnvironmentsArea(sdkContext), + files: createFilesArea(sdkContext), guide: createGuideArea(), hosts: createHostsArea(sdkContext), on(args) { diff --git a/packages/server-contract/src/api-types.ts b/packages/server-contract/src/api-types.ts index f58d69f5a..8ca55c65e 100644 --- a/packages/server-contract/src/api-types.ts +++ b/packages/server-contract/src/api-types.ts @@ -2,6 +2,7 @@ export * from "./api/shared.js"; export * from "./api/automations.js"; export * from "./api/projects.js"; export * from "./api/environments.js"; +export * from "./api/files.js"; export * from "./api/hosts.js"; export * from "./api/system.js"; export * from "./api/terminals.js"; diff --git a/packages/server-contract/src/api/files.ts b/packages/server-contract/src/api/files.ts new file mode 100644 index 000000000..1e32752a0 --- /dev/null +++ b/packages/server-contract/src/api/files.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; +import { + FILE_LIST_LIMIT_MAX, + type HostDaemonOnlineRpcResultByType, +} from "@bb/host-daemon-contract"; + +/** + * Host file read/write API (`POST /files/*`). Unlike the preview-oriented + * thread file routes, these are host-scoped primitives for callers (plugins, + * editors) that do read-modify-write against files on a connected host. + * + * `hostId` omission has real semantics: the server resolves it to the primary + * (local) host once at the route boundary. `rootPath`, when set, confines the + * resolved target beneath that absolute root on the daemon side. + */ +export const hostFileReadRequestSchema = z + .object({ + hostId: z.string().min(1).optional(), + path: z.string().min(1), + rootPath: z.string().min(1).optional(), + }) + .strict(); +export type HostFileReadRequest = z.infer; + +export const hostFileWriteRequestSchema = z + .object({ + hostId: z.string().min(1).optional(), + path: z.string().min(1), + rootPath: z.string().min(1).optional(), + content: z.string(), + contentEncoding: z.enum(["utf8", "base64"]).optional(), + createParents: z.boolean().optional(), + // Omitted → unconditional write; hash → compare-and-swap against the + // current content; null → create-only (fail if the file exists). + expectedSha256: z.string().nullable().optional(), + }) + .strict(); +export type HostFileWriteRequest = z.infer; + +export const hostFileListRequestSchema = z + .object({ + hostId: z.string().min(1).optional(), + path: z.string().min(1), + query: z.string().optional(), + limit: z.number().int().positive().max(FILE_LIST_LIMIT_MAX).optional(), + }) + .strict(); +export type HostFileListRequest = z.infer; + +export type HostFileReadResponse = + HostDaemonOnlineRpcResultByType["host.read_file"]; +export type HostFileWriteResponse = + HostDaemonOnlineRpcResultByType["host.write_file"]; +export type HostFileListResponse = + HostDaemonOnlineRpcResultByType["host.list_files"]; diff --git a/packages/server-contract/src/public-api.ts b/packages/server-contract/src/public-api.ts index 73f09c1c8..ab59507bd 100644 --- a/packages/server-contract/src/public-api.ts +++ b/packages/server-contract/src/public-api.ts @@ -74,6 +74,12 @@ import type { EnvironmentStatusResponse, HostDirectoryListing, HostDirectoryQuery, + HostFileListRequest, + HostFileListResponse, + HostFileReadRequest, + HostFileReadResponse, + HostFileWriteRequest, + HostFileWriteResponse, HostPickFolderRequest, HostPickFolderResponse, HostPathsExistRequest, @@ -182,6 +188,9 @@ import { environmentPathsQuerySchema, environmentStatusQuerySchema, hostDirectoryQuerySchema, + hostFileListRequestSchema, + hostFileReadRequestSchema, + hostFileWriteRequestSchema, hostPickFolderRequestSchema, hostPathsExistRequestSchema, hostProviderCliInstallRequestSchema, @@ -381,6 +390,33 @@ export const publicApiRoutes = { }), }, + files: { + read: defineRoute({ + path: "/files/read", + method: "post", + request: jsonRequest( + hostFileReadRequestSchema, + ), + response: jsonResponse(), + }), + write: defineRoute({ + path: "/files/write", + method: "post", + request: jsonRequest( + hostFileWriteRequestSchema, + ), + response: jsonResponse(), + }), + list: defineRoute({ + path: "/files/list", + method: "post", + request: jsonRequest( + hostFileListRequestSchema, + ), + response: jsonResponse(), + }), + }, + hosts: { list: defineRoute({ path: "/hosts", diff --git a/plans/notes-plugin-api-spec.html b/plans/notes-plugin-api-spec.html new file mode 100644 index 000000000..3a3d4b62c --- /dev/null +++ b/plans/notes-plugin-api-spec.html @@ -0,0 +1,371 @@ + + + + + +BB Plugin API Additions — Notes Plugin Spec + + + + +

BB Plugin API Additions

+

Spec for the platform surfaces needed by the Markdown notes plugin · draft · 2026-07-04

+ +

Five additions, motivated by an Obsidian-style notes plugin (nav-panel tree + WYSIWYG +markdown editor + open-links-in-editor + highlight-to-chat), but each is deliberately +generic — a code editor, a CSV viewer, or a Jupyter plugin would use the same surfaces. +The notes plugin itself needs no other platform changes: nav panel, thread-panel +action, settings, rpc/realtime, and mention providers all exist today.

+ + + + +

1. useComposer() new — small

+ +

Lets a plugin component push content into the chat composer draft: quote a selection, +attach a file, or insert an @-mention pill. This is the "highlight text → reference in +chat" affordance.

+ +
// @bb/plugin-sdk/app
+export interface PluginComposerApi {
+  /** Which draft writes land in (resolved from surrounding context). */
+  scope:
+    | { kind: "thread"; threadId: string }        // thread detail / side panel
+    | { kind: "new-thread"; projectId: string | null }; // compose view, nav panels
+
+  /** Append text as a `> ` blockquote block and focus the composer. */
+  addQuote(text: string): void;
+
+  /** Attach a file reference (deduped by path). */
+  addAttachment(attachment: { path: string }): void;
+
+  /**
+   * Insert an @-mention pill bound to one of THIS plugin's mention
+   * providers. Resolves to context at send via the provider's resolve().
+   */
+  insertMention(mention: { provider: string; id: string; label: string }): void;
+
+  focus(): void;
+}
+
+export function useComposer(): PluginComposerApi;
+ +

Semantics

+
    +
  • Scope resolution: inside a thread context (thread panel tab, thread + detail) writes go to that thread's draft; anywhere else (nav panel, homepage + section) they go to the new-thread draft — so "reference in chat" from the notes + panel seeds the composer you land on next. Never null; always writable.
  • +
  • Implementation is a thin wrapper over the existing shared draft + store: usePromptDraftStorage is already a module-level + localStorage-backed store, and its addQuote/addAttachment + are exactly what the built-in add-to-chat affordances (file preview line selection, + diff, terminal) already call. This API exposes the same funnel to plugins, plus the + focus nonce.
  • +
  • insertMention appends the pill's replacement text and an + offset-based mention record (the existing PromptTextMention model, + mention kind plugin already exists). Provider must belong to the + calling plugin.
  • +
+ +

Touches: packages/plugin-sdk/src/app-contract.ts, +apps/app/src/lib/plugin-frontend.ts (runtime hook impl over +usePromptDraftStorage + useBbContext). No server changes.

+ + +

2. fileOpener slot new — medium

+ +

A plugin registers itself as an opener for file extensions; the user can make it the +default. Every path that opens files — markdown link clicks, the file picker, +bb thread open — then routes matching files to the plugin's component +instead of the built-in read-only preview.

+ +
// @bb/plugin-sdk/app
+export interface PluginFileOpenerSource {
+  kind: "workspace" | "host" | "thread-storage";
+  threadId?: string;   // workspace / thread-storage
+  hostId?: string;     // host
+  root: string;        // containing root the path was resolved against
+}
+
+export interface PluginFileOpenerProps {
+  path: string;                    // root-relative file path
+  source: PluginFileOpenerSource;
+}
+
+export interface PluginFileOpenerRegistration {
+  id: string;
+  title: string;                        // shown in "Open with…"
+  extensions: readonly string[];        // ["md", "mdx"] — lowercase, no dot
+  component: ComponentType<PluginFileOpenerProps>;
+}
+
+app.slots.fileOpener(registration);
+ +

Host behavior

+
    +
  • One interception point. useThreadFileTabs.openTab is + the single funnel all file-opening flows already pass through (markdown local-file + links via resolveThreadLocalFileLink, the new-tab file search, the + bb thread open WS signal). When the request is a file-preview kind and + the extension's default opener is a plugin, the tab opens as the existing + plugin-panel tab kind instead — paramsJson = + { path, source }, plugin logo as the tab icon, same identity/dedupe + semantics shipped for threadPanelAction.
  • +
  • Default-opener preference. A host-level map + { ext → openerId | "builtin" } (default "builtin" for + everything). Surfaced two ways: an Open with… menu on file tabs + listing the built-in preview plus every matching registered opener, with + "Always open .md with…"; and a section in Settings.
  • +
  • Graceful degrade. Opener plugin disabled/removed → fall back to the + built-in preview (never a dead tab). "Open with → Built-in preview" is always + available as a per-open override.
  • +
  • Applies in both hosts of the secondary panel — thread detail and + the root compose view.
  • +
+ +
This is what makes "links in markdown open in my editor" fall out for +free: link clicks already resolve to openTab requests, so they inherit the +preference with zero link-handling changes.
+ +

Touches: app-contract.ts, plugin-slots.ts, +useThreadFileTabs.ts, fixed-panel-tabs-state.ts (reuse +plugin-panel tab), an Open with… menu, one new persisted app setting.

+ + +

3. bb.sdk.files — host file read/write new — medium

+ +

A first-class host-daemon API for reading and writing files on a host, exposed through +the SDK (and therefore to plugins, which reach it from their server code or via their own +rpc from the frontend). Reads exist internally today +(hostReadFile/hostReadFileRelative); writes are entirely new — +nothing in the daemon writes workspace files today.

+ +

Daemon contract

+
// packages/host-daemon-contract — new command
+host.writeFile {
+  path: string;                    // absolute host path
+  content: string;
+  encoding: "utf8" | "base64";
+  createParents?: boolean;         // default false
+  /**
+   * Optimistic concurrency:
+   *   omitted  → unconditional write
+   *   sha256   → write only if current content hashes to this (else conflict)
+   *   null     → write only if the file does NOT exist (create)
+   */
+  expectedSha256?: string | null;
+} → { sha256: string; bytes: number }
+ +
    +
  • Runs on the environment write lane (envLane: "write") like other + workspace mutations, so writes serialize against git operations.
  • +
  • Size cap mirrors reads (25 MB). Conflict → typed file-conflict + error carrying the current sha256.
  • +
  • Reads gain a sha256 field in their response so an editor can do + read-modify-write safely.
  • +
+ +

SDK / plugin surface

+
// bb.sdk.files (server assembles; daemon stays a raw primitive)
+bb.sdk.files.read({
+  hostId?: string;                 // default: the server's local host
+  path: string;
+  encoding?: "utf8" | "base64";
+}) → { content, encoding, sha256, size, mtimeMs }
+
+bb.sdk.files.write({
+  hostId?: string;
+  path: string;
+  content: string;
+  encoding?: "utf8" | "base64";    // default utf8
+  expectedSha256?: string | null;
+  createParents?: boolean;
+}) → { sha256 }
+
+bb.sdk.files.list({ hostId?, path }) → { entries }   // wraps existing hostListFiles
+ +

Why, given plugins already have node:fs

+
    +
  • Reach: node:fs sees only the server machine. This API + works against any connected host, including remote ones — the notes plugin's + "mount a directory" works no matter where the directory lives, and a future + workspace-file editor gets a write path into remote worktrees.
  • +
  • Concurrency: the CAS write (expectedSha256) is what + makes a save button safe against agents editing the same file mid-session.
  • +
  • Boundary hygiene: daemon owns the host-local primitive, server + exposes the product API — per the server/daemon rules. No new policy in the + daemon.
  • +
+ +
Security posture: unchanged. Plugins are already +full-trust in-process code; this adds reach and safety rails, not permissions. If a +sandbox ever lands, this API is the natural choke point to gate.
+ +

Touches: packages/host-daemon-contract/src/commands.ts, +apps/host-daemon/src/command-handlers/ (new file-write.ts), +server route /api/v1/hosts/:hostId/files, +packages/sdk/src/areas/files.ts (new area).

+ + +

4. navPanel sub-routing new — small · backlog #26

+ +

Nav panels currently own exactly one route segment +(/plugins/:pluginId/:panelPath); the github example fakes inner navigation +with #hash routing. Give panels a real splat.

+ +
// Route becomes /plugins/:pluginId/:panelPath/*
+export interface PluginNavPanelProps {
+  subPath: string;                 // "" at the panel root, e.g. "work/ideas.md"
+}
+
+// @bb/plugin-sdk/app — useBbNavigate() gains:
+navigate.toPluginPanel(path: string, options?: {
+  subPath?: string;
+  replace?: boolean;
+});
+ +
    +
  • Deep links work (/plugins/notes/notes/work/ideas.md), browser + back/forward navigate within the panel, and the notes tree can be plain + <a>-style navigation.
  • +
  • Migration: subPath is additive ("" today), so existing + panels are unaffected; the github example migrates off hash routing as + validation.
  • +
+ +

Touches: route-paths.ts, PluginPanelView.tsx, +app-contract.ts (PluginNavPanelProps + navigate).

+ + +

5. How the notes plugin composes these

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeatureSurfaceStatus
Notes view in main nav (tree + editor)navPanel slot, chrome: "none"exists
Mounted directoriesbb.settings.define (+ KV for UI-driven mounts); files via + bb.sdk.filesexists + §3
WYSIWYG editorBundled dep — recommend Milkdown "Crepe" (markdown-native ProseMirror)exists
Load/save notes, live tree refreshbb.rpc.register + bb.realtime.publish from an fs + watcher; CAS saves via expectedSha256exists + §3
Markdown links open in the editor by defaultfileOpener slot + default-opener preference§2
Editor in the thread side panelthreadPanelActionopenPanel({ params: { path } })exists
Highlight → reference in chatuseComposer().addQuote() / insertMention()§1
Type @note: in the composerbb.ui.registerMentionProvider (resolve-at-send)exists
Deep-link a notenavPanel subPath§4
+ +

Suggested build order

+
    +
  1. §3 bb.sdk.files — unblocks the plugin's core + read/write loop and is the only server/daemon-boundary change.
  2. +
  3. §4 sub-routing — small, and the notes tree wants it from day + one.
  4. +
  5. Notes plugin v1 — nav panel, Milkdown, settings, rpc/realtime, + thread-panel action, mention provider. Platform-change-free beyond §3/§4.
  6. +
  7. §1 useComposer() — adds highlight-to-chat.
  8. +
  9. §2 fileOpener — the one design-worthy piece; ship + last once the editor proves the component contract.
  10. +
+ +

Open questions

+
    +
  • §2: should openers match on more than extension (glob? content sniff)? V1 says + extension-only.
  • +
  • §2: does the default-opener preference live per-project or app-wide? Proposal: + app-wide.
  • +
  • §3: expose files.watch (daemon-side watcher → realtime events) in V1, + or let plugins poll/watch server-locally? Proposal: defer; the notes plugin + watches server-locally.
  • +
  • §1: should addAttachment accept host paths that need staging through + the attachment-upload pipeline, or workspace-relative paths only?
  • +
+ + + diff --git a/plans/plugin-system-design.md b/plans/plugin-system-design.md index 8dd233ea5..42038f35f 100644 --- a/plans/plugin-system-design.md +++ b/plans/plugin-system-design.md @@ -227,6 +227,13 @@ Two SDK-level additions plugins need (server-contract changes): `originPluginId`, rendered as a badge/filter in the thread list so plugin-spawned threads are distinguishable. +*(Added 2026-07-04)* **`bb.sdk.files`** — host file primitives (`read`/`write`/`list`) +backed by a new `host.write_file` daemon command (25 MB cap, optional `rootPath` +containment, `expectedSha256` compare-and-swap returning a `conflict` outcome instead of +an error; reads now include `sha256`). `hostId` optional → primary host, resolved at the +server boundary. This is the sanctioned path for plugins that edit user files (e.g. the +notes plugin) — it reaches remote hosts where in-process `node:fs` cannot. + ### 4.2 `bb.settings` ```ts From ccd0c170d5dcdb07e57ff4d1cc07db331f166c53 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Sat, 4 Jul 2026 08:42:46 -0700 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20navPanel=20sub-routing=20=E2=80=94?= =?UTF-8?q?=20/plugins/:id/:panel/*=20splat=20as=20subPath=20prop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PluginNavPanelProps gains subPath (route remainder, "" at the root); useBbNavigate().toPluginPanel(path, { subPath?, replace? }) navigates within a panel so deep links and browser back/forward work. headerContent receives subPath too. The github example drops its hash routing for subPath routing. Co-Authored-By: Claude Fable 5 --- apps/app/src/components/layout/AppLayout.tsx | 9 ++- .../components/plugin/PluginPanelHeader.tsx | 4 +- .../plugin/plugin-slot-mounts.test.tsx | 44 ++++++++++++- apps/app/src/lib/plugin-sdk-hooks.ts | 13 +++- apps/app/src/lib/plugin-slots.test.ts | 7 +- apps/app/src/lib/route-paths.ts | 17 ++++- apps/app/src/views/PluginPanelView.tsx | 8 ++- .../bb-plugin-authoring/SKILL.md | 14 ++-- .../plugins/plugin-authoring-docs.test.ts | 2 +- examples/plugins/github/app.tsx | 51 +++++++------- .../bundled-types/bb-plugin-sdk-app.d.ts | 21 +++++- .../bundled-types/bb-plugin-sdk.d.ts | 66 ++++++++++++++++++- packages/plugin-sdk/src/app-contract.ts | 25 +++++-- .../src/generated/plugin-sdk-dts.generated.ts | 4 +- .../src/generated/templates.generated.ts | 2 +- .../src/templates/bb-guide-plugins.md | 4 +- 16 files changed, 236 insertions(+), 55 deletions(-) diff --git a/apps/app/src/components/layout/AppLayout.tsx b/apps/app/src/components/layout/AppLayout.tsx index dff63bef1..8246aeb58 100644 --- a/apps/app/src/components/layout/AppLayout.tsx +++ b/apps/app/src/components/layout/AppLayout.tsx @@ -245,6 +245,8 @@ interface AppHeaderProps { * the shared header shows plugin logo + title, plus the registration's * `headerContent` as the actions. */ pluginPanel?: PluginNavPanelSlot; + /** The panel route's splat remainder ("" at the panel root). */ + pluginPanelSubPath?: string; meta: { title: string; subtitle?: string; @@ -260,6 +262,7 @@ function AppHeader({ projectId, project, pluginPanel, + pluginPanelSubPath, meta, }: AppHeaderProps) { const headerBreadcrumbs = meta.breadcrumbs; @@ -325,7 +328,10 @@ function AppHeader({ ) : null; const actions = pluginPanel ? ( - + ) : usesProjectChromeStyle && projectId && !isProjectlessProjectId(projectId) ? ( @@ -684,6 +690,7 @@ export function AppLayout({ children }: AppLayoutProps) { projectId={projectId} project={project} pluginPanel={pluginPanel} + pluginPanelSubPath={pluginPanelMatch?.params["*"] ?? ""} meta={meta} /> ) : null} diff --git a/apps/app/src/components/plugin/PluginPanelHeader.tsx b/apps/app/src/components/plugin/PluginPanelHeader.tsx index cac4e3d36..341c8dbfc 100644 --- a/apps/app/src/components/plugin/PluginPanelHeader.tsx +++ b/apps/app/src/components/plugin/PluginPanelHeader.tsx @@ -63,8 +63,10 @@ export function PluginPanelHeaderCenter({ */ export function PluginPanelHeaderActions({ panel, + subPath, }: { panel: PluginNavPanelSlot; + subPath: string; }) { const HeaderContent = panel.headerContent; if (HeaderContent === undefined || panel.chrome === "none") return null; @@ -83,7 +85,7 @@ export function PluginPanelHeaderActions({ data-bb-plugin={panel.pluginId} className="flex shrink-0 items-center gap-2" > - + diff --git a/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx b/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx index 9e68d776b..6f5a71d22 100644 --- a/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx +++ b/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx @@ -176,6 +176,44 @@ describe("PluginNavSidebarItems + PluginPanelView", () => { expect(screen.getByText("board panel body")).toBeDefined(); }); + it("passes the route splat to the panel as subPath ('' at the root)", () => { + function SubPathProbe({ subPath }: { subPath: string }) { + return
subPath: "{subPath}"
; + } + setPluginSlotRegistrations( + "demo", + registrationSet({ + navPanels: [ + { + id: "board", + title: "Demo board", + icon: "columns", + path: "board", + component: SubPathProbe, + }, + ], + }), + ); + const { unmount } = render( + + + } /> + + , + ); + expect(screen.getByText('subPath: "work/ideas.md"')).toBeDefined(); + unmount(); + + render( + + + } /> + + , + ); + expect(screen.getByText('subPath: ""')).toBeDefined(); + }); + it("shows a placeholder for an unknown plugin panel route", () => { render( @@ -269,7 +307,7 @@ describe("plugin panel chrome (shared header + body modes)", () => { render( <> - + , ); expect(screen.getByText("Demo board")).toBeDefined(); @@ -289,7 +327,7 @@ describe("plugin panel chrome (shared header + body modes)", () => { render( <> - + , ); // The header center survives; the accessory is hidden, not chip-ified. @@ -305,7 +343,7 @@ describe("plugin panel chrome (shared header + body modes)", () => { chrome: "none", headerContent: HeaderAccessory, }); - const { container } = render(); + const { container } = render(); expect(container.innerHTML).toBe(""); }); diff --git a/apps/app/src/lib/plugin-sdk-hooks.ts b/apps/app/src/lib/plugin-sdk-hooks.ts index ed1998cf2..5a7d0531d 100644 --- a/apps/app/src/lib/plugin-sdk-hooks.ts +++ b/apps/app/src/lib/plugin-sdk-hooks.ts @@ -178,8 +178,17 @@ export function useBbNavigate(): BbNavigate { [navigate], ); const toPluginPanel = useCallback( - (path: string) => { - void navigate(getPluginPanelRoutePath({ pluginId, path })); + (path: string, options?: { subPath?: string; replace?: boolean }) => { + void navigate( + getPluginPanelRoutePath({ + pluginId, + path, + ...(options?.subPath !== undefined + ? { subPath: options.subPath } + : {}), + }), + options?.replace ? { replace: true } : undefined, + ); }, [navigate, pluginId], ); diff --git a/apps/app/src/lib/plugin-slots.test.ts b/apps/app/src/lib/plugin-slots.test.ts index c38b0003b..3608880a9 100644 --- a/apps/app/src/lib/plugin-slots.test.ts +++ b/apps/app/src/lib/plugin-slots.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import type { PluginHomepageSectionProps } from "@bb/plugin-sdk"; +import type { PluginNavPanelProps, PluginHomepageSectionProps } from "@bb/plugin-sdk"; import { getPluginSlotSnapshot, removePluginSlotRegistrations, @@ -12,6 +12,9 @@ import { function SectionComponent(_props: Partial) { return null; } +function PanelComponent(_props: PluginNavPanelProps) { + return null; +} function registrationSet( overrides: Partial = {}, @@ -98,7 +101,7 @@ describe("plugin slot store", () => { title: "Board", icon: "columns", path: "board", - component: SectionComponent, + component: PanelComponent, }, ], }), diff --git a/apps/app/src/lib/route-paths.ts b/apps/app/src/lib/route-paths.ts index afe996dcc..5c9fcd930 100644 --- a/apps/app/src/lib/route-paths.ts +++ b/apps/app/src/lib/route-paths.ts @@ -26,7 +26,8 @@ export const PROJECT_SETTINGS_ROUTE_PATH = "/projects/:projectId/settings"; export const PROJECT_ARCHIVED_ROUTE_PATH = "/projects/:projectId/archived"; export const THREAD_DETAIL_ROUTE_PATH = "/projects/:projectId/threads/:threadId"; -export const PLUGIN_PANEL_ROUTE_PATH = "/plugins/:pluginId/:panelPath"; +// Trailing splat: the remainder is the panel's `subPath` (empty at the root). +export const PLUGIN_PANEL_ROUTE_PATH = "/plugins/:pluginId/:panelPath/*"; export interface ThreadRoutePathArgs { projectId: string; @@ -129,13 +130,25 @@ export interface PluginPanelRoutePathArgs { pluginId: string; /** The nav panel's registered `path` segment (validated: [a-zA-Z0-9_-]+). */ path: string; + /** Location inside the panel; segments are encoded, slashes preserved. */ + subPath?: string; } export function getPluginPanelRoutePath({ pluginId, path, + subPath, }: PluginPanelRoutePathArgs): string { - return `/plugins/${encodeURIComponent(pluginId)}/${encodeURIComponent(path)}`; + const root = `/plugins/${encodeURIComponent(pluginId)}/${encodeURIComponent(path)}`; + if (subPath === undefined || subPath === "") { + return root; + } + const encoded = subPath + .split("/") + .filter((segment) => segment.length > 0) + .map((segment) => encodeURIComponent(segment)) + .join("/"); + return encoded.length > 0 ? `${root}/${encoded}` : root; } export function getThreadRoutePath(args: ThreadRoutePathArgs): string { diff --git a/apps/app/src/views/PluginPanelView.tsx b/apps/app/src/views/PluginPanelView.tsx index 224791835..7ffff2900 100644 --- a/apps/app/src/views/PluginPanelView.tsx +++ b/apps/app/src/views/PluginPanelView.tsx @@ -36,10 +36,14 @@ const HIGHLIGHTER_OPTIONS = {}; * padding — with only the error boundary remaining. */ export function PluginPanelView() { - const { pluginId, panelPath } = useParams<{ + const params = useParams<{ pluginId: string; panelPath: string; + "*": string; }>(); + const { pluginId, panelPath } = params; + // The route's trailing splat: panel-internal location ("" at the root). + const subPath = params["*"] ?? ""; const { navPanels } = usePluginSlots(); const panel = navPanels.find( @@ -67,7 +71,7 @@ export function PluginPanelView() { slotKind="navPanel" slotId={panel.id} > - + ); // The provider spawns workers eagerly; environments without Worker diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md index 1545a0cda..a007f77bd 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md @@ -404,8 +404,14 @@ Slot props contracts (versioned, additive-only): - `homepageSection` → `{ projectId: string | null }` (project in view on the compose surface). Registration: `{ id, title, component }`. -- `navPanel` → `{}` — owns the whole route at `/plugins//` - and gets its own sidebar entry. +- `navPanel` → `{ subPath: string }` — owns the whole route at + `/plugins///*` and gets its own sidebar entry. `subPath` + is the route remainder after the panel root (`""` at the root), so deep + links like `/plugins/notes/notes/work/ideas.md` land with + `subPath: "work/ideas.md"`. Navigate within the panel via + `useBbNavigate().toPluginPanel(path, { subPath, replace? })` — browser + back/forward then walks panel-internal history (prefer this over hash + routing). Registration: `{ id, title, icon, path, component, chrome?, headerContent? }`. The host renders your plugin logo + `title` into the SHARED app header (the same chrome as Settings/Automations) with your optional @@ -445,7 +451,7 @@ Hooks: - `useSettings()` → `{ values, isLoading }` — effective non-secret values (secret settings are excluded; read them server-side only). - `useBbContext()` → `{ projectId, threadId }` from the current route. -- `useBbNavigate()` → `{ toThread(id), toProject(id), toPluginPanel(path) }`. +- `useBbNavigate()` → `{ toThread(id), toProject(id), toPluginPanel(path, { subPath?, replace? }?) }`. UI components — **vendored shadcn source you own** (the shadcn model; the old host-provided component kit is REMOVED — `@bb/plugin-sdk/app` exports @@ -533,7 +539,7 @@ hardcoded colors break custom palettes. Reference examples in `examples/plugins/` (a bb checkout): - `github` — vendored-component showcase: a gh-CLI-backed issue/PR browser - in a single navPanel (with `headerContent`), hash-based sub-navigation, + in a single navPanel (with `headerContent`), subPath-based sub-navigation, vendored Tabs/Select/DropdownMenu/Badge/Skeleton + sonner toast throughout, background sync service, rpc + realtime, project setting, a `bb github` CLI command, and agent-spawn buttons. 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 55776bfa9..c55b524e9 100644 --- a/apps/server/test/services/plugins/plugin-authoring-docs.test.ts +++ b/apps/server/test/services/plugins/plugin-authoring-docs.test.ts @@ -141,7 +141,7 @@ void _assertAllSlotsListed; const FRONTEND_SLOT_PROP_FIELDS = { homepageSection: ["projectId"], - navPanel: [], + navPanel: ["subPath"], threadPanelAction: ["threadId", "params"], composerAccessory: ["projectId", "threadId"], } as const satisfies { diff --git a/examples/plugins/github/app.tsx b/examples/plugins/github/app.tsx index 4031c1cd9..4f486e263 100644 --- a/examples/plugins/github/app.tsx +++ b/examples/plugins/github/app.tsx @@ -7,7 +7,7 @@ // GitHub integration, shrunk to a panel). "Send agent" buttons everywhere an // issue or PR shows up. Deep links use the URL hash // (#/issues///, #/pulls///) since navPanel -// owns a single route today. A threadPanelAction opens the same PR view in a +// owns /plugins/github/github/* via subPath routing. A threadPanelAction opens the same PR view in a // thread's right panel, auto-resolved to that thread's PR. import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { @@ -15,6 +15,7 @@ import { useBbNavigate, useRealtime, useRpc, + type PluginNavPanelProps, type PluginThreadPanelProps, } from "@bb/plugin-sdk/app"; // Shimmed to the host's copy at build time (shared worker-pool context + @@ -162,10 +163,14 @@ function relativeTime(iso: string): string { } // --------------------------------------------------------------------------- -// Hash routing — the navPanel owns one route, so sub-navigation lives in the -// URL hash: #/issues, #/pulls, #/new, #/issues///. +// Sub-routing — the navPanel owns /plugins/github/github/*, so sub-navigation +// lives in the route's subPath: "issues", "pulls", "new", +// "issues///". Deep-linkable, and browser back/forward +// walks panel history. // --------------------------------------------------------------------------- +const PANEL_PATH = "github"; + type Route = | { view: "issues" } | { view: "pulls" } @@ -173,8 +178,8 @@ type Route = | { view: "issue"; repo: string; number: number } | { view: "pull"; repo: string; number: number }; -function parseHash(hash: string): Route { - const parts = hash.replace(/^#\/?/, "").split("/").filter((p) => p.length > 0); +function parseSubPath(subPath: string): Route { + const parts = subPath.split("/").filter((p) => p.length > 0); if (parts[0] === "pulls" && parts.length === 4) { const number = Number(parts[3]); if (Number.isFinite(number)) { @@ -192,32 +197,30 @@ function parseHash(hash: string): Route { return { view: "issues" }; } -function routeToHash(route: Route): string { +function routeToSubPath(route: Route): string { switch (route.view) { case "issues": - return "#/issues"; + return "issues"; case "pulls": - return "#/pulls"; + return "pulls"; case "new": - return "#/new"; + return "new"; case "issue": - return `#/issues/${route.repo}/${route.number}`; + return `issues/${route.repo}/${route.number}`; case "pull": - return `#/pulls/${route.repo}/${route.number}`; + return `pulls/${route.repo}/${route.number}`; } } -function useHashRoute(): [Route, (route: Route) => void] { - const [route, setRoute] = useState(() => parseHash(window.location.hash)); - useEffect(() => { - const onChange = () => setRoute(parseHash(window.location.hash)); - window.addEventListener("hashchange", onChange); - return () => window.removeEventListener("hashchange", onChange); - }, []); - const navigate = useCallback((next: Route) => { - window.location.hash = routeToHash(next); - setRoute(next); - }, []); +function useSubPathRoute(subPath: string): [Route, (route: Route) => void] { + const bbNavigate = useBbNavigate(); + const route = useMemo(() => parseSubPath(subPath), [subPath]); + const navigate = useCallback( + (next: Route) => { + bbNavigate.toPluginPanel(PANEL_PATH, { subPath: routeToSubPath(next) }); + }, + [bbNavigate], + ); return [route, navigate]; } @@ -2117,8 +2120,8 @@ function PanelHeader() { const QUERY_KEY = "bb-plugin-github:query"; const DEFAULT_QUERY = "is:open "; -function GithubPanel() { - const [route, navigate] = useHashRoute(); +function GithubPanel({ subPath }: PluginNavPanelProps) { + const [route, navigate] = useSubPathRoute(subPath); const { status } = useStatus(); const [query, setQueryState] = useState(() => { try { diff --git a/packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts b/packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts index 5d3b61172..1c45d0c9f 100644 --- a/packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts +++ b/packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts @@ -23,6 +23,15 @@ interface PluginHomepageSectionProps { } /** Props passed to a `navPanel` component (it owns its whole route). */ interface PluginNavPanelProps { + /** + * The route remainder after the panel root, "" at the root. The panel's + * route is `/plugins///*`, so a deep link like + * `/plugins/notes/notes/work/ideas.md` renders the panel with + * `subPath: "work/ideas.md"`. Navigate within the panel via + * `useBbNavigate().toPluginPanel(path, { subPath })` — browser + * back/forward then walks panel-internal history. + */ + subPath: string; } /** Props passed to a panel tab opened by a `threadPanelAction`. */ interface PluginThreadPanelProps { @@ -162,8 +171,16 @@ interface BbContext { interface BbNavigate { toThread(threadId: string): void; toProject(projectId: string): void; - /** Navigate to one of this plugin's own nav panels by its `path`. */ - toPluginPanel(path: string): void; + /** + * Navigate to one of this plugin's own nav panels by its `path`. + * `subPath` targets a location inside the panel (the component's + * `subPath` prop); `replace` swaps the current history entry instead of + * pushing — use it for redirects so back does not bounce. + */ + toPluginPanel(path: string, options?: { + subPath?: string; + replace?: boolean; + }): void; } /** * Everything `@bb/plugin-sdk/app` resolves to at runtime. The BB app builds diff --git a/packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts b/packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts index 0765436e9..54c1531fd 100644 --- a/packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts +++ b/packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts @@ -27,6 +27,15 @@ interface PluginHomepageSectionProps { } /** Props passed to a `navPanel` component (it owns its whole route). */ interface PluginNavPanelProps { + /** + * The route remainder after the panel root, "" at the root. The panel's + * route is `/plugins///*`, so a deep link like + * `/plugins/notes/notes/work/ideas.md` renders the panel with + * `subPath: "work/ideas.md"`. Navigate within the panel via + * `useBbNavigate().toPluginPanel(path, { subPath })` — browser + * back/forward then walks panel-internal history. + */ + subPath: string; } /** Props passed to a panel tab opened by a `threadPanelAction`. */ interface PluginThreadPanelProps { @@ -166,8 +175,16 @@ interface BbContext { interface BbNavigate { toThread(threadId: string): void; toProject(projectId: string): void; - /** Navigate to one of this plugin's own nav panels by its `path`. */ - toPluginPanel(path: string): void; + /** + * Navigate to one of this plugin's own nav panels by its `path`. + * `subPath` targets a location inside the panel (the component's + * `subPath` prop); `replace` swaps the current history entry instead of + * pushing — use it for redirects so back does not bounce. + */ + toPluginPanel(path: string, options?: { + subPath?: string; + replace?: boolean; + }): void; } /** * Everything `@bb/plugin-sdk/app` resolves to at runtime. The BB app builds @@ -329,8 +346,8 @@ declare const threadTimelinePendingTodosSchema: z$1.ZodObject<{ id: z$1.ZodString; text: z$1.ZodString; status: z$1.ZodEnum<{ - completed: "completed"; pending: "pending"; + completed: "completed"; in_progress: "in_progress"; }>; }, z$1.core.$strip>>; @@ -1237,6 +1254,48 @@ interface EnvironmentsArea { } declare function createEnvironmentsArea(args: CreateSdkAreaArgs): EnvironmentsArea; +/** + * Host file primitives. `hostId` may be omitted to target the server's + * primary (local) host. `rootPath`, when set, confines the target beneath + * that absolute root on the host (symlink-safe). + */ +interface FileReadArgs { + hostId?: string; + path: string; + rootPath?: string; +} +interface FileWriteArgs { + hostId?: string; + path: string; + rootPath?: string; + content: string; + /** Defaults to "utf8". */ + contentEncoding?: "utf8" | "base64"; + /** Defaults to false. */ + createParents?: boolean; + /** + * Optimistic-concurrency guard: omitted → unconditional write; a hash → + * write only when the current content hashes to it (use `read().sha256`); + * null → create-only. A failed guard resolves to the `conflict` outcome. + */ + expectedSha256?: string | null; +} +interface FileListArgs { + hostId?: string; + path: string; + query?: string; + limit?: number; +} +type FileReadResult = PublicApiOutput<"/files/read", "$post">; +type FileWriteResult = PublicApiOutput<"/files/write", "$post">; +type FileListResult = PublicApiOutput<"/files/list", "$post">; +interface FilesArea { + read(args: FileReadArgs): Promise; + write(args: FileWriteArgs): Promise; + list(args: FileListArgs): Promise; +} +declare function createFilesArea(args: CreateSdkAreaArgs): FilesArea; + interface GuideRenderArgs { chapter?: string; } @@ -1623,6 +1682,7 @@ declare function createThreadsArea(args: CreateSdkAreaArgs): ThreadsArea; interface BbSdk extends BbRealtime { automations: ReturnType; environments: ReturnType; + files: ReturnType; guide: ReturnType; hosts: ReturnType; projects: ReturnType; diff --git a/packages/plugin-sdk/src/app-contract.ts b/packages/plugin-sdk/src/app-contract.ts index f130c7041..407b9003c 100644 --- a/packages/plugin-sdk/src/app-contract.ts +++ b/packages/plugin-sdk/src/app-contract.ts @@ -24,8 +24,17 @@ export interface PluginHomepageSectionProps { } /** Props passed to a `navPanel` component (it owns its whole route). */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface PluginNavPanelProps {} +export interface PluginNavPanelProps { + /** + * The route remainder after the panel root, "" at the root. The panel's + * route is `/plugins///*`, so a deep link like + * `/plugins/notes/notes/work/ideas.md` renders the panel with + * `subPath: "work/ideas.md"`. Navigate within the panel via + * `useBbNavigate().toPluginPanel(path, { subPath })` — browser + * back/forward then walks panel-internal history. + */ + subPath: string; +} /** Props passed to a panel tab opened by a `threadPanelAction`. */ export interface PluginThreadPanelProps { @@ -189,8 +198,16 @@ export interface BbContext { export interface BbNavigate { toThread(threadId: string): void; toProject(projectId: string): void; - /** Navigate to one of this plugin's own nav panels by its `path`. */ - toPluginPanel(path: string): void; + /** + * Navigate to one of this plugin's own nav panels by its `path`. + * `subPath` targets a location inside the panel (the component's + * `subPath` prop); `replace` swaps the current history entry instead of + * pushing — use it for redirects so back does not bounce. + */ + toPluginPanel( + path: string, + options?: { subPath?: string; replace?: boolean }, + ): void; } // --------------------------------------------------------------------------- diff --git a/packages/templates/src/generated/plugin-sdk-dts.generated.ts b/packages/templates/src/generated/plugin-sdk-dts.generated.ts index eced5daea..e642505d5 100644 --- a/packages/templates/src/generated/plugin-sdk-dts.generated.ts +++ b/packages/templates/src/generated/plugin-sdk-dts.generated.ts @@ -2,6 +2,6 @@ // Generated by packages/templates/scripts/generate-templates.mjs from // @bb/plugin-sdk/bundled-types. Do not edit directly. -export const PLUGIN_SDK_DTS = "// Bundled type declarations for `@bb/plugin-sdk`, shipped into scaffolded\n// plugins so they typecheck without the @bb/* workspace on disk.\n//\n// Confused by the API, or need a symbol that isn't here? Clone the BB repo\n// and read the real source: https://github.com/ymichael/bb\n\nimport { ComponentType } from 'react';\nimport Database from 'better-sqlite3';\nimport { Context } from 'hono';\nimport * as z from 'zod';\nimport { z as z$1 } from 'zod';\n\n/**\n * The `@bb/plugin-sdk/app` contract (plugin design §5.2) — pure types plus\n * the runtime export-name list, with no side effects. This module is what the\n * BB app imports to keep its real implementation in sync (`satisfies\n * PluginSdkApp`) and what `bb plugin build` imports to generate the shim's\n * named-export list. Plugin authors import the same shapes through\n * `@bb/plugin-sdk/app`.\n *\n * Per-slot props are versioned contracts: additive-only within an SDK major.\n */\n/** Props passed to a `homepageSection` component. */\ninterface PluginHomepageSectionProps {\n /** Project in view on the compose surface; null when none is selected. */\n projectId: string | null;\n}\n/** Props passed to a `navPanel` component (it owns its whole route). */\ninterface PluginNavPanelProps {\n}\n/** Props passed to a panel tab opened by a `threadPanelAction`. */\ninterface PluginThreadPanelProps {\n threadId: string;\n /**\n * The JSON value the action's `openPanel` call passed (round-tripped\n * through persistence, so the tab restores across reloads); null when the\n * action opened the panel without params.\n */\n params: unknown;\n}\n/** Props passed to a `composerAccessory` component. */\ninterface PluginComposerAccessoryProps {\n projectId: string | null;\n threadId: string | null;\n}\n/**\n * Slot/panel ids and nav-panel paths must match this pattern (letters,\n * digits, `-`, `_`): they ride URLs and persisted panel-tab keys.\n */\ndeclare const PLUGIN_SLOT_ID_PATTERN: RegExp;\ninterface PluginHomepageSectionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n component: ComponentType;\n}\ninterface PluginNavPanelRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n /** Icon hint (BB icon name); unknown names fall back to a generic icon. */\n icon: string;\n /** URL segment under `/plugins//`; letters, digits, `-`, `_`. */\n path: string;\n component: ComponentType;\n /**\n * Panel chrome (default \"page\"): \"page\" renders the host title bar (plugin\n * logo + `title` + your `headerContent`) above a full-width padded body;\n * \"none\" hands the ENTIRE panel area to `component` — no host padding, no\n * title bar (`headerContent` is ignored) — only the per-plugin error\n * boundary remains.\n */\n chrome?: \"page\" | \"none\";\n /**\n * Optional component rendered on the right side of the \"page\" title bar\n * (e.g. a sync button or a count). Contained separately from the body: a\n * throwing headerContent is hidden without breaking the title bar.\n */\n headerContent?: ComponentType;\n}\n/** Context handed to a `threadPanelAction`'s `run`. */\ninterface PluginThreadPanelActionContext {\n /** The thread whose panel launcher invoked the action. */\n threadId: string;\n /**\n * Open a tab in the thread's side panel rendering this action's\n * `component`. `title` labels the tab (default: the action's `title`);\n * `params` must be JSON-serializable — it is persisted with the tab and\n * reaches the component as its `params` prop. Opening with params\n * identical to an already-open tab of this action focuses that tab\n * (updating its title) instead of duplicating it. May be called more than\n * once (different params ⇒ multiple tabs) or not at all.\n */\n openPanel(options?: {\n title?: string;\n params?: unknown;\n }): void;\n}\ninterface PluginThreadPanelActionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n /** Label of the action row in the panel's new-tab launcher. */\n title: string;\n /**\n * Icon hint (BB icon name) used when the plugin ships no logo; the\n * launcher row and opened tabs prefer the plugin's logo.\n */\n icon?: string;\n /** Rendered inside every panel tab this action opens. */\n component: ComponentType;\n /**\n * Runs when the user activates the action: call your RPC methods, show a\n * toast, and/or open panel tabs via `context.openPanel`. Omitted =\n * immediately open a panel tab with defaults. Errors (sync or async) are\n * contained and logged; they never break the launcher.\n */\n run?(context: PluginThreadPanelActionContext): void | Promise;\n}\ninterface PluginComposerAccessoryRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n component: ComponentType;\n}\ninterface PluginAppSlots {\n homepageSection(registration: PluginHomepageSectionRegistration): void;\n navPanel(registration: PluginNavPanelRegistration): void;\n threadPanelAction(registration: PluginThreadPanelActionRegistration): void;\n composerAccessory(registration: PluginComposerAccessoryRegistration): void;\n}\ninterface PluginAppBuilder {\n slots: PluginAppSlots;\n}\ntype PluginAppSetup = (app: PluginAppBuilder) => void;\n/**\n * The opaque product of `definePluginApp` — a plugin's `app.tsx` default\n * export. The host re-runs `setup` against a fresh collector on every\n * (re)interpretation, replacing that plugin's registrations wholesale.\n */\ninterface PluginAppDefinition {\n /** Brand the host checks before interpreting a bundle's default export. */\n readonly __bbPluginApp: true;\n readonly setup: PluginAppSetup;\n}\ninterface PluginRpcClient {\n /**\n * Invoke one of the plugin's `bb.rpc` methods (POST\n * /api/v1/plugins/<id>/rpc/<method>). Resolves with the method's\n * result; rejects with an `Error` carrying the server's message when the\n * handler fails or the plugin is not running.\n */\n call(method: string, input?: unknown): Promise;\n}\ninterface PluginSettingsState {\n /**\n * Effective non-secret setting values (secret settings are excluded —\n * read them server-side). Undefined while loading or unavailable.\n */\n values: Record | undefined;\n isLoading: boolean;\n}\n/** Current app selection, derived from the route. */\ninterface BbContext {\n projectId: string | null;\n threadId: string | null;\n}\ninterface BbNavigate {\n toThread(threadId: string): void;\n toProject(projectId: string): void;\n /** Navigate to one of this plugin's own nav panels by its `path`. */\n toPluginPanel(path: string): void;\n}\n/**\n * Everything `@bb/plugin-sdk/app` resolves to at runtime. The BB app builds\n * the real implementation and `satisfies` this interface; `bb plugin build`\n * shims the specifier to that object on `globalThis.__bbPluginRuntime`.\n */\ninterface PluginSdkApp {\n definePluginApp(setup: PluginAppSetup): PluginAppDefinition;\n useRpc(): PluginRpcClient;\n useRealtime(channel: string, handler: (payload: unknown) => void): void;\n useSettings(): PluginSettingsState;\n useBbContext(): BbContext;\n useBbNavigate(): BbNavigate;\n}\n/**\n * Named runtime exports of `@bb/plugin-sdk/app`, in sorted order. Single\n * source of truth for the build shim's export list and the app's\n * implementation-key test — adding a surface member without updating this\n * list fails the type assertion below.\n */\ndeclare const PLUGIN_SDK_APP_EXPORT_NAMES: readonly [\"definePluginApp\", \"useBbContext\", \"useBbNavigate\", \"useRealtime\", \"useRpc\", \"useSettings\"];\n\ndeclare const appThemeSchema: z$1.ZodObject<{\n themeId: z$1.ZodString;\n customCss: z$1.ZodNullable;\n faviconColor: z$1.ZodEnum<{\n default: \"default\";\n red: \"red\";\n orange: \"orange\";\n yellow: \"yellow\";\n green: \"green\";\n teal: \"teal\";\n blue: \"blue\";\n purple: \"purple\";\n pink: \"pink\";\n }>;\n}, z$1.core.$strip>;\ntype AppTheme = z$1.infer;\n\ndeclare const changedMessageSchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"thread\">;\n id: z$1.ZodOptional;\n metadata: z$1.ZodOptional;\n eventTypes: z$1.ZodOptional>>>>;\n hasPendingInteraction: z$1.ZodOptional;\n projectId: z$1.ZodOptional;\n }, z$1.core.$strict>>;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"project\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"environment\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"host\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"system\">;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>], \"entity\">;\ntype ChangedMessage = z$1.infer;\n\ndeclare const pendingInteractionResolutionSchema: z$1.ZodUnion;\n grantedPermissions: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n fileSystem: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n decision: z$1.ZodLiteral<\"allow_for_session\">;\n grantedPermissions: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n fileSystem: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n decision: z$1.ZodLiteral<\"deny\">;\n}, z$1.core.$strip>], \"decision\">, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"user_answer\">;\n answers: z$1.ZodRecord;\n freeText: z$1.ZodOptional;\n }, z$1.core.$strip>>;\n}, z$1.core.$strip>]>;\ntype PendingInteractionResolution = z$1.infer;\n\ndeclare const threadStatusSchema: z$1.ZodEnum<{\n error: \"error\";\n active: \"active\";\n starting: \"starting\";\n idle: \"idle\";\n stopping: \"stopping\";\n}>;\ntype ThreadStatus = z$1.infer;\n\ndeclare const threadTimelinePendingTodosSchema: z$1.ZodObject<{\n sourceSeq: z$1.ZodNumber;\n updatedAt: z$1.ZodNumber;\n items: z$1.ZodArray;\n }, z$1.core.$strip>>;\n}, z$1.core.$strip>;\ntype ThreadTimelinePendingTodos = z$1.infer;\n\ndeclare const createAutomationRequestSchema: z$1.ZodObject<{\n name: z$1.ZodString;\n enabled: z$1.ZodDefault;\n trigger: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n triggerType: z$1.ZodLiteral<\"schedule\">;\n cron: z$1.ZodString;\n timezone: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n triggerType: z$1.ZodLiteral<\"once\">;\n runAt: z$1.ZodNumber;\n }, z$1.core.$strip>], \"triggerType\">;\n execution: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"agent\">;\n prompt: z$1.ZodString;\n providerId: z$1.ZodString;\n model: z$1.ZodString;\n permissionMode: z$1.ZodEnum<{\n readonly: \"readonly\";\n full: \"full\";\n \"workspace-write\": \"workspace-write\";\n }>;\n targetThreadId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"script\">;\n script: z$1.ZodOptional;\n scriptFile: z$1.ZodOptional;\n interpreter: z$1.ZodOptional>;\n timeoutMs: z$1.ZodDefault;\n env: z$1.ZodOptional>;\n }, z$1.core.$strip>], \"mode\">;\n environment: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"reuse\">;\n environmentId: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"host\">;\n hostId: z$1.ZodOptional;\n workspace: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"unmanaged\">;\n path: z$1.ZodNullable;\n branch: z$1.ZodOptional;\n name: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"new\">;\n baseBranch: z$1.ZodString;\n }, z$1.core.$strict>], \"kind\">>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"managed-worktree\">;\n baseBranch: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"named\">;\n name: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"default\">;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"personal\">;\n }, z$1.core.$strip>], \"type\">;\n }, z$1.core.$strip>], \"type\">;\n autoArchive: z$1.ZodDefault;\n origin: z$1.ZodEnum<{\n agent: \"agent\";\n human: \"human\";\n app: \"app\";\n }>;\n createdByThreadId: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype CreateAutomationRequest = z$1.input;\ndeclare const updateAutomationRequestSchema: z$1.ZodObject<{\n name: z$1.ZodOptional;\n trigger: z$1.ZodOptional;\n cron: z$1.ZodString;\n timezone: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n triggerType: z$1.ZodLiteral<\"once\">;\n runAt: z$1.ZodNumber;\n }, z$1.core.$strip>], \"triggerType\">>;\n execution: z$1.ZodOptional;\n prompt: z$1.ZodString;\n providerId: z$1.ZodString;\n model: z$1.ZodString;\n permissionMode: z$1.ZodEnum<{\n readonly: \"readonly\";\n full: \"full\";\n \"workspace-write\": \"workspace-write\";\n }>;\n targetThreadId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"script\">;\n script: z$1.ZodOptional;\n scriptFile: z$1.ZodOptional;\n interpreter: z$1.ZodOptional>;\n timeoutMs: z$1.ZodDefault;\n env: z$1.ZodOptional>;\n }, z$1.core.$strip>], \"mode\">>;\n environment: z$1.ZodOptional;\n environmentId: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"host\">;\n hostId: z$1.ZodOptional;\n workspace: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"unmanaged\">;\n path: z$1.ZodNullable;\n branch: z$1.ZodOptional;\n name: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"new\">;\n baseBranch: z$1.ZodString;\n }, z$1.core.$strict>], \"kind\">>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"managed-worktree\">;\n baseBranch: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"named\">;\n name: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"default\">;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"personal\">;\n }, z$1.core.$strip>], \"type\">;\n }, z$1.core.$strip>], \"type\">>;\n autoArchive: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype UpdateAutomationRequest = z$1.infer;\ndeclare const runAutomationRequestSchema: z$1.ZodObject<{\n idempotencyKey: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype RunAutomationRequest = z$1.infer;\n\ndeclare const createProjectSourceRequestSchema: z$1.ZodObject<{\n hostId: z$1.ZodString;\n type: z$1.ZodLiteral<\"local_path\">;\n path: z$1.ZodPipe>;\n}, z$1.core.$strict>;\ntype CreateProjectSourceRequest = z$1.infer;\ndeclare const createProjectRequestSchema: z$1.ZodObject<{\n name: z$1.ZodString;\n source: z$1.ZodObject<{\n hostId: z$1.ZodString;\n type: z$1.ZodLiteral<\"local_path\">;\n path: z$1.ZodPipe>;\n }, z$1.core.$strict>;\n}, z$1.core.$strip>;\ntype CreateProjectRequest = z$1.infer;\ndeclare const projectListQuerySchema: z$1.ZodObject<{\n include: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype ProjectListQuery = z$1.infer;\ndeclare const updateProjectRequestSchema: z$1.ZodObject<{\n name: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype UpdateProjectRequest = z$1.infer;\ndeclare const updateProjectSourceRequestSchema: z$1.ZodObject<{\n type: z$1.ZodLiteral<\"local_path\">;\n path: z$1.ZodOptional>>;\n isDefault: z$1.ZodOptional>;\n}, z$1.core.$strict>;\ntype UpdateProjectSourceRequest = z$1.infer;\n\ndeclare const updateEnvironmentRequestSchema: z$1.ZodObject<{\n mergeBaseBranch: z$1.ZodOptional>;\n name: z$1.ZodOptional>;\n}, z$1.core.$strip>;\ntype UpdateEnvironmentRequest = z$1.infer;\ndeclare const environmentDiffBranchesQuerySchema: z$1.ZodObject<{\n query: z$1.ZodOptional;\n limit: z$1.ZodOptional;\n selectedBranch: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype EnvironmentDiffBranchesQuery = z$1.infer;\ndeclare const environmentStatusQuerySchema: z$1.ZodObject<{\n mergeBaseBranch: z$1.ZodOptional>;\n}, z$1.core.$strip>;\ntype EnvironmentStatusQuery = z$1.infer;\ndeclare const environmentDiffQuerySchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n target: z$1.ZodLiteral<\"uncommitted\">;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"branch_committed\">;\n mergeBaseBranch: z$1.ZodPipe;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"all\">;\n mergeBaseBranch: z$1.ZodPipe;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"commit\">;\n sha: z$1.ZodString;\n}, z$1.core.$strip>], \"target\">;\ntype EnvironmentDiffQuery = z$1.infer;\n/**\n * Query for fetching a single file's contents at one side of a diff target.\n * Used by the diff card to reparse the card's patch with full old/new contents\n * so `@pierre/diffs` can render expand-context buttons between hunks.\n *\n * For `branch_committed` / `all`, callers pass the resolved merge-base SHA\n * (`mergeBaseRef`, surfaced by `workspace.diff`) rather than the branch name\n * — the diff itself was computed against that SHA, so reading the old side\n * from the same SHA keeps the file content aligned with the hunk line\n * numbers. Reading from the branch tip is wrong whenever the branch has\n * moved past the merge-base since the file existed there.\n */\ndeclare const environmentDiffFileQuerySchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n target: z$1.ZodLiteral<\"uncommitted\">;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"branch_committed\">;\n mergeBaseRef: z$1.ZodString;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"all\">;\n mergeBaseRef: z$1.ZodString;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"commit\">;\n sha: z$1.ZodString;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>], \"target\">;\ntype EnvironmentDiffFileQuery = z$1.infer;\n\ndeclare const systemExecutionOptionsQuerySchema: z$1.ZodObject<{\n providerId: z$1.ZodOptional;\n hostId: z$1.ZodOptional;\n environmentId: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype SystemExecutionOptionsQuery = z$1.infer;\n/**\n * Theme catalog: the on-disk custom-theme directory plus the discovered custom\n * themes and the active palette. Drives `bb theme list` / `bb theme dir`.\n */\ndeclare const themeCatalogResponseSchema: z$1.ZodObject<{\n dir: z$1.ZodString;\n custom: z$1.ZodArray;\n active: z$1.ZodObject<{\n themeId: z$1.ZodString;\n customCss: z$1.ZodNullable;\n faviconColor: z$1.ZodEnum<{\n default: \"default\";\n red: \"red\";\n orange: \"orange\";\n yellow: \"yellow\";\n green: \"green\";\n teal: \"teal\";\n blue: \"blue\";\n purple: \"purple\";\n pink: \"pink\";\n }>;\n }, z$1.core.$strip>;\n}, z$1.core.$strip>;\ntype ThemeCatalogResponse = z$1.infer;\n\ndeclare const createTerminalRequestSchema: z$1.ZodObject<{\n cols: z$1.ZodNumber;\n rows: z$1.ZodNumber;\n start: z$1.ZodOptional;\n }, z$1.core.$strict>, z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"command\">;\n command: z$1.ZodString;\n }, z$1.core.$strict>], \"mode\">>;\n target: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"thread\">;\n threadId: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"environment\">;\n environmentId: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"host_path\">;\n hostId: z$1.ZodString;\n cwd: z$1.ZodNullable;\n }, z$1.core.$strict>], \"kind\">;\n title: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype CreateTerminalRequest = z$1.infer;\ndeclare const closeTerminalRequestSchema: z$1.ZodObject<{\n mode: z$1.ZodEnum<{\n force: \"force\";\n \"if-clean\": \"if-clean\";\n }>;\n reason: z$1.ZodLiteral<\"user\">;\n}, z$1.core.$strict>;\ntype CloseTerminalRequest = z$1.infer;\ndeclare const updateTerminalRequestSchema: z$1.ZodObject<{\n title: z$1.ZodString;\n}, z$1.core.$strict>;\ntype UpdateTerminalRequest = z$1.infer;\ndeclare const terminalInputRequestSchema: z$1.ZodObject<{\n dataBase64: z$1.ZodString;\n}, z$1.core.$strict>;\ntype TerminalInputRequest = z$1.infer;\ndeclare const terminalResizeRequestSchema: z$1.ZodObject<{\n cols: z$1.ZodNumber;\n rows: z$1.ZodNumber;\n}, z$1.core.$strict>;\ntype TerminalResizeRequest = z$1.infer;\ndeclare const terminalOutputQuerySchema: z$1.ZodObject<{\n sinceSeq: z$1.ZodOptional>;\n tailBytes: z$1.ZodOptional>;\n limitChunks: z$1.ZodOptional>;\n}, z$1.core.$strict>;\ntype TerminalOutputQuery = z$1.infer;\n\ndeclare const createThreadRequestSchema: z$1.ZodObject<{\n projectId: z$1.ZodString;\n providerId: z$1.ZodOptional;\n origin: z$1.ZodEnum<{\n plugin: \"plugin\";\n app: \"app\";\n automation: \"automation\";\n cli: \"cli\";\n sdk: \"sdk\";\n }>;\n originPluginId: z$1.ZodOptional;\n title: z$1.ZodOptional;\n input: z$1.ZodArray>;\n type: z$1.ZodLiteral<\"text\">;\n text: z$1.ZodString;\n mentions: z$1.ZodDefault;\n threadId: z$1.ZodString;\n projectId: z$1.ZodOptional;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"project\">;\n projectId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"path\">;\n source: z$1.ZodEnum<{\n workspace: \"workspace\";\n \"thread-storage\": \"thread-storage\";\n }>;\n entryKind: z$1.ZodEnum<{\n file: \"file\";\n directory: \"directory\";\n }>;\n path: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"command\">;\n trigger: z$1.ZodEnum<{\n \"/\": \"/\";\n }>;\n name: z$1.ZodString;\n source: z$1.ZodEnum<{\n command: \"command\";\n skill: \"skill\";\n }>;\n origin: z$1.ZodEnum<{\n user: \"user\";\n project: \"project\";\n builtin: \"builtin\";\n }>;\n label: z$1.ZodString;\n argumentHint: z$1.ZodNullable;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"plugin\">;\n pluginId: z$1.ZodString;\n itemId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>>>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"image\">;\n url: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localImage\">;\n path: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localFile\">;\n path: z$1.ZodString;\n name: z$1.ZodOptional;\n sizeBytes: z$1.ZodOptional;\n mimeType: z$1.ZodOptional;\n }, z$1.core.$strip>], \"type\">>;\n model: z$1.ZodOptional;\n serviceTier: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>;\n permissionMode: z$1.ZodOptional>;\n executionInputSources: z$1.ZodOptional>;\n model: z$1.ZodOptional>;\n serviceTier: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>;\n permissionMode: z$1.ZodOptional>;\n }, z$1.core.$strict>>;\n environment: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"reuse\">;\n environmentId: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"host\">;\n hostId: z$1.ZodOptional;\n workspace: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"unmanaged\">;\n path: z$1.ZodNullable;\n branch: z$1.ZodOptional;\n name: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"new\">;\n baseBranch: z$1.ZodString;\n }, z$1.core.$strict>], \"kind\">>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"managed-worktree\">;\n baseBranch: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"named\">;\n name: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"default\">;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"personal\">;\n }, z$1.core.$strip>], \"type\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"project-default\">;\n }, z$1.core.$strip>], \"type\">;\n parentThreadId: z$1.ZodOptional;\n folderId: z$1.ZodOptional>;\n sourceThreadId: z$1.ZodOptional;\n sourceSeqEnd: z$1.ZodOptional;\n startedOnBehalfOf: z$1.ZodDefault;\n senderThreadId: z$1.ZodString;\n }, z$1.core.$strip>>>;\n originKind: z$1.ZodDefault>>;\n childOrigin: z$1.ZodDefault>>;\n}, z$1.core.$strip>;\ntype CreateThreadRequest = z$1.infer;\ndeclare const sendMessageRequestSchema: z$1.ZodObject<{\n input: z$1.ZodArray>;\n type: z$1.ZodLiteral<\"text\">;\n text: z$1.ZodString;\n mentions: z$1.ZodDefault;\n threadId: z$1.ZodString;\n projectId: z$1.ZodOptional;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"project\">;\n projectId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"path\">;\n source: z$1.ZodEnum<{\n workspace: \"workspace\";\n \"thread-storage\": \"thread-storage\";\n }>;\n entryKind: z$1.ZodEnum<{\n file: \"file\";\n directory: \"directory\";\n }>;\n path: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"command\">;\n trigger: z$1.ZodEnum<{\n \"/\": \"/\";\n }>;\n name: z$1.ZodString;\n source: z$1.ZodEnum<{\n command: \"command\";\n skill: \"skill\";\n }>;\n origin: z$1.ZodEnum<{\n user: \"user\";\n project: \"project\";\n builtin: \"builtin\";\n }>;\n label: z$1.ZodString;\n argumentHint: z$1.ZodNullable;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"plugin\">;\n pluginId: z$1.ZodString;\n itemId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>>>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"image\">;\n url: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localImage\">;\n path: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localFile\">;\n path: z$1.ZodString;\n name: z$1.ZodOptional;\n sizeBytes: z$1.ZodOptional;\n mimeType: z$1.ZodOptional;\n }, z$1.core.$strip>], \"type\">>;\n model: z$1.ZodOptional;\n serviceTier: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>;\n permissionMode: z$1.ZodOptional>;\n executionInputSources: z$1.ZodOptional>;\n serviceTier: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>;\n permissionMode: z$1.ZodOptional>;\n }, z$1.core.$strict>>;\n mode: z$1.ZodEnum<{\n steer: \"steer\";\n start: \"start\";\n auto: \"auto\";\n \"queue-if-active\": \"queue-if-active\";\n \"steer-if-active\": \"steer-if-active\";\n }>;\n senderThreadId: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype SendMessageRequest = z$1.infer;\ndeclare const threadResponseSchema: z$1.ZodObject<{\n id: z$1.ZodString;\n projectId: z$1.ZodString;\n environmentId: z$1.ZodNullable;\n providerId: z$1.ZodString;\n title: z$1.ZodNullable;\n titleFallback: z$1.ZodNullable;\n folderId: z$1.ZodNullable;\n status: z$1.ZodEnum<{\n error: \"error\";\n stopping: \"stopping\";\n idle: \"idle\";\n starting: \"starting\";\n active: \"active\";\n }>;\n parentThreadId: z$1.ZodNullable;\n sourceThreadId: z$1.ZodNullable;\n originKind: z$1.ZodNullable>;\n childOrigin: z$1.ZodNullable>;\n originPluginId: z$1.ZodNullable;\n archivedAt: z$1.ZodNullable;\n pinnedAt: z$1.ZodNullable;\n deletedAt: z$1.ZodNullable;\n lastReadAt: z$1.ZodNullable;\n latestAttentionAt: z$1.ZodNumber;\n createdAt: z$1.ZodNumber;\n updatedAt: z$1.ZodNumber;\n runtime: z$1.ZodObject<{\n displayStatus: z$1.ZodEnum<{\n error: \"error\";\n provisioning: \"provisioning\";\n stopping: \"stopping\";\n idle: \"idle\";\n starting: \"starting\";\n active: \"active\";\n \"host-reconnecting\": \"host-reconnecting\";\n \"waiting-for-host\": \"waiting-for-host\";\n }>;\n hostReconnectGraceExpiresAt: z$1.ZodNullable;\n }, z$1.core.$strip>;\n canSpawnChild: z$1.ZodBoolean;\n}, z$1.core.$strip>;\ntype ThreadResponse = z$1.infer;\ndeclare const threadGetQuerySchema: z$1.ZodObject<{\n include: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype ThreadGetQuery = z$1.infer;\ndeclare const deleteThreadRequestSchema: z$1.ZodObject<{\n childThreadsConfirmed: z$1.ZodBoolean;\n}, z$1.core.$strip>;\ntype DeleteThreadRequest = z$1.infer;\ndeclare const updateThreadRequestSchema: z$1.ZodObject<{\n title: z$1.ZodOptional>;\n folderId: z$1.ZodOptional>;\n parentThreadId: z$1.ZodOptional>;\n model: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>>;\n}, z$1.core.$strip>;\ntype UpdateThreadRequest = z$1.infer;\n/** Which root a secondary-panel file path is relative to. */\ndeclare const panelFileSourceSchema: z$1.ZodEnum<{\n workspace: \"workspace\";\n \"thread-storage\": \"thread-storage\";\n}>;\ntype PanelFileSource = z$1.infer;\ndeclare const threadTimelineQuerySchema: z$1.ZodObject<{\n includeNestedRows: z$1.ZodOptional>;\n segmentLimit: z$1.ZodOptional;\n beforeAnchorSeq: z$1.ZodOptional;\n beforeAnchorId: z$1.ZodOptional;\n summaryOnly: z$1.ZodOptional>;\n afterSequence: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype ThreadTimelineQuery = z$1.infer;\n\ntype PublicApiSchema = unknown;\ntype ApiClient = unknown;\n\ntype FetchImplementation = typeof fetch;\ntype JsonBodyOf = TResponse extends {\n json(): Promise;\n} ? TBody : never;\n\ntype BbSdkRuntime = \"node\" | \"browser\";\ninterface BbSdkTransport {\n api: ApiClient[\"api\"];\n baseUrl: string;\n fetch: FetchImplementation;\n realtimeUrl?: string;\n runtime: BbSdkRuntime;\n readJson(response: Promise): Promise>;\n readVoid(response: Promise): Promise;\n resolve(response: Promise): Promise;\n websocket?: BbRealtimeSocketFactory;\n}\n/**\n * Raw socket payload. Treated as opaque until decoded — realtime ignores\n * non-string frames.\n */\ninterface BbRealtimeSocketMessageEvent {\n data: unknown;\n}\n/**\n * Minimal runtime-agnostic socket shape consumed by the realtime client.\n * Default factories adapt the environment's WebSocket (browser/Node global,\n * or the `ws` package on older Node) to this interface; custom `websocket`\n * factories can wrap any implementation the same way.\n */\ninterface BbRealtimeSocket {\n close(): void;\n onclose: (() => void) | null;\n onerror: (() => void) | null;\n onmessage: ((event: BbRealtimeSocketMessageEvent) => void) | null;\n onopen: (() => void) | null;\n readyState: number;\n send(data: string): void;\n}\ntype BbRealtimeSocketFactory = (url: string) => BbRealtimeSocket;\ninterface BbSdkContext {\n}\n\ninterface CreateSdkAreaArgs {\n context: BbSdkContext;\n transport: BbSdkTransport;\n}\ntype PublicApiEndpointOutput = TEndpoint extends {\n status: infer Status;\n output: infer Output;\n} ? Status extends SuccessfulHttpStatus ? Output : never : never;\ntype SuccessfulHttpStatus = 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226;\ntype PublicApiOutput = PublicApiEndpointOutput;\n\ninterface AutomationCreateArgs extends CreateAutomationRequest {\n projectId?: string;\n}\ninterface AutomationListArgs {\n projectId?: string;\n}\ninterface AutomationGetArgs {\n projectId?: string;\n automationId: string;\n}\ninterface AutomationUpdateArgs extends UpdateAutomationRequest {\n projectId?: string;\n automationId: string;\n}\ninterface AutomationActionArgs {\n projectId?: string;\n automationId: string;\n}\ninterface AutomationRunArgs extends RunAutomationRequest {\n projectId?: string;\n automationId: string;\n}\ninterface AutomationRunsArgs {\n projectId?: string;\n automationId: string;\n limit?: number;\n cursor?: string;\n}\ntype AutomationCreateResult = PublicApiOutput<\"/projects/:id/automations\", \"$post\">;\ntype AutomationListResult = PublicApiOutput<\"/projects/:id/automations\", \"$get\">;\ntype AutomationGetResult = PublicApiOutput<\"/projects/:id/automations/:automationId\", \"$get\">;\ntype AutomationUpdateResult = PublicApiOutput<\"/projects/:id/automations/:automationId\", \"$patch\">;\ntype AutomationPauseResult = PublicApiOutput<\"/projects/:id/automations/:automationId/pause\", \"$post\">;\ntype AutomationResumeResult = PublicApiOutput<\"/projects/:id/automations/:automationId/resume\", \"$post\">;\ntype AutomationRunResult = PublicApiOutput<\"/projects/:id/automations/:automationId/run\", \"$post\">;\ntype AutomationRunsResult = PublicApiOutput<\"/projects/:id/automations/:automationId/runs\", \"$get\">;\ntype AutomationsOverviewResult = PublicApiOutput<\"/automations\", \"$get\">;\ninterface AutomationsArea {\n create(args: AutomationCreateArgs): Promise;\n delete(args: AutomationActionArgs): Promise<{\n ok: true;\n }>;\n get(args: AutomationGetArgs): Promise;\n list(args?: AutomationListArgs): Promise;\n overview(): Promise;\n pause(args: AutomationActionArgs): Promise;\n resume(args: AutomationActionArgs): Promise;\n run(args: AutomationRunArgs): Promise;\n runs(args: AutomationRunsArgs): Promise;\n update(args: AutomationUpdateArgs): Promise;\n}\ndeclare function createAutomationsArea(args: CreateSdkAreaArgs): AutomationsArea;\n\ninterface EnvironmentGetArgs {\n environmentId: string;\n}\ntype EnvironmentMergeBaseBranchUpdateValue = Exclude;\ntype EnvironmentNameUpdateValue = Exclude;\ninterface EnvironmentMergeBaseBranchUpdate {\n mergeBaseBranch: EnvironmentMergeBaseBranchUpdateValue;\n name?: EnvironmentNameUpdateValue;\n}\ninterface EnvironmentNameUpdate {\n mergeBaseBranch?: EnvironmentMergeBaseBranchUpdateValue;\n name: EnvironmentNameUpdateValue;\n}\ntype EnvironmentUpdateFields = EnvironmentMergeBaseBranchUpdate | EnvironmentNameUpdate;\ntype EnvironmentUpdateArgs = EnvironmentUpdateFields & {\n environmentId: string;\n};\ninterface EnvironmentStatusArgs extends EnvironmentStatusQuery {\n environmentId: string;\n}\ntype EnvironmentDiffArgs = EnvironmentDiffQuery & {\n environmentId: string;\n};\ntype EnvironmentDiffFileArgs = EnvironmentDiffFileQuery & {\n environmentId: string;\n};\ninterface EnvironmentDiffBranchesArgs extends EnvironmentDiffBranchesQuery {\n environmentId: string;\n}\ninterface EnvironmentCommitArgs {\n environmentId: string;\n}\ninterface EnvironmentSquashMergeArgs {\n environmentId: string;\n mergeBaseBranch: string;\n}\ntype EnvironmentActionResult = PublicApiOutput<\"/environments/:id/actions\", \"$post\">;\ntype EnvironmentCommitResult = Extract;\ntype EnvironmentDiffResult = PublicApiOutput<\"/environments/:id/diff\", \"$get\">;\ntype EnvironmentDiffBranchesResult = PublicApiOutput<\"/environments/:id/diff/branches\", \"$get\">;\ntype EnvironmentDiffFileResult = PublicApiOutput<\"/environments/:id/diff/file\", \"$get\">;\ntype EnvironmentGetResult = PublicApiOutput<\"/environments/:id\", \"$get\">;\ntype EnvironmentPullRequestResult = PublicApiOutput<\"/environments/:id/pull-request\", \"$get\">;\ntype EnvironmentSquashMergeResult = Extract;\ntype EnvironmentStatusResult = PublicApiOutput<\"/environments/:id/status\", \"$get\">;\ntype EnvironmentUpdateResult = PublicApiOutput<\"/environments/:id\", \"$patch\">;\ninterface EnvironmentsArea {\n commit(args: EnvironmentCommitArgs): Promise;\n diff(args: EnvironmentDiffArgs): Promise;\n diffBranches(args: EnvironmentDiffBranchesArgs): Promise;\n diffFile(args: EnvironmentDiffFileArgs): Promise;\n get(args: EnvironmentGetArgs): Promise;\n pullRequest(args: EnvironmentGetArgs): Promise;\n squashMerge(args: EnvironmentSquashMergeArgs): Promise;\n status(args: EnvironmentStatusArgs): Promise;\n update(args: EnvironmentUpdateArgs): Promise;\n}\ndeclare function createEnvironmentsArea(args: CreateSdkAreaArgs): EnvironmentsArea;\n\ninterface GuideRenderArgs {\n chapter?: string;\n}\ninterface GuideRenderResult {\n chapter?: string;\n content: string;\n}\ninterface GuideArea {\n render(args?: GuideRenderArgs): GuideRenderResult;\n}\ndeclare function createGuideArea(): GuideArea;\n\ninterface HostGetArgs {\n hostId: string;\n}\ntype HostGetResult = PublicApiOutput<\"/hosts/:id\", \"$get\">;\ntype HostListResult = PublicApiOutput<\"/hosts\", \"$get\">;\ninterface HostsArea {\n get(args: HostGetArgs): Promise;\n list(): Promise;\n}\ndeclare function createHostsArea(args: CreateSdkAreaArgs): HostsArea;\n\ninterface ProjectListArgs extends ProjectListQuery {\n}\ninterface ProjectCreateArgs extends CreateProjectRequest {\n}\ninterface ProjectGetArgs {\n projectId: string;\n}\ninterface ProjectUpdateArgs extends UpdateProjectRequest {\n projectId: string;\n}\ninterface ProjectDeleteArgs {\n projectId: string;\n}\ninterface ProjectSourceAddArgs extends CreateProjectSourceRequest {\n projectId: string;\n}\ninterface ProjectSourceUpdateArgs extends UpdateProjectSourceRequest {\n projectId: string;\n sourceId: string;\n}\ninterface ProjectSourceDeleteArgs {\n projectId: string;\n sourceId: string;\n}\ntype ProjectCreateResult = PublicApiOutput<\"/projects\", \"$post\">;\ntype ProjectDeleteResult = PublicApiOutput<\"/projects/:id\", \"$delete\">;\ntype ProjectGetResult = PublicApiOutput<\"/projects/:id\", \"$get\">;\ntype ProjectListResult = PublicApiOutput<\"/projects\", \"$get\">;\ntype ProjectUpdateResult = PublicApiOutput<\"/projects/:id\", \"$patch\">;\ntype ProjectSourceAddResult = PublicApiOutput<\"/projects/:id/sources\", \"$post\">;\ntype ProjectSourceUpdateResult = PublicApiOutput<\"/projects/:id/sources/:sourceId\", \"$patch\">;\ntype ProjectSourceDeleteResult = PublicApiOutput<\"/projects/:id/sources/:sourceId\", \"$delete\">;\ninterface ProjectSourcesArea {\n add(args: ProjectSourceAddArgs): Promise;\n delete(args: ProjectSourceDeleteArgs): Promise;\n update(args: ProjectSourceUpdateArgs): Promise;\n}\ninterface ProjectsArea {\n create(args: ProjectCreateArgs): Promise;\n delete(args: ProjectDeleteArgs): Promise;\n get(args: ProjectGetArgs): Promise;\n list(args?: ProjectListArgs): Promise;\n sources: ProjectSourcesArea;\n update(args: ProjectUpdateArgs): Promise;\n}\ndeclare function createProjectsArea(args: CreateSdkAreaArgs): ProjectsArea;\n\ninterface ProviderModelsArgs extends SystemExecutionOptionsQuery {\n}\ntype ProviderListResult = PublicApiOutput<\"/system/providers\", \"$get\">;\ntype ProviderModelsResult = PublicApiOutput<\"/system/execution-options\", \"$get\">;\ninterface ProvidersArea {\n list(): Promise;\n models(args?: ProviderModelsArgs): Promise;\n}\ndeclare function createProvidersArea(args: CreateSdkAreaArgs): ProvidersArea;\n\ntype BbRealtimeUnsubscribe = () => void;\ntype BbRealtimeEventName = \"thread:changed\" | \"project:changed\" | \"environment:changed\" | \"host:changed\" | \"system:changed\" | \"system:config-changed\" | \"realtime:connection\";\ntype ThreadRealtimeEvent = Extract;\ntype ProjectRealtimeEvent = Extract;\ntype EnvironmentRealtimeEvent = Extract;\ntype HostRealtimeEvent = Extract;\ntype SystemRealtimeEvent = Extract;\ntype BbRealtimeConnectionState = \"connecting\" | \"connected\" | \"disconnected\";\ninterface BbRealtimeConnectionEvent {\n reconnectDelayMs: number | null;\n reconnected: boolean;\n state: BbRealtimeConnectionState;\n}\n/**\n * Entity-changed events are delivered as one shared object to every matching\n * listener; their payload types are readonly so a listener cannot mutate what\n * the next listener receives.\n */\ninterface BbRealtimeEventMap {\n \"thread:changed\": ThreadRealtimeEvent;\n \"project:changed\": ProjectRealtimeEvent;\n \"environment:changed\": EnvironmentRealtimeEvent;\n \"host:changed\": HostRealtimeEvent;\n \"system:changed\": SystemRealtimeEvent;\n \"system:config-changed\": SystemRealtimeEvent;\n \"realtime:connection\": BbRealtimeConnectionEvent;\n}\ntype BbRealtimeCallback = (event: BbRealtimeEventMap[TEventName]) => void;\ninterface ThreadRealtimeOnArgs {\n callback: BbRealtimeCallback<\"thread:changed\">;\n event: \"thread:changed\";\n threadId?: string;\n}\ninterface ProjectRealtimeOnArgs {\n callback: BbRealtimeCallback<\"project:changed\">;\n event: \"project:changed\";\n projectId?: string;\n}\ninterface EnvironmentRealtimeOnArgs {\n callback: BbRealtimeCallback<\"environment:changed\">;\n environmentId?: string;\n event: \"environment:changed\";\n}\ninterface HostRealtimeOnArgs {\n callback: BbRealtimeCallback<\"host:changed\">;\n event: \"host:changed\";\n hostId?: string;\n}\ninterface SystemRealtimeOnArgs {\n callback: BbRealtimeCallback<\"system:changed\">;\n event: \"system:changed\";\n}\ninterface SystemConfigRealtimeOnArgs {\n callback: BbRealtimeCallback<\"system:config-changed\">;\n event: \"system:config-changed\";\n}\n/**\n * Connection listeners are pure observers — they never open or hold the\n * socket. A listener registered while a socket already exists receives the\n * latest connection event as a snapshot on the next microtask, so a status\n * UI mounted after connect still learns the current state.\n */\ninterface RealtimeConnectionOnArgs {\n callback: BbRealtimeCallback<\"realtime:connection\">;\n event: \"realtime:connection\";\n}\ntype BbRealtimeOnArgsUnion = ThreadRealtimeOnArgs | ProjectRealtimeOnArgs | EnvironmentRealtimeOnArgs | HostRealtimeOnArgs | SystemRealtimeOnArgs | SystemConfigRealtimeOnArgs | RealtimeConnectionOnArgs;\ntype BbRealtimeOnArgs = Extract;\ninterface BbRealtime {\n on(args: BbRealtimeOnArgs): BbRealtimeUnsubscribe;\n}\n\ninterface StatusGetArgs {\n projectId?: string;\n threadId?: string;\n}\ninterface StatusThreadSummary {\n environmentId: string | null;\n id: string;\n parentThreadId: string | null;\n pinnedAt: number | null;\n projectId: string;\n status: ThreadStatus;\n title: string | null;\n}\ntype StatusProject = PublicApiOutput<\"/projects/:id\", \"$get\">;\ntype StatusChildThreads = PublicApiOutput<\"/threads\", \"$get\">;\ninterface StatusResult {\n childThreads: StatusChildThreads | null;\n pendingTodos: ThreadTimelinePendingTodos | null;\n project: StatusProject | null;\n thread: StatusThreadSummary | null;\n}\ninterface StatusArea {\n get(args?: StatusGetArgs): Promise;\n}\ndeclare function createStatusArea(args: CreateSdkAreaArgs): StatusArea;\n\ninterface ThemeArea {\n /** The active app palette, resolved server-side (built-in id or custom CSS). */\n get(): Promise;\n /** The custom-theme directory plus discovered themes and the active palette. */\n catalog(): Promise;\n /**\n * Activate a palette by id — a built-in id or a custom theme name that exists\n * under `/theme//theme.css`. Broadcasts to all open windows.\n */\n set(themeId: string): Promise;\n}\ndeclare function createThemeArea(args: CreateSdkAreaArgs): ThemeArea;\n\ninterface ThreadListArgs {\n archived?: boolean;\n hasParent?: boolean;\n parentThreadId?: string;\n projectId?: string;\n}\ninterface ThreadGetArgs {\n include?: ThreadGetQuery[\"include\"];\n threadId: string;\n}\ntype ThreadGetResult = PublicApiOutput<\"/threads/:id\", \"$get\">;\ntype ThreadListResult = PublicApiOutput<\"/threads\", \"$get\">;\ntype ThreadOutputResponse = PublicApiOutput<\"/threads/:id/output\", \"$get\">;\ntype ThreadMutationResult = PublicApiOutput<\"/threads/:id\", \"$patch\">;\ntype ThreadSpawnResult = PublicApiOutput<\"/threads\", \"$post\">;\ntype ThreadInteractionGetResult = PublicApiOutput<\"/threads/:id/interactions/:interactionId\", \"$get\">;\ntype ThreadInteractionListResult = PublicApiOutput<\"/threads/:id/interactions\", \"$get\">;\ntype ThreadInteractionResolveResult = PublicApiOutput<\"/threads/:id/interactions/:interactionId/resolve\", \"$post\">;\ntype ThreadEventsListResult = PublicApiOutput<\"/threads/:id/events\", \"$get\">;\ntype ThreadEventWaitResult = PublicApiOutput<\"/threads/:id/events/wait\", \"$get\">;\ntype ThreadTimelineResult = PublicApiOutput<\"/threads/:id/timeline\", \"$get\">;\ntype ThreadArchiveResult = PublicApiOutput<\"/threads/:id/archive\", \"$post\">;\ntype ThreadOpenResult = PublicApiOutput<\"/threads/:id/open\", \"$post\">;\ntype ThreadDeleteResult = PublicApiOutput<\"/threads/:id\", \"$delete\">;\ntype ThreadSendResult = PublicApiOutput<\"/threads/:id/send\", \"$post\">;\ntype ThreadStopResult = PublicApiOutput<\"/threads/:id/stop\", \"$post\">;\ntype ThreadTerminalCloseResult = PublicApiOutput<\"/terminals/:terminalId/close\", \"$post\">;\ntype ThreadTerminalCreateResult = PublicApiOutput<\"/terminals\", \"$post\">;\ntype ThreadTerminalInputResult = PublicApiOutput<\"/terminals/:terminalId/input\", \"$post\">;\ntype ThreadTerminalListResult = PublicApiOutput<\"/terminals\", \"$get\">;\ntype ThreadTerminalOutputResult = PublicApiOutput<\"/terminals/:terminalId/output\", \"$get\">;\ntype ThreadTerminalResizeResult = PublicApiOutput<\"/terminals/:terminalId/resize\", \"$post\">;\ntype ThreadTerminalUpdateResult = PublicApiOutput<\"/terminals/:terminalId\", \"$patch\">;\ntype ThreadUnarchiveResult = PublicApiOutput<\"/threads/:id/unarchive\", \"$post\">;\ninterface ThreadSpawnBaseArgs extends Omit {\n childOrigin?: CreateThreadRequest[\"childOrigin\"];\n origin?: CreateThreadRequest[\"origin\"];\n originKind?: CreateThreadRequest[\"originKind\"];\n startedOnBehalfOf?: CreateThreadRequest[\"startedOnBehalfOf\"];\n}\ntype ThreadSpawnArgs = ThreadSpawnBaseArgs & ({\n input: CreateThreadRequest[\"input\"];\n prompt?: never;\n} | {\n input?: never;\n prompt: string;\n});\ninterface ThreadUpdateArgs extends UpdateThreadRequest {\n threadId: string;\n}\ninterface ThreadDeleteArgs extends DeleteThreadRequest {\n threadId: string;\n}\ninterface ThreadSendArgs extends SendMessageRequest {\n threadId: string;\n}\ninterface ThreadStatusArgs {\n threadId: string;\n}\ninterface ThreadOpenArgs {\n threadId: string;\n source: PanelFileSource;\n path: string;\n lineNumber: number | null;\n}\ninterface ThreadEventsListArgs {\n afterSeq?: string;\n limit?: string;\n threadId: string;\n}\ninterface ThreadEventWaitArgs {\n afterSeq?: string;\n threadId: string;\n type: string;\n waitMs: string;\n}\ninterface ThreadTimelineArgs extends ThreadTimelineQuery {\n threadId: string;\n}\ninterface ThreadOutputArgs {\n threadId: string;\n}\ninterface ThreadTerminalListArgs {\n threadId: string;\n}\ninterface ThreadTerminalCreateArgs extends Omit {\n threadId: string;\n}\ninterface ThreadTerminalTargetArgs {\n terminalId: string;\n threadId: string;\n}\ninterface ThreadTerminalUpdateArgs extends ThreadTerminalTargetArgs, UpdateTerminalRequest {\n}\ninterface ThreadTerminalCloseArgs extends ThreadTerminalTargetArgs, CloseTerminalRequest {\n}\ninterface ThreadTerminalInputArgs extends ThreadTerminalTargetArgs, TerminalInputRequest {\n}\ninterface ThreadTerminalResizeArgs extends ThreadTerminalTargetArgs, TerminalResizeRequest {\n}\ninterface ThreadTerminalOutputArgs extends ThreadTerminalTargetArgs, TerminalOutputQuery {\n}\ninterface ThreadInteractionListArgs {\n threadId: string;\n}\ninterface ThreadInteractionGetArgs extends ThreadInteractionListArgs {\n interactionId: string;\n}\ninterface ThreadInteractionResolveArgs extends ThreadInteractionGetArgs {\n resolution: PendingInteractionResolution;\n}\ntype ThreadWaitTarget = {\n kind: \"status\";\n status: ThreadStatus;\n} | {\n kind: \"event\";\n eventType: string;\n};\ninterface ThreadWaitArgs {\n event?: string;\n pollIntervalMs?: number;\n status?: ThreadStatus;\n threadId: string;\n timeoutMs?: number;\n}\ntype ThreadWaitResult = {\n event: NonNullable;\n matched: true;\n target: Extract;\n threadId: string;\n} | {\n matched: true;\n target: Extract;\n thread: ThreadGetResult;\n threadId: string;\n};\ninterface ThreadInteractionsArea {\n get(args: ThreadInteractionGetArgs): Promise;\n list(args: ThreadInteractionListArgs): Promise;\n resolve(args: ThreadInteractionResolveArgs): Promise;\n}\ninterface ThreadEventsArea {\n list(args: ThreadEventsListArgs): Promise;\n wait(args: ThreadEventWaitArgs): Promise;\n}\ninterface ThreadTerminalsArea {\n close(args: ThreadTerminalCloseArgs): Promise;\n create(args: ThreadTerminalCreateArgs): Promise;\n input(args: ThreadTerminalInputArgs): Promise;\n list(args: ThreadTerminalListArgs): Promise;\n output(args: ThreadTerminalOutputArgs): Promise;\n resize(args: ThreadTerminalResizeArgs): Promise;\n update(args: ThreadTerminalUpdateArgs): Promise;\n}\ninterface ThreadsArea {\n archive(args: ThreadStatusArgs): Promise;\n delete(args: ThreadDeleteArgs): Promise;\n events: ThreadEventsArea;\n get(args: ThreadGetArgs): Promise;\n interactions: ThreadInteractionsArea;\n list(args?: ThreadListArgs): Promise;\n open(args: ThreadOpenArgs): Promise;\n output(args: ThreadOutputArgs): Promise;\n pin(args: ThreadStatusArgs): Promise;\n send(args: ThreadSendArgs): Promise;\n spawn(args: ThreadSpawnArgs): Promise;\n stop(args: ThreadStatusArgs): Promise;\n terminals: ThreadTerminalsArea;\n timeline(args: ThreadTimelineArgs): Promise;\n unarchive(args: ThreadStatusArgs): Promise;\n unpin(args: ThreadStatusArgs): Promise;\n update(args: ThreadUpdateArgs): Promise;\n wait(args: ThreadWaitArgs): Promise;\n}\ndeclare function createThreadsArea(args: CreateSdkAreaArgs): ThreadsArea;\n\ninterface BbSdk extends BbRealtime {\n automations: ReturnType;\n environments: ReturnType;\n guide: ReturnType;\n hosts: ReturnType;\n projects: ReturnType;\n providers: ReturnType;\n status: ReturnType;\n theme: ReturnType;\n threads: ReturnType;\n}\n\n/**\n * The backend plugin API contract — the `bb` object handed to a plugin's\n * `server.ts` factory (`export default function plugin(bb: BbPluginApi)`).\n *\n * Types only: the implementation lives in the BB server\n * (apps/server/src/services/plugins/plugin-api.ts), which imports these\n * shapes so the contract and the implementation cannot drift. Plugin authors\n * import them type-only (`import type { BbPluginApi } from\n * \"@bb/plugin-sdk\"`); the import is erased when BB loads the file.\n *\n * Runtime classes stay host-side. NeedsConfigurationError in particular is\n * matched by NAME, so plugin code needs no runtime import:\n * `throw Object.assign(new Error(msg), { name: \"NeedsConfigurationError\" })`.\n */\ninterface PluginLogger {\n debug(message: string): void;\n info(message: string): void;\n warn(message: string): void;\n error(message: string): void;\n}\n/**\n * Declarative settings descriptors (`bb.settings.define`). Deliberately plain\n * data — not zod — so the host can render settings forms and the CLI can\n * parse values without executing plugin code.\n */\ntype PluginSettingDescriptor = {\n type: \"string\";\n label: string;\n description?: string;\n /** Stored in a 0600 file under /plugins//secrets/, never in the db or sent to the frontend. */\n secret?: true;\n default?: string;\n} | {\n type: \"boolean\";\n label: string;\n description?: string;\n default?: boolean;\n} | {\n type: \"select\";\n label: string;\n description?: string;\n options: string[];\n default?: string;\n} | {\n type: \"project\";\n label: string;\n description?: string;\n default?: string;\n};\ntype PluginSettingDescriptors = Record;\ntype PluginSettingValue = string | boolean;\n/** `default` present → non-optional value; absent → `T | undefined`. */\ntype PluginSettingsValues> = {\n [K in keyof Ds]: Ds[K] extends {\n default: string | boolean;\n } ? PluginSettingValueOf : PluginSettingValueOf | undefined;\n};\ntype PluginSettingValueOf = D extends {\n type: \"boolean\";\n} ? boolean : string;\ninterface PluginSettingsHandle> {\n /** Load-safe: callable inside the factory. */\n get(): Promise>;\n /** Fires after values change through the settings route/CLI. */\n onChange(listener: (next: PluginSettingsValues, prev: PluginSettingsValues) => void): void;\n}\ninterface PluginSettings {\n define>(descriptors: Ds): PluginSettingsHandle;\n}\ninterface PluginKvStorage {\n get(key: string): Promise;\n set(key: string, value: unknown): Promise;\n delete(key: string): Promise;\n list(prefix?: string): Promise;\n}\ninterface PluginStorage {\n /** Namespaced JSON key-value rows in bb.db; values ≤256KB each. */\n kv: PluginKvStorage;\n /**\n * Open (or reuse the path of) the plugin's own SQLite database at\n * /plugins//data.db — the server's better-sqlite3, WAL mode,\n * busy_timeout 5000. Handles are host-tracked and closed on\n * dispose/reload; a closed handle throws on use.\n */\n sqlite(): Database.Database;\n /**\n * Ordered-statement migration helper: statement index = migration id in a\n * `_bb_migrations` table; unapplied statements run in one transaction.\n * Append-only — never reorder or edit shipped statements.\n */\n migrate(db: Database.Database, statements: string[]): void;\n}\n/**\n * Thread lifecycle events a plugin can observe (design §4.5). Observe-only:\n * handlers run fire-and-forget after the transition is applied and can never\n * block or veto it. `thread` is the same public DTO GET /threads/:id serves.\n */\ninterface PluginThreadEventPayloads {\n \"thread.created\": {\n thread: ThreadResponse;\n };\n /** Fired when a thread transitions into `idle`. `lastAssistantText` is\n * assembled the same way GET /threads/:id/output is. */\n \"thread.idle\": {\n thread: ThreadResponse;\n lastAssistantText: string | null;\n };\n /** Fired when a thread transitions into `error`. `error` is the latest\n * system/error event message, when one exists. */\n \"thread.failed\": {\n thread: ThreadResponse;\n error: string | null;\n };\n}\ntype PluginThreadEventName = keyof PluginThreadEventPayloads;\ntype PluginThreadEventHandler = (payload: PluginThreadEventPayloads[E]) => void | Promise;\ntype PluginHttpAuthMode = \"local\" | \"token\" | \"none\";\ntype PluginHttpHandler = (context: Context) => Response | Promise;\ninterface PluginHttp {\n /**\n * Register an HTTP route, mounted at\n * `/api/v1/plugins//http/`. Auth modes (default \"local\"):\n * - \"local\": Origin/Host must be a local BB app origin; non-GET requires\n * content-type application/json (forces a CORS preflight).\n * - \"token\": requires the per-plugin token (`bb plugin token `) via\n * the x-bb-plugin-token header or ?token=.\n * - \"none\": no checks — only for signature-verified webhooks.\n */\n route(method: string, path: string, handler: PluginHttpHandler, opts?: {\n auth?: PluginHttpAuthMode;\n }): void;\n}\ninterface PluginRpc {\n /**\n * Register rpc methods, served at POST\n * `/api/v1/plugins//rpc/` with \"local\" auth semantics. The\n * JSON request body is the input; the response is\n * `{ ok: true, result }` or `{ ok: false, error }`. Inputs and outputs\n * must survive a JSON round-trip — results are serialized with\n * JSON.stringify and nothing else.\n */\n register(handlers: Record unknown>): void;\n}\ninterface PluginRealtime {\n /**\n * Broadcast an ephemeral `plugin-signal` WS message\n * `{ pluginId, channel, payload }` to every connected client (V1 has no\n * per-channel subscriptions). `payload` must be JSON-serializable;\n * `undefined` is normalized to `null`. Nothing is persisted.\n */\n publish(channel: string, payload: unknown): void;\n}\ninterface PluginBackground {\n /**\n * Register a long-lived background service. `start` runs after the\n * factory completes and should resolve when `signal` aborts\n * (dispose/reload/disable/shutdown). A crash restarts it with capped\n * exponential backoff; throwing NeedsConfigurationError marks the plugin\n * `needs-configuration` and stops restarting until the next load.\n */\n service(name: string, service: {\n start(signal: AbortSignal): void | Promise;\n }): void;\n /**\n * Register a cron schedule (5-field expression, server-local time). The\n * durable row keyed (pluginId, name) is upserted at load; the periodic\n * sweep claims due rows with a CAS on next_run_at, but only while this\n * plugin is loaded. Failures land in last_status/last_error, visible in\n * `bb plugin list`.\n */\n schedule(name: string, cron: string, fn: () => void | Promise): void;\n}\ninterface PluginCliCommandInfo {\n name: string;\n summary: string;\n usage: string;\n}\n/** Context forwarded from the invoking CLI when known; all fields optional. */\ninterface PluginCliContext {\n cwd?: string;\n threadId?: string;\n projectId?: string;\n}\ninterface PluginCliResult {\n exitCode: number;\n stdout?: string;\n stderr?: string;\n}\ninterface PluginCliRegistration {\n /** Top-level command name (`bb …`): lowercase [a-z0-9-]+, and not\n * a core bb command (see RESERVED_BB_CLI_COMMANDS in the server). */\n name: string;\n summary: string;\n /** Subcommand metadata rendered in help and the plugin-commands skill\n * without executing plugin code. Parsing argv is plugin-owned. */\n commands?: PluginCliCommandInfo[];\n run(argv: string[], ctx: PluginCliContext): PluginCliResult | Promise;\n}\ninterface PluginCli {\n /**\n * Register this plugin's `bb` subcommand. One registration per plugin —\n * a second call replaces the first. Core bb commands always win name\n * collisions; reserved names are rejected at registration.\n */\n register(registration: PluginCliRegistration): void;\n}\n/** Per-turn context handed to bb.agents context providers (design §4.4). */\n/** MCP-style content parts a native tool may return (design §4.4). */\ntype PluginAgentToolContentPart = {\n type: \"text\";\n text: string;\n} | {\n type: \"image\";\n data: string;\n mimeType: string;\n};\ntype PluginAgentToolResult = string | {\n content: PluginAgentToolContentPart[];\n isError?: boolean;\n};\n/** Per-call context handed to a native tool's execute (design §4.4). */\ninterface PluginAgentToolContext {\n threadId: string;\n projectId: string;\n /** The tool-call request's abort signal (aborts if the daemon round-trip\n * is torn down mid-call). */\n signal: AbortSignal;\n}\ninterface PluginAgentToolRegistrationBase {\n /** Tool name shown to the model: [a-zA-Z0-9_-]+, unique across plugins,\n * and not a built-in dynamic tool (see RESERVED_AGENT_TOOL_NAMES in the\n * server). */\n name: string;\n description: string;\n /**\n * Optional usage snippet appended to the thread instructions whenever\n * this tool is in the session's tool set (mirrors the built-in\n * update_environment_directory guidance). Keep it short.\n */\n instructions?: string;\n}\ninterface PluginAgents {\n /**\n * Register a native dynamic tool (design §4.4). `parameters` is either a\n * zod schema (validated per call; execute receives the parsed value) or a\n * plain JSON-schema object (no validation; execute receives the raw\n * arguments as `unknown`). Tool-set changes apply on the NEXT session\n * start — a tool registered mid-session is not hot-added to running\n * provider sessions. A second registration of the same name within this\n * plugin replaces the first; a name already registered by another plugin\n * is rejected and surfaced as this plugin's status detail.\n */\n registerTool(tool: PluginAgentToolRegistrationBase & {\n parameters: Schema;\n execute(params: z.output, ctx: PluginAgentToolContext): PluginAgentToolResult | Promise;\n }): void;\n registerTool(tool: PluginAgentToolRegistrationBase & {\n /** Raw JSON-schema escape hatch; params arrive unvalidated. */\n parameters: Record;\n execute(params: unknown, ctx: PluginAgentToolContext): PluginAgentToolResult | Promise;\n }): void;\n}\ninterface PluginThreadActionContext {\n threadId: string;\n projectId: string;\n}\ninterface PluginThreadActionToast {\n kind: \"success\" | \"error\" | \"info\";\n message: string;\n}\ntype PluginThreadActionResult = void | {\n toast?: PluginThreadActionToast;\n};\ninterface PluginThreadActionRegistration {\n /** Unique within this plugin: [a-zA-Z0-9_-]+ (becomes a URL segment). */\n id: string;\n /** Button label rendered in the thread header. */\n title: string;\n /** Optional icon name; the host falls back to a generic icon. */\n icon?: string;\n /** Optional confirmation prompt the host shows before running. */\n confirm?: string;\n /**\n * Runs server-side when the user clicks the action. The host shows a\n * pending state while in flight, the returned toast on completion, and an\n * automatic error toast when this throws.\n */\n run(ctx: PluginThreadActionContext): PluginThreadActionResult | Promise;\n}\n/** Search context handed to a mention provider (design §4.9). `projectId`/\n * `threadId` are null when the composer has not committed one yet. */\ninterface PluginMentionSearchContext {\n query: string;\n projectId: string | null;\n threadId: string | null;\n}\n/** One row a mention provider returns from `search`. `id` is the provider's\n * own item id — the host namespaces it before it reaches the wire. */\ninterface PluginMentionItem {\n id: string;\n title: string;\n subtitle?: string;\n icon?: string;\n}\ninterface PluginMentionProviderRegistration {\n /** Unique within this plugin: [a-zA-Z0-9_-]+ (no \":\" — the host composes\n * wire item ids as \":\"). */\n id: string;\n /** Section label shown above this provider's rows in the mention menu. */\n label: string;\n /**\n * Runs server-side as the user types after `@` in the composer. Each call\n * is time-boxed (2s) and failure-isolated: a slow or throwing provider\n * contributes an empty list — it can never break the mention menu.\n */\n search(ctx: PluginMentionSearchContext): PluginMentionItem[] | Promise;\n /**\n * Resolves one picked item into agent context, called once per unique\n * item at message send time. The returned `context` is attached to the\n * message as an agent-visible (user-hidden) prompt input. Throwing blocks\n * the send with a visible error.\n */\n resolve(itemId: string): {\n context: string;\n } | Promise<{\n context: string;\n }>;\n}\ninterface PluginUi {\n /**\n * Register a thread action rendered in the shipped app's thread header\n * (design §4.9). Multiple actions per plugin; ids must be unique within\n * the plugin. Invoked via POST /plugins/:id/actions/:actionId.\n */\n registerThreadAction(action: PluginThreadActionRegistration): void;\n /**\n * Register an `@`-mention provider for the shipped app's composer\n * (design §4.9). Items group under `label` in the mention menu; a picked\n * item becomes a `{ kind: \"plugin\" }` mention resource whose context is\n * resolved once at send time. Multiple providers per plugin; ids must be\n * unique within the plugin.\n */\n registerMentionProvider(provider: PluginMentionProviderRegistration): void;\n}\ninterface PluginStatusApi {\n /**\n * Mark this plugin `needs-configuration` (with a message shown in\n * `bb plugin list` and the UI) instead of failing — e.g. a factory or\n * service that finds no API key configured. Cleared on the next load;\n * saving settings does not auto-reload in V1, so ask the user to\n * `bb plugin reload ` after configuring.\n */\n needsConfiguration(message: string): void;\n}\n/**\n * The API object handed to a plugin's factory (design §4). Implemented by\n * the BB server; this contract is what plugin `server.ts` files compile\n * against.\n */\ninterface BbPluginApi {\n /** The plugin's own id (namespaces storage, routes, commands). */\n readonly pluginId: string;\n /** Leveled, plugin-scoped logger. */\n readonly log: PluginLogger;\n /** Declarative settings (design §4.2). */\n readonly settings: PluginSettings;\n /** Namespaced KV + per-plugin SQLite (design §4.3). */\n readonly storage: PluginStorage;\n /** HTTP routes under /api/v1/plugins//http/* (design §4.6). */\n readonly http: PluginHttp;\n /** RPC methods under /api/v1/plugins//rpc/ (design §4.6). */\n readonly rpc: PluginRpc;\n /** Ephemeral push to connected frontends (design §4.7). */\n readonly realtime: PluginRealtime;\n /** Long-lived services + cron schedules (design §4.8). */\n readonly background: PluginBackground;\n /** Agent-facing `bb` CLI subcommand (design §4.4). */\n readonly cli: PluginCli;\n /** Per-turn agent context contributions (design §4.4). */\n readonly agents: PluginAgents;\n /** Host-rendered UI contributions (design §4.9). */\n readonly ui: PluginUi;\n /** Plugin-reported status (needs-configuration). */\n readonly status: PluginStatusApi;\n /**\n * The full BB SDK, bound to this server over loopback (design §4.1).\n * Bind-gated: reading this before the host binds the SDK throws. The real\n * server binds it before loading plugins, so it is available from the\n * moment factories run there — but isolated harnesses may not, so prefer\n * using it from handlers, services, and timers for portability.\n * `threads.spawn` defaults `origin` to \"plugin\" and `originPluginId` to\n * this plugin's id so spawned threads are attributed automatically.\n */\n readonly sdk: BbSdk;\n /**\n * Observe thread lifecycle events (design §4.5). Load-safe registration;\n * handlers run async after the transition and never affect it. Errors are\n * caught, logged, and counted against this plugin's handler stats.\n */\n on(event: E, handler: PluginThreadEventHandler): void;\n /**\n * Register cleanup to run on reload/disable/shutdown. Hooks run LIFO.\n * The sanctioned place to clear timers and close connections.\n */\n onDispose(hook: () => void | Promise): void;\n}\n\nexport { PLUGIN_SDK_APP_EXPORT_NAMES, PLUGIN_SLOT_ID_PATTERN };\nexport type { BbContext, BbNavigate, BbPluginApi, PluginAgentToolContentPart, PluginAgentToolContext, PluginAgentToolRegistrationBase, PluginAgentToolResult, PluginAgents, PluginAppBuilder, PluginAppDefinition, PluginAppSetup, PluginAppSlots, PluginBackground, PluginCli, PluginCliCommandInfo, PluginCliContext, PluginCliRegistration, PluginCliResult, PluginComposerAccessoryProps, PluginComposerAccessoryRegistration, PluginHomepageSectionProps, PluginHomepageSectionRegistration, PluginHttp, PluginHttpAuthMode, PluginHttpHandler, PluginKvStorage, PluginLogger, PluginMentionItem, PluginMentionProviderRegistration, PluginMentionSearchContext, PluginNavPanelProps, PluginNavPanelRegistration, PluginRealtime, PluginRpc, PluginRpcClient, PluginSdkApp, PluginSettingDescriptor, PluginSettingDescriptors, PluginSettingValue, PluginSettings, PluginSettingsHandle, PluginSettingsState, PluginSettingsValues, PluginStatusApi, PluginStorage, PluginThreadActionContext, PluginThreadActionRegistration, PluginThreadActionResult, PluginThreadActionToast, PluginThreadEventHandler, PluginThreadEventName, PluginThreadEventPayloads, PluginThreadPanelActionContext, PluginThreadPanelActionRegistration, PluginThreadPanelProps, PluginUi };\n"; +export const PLUGIN_SDK_DTS = "// Bundled type declarations for `@bb/plugin-sdk`, shipped into scaffolded\n// plugins so they typecheck without the @bb/* workspace on disk.\n//\n// Confused by the API, or need a symbol that isn't here? Clone the BB repo\n// and read the real source: https://github.com/ymichael/bb\n\nimport { ComponentType } from 'react';\nimport Database from 'better-sqlite3';\nimport { Context } from 'hono';\nimport * as z from 'zod';\nimport { z as z$1 } from 'zod';\n\n/**\n * The `@bb/plugin-sdk/app` contract (plugin design §5.2) — pure types plus\n * the runtime export-name list, with no side effects. This module is what the\n * BB app imports to keep its real implementation in sync (`satisfies\n * PluginSdkApp`) and what `bb plugin build` imports to generate the shim's\n * named-export list. Plugin authors import the same shapes through\n * `@bb/plugin-sdk/app`.\n *\n * Per-slot props are versioned contracts: additive-only within an SDK major.\n */\n/** Props passed to a `homepageSection` component. */\ninterface PluginHomepageSectionProps {\n /** Project in view on the compose surface; null when none is selected. */\n projectId: string | null;\n}\n/** Props passed to a `navPanel` component (it owns its whole route). */\ninterface PluginNavPanelProps {\n /**\n * The route remainder after the panel root, \"\" at the root. The panel's\n * route is `/plugins///*`, so a deep link like\n * `/plugins/notes/notes/work/ideas.md` renders the panel with\n * `subPath: \"work/ideas.md\"`. Navigate within the panel via\n * `useBbNavigate().toPluginPanel(path, { subPath })` — browser\n * back/forward then walks panel-internal history.\n */\n subPath: string;\n}\n/** Props passed to a panel tab opened by a `threadPanelAction`. */\ninterface PluginThreadPanelProps {\n threadId: string;\n /**\n * The JSON value the action's `openPanel` call passed (round-tripped\n * through persistence, so the tab restores across reloads); null when the\n * action opened the panel without params.\n */\n params: unknown;\n}\n/** Props passed to a `composerAccessory` component. */\ninterface PluginComposerAccessoryProps {\n projectId: string | null;\n threadId: string | null;\n}\n/**\n * Slot/panel ids and nav-panel paths must match this pattern (letters,\n * digits, `-`, `_`): they ride URLs and persisted panel-tab keys.\n */\ndeclare const PLUGIN_SLOT_ID_PATTERN: RegExp;\ninterface PluginHomepageSectionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n component: ComponentType;\n}\ninterface PluginNavPanelRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n /** Icon hint (BB icon name); unknown names fall back to a generic icon. */\n icon: string;\n /** URL segment under `/plugins//`; letters, digits, `-`, `_`. */\n path: string;\n component: ComponentType;\n /**\n * Panel chrome (default \"page\"): \"page\" renders the host title bar (plugin\n * logo + `title` + your `headerContent`) above a full-width padded body;\n * \"none\" hands the ENTIRE panel area to `component` — no host padding, no\n * title bar (`headerContent` is ignored) — only the per-plugin error\n * boundary remains.\n */\n chrome?: \"page\" | \"none\";\n /**\n * Optional component rendered on the right side of the \"page\" title bar\n * (e.g. a sync button or a count). Contained separately from the body: a\n * throwing headerContent is hidden without breaking the title bar.\n */\n headerContent?: ComponentType;\n}\n/** Context handed to a `threadPanelAction`'s `run`. */\ninterface PluginThreadPanelActionContext {\n /** The thread whose panel launcher invoked the action. */\n threadId: string;\n /**\n * Open a tab in the thread's side panel rendering this action's\n * `component`. `title` labels the tab (default: the action's `title`);\n * `params` must be JSON-serializable — it is persisted with the tab and\n * reaches the component as its `params` prop. Opening with params\n * identical to an already-open tab of this action focuses that tab\n * (updating its title) instead of duplicating it. May be called more than\n * once (different params ⇒ multiple tabs) or not at all.\n */\n openPanel(options?: {\n title?: string;\n params?: unknown;\n }): void;\n}\ninterface PluginThreadPanelActionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n /** Label of the action row in the panel's new-tab launcher. */\n title: string;\n /**\n * Icon hint (BB icon name) used when the plugin ships no logo; the\n * launcher row and opened tabs prefer the plugin's logo.\n */\n icon?: string;\n /** Rendered inside every panel tab this action opens. */\n component: ComponentType;\n /**\n * Runs when the user activates the action: call your RPC methods, show a\n * toast, and/or open panel tabs via `context.openPanel`. Omitted =\n * immediately open a panel tab with defaults. Errors (sync or async) are\n * contained and logged; they never break the launcher.\n */\n run?(context: PluginThreadPanelActionContext): void | Promise;\n}\ninterface PluginComposerAccessoryRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n component: ComponentType;\n}\ninterface PluginAppSlots {\n homepageSection(registration: PluginHomepageSectionRegistration): void;\n navPanel(registration: PluginNavPanelRegistration): void;\n threadPanelAction(registration: PluginThreadPanelActionRegistration): void;\n composerAccessory(registration: PluginComposerAccessoryRegistration): void;\n}\ninterface PluginAppBuilder {\n slots: PluginAppSlots;\n}\ntype PluginAppSetup = (app: PluginAppBuilder) => void;\n/**\n * The opaque product of `definePluginApp` — a plugin's `app.tsx` default\n * export. The host re-runs `setup` against a fresh collector on every\n * (re)interpretation, replacing that plugin's registrations wholesale.\n */\ninterface PluginAppDefinition {\n /** Brand the host checks before interpreting a bundle's default export. */\n readonly __bbPluginApp: true;\n readonly setup: PluginAppSetup;\n}\ninterface PluginRpcClient {\n /**\n * Invoke one of the plugin's `bb.rpc` methods (POST\n * /api/v1/plugins/<id>/rpc/<method>). Resolves with the method's\n * result; rejects with an `Error` carrying the server's message when the\n * handler fails or the plugin is not running.\n */\n call(method: string, input?: unknown): Promise;\n}\ninterface PluginSettingsState {\n /**\n * Effective non-secret setting values (secret settings are excluded —\n * read them server-side). Undefined while loading or unavailable.\n */\n values: Record | undefined;\n isLoading: boolean;\n}\n/** Current app selection, derived from the route. */\ninterface BbContext {\n projectId: string | null;\n threadId: string | null;\n}\ninterface BbNavigate {\n toThread(threadId: string): void;\n toProject(projectId: string): void;\n /**\n * Navigate to one of this plugin's own nav panels by its `path`.\n * `subPath` targets a location inside the panel (the component's\n * `subPath` prop); `replace` swaps the current history entry instead of\n * pushing — use it for redirects so back does not bounce.\n */\n toPluginPanel(path: string, options?: {\n subPath?: string;\n replace?: boolean;\n }): void;\n}\n/**\n * Everything `@bb/plugin-sdk/app` resolves to at runtime. The BB app builds\n * the real implementation and `satisfies` this interface; `bb plugin build`\n * shims the specifier to that object on `globalThis.__bbPluginRuntime`.\n */\ninterface PluginSdkApp {\n definePluginApp(setup: PluginAppSetup): PluginAppDefinition;\n useRpc(): PluginRpcClient;\n useRealtime(channel: string, handler: (payload: unknown) => void): void;\n useSettings(): PluginSettingsState;\n useBbContext(): BbContext;\n useBbNavigate(): BbNavigate;\n}\n/**\n * Named runtime exports of `@bb/plugin-sdk/app`, in sorted order. Single\n * source of truth for the build shim's export list and the app's\n * implementation-key test — adding a surface member without updating this\n * list fails the type assertion below.\n */\ndeclare const PLUGIN_SDK_APP_EXPORT_NAMES: readonly [\"definePluginApp\", \"useBbContext\", \"useBbNavigate\", \"useRealtime\", \"useRpc\", \"useSettings\"];\n\ndeclare const appThemeSchema: z$1.ZodObject<{\n themeId: z$1.ZodString;\n customCss: z$1.ZodNullable;\n faviconColor: z$1.ZodEnum<{\n default: \"default\";\n red: \"red\";\n orange: \"orange\";\n yellow: \"yellow\";\n green: \"green\";\n teal: \"teal\";\n blue: \"blue\";\n purple: \"purple\";\n pink: \"pink\";\n }>;\n}, z$1.core.$strip>;\ntype AppTheme = z$1.infer;\n\ndeclare const changedMessageSchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"thread\">;\n id: z$1.ZodOptional;\n metadata: z$1.ZodOptional;\n eventTypes: z$1.ZodOptional>>>>;\n hasPendingInteraction: z$1.ZodOptional;\n projectId: z$1.ZodOptional;\n }, z$1.core.$strict>>;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"project\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"environment\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"host\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"system\">;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>], \"entity\">;\ntype ChangedMessage = z$1.infer;\n\ndeclare const pendingInteractionResolutionSchema: z$1.ZodUnion;\n grantedPermissions: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n fileSystem: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n decision: z$1.ZodLiteral<\"allow_for_session\">;\n grantedPermissions: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n fileSystem: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n decision: z$1.ZodLiteral<\"deny\">;\n}, z$1.core.$strip>], \"decision\">, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"user_answer\">;\n answers: z$1.ZodRecord;\n freeText: z$1.ZodOptional;\n }, z$1.core.$strip>>;\n}, z$1.core.$strip>]>;\ntype PendingInteractionResolution = z$1.infer;\n\ndeclare const threadStatusSchema: z$1.ZodEnum<{\n error: \"error\";\n active: \"active\";\n starting: \"starting\";\n idle: \"idle\";\n stopping: \"stopping\";\n}>;\ntype ThreadStatus = z$1.infer;\n\ndeclare const threadTimelinePendingTodosSchema: z$1.ZodObject<{\n sourceSeq: z$1.ZodNumber;\n updatedAt: z$1.ZodNumber;\n items: z$1.ZodArray;\n }, z$1.core.$strip>>;\n}, z$1.core.$strip>;\ntype ThreadTimelinePendingTodos = z$1.infer;\n\ndeclare const createAutomationRequestSchema: z$1.ZodObject<{\n name: z$1.ZodString;\n enabled: z$1.ZodDefault;\n trigger: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n triggerType: z$1.ZodLiteral<\"schedule\">;\n cron: z$1.ZodString;\n timezone: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n triggerType: z$1.ZodLiteral<\"once\">;\n runAt: z$1.ZodNumber;\n }, z$1.core.$strip>], \"triggerType\">;\n execution: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"agent\">;\n prompt: z$1.ZodString;\n providerId: z$1.ZodString;\n model: z$1.ZodString;\n permissionMode: z$1.ZodEnum<{\n readonly: \"readonly\";\n full: \"full\";\n \"workspace-write\": \"workspace-write\";\n }>;\n targetThreadId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"script\">;\n script: z$1.ZodOptional;\n scriptFile: z$1.ZodOptional;\n interpreter: z$1.ZodOptional>;\n timeoutMs: z$1.ZodDefault;\n env: z$1.ZodOptional>;\n }, z$1.core.$strip>], \"mode\">;\n environment: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"reuse\">;\n environmentId: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"host\">;\n hostId: z$1.ZodOptional;\n workspace: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"unmanaged\">;\n path: z$1.ZodNullable;\n branch: z$1.ZodOptional;\n name: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"new\">;\n baseBranch: z$1.ZodString;\n }, z$1.core.$strict>], \"kind\">>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"managed-worktree\">;\n baseBranch: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"named\">;\n name: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"default\">;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"personal\">;\n }, z$1.core.$strip>], \"type\">;\n }, z$1.core.$strip>], \"type\">;\n autoArchive: z$1.ZodDefault;\n origin: z$1.ZodEnum<{\n agent: \"agent\";\n human: \"human\";\n app: \"app\";\n }>;\n createdByThreadId: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype CreateAutomationRequest = z$1.input;\ndeclare const updateAutomationRequestSchema: z$1.ZodObject<{\n name: z$1.ZodOptional;\n trigger: z$1.ZodOptional;\n cron: z$1.ZodString;\n timezone: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n triggerType: z$1.ZodLiteral<\"once\">;\n runAt: z$1.ZodNumber;\n }, z$1.core.$strip>], \"triggerType\">>;\n execution: z$1.ZodOptional;\n prompt: z$1.ZodString;\n providerId: z$1.ZodString;\n model: z$1.ZodString;\n permissionMode: z$1.ZodEnum<{\n readonly: \"readonly\";\n full: \"full\";\n \"workspace-write\": \"workspace-write\";\n }>;\n targetThreadId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"script\">;\n script: z$1.ZodOptional;\n scriptFile: z$1.ZodOptional;\n interpreter: z$1.ZodOptional>;\n timeoutMs: z$1.ZodDefault;\n env: z$1.ZodOptional>;\n }, z$1.core.$strip>], \"mode\">>;\n environment: z$1.ZodOptional;\n environmentId: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"host\">;\n hostId: z$1.ZodOptional;\n workspace: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"unmanaged\">;\n path: z$1.ZodNullable;\n branch: z$1.ZodOptional;\n name: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"new\">;\n baseBranch: z$1.ZodString;\n }, z$1.core.$strict>], \"kind\">>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"managed-worktree\">;\n baseBranch: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"named\">;\n name: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"default\">;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"personal\">;\n }, z$1.core.$strip>], \"type\">;\n }, z$1.core.$strip>], \"type\">>;\n autoArchive: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype UpdateAutomationRequest = z$1.infer;\ndeclare const runAutomationRequestSchema: z$1.ZodObject<{\n idempotencyKey: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype RunAutomationRequest = z$1.infer;\n\ndeclare const createProjectSourceRequestSchema: z$1.ZodObject<{\n hostId: z$1.ZodString;\n type: z$1.ZodLiteral<\"local_path\">;\n path: z$1.ZodPipe>;\n}, z$1.core.$strict>;\ntype CreateProjectSourceRequest = z$1.infer;\ndeclare const createProjectRequestSchema: z$1.ZodObject<{\n name: z$1.ZodString;\n source: z$1.ZodObject<{\n hostId: z$1.ZodString;\n type: z$1.ZodLiteral<\"local_path\">;\n path: z$1.ZodPipe>;\n }, z$1.core.$strict>;\n}, z$1.core.$strip>;\ntype CreateProjectRequest = z$1.infer;\ndeclare const projectListQuerySchema: z$1.ZodObject<{\n include: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype ProjectListQuery = z$1.infer;\ndeclare const updateProjectRequestSchema: z$1.ZodObject<{\n name: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype UpdateProjectRequest = z$1.infer;\ndeclare const updateProjectSourceRequestSchema: z$1.ZodObject<{\n type: z$1.ZodLiteral<\"local_path\">;\n path: z$1.ZodOptional>>;\n isDefault: z$1.ZodOptional>;\n}, z$1.core.$strict>;\ntype UpdateProjectSourceRequest = z$1.infer;\n\ndeclare const updateEnvironmentRequestSchema: z$1.ZodObject<{\n mergeBaseBranch: z$1.ZodOptional>;\n name: z$1.ZodOptional>;\n}, z$1.core.$strip>;\ntype UpdateEnvironmentRequest = z$1.infer;\ndeclare const environmentDiffBranchesQuerySchema: z$1.ZodObject<{\n query: z$1.ZodOptional;\n limit: z$1.ZodOptional;\n selectedBranch: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype EnvironmentDiffBranchesQuery = z$1.infer;\ndeclare const environmentStatusQuerySchema: z$1.ZodObject<{\n mergeBaseBranch: z$1.ZodOptional>;\n}, z$1.core.$strip>;\ntype EnvironmentStatusQuery = z$1.infer;\ndeclare const environmentDiffQuerySchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n target: z$1.ZodLiteral<\"uncommitted\">;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"branch_committed\">;\n mergeBaseBranch: z$1.ZodPipe;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"all\">;\n mergeBaseBranch: z$1.ZodPipe;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"commit\">;\n sha: z$1.ZodString;\n}, z$1.core.$strip>], \"target\">;\ntype EnvironmentDiffQuery = z$1.infer;\n/**\n * Query for fetching a single file's contents at one side of a diff target.\n * Used by the diff card to reparse the card's patch with full old/new contents\n * so `@pierre/diffs` can render expand-context buttons between hunks.\n *\n * For `branch_committed` / `all`, callers pass the resolved merge-base SHA\n * (`mergeBaseRef`, surfaced by `workspace.diff`) rather than the branch name\n * — the diff itself was computed against that SHA, so reading the old side\n * from the same SHA keeps the file content aligned with the hunk line\n * numbers. Reading from the branch tip is wrong whenever the branch has\n * moved past the merge-base since the file existed there.\n */\ndeclare const environmentDiffFileQuerySchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n target: z$1.ZodLiteral<\"uncommitted\">;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"branch_committed\">;\n mergeBaseRef: z$1.ZodString;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"all\">;\n mergeBaseRef: z$1.ZodString;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"commit\">;\n sha: z$1.ZodString;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>], \"target\">;\ntype EnvironmentDiffFileQuery = z$1.infer;\n\ndeclare const systemExecutionOptionsQuerySchema: z$1.ZodObject<{\n providerId: z$1.ZodOptional;\n hostId: z$1.ZodOptional;\n environmentId: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype SystemExecutionOptionsQuery = z$1.infer;\n/**\n * Theme catalog: the on-disk custom-theme directory plus the discovered custom\n * themes and the active palette. Drives `bb theme list` / `bb theme dir`.\n */\ndeclare const themeCatalogResponseSchema: z$1.ZodObject<{\n dir: z$1.ZodString;\n custom: z$1.ZodArray;\n active: z$1.ZodObject<{\n themeId: z$1.ZodString;\n customCss: z$1.ZodNullable;\n faviconColor: z$1.ZodEnum<{\n default: \"default\";\n red: \"red\";\n orange: \"orange\";\n yellow: \"yellow\";\n green: \"green\";\n teal: \"teal\";\n blue: \"blue\";\n purple: \"purple\";\n pink: \"pink\";\n }>;\n }, z$1.core.$strip>;\n}, z$1.core.$strip>;\ntype ThemeCatalogResponse = z$1.infer;\n\ndeclare const createTerminalRequestSchema: z$1.ZodObject<{\n cols: z$1.ZodNumber;\n rows: z$1.ZodNumber;\n start: z$1.ZodOptional;\n }, z$1.core.$strict>, z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"command\">;\n command: z$1.ZodString;\n }, z$1.core.$strict>], \"mode\">>;\n target: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"thread\">;\n threadId: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"environment\">;\n environmentId: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"host_path\">;\n hostId: z$1.ZodString;\n cwd: z$1.ZodNullable;\n }, z$1.core.$strict>], \"kind\">;\n title: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype CreateTerminalRequest = z$1.infer;\ndeclare const closeTerminalRequestSchema: z$1.ZodObject<{\n mode: z$1.ZodEnum<{\n force: \"force\";\n \"if-clean\": \"if-clean\";\n }>;\n reason: z$1.ZodLiteral<\"user\">;\n}, z$1.core.$strict>;\ntype CloseTerminalRequest = z$1.infer;\ndeclare const updateTerminalRequestSchema: z$1.ZodObject<{\n title: z$1.ZodString;\n}, z$1.core.$strict>;\ntype UpdateTerminalRequest = z$1.infer;\ndeclare const terminalInputRequestSchema: z$1.ZodObject<{\n dataBase64: z$1.ZodString;\n}, z$1.core.$strict>;\ntype TerminalInputRequest = z$1.infer;\ndeclare const terminalResizeRequestSchema: z$1.ZodObject<{\n cols: z$1.ZodNumber;\n rows: z$1.ZodNumber;\n}, z$1.core.$strict>;\ntype TerminalResizeRequest = z$1.infer;\ndeclare const terminalOutputQuerySchema: z$1.ZodObject<{\n sinceSeq: z$1.ZodOptional>;\n tailBytes: z$1.ZodOptional>;\n limitChunks: z$1.ZodOptional>;\n}, z$1.core.$strict>;\ntype TerminalOutputQuery = z$1.infer;\n\ndeclare const createThreadRequestSchema: z$1.ZodObject<{\n projectId: z$1.ZodString;\n providerId: z$1.ZodOptional;\n origin: z$1.ZodEnum<{\n plugin: \"plugin\";\n app: \"app\";\n automation: \"automation\";\n cli: \"cli\";\n sdk: \"sdk\";\n }>;\n originPluginId: z$1.ZodOptional;\n title: z$1.ZodOptional;\n input: z$1.ZodArray>;\n type: z$1.ZodLiteral<\"text\">;\n text: z$1.ZodString;\n mentions: z$1.ZodDefault;\n threadId: z$1.ZodString;\n projectId: z$1.ZodOptional;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"project\">;\n projectId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"path\">;\n source: z$1.ZodEnum<{\n workspace: \"workspace\";\n \"thread-storage\": \"thread-storage\";\n }>;\n entryKind: z$1.ZodEnum<{\n file: \"file\";\n directory: \"directory\";\n }>;\n path: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"command\">;\n trigger: z$1.ZodEnum<{\n \"/\": \"/\";\n }>;\n name: z$1.ZodString;\n source: z$1.ZodEnum<{\n command: \"command\";\n skill: \"skill\";\n }>;\n origin: z$1.ZodEnum<{\n user: \"user\";\n project: \"project\";\n builtin: \"builtin\";\n }>;\n label: z$1.ZodString;\n argumentHint: z$1.ZodNullable;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"plugin\">;\n pluginId: z$1.ZodString;\n itemId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>>>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"image\">;\n url: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localImage\">;\n path: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localFile\">;\n path: z$1.ZodString;\n name: z$1.ZodOptional;\n sizeBytes: z$1.ZodOptional;\n mimeType: z$1.ZodOptional;\n }, z$1.core.$strip>], \"type\">>;\n model: z$1.ZodOptional;\n serviceTier: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>;\n permissionMode: z$1.ZodOptional>;\n executionInputSources: z$1.ZodOptional>;\n model: z$1.ZodOptional>;\n serviceTier: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>;\n permissionMode: z$1.ZodOptional>;\n }, z$1.core.$strict>>;\n environment: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"reuse\">;\n environmentId: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"host\">;\n hostId: z$1.ZodOptional;\n workspace: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"unmanaged\">;\n path: z$1.ZodNullable;\n branch: z$1.ZodOptional;\n name: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"new\">;\n baseBranch: z$1.ZodString;\n }, z$1.core.$strict>], \"kind\">>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"managed-worktree\">;\n baseBranch: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"named\">;\n name: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"default\">;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"personal\">;\n }, z$1.core.$strip>], \"type\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"project-default\">;\n }, z$1.core.$strip>], \"type\">;\n parentThreadId: z$1.ZodOptional;\n folderId: z$1.ZodOptional>;\n sourceThreadId: z$1.ZodOptional;\n sourceSeqEnd: z$1.ZodOptional;\n startedOnBehalfOf: z$1.ZodDefault;\n senderThreadId: z$1.ZodString;\n }, z$1.core.$strip>>>;\n originKind: z$1.ZodDefault>>;\n childOrigin: z$1.ZodDefault>>;\n}, z$1.core.$strip>;\ntype CreateThreadRequest = z$1.infer;\ndeclare const sendMessageRequestSchema: z$1.ZodObject<{\n input: z$1.ZodArray>;\n type: z$1.ZodLiteral<\"text\">;\n text: z$1.ZodString;\n mentions: z$1.ZodDefault;\n threadId: z$1.ZodString;\n projectId: z$1.ZodOptional;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"project\">;\n projectId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"path\">;\n source: z$1.ZodEnum<{\n workspace: \"workspace\";\n \"thread-storage\": \"thread-storage\";\n }>;\n entryKind: z$1.ZodEnum<{\n file: \"file\";\n directory: \"directory\";\n }>;\n path: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"command\">;\n trigger: z$1.ZodEnum<{\n \"/\": \"/\";\n }>;\n name: z$1.ZodString;\n source: z$1.ZodEnum<{\n command: \"command\";\n skill: \"skill\";\n }>;\n origin: z$1.ZodEnum<{\n user: \"user\";\n project: \"project\";\n builtin: \"builtin\";\n }>;\n label: z$1.ZodString;\n argumentHint: z$1.ZodNullable;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"plugin\">;\n pluginId: z$1.ZodString;\n itemId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>>>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"image\">;\n url: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localImage\">;\n path: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localFile\">;\n path: z$1.ZodString;\n name: z$1.ZodOptional;\n sizeBytes: z$1.ZodOptional;\n mimeType: z$1.ZodOptional;\n }, z$1.core.$strip>], \"type\">>;\n model: z$1.ZodOptional;\n serviceTier: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>;\n permissionMode: z$1.ZodOptional>;\n executionInputSources: z$1.ZodOptional>;\n serviceTier: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>;\n permissionMode: z$1.ZodOptional>;\n }, z$1.core.$strict>>;\n mode: z$1.ZodEnum<{\n steer: \"steer\";\n start: \"start\";\n auto: \"auto\";\n \"queue-if-active\": \"queue-if-active\";\n \"steer-if-active\": \"steer-if-active\";\n }>;\n senderThreadId: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype SendMessageRequest = z$1.infer;\ndeclare const threadResponseSchema: z$1.ZodObject<{\n id: z$1.ZodString;\n projectId: z$1.ZodString;\n environmentId: z$1.ZodNullable;\n providerId: z$1.ZodString;\n title: z$1.ZodNullable;\n titleFallback: z$1.ZodNullable;\n folderId: z$1.ZodNullable;\n status: z$1.ZodEnum<{\n error: \"error\";\n stopping: \"stopping\";\n idle: \"idle\";\n starting: \"starting\";\n active: \"active\";\n }>;\n parentThreadId: z$1.ZodNullable;\n sourceThreadId: z$1.ZodNullable;\n originKind: z$1.ZodNullable>;\n childOrigin: z$1.ZodNullable>;\n originPluginId: z$1.ZodNullable;\n archivedAt: z$1.ZodNullable;\n pinnedAt: z$1.ZodNullable;\n deletedAt: z$1.ZodNullable;\n lastReadAt: z$1.ZodNullable;\n latestAttentionAt: z$1.ZodNumber;\n createdAt: z$1.ZodNumber;\n updatedAt: z$1.ZodNumber;\n runtime: z$1.ZodObject<{\n displayStatus: z$1.ZodEnum<{\n error: \"error\";\n provisioning: \"provisioning\";\n stopping: \"stopping\";\n idle: \"idle\";\n starting: \"starting\";\n active: \"active\";\n \"host-reconnecting\": \"host-reconnecting\";\n \"waiting-for-host\": \"waiting-for-host\";\n }>;\n hostReconnectGraceExpiresAt: z$1.ZodNullable;\n }, z$1.core.$strip>;\n canSpawnChild: z$1.ZodBoolean;\n}, z$1.core.$strip>;\ntype ThreadResponse = z$1.infer;\ndeclare const threadGetQuerySchema: z$1.ZodObject<{\n include: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype ThreadGetQuery = z$1.infer;\ndeclare const deleteThreadRequestSchema: z$1.ZodObject<{\n childThreadsConfirmed: z$1.ZodBoolean;\n}, z$1.core.$strip>;\ntype DeleteThreadRequest = z$1.infer;\ndeclare const updateThreadRequestSchema: z$1.ZodObject<{\n title: z$1.ZodOptional>;\n folderId: z$1.ZodOptional>;\n parentThreadId: z$1.ZodOptional>;\n model: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>>;\n}, z$1.core.$strip>;\ntype UpdateThreadRequest = z$1.infer;\n/** Which root a secondary-panel file path is relative to. */\ndeclare const panelFileSourceSchema: z$1.ZodEnum<{\n workspace: \"workspace\";\n \"thread-storage\": \"thread-storage\";\n}>;\ntype PanelFileSource = z$1.infer;\ndeclare const threadTimelineQuerySchema: z$1.ZodObject<{\n includeNestedRows: z$1.ZodOptional>;\n segmentLimit: z$1.ZodOptional;\n beforeAnchorSeq: z$1.ZodOptional;\n beforeAnchorId: z$1.ZodOptional;\n summaryOnly: z$1.ZodOptional>;\n afterSequence: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype ThreadTimelineQuery = z$1.infer;\n\ntype PublicApiSchema = unknown;\ntype ApiClient = unknown;\n\ntype FetchImplementation = typeof fetch;\ntype JsonBodyOf = TResponse extends {\n json(): Promise;\n} ? TBody : never;\n\ntype BbSdkRuntime = \"node\" | \"browser\";\ninterface BbSdkTransport {\n api: ApiClient[\"api\"];\n baseUrl: string;\n fetch: FetchImplementation;\n realtimeUrl?: string;\n runtime: BbSdkRuntime;\n readJson(response: Promise): Promise>;\n readVoid(response: Promise): Promise;\n resolve(response: Promise): Promise;\n websocket?: BbRealtimeSocketFactory;\n}\n/**\n * Raw socket payload. Treated as opaque until decoded — realtime ignores\n * non-string frames.\n */\ninterface BbRealtimeSocketMessageEvent {\n data: unknown;\n}\n/**\n * Minimal runtime-agnostic socket shape consumed by the realtime client.\n * Default factories adapt the environment's WebSocket (browser/Node global,\n * or the `ws` package on older Node) to this interface; custom `websocket`\n * factories can wrap any implementation the same way.\n */\ninterface BbRealtimeSocket {\n close(): void;\n onclose: (() => void) | null;\n onerror: (() => void) | null;\n onmessage: ((event: BbRealtimeSocketMessageEvent) => void) | null;\n onopen: (() => void) | null;\n readyState: number;\n send(data: string): void;\n}\ntype BbRealtimeSocketFactory = (url: string) => BbRealtimeSocket;\ninterface BbSdkContext {\n}\n\ninterface CreateSdkAreaArgs {\n context: BbSdkContext;\n transport: BbSdkTransport;\n}\ntype PublicApiEndpointOutput = TEndpoint extends {\n status: infer Status;\n output: infer Output;\n} ? Status extends SuccessfulHttpStatus ? Output : never : never;\ntype SuccessfulHttpStatus = 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226;\ntype PublicApiOutput = PublicApiEndpointOutput;\n\ninterface AutomationCreateArgs extends CreateAutomationRequest {\n projectId?: string;\n}\ninterface AutomationListArgs {\n projectId?: string;\n}\ninterface AutomationGetArgs {\n projectId?: string;\n automationId: string;\n}\ninterface AutomationUpdateArgs extends UpdateAutomationRequest {\n projectId?: string;\n automationId: string;\n}\ninterface AutomationActionArgs {\n projectId?: string;\n automationId: string;\n}\ninterface AutomationRunArgs extends RunAutomationRequest {\n projectId?: string;\n automationId: string;\n}\ninterface AutomationRunsArgs {\n projectId?: string;\n automationId: string;\n limit?: number;\n cursor?: string;\n}\ntype AutomationCreateResult = PublicApiOutput<\"/projects/:id/automations\", \"$post\">;\ntype AutomationListResult = PublicApiOutput<\"/projects/:id/automations\", \"$get\">;\ntype AutomationGetResult = PublicApiOutput<\"/projects/:id/automations/:automationId\", \"$get\">;\ntype AutomationUpdateResult = PublicApiOutput<\"/projects/:id/automations/:automationId\", \"$patch\">;\ntype AutomationPauseResult = PublicApiOutput<\"/projects/:id/automations/:automationId/pause\", \"$post\">;\ntype AutomationResumeResult = PublicApiOutput<\"/projects/:id/automations/:automationId/resume\", \"$post\">;\ntype AutomationRunResult = PublicApiOutput<\"/projects/:id/automations/:automationId/run\", \"$post\">;\ntype AutomationRunsResult = PublicApiOutput<\"/projects/:id/automations/:automationId/runs\", \"$get\">;\ntype AutomationsOverviewResult = PublicApiOutput<\"/automations\", \"$get\">;\ninterface AutomationsArea {\n create(args: AutomationCreateArgs): Promise;\n delete(args: AutomationActionArgs): Promise<{\n ok: true;\n }>;\n get(args: AutomationGetArgs): Promise;\n list(args?: AutomationListArgs): Promise;\n overview(): Promise;\n pause(args: AutomationActionArgs): Promise;\n resume(args: AutomationActionArgs): Promise;\n run(args: AutomationRunArgs): Promise;\n runs(args: AutomationRunsArgs): Promise;\n update(args: AutomationUpdateArgs): Promise;\n}\ndeclare function createAutomationsArea(args: CreateSdkAreaArgs): AutomationsArea;\n\ninterface EnvironmentGetArgs {\n environmentId: string;\n}\ntype EnvironmentMergeBaseBranchUpdateValue = Exclude;\ntype EnvironmentNameUpdateValue = Exclude;\ninterface EnvironmentMergeBaseBranchUpdate {\n mergeBaseBranch: EnvironmentMergeBaseBranchUpdateValue;\n name?: EnvironmentNameUpdateValue;\n}\ninterface EnvironmentNameUpdate {\n mergeBaseBranch?: EnvironmentMergeBaseBranchUpdateValue;\n name: EnvironmentNameUpdateValue;\n}\ntype EnvironmentUpdateFields = EnvironmentMergeBaseBranchUpdate | EnvironmentNameUpdate;\ntype EnvironmentUpdateArgs = EnvironmentUpdateFields & {\n environmentId: string;\n};\ninterface EnvironmentStatusArgs extends EnvironmentStatusQuery {\n environmentId: string;\n}\ntype EnvironmentDiffArgs = EnvironmentDiffQuery & {\n environmentId: string;\n};\ntype EnvironmentDiffFileArgs = EnvironmentDiffFileQuery & {\n environmentId: string;\n};\ninterface EnvironmentDiffBranchesArgs extends EnvironmentDiffBranchesQuery {\n environmentId: string;\n}\ninterface EnvironmentCommitArgs {\n environmentId: string;\n}\ninterface EnvironmentSquashMergeArgs {\n environmentId: string;\n mergeBaseBranch: string;\n}\ntype EnvironmentActionResult = PublicApiOutput<\"/environments/:id/actions\", \"$post\">;\ntype EnvironmentCommitResult = Extract;\ntype EnvironmentDiffResult = PublicApiOutput<\"/environments/:id/diff\", \"$get\">;\ntype EnvironmentDiffBranchesResult = PublicApiOutput<\"/environments/:id/diff/branches\", \"$get\">;\ntype EnvironmentDiffFileResult = PublicApiOutput<\"/environments/:id/diff/file\", \"$get\">;\ntype EnvironmentGetResult = PublicApiOutput<\"/environments/:id\", \"$get\">;\ntype EnvironmentPullRequestResult = PublicApiOutput<\"/environments/:id/pull-request\", \"$get\">;\ntype EnvironmentSquashMergeResult = Extract;\ntype EnvironmentStatusResult = PublicApiOutput<\"/environments/:id/status\", \"$get\">;\ntype EnvironmentUpdateResult = PublicApiOutput<\"/environments/:id\", \"$patch\">;\ninterface EnvironmentsArea {\n commit(args: EnvironmentCommitArgs): Promise;\n diff(args: EnvironmentDiffArgs): Promise;\n diffBranches(args: EnvironmentDiffBranchesArgs): Promise;\n diffFile(args: EnvironmentDiffFileArgs): Promise;\n get(args: EnvironmentGetArgs): Promise;\n pullRequest(args: EnvironmentGetArgs): Promise;\n squashMerge(args: EnvironmentSquashMergeArgs): Promise;\n status(args: EnvironmentStatusArgs): Promise;\n update(args: EnvironmentUpdateArgs): Promise;\n}\ndeclare function createEnvironmentsArea(args: CreateSdkAreaArgs): EnvironmentsArea;\n\n/**\n * Host file primitives. `hostId` may be omitted to target the server's\n * primary (local) host. `rootPath`, when set, confines the target beneath\n * that absolute root on the host (symlink-safe).\n */\ninterface FileReadArgs {\n hostId?: string;\n path: string;\n rootPath?: string;\n}\ninterface FileWriteArgs {\n hostId?: string;\n path: string;\n rootPath?: string;\n content: string;\n /** Defaults to \"utf8\". */\n contentEncoding?: \"utf8\" | \"base64\";\n /** Defaults to false. */\n createParents?: boolean;\n /**\n * Optimistic-concurrency guard: omitted → unconditional write; a hash →\n * write only when the current content hashes to it (use `read().sha256`);\n * null → create-only. A failed guard resolves to the `conflict` outcome.\n */\n expectedSha256?: string | null;\n}\ninterface FileListArgs {\n hostId?: string;\n path: string;\n query?: string;\n limit?: number;\n}\ntype FileReadResult = PublicApiOutput<\"/files/read\", \"$post\">;\ntype FileWriteResult = PublicApiOutput<\"/files/write\", \"$post\">;\ntype FileListResult = PublicApiOutput<\"/files/list\", \"$post\">;\ninterface FilesArea {\n read(args: FileReadArgs): Promise;\n write(args: FileWriteArgs): Promise;\n list(args: FileListArgs): Promise;\n}\ndeclare function createFilesArea(args: CreateSdkAreaArgs): FilesArea;\n\ninterface GuideRenderArgs {\n chapter?: string;\n}\ninterface GuideRenderResult {\n chapter?: string;\n content: string;\n}\ninterface GuideArea {\n render(args?: GuideRenderArgs): GuideRenderResult;\n}\ndeclare function createGuideArea(): GuideArea;\n\ninterface HostGetArgs {\n hostId: string;\n}\ntype HostGetResult = PublicApiOutput<\"/hosts/:id\", \"$get\">;\ntype HostListResult = PublicApiOutput<\"/hosts\", \"$get\">;\ninterface HostsArea {\n get(args: HostGetArgs): Promise;\n list(): Promise;\n}\ndeclare function createHostsArea(args: CreateSdkAreaArgs): HostsArea;\n\ninterface ProjectListArgs extends ProjectListQuery {\n}\ninterface ProjectCreateArgs extends CreateProjectRequest {\n}\ninterface ProjectGetArgs {\n projectId: string;\n}\ninterface ProjectUpdateArgs extends UpdateProjectRequest {\n projectId: string;\n}\ninterface ProjectDeleteArgs {\n projectId: string;\n}\ninterface ProjectSourceAddArgs extends CreateProjectSourceRequest {\n projectId: string;\n}\ninterface ProjectSourceUpdateArgs extends UpdateProjectSourceRequest {\n projectId: string;\n sourceId: string;\n}\ninterface ProjectSourceDeleteArgs {\n projectId: string;\n sourceId: string;\n}\ntype ProjectCreateResult = PublicApiOutput<\"/projects\", \"$post\">;\ntype ProjectDeleteResult = PublicApiOutput<\"/projects/:id\", \"$delete\">;\ntype ProjectGetResult = PublicApiOutput<\"/projects/:id\", \"$get\">;\ntype ProjectListResult = PublicApiOutput<\"/projects\", \"$get\">;\ntype ProjectUpdateResult = PublicApiOutput<\"/projects/:id\", \"$patch\">;\ntype ProjectSourceAddResult = PublicApiOutput<\"/projects/:id/sources\", \"$post\">;\ntype ProjectSourceUpdateResult = PublicApiOutput<\"/projects/:id/sources/:sourceId\", \"$patch\">;\ntype ProjectSourceDeleteResult = PublicApiOutput<\"/projects/:id/sources/:sourceId\", \"$delete\">;\ninterface ProjectSourcesArea {\n add(args: ProjectSourceAddArgs): Promise;\n delete(args: ProjectSourceDeleteArgs): Promise;\n update(args: ProjectSourceUpdateArgs): Promise;\n}\ninterface ProjectsArea {\n create(args: ProjectCreateArgs): Promise;\n delete(args: ProjectDeleteArgs): Promise;\n get(args: ProjectGetArgs): Promise;\n list(args?: ProjectListArgs): Promise;\n sources: ProjectSourcesArea;\n update(args: ProjectUpdateArgs): Promise;\n}\ndeclare function createProjectsArea(args: CreateSdkAreaArgs): ProjectsArea;\n\ninterface ProviderModelsArgs extends SystemExecutionOptionsQuery {\n}\ntype ProviderListResult = PublicApiOutput<\"/system/providers\", \"$get\">;\ntype ProviderModelsResult = PublicApiOutput<\"/system/execution-options\", \"$get\">;\ninterface ProvidersArea {\n list(): Promise;\n models(args?: ProviderModelsArgs): Promise;\n}\ndeclare function createProvidersArea(args: CreateSdkAreaArgs): ProvidersArea;\n\ntype BbRealtimeUnsubscribe = () => void;\ntype BbRealtimeEventName = \"thread:changed\" | \"project:changed\" | \"environment:changed\" | \"host:changed\" | \"system:changed\" | \"system:config-changed\" | \"realtime:connection\";\ntype ThreadRealtimeEvent = Extract;\ntype ProjectRealtimeEvent = Extract;\ntype EnvironmentRealtimeEvent = Extract;\ntype HostRealtimeEvent = Extract;\ntype SystemRealtimeEvent = Extract;\ntype BbRealtimeConnectionState = \"connecting\" | \"connected\" | \"disconnected\";\ninterface BbRealtimeConnectionEvent {\n reconnectDelayMs: number | null;\n reconnected: boolean;\n state: BbRealtimeConnectionState;\n}\n/**\n * Entity-changed events are delivered as one shared object to every matching\n * listener; their payload types are readonly so a listener cannot mutate what\n * the next listener receives.\n */\ninterface BbRealtimeEventMap {\n \"thread:changed\": ThreadRealtimeEvent;\n \"project:changed\": ProjectRealtimeEvent;\n \"environment:changed\": EnvironmentRealtimeEvent;\n \"host:changed\": HostRealtimeEvent;\n \"system:changed\": SystemRealtimeEvent;\n \"system:config-changed\": SystemRealtimeEvent;\n \"realtime:connection\": BbRealtimeConnectionEvent;\n}\ntype BbRealtimeCallback = (event: BbRealtimeEventMap[TEventName]) => void;\ninterface ThreadRealtimeOnArgs {\n callback: BbRealtimeCallback<\"thread:changed\">;\n event: \"thread:changed\";\n threadId?: string;\n}\ninterface ProjectRealtimeOnArgs {\n callback: BbRealtimeCallback<\"project:changed\">;\n event: \"project:changed\";\n projectId?: string;\n}\ninterface EnvironmentRealtimeOnArgs {\n callback: BbRealtimeCallback<\"environment:changed\">;\n environmentId?: string;\n event: \"environment:changed\";\n}\ninterface HostRealtimeOnArgs {\n callback: BbRealtimeCallback<\"host:changed\">;\n event: \"host:changed\";\n hostId?: string;\n}\ninterface SystemRealtimeOnArgs {\n callback: BbRealtimeCallback<\"system:changed\">;\n event: \"system:changed\";\n}\ninterface SystemConfigRealtimeOnArgs {\n callback: BbRealtimeCallback<\"system:config-changed\">;\n event: \"system:config-changed\";\n}\n/**\n * Connection listeners are pure observers — they never open or hold the\n * socket. A listener registered while a socket already exists receives the\n * latest connection event as a snapshot on the next microtask, so a status\n * UI mounted after connect still learns the current state.\n */\ninterface RealtimeConnectionOnArgs {\n callback: BbRealtimeCallback<\"realtime:connection\">;\n event: \"realtime:connection\";\n}\ntype BbRealtimeOnArgsUnion = ThreadRealtimeOnArgs | ProjectRealtimeOnArgs | EnvironmentRealtimeOnArgs | HostRealtimeOnArgs | SystemRealtimeOnArgs | SystemConfigRealtimeOnArgs | RealtimeConnectionOnArgs;\ntype BbRealtimeOnArgs = Extract;\ninterface BbRealtime {\n on(args: BbRealtimeOnArgs): BbRealtimeUnsubscribe;\n}\n\ninterface StatusGetArgs {\n projectId?: string;\n threadId?: string;\n}\ninterface StatusThreadSummary {\n environmentId: string | null;\n id: string;\n parentThreadId: string | null;\n pinnedAt: number | null;\n projectId: string;\n status: ThreadStatus;\n title: string | null;\n}\ntype StatusProject = PublicApiOutput<\"/projects/:id\", \"$get\">;\ntype StatusChildThreads = PublicApiOutput<\"/threads\", \"$get\">;\ninterface StatusResult {\n childThreads: StatusChildThreads | null;\n pendingTodos: ThreadTimelinePendingTodos | null;\n project: StatusProject | null;\n thread: StatusThreadSummary | null;\n}\ninterface StatusArea {\n get(args?: StatusGetArgs): Promise;\n}\ndeclare function createStatusArea(args: CreateSdkAreaArgs): StatusArea;\n\ninterface ThemeArea {\n /** The active app palette, resolved server-side (built-in id or custom CSS). */\n get(): Promise;\n /** The custom-theme directory plus discovered themes and the active palette. */\n catalog(): Promise;\n /**\n * Activate a palette by id — a built-in id or a custom theme name that exists\n * under `/theme//theme.css`. Broadcasts to all open windows.\n */\n set(themeId: string): Promise;\n}\ndeclare function createThemeArea(args: CreateSdkAreaArgs): ThemeArea;\n\ninterface ThreadListArgs {\n archived?: boolean;\n hasParent?: boolean;\n parentThreadId?: string;\n projectId?: string;\n}\ninterface ThreadGetArgs {\n include?: ThreadGetQuery[\"include\"];\n threadId: string;\n}\ntype ThreadGetResult = PublicApiOutput<\"/threads/:id\", \"$get\">;\ntype ThreadListResult = PublicApiOutput<\"/threads\", \"$get\">;\ntype ThreadOutputResponse = PublicApiOutput<\"/threads/:id/output\", \"$get\">;\ntype ThreadMutationResult = PublicApiOutput<\"/threads/:id\", \"$patch\">;\ntype ThreadSpawnResult = PublicApiOutput<\"/threads\", \"$post\">;\ntype ThreadInteractionGetResult = PublicApiOutput<\"/threads/:id/interactions/:interactionId\", \"$get\">;\ntype ThreadInteractionListResult = PublicApiOutput<\"/threads/:id/interactions\", \"$get\">;\ntype ThreadInteractionResolveResult = PublicApiOutput<\"/threads/:id/interactions/:interactionId/resolve\", \"$post\">;\ntype ThreadEventsListResult = PublicApiOutput<\"/threads/:id/events\", \"$get\">;\ntype ThreadEventWaitResult = PublicApiOutput<\"/threads/:id/events/wait\", \"$get\">;\ntype ThreadTimelineResult = PublicApiOutput<\"/threads/:id/timeline\", \"$get\">;\ntype ThreadArchiveResult = PublicApiOutput<\"/threads/:id/archive\", \"$post\">;\ntype ThreadOpenResult = PublicApiOutput<\"/threads/:id/open\", \"$post\">;\ntype ThreadDeleteResult = PublicApiOutput<\"/threads/:id\", \"$delete\">;\ntype ThreadSendResult = PublicApiOutput<\"/threads/:id/send\", \"$post\">;\ntype ThreadStopResult = PublicApiOutput<\"/threads/:id/stop\", \"$post\">;\ntype ThreadTerminalCloseResult = PublicApiOutput<\"/terminals/:terminalId/close\", \"$post\">;\ntype ThreadTerminalCreateResult = PublicApiOutput<\"/terminals\", \"$post\">;\ntype ThreadTerminalInputResult = PublicApiOutput<\"/terminals/:terminalId/input\", \"$post\">;\ntype ThreadTerminalListResult = PublicApiOutput<\"/terminals\", \"$get\">;\ntype ThreadTerminalOutputResult = PublicApiOutput<\"/terminals/:terminalId/output\", \"$get\">;\ntype ThreadTerminalResizeResult = PublicApiOutput<\"/terminals/:terminalId/resize\", \"$post\">;\ntype ThreadTerminalUpdateResult = PublicApiOutput<\"/terminals/:terminalId\", \"$patch\">;\ntype ThreadUnarchiveResult = PublicApiOutput<\"/threads/:id/unarchive\", \"$post\">;\ninterface ThreadSpawnBaseArgs extends Omit {\n childOrigin?: CreateThreadRequest[\"childOrigin\"];\n origin?: CreateThreadRequest[\"origin\"];\n originKind?: CreateThreadRequest[\"originKind\"];\n startedOnBehalfOf?: CreateThreadRequest[\"startedOnBehalfOf\"];\n}\ntype ThreadSpawnArgs = ThreadSpawnBaseArgs & ({\n input: CreateThreadRequest[\"input\"];\n prompt?: never;\n} | {\n input?: never;\n prompt: string;\n});\ninterface ThreadUpdateArgs extends UpdateThreadRequest {\n threadId: string;\n}\ninterface ThreadDeleteArgs extends DeleteThreadRequest {\n threadId: string;\n}\ninterface ThreadSendArgs extends SendMessageRequest {\n threadId: string;\n}\ninterface ThreadStatusArgs {\n threadId: string;\n}\ninterface ThreadOpenArgs {\n threadId: string;\n source: PanelFileSource;\n path: string;\n lineNumber: number | null;\n}\ninterface ThreadEventsListArgs {\n afterSeq?: string;\n limit?: string;\n threadId: string;\n}\ninterface ThreadEventWaitArgs {\n afterSeq?: string;\n threadId: string;\n type: string;\n waitMs: string;\n}\ninterface ThreadTimelineArgs extends ThreadTimelineQuery {\n threadId: string;\n}\ninterface ThreadOutputArgs {\n threadId: string;\n}\ninterface ThreadTerminalListArgs {\n threadId: string;\n}\ninterface ThreadTerminalCreateArgs extends Omit {\n threadId: string;\n}\ninterface ThreadTerminalTargetArgs {\n terminalId: string;\n threadId: string;\n}\ninterface ThreadTerminalUpdateArgs extends ThreadTerminalTargetArgs, UpdateTerminalRequest {\n}\ninterface ThreadTerminalCloseArgs extends ThreadTerminalTargetArgs, CloseTerminalRequest {\n}\ninterface ThreadTerminalInputArgs extends ThreadTerminalTargetArgs, TerminalInputRequest {\n}\ninterface ThreadTerminalResizeArgs extends ThreadTerminalTargetArgs, TerminalResizeRequest {\n}\ninterface ThreadTerminalOutputArgs extends ThreadTerminalTargetArgs, TerminalOutputQuery {\n}\ninterface ThreadInteractionListArgs {\n threadId: string;\n}\ninterface ThreadInteractionGetArgs extends ThreadInteractionListArgs {\n interactionId: string;\n}\ninterface ThreadInteractionResolveArgs extends ThreadInteractionGetArgs {\n resolution: PendingInteractionResolution;\n}\ntype ThreadWaitTarget = {\n kind: \"status\";\n status: ThreadStatus;\n} | {\n kind: \"event\";\n eventType: string;\n};\ninterface ThreadWaitArgs {\n event?: string;\n pollIntervalMs?: number;\n status?: ThreadStatus;\n threadId: string;\n timeoutMs?: number;\n}\ntype ThreadWaitResult = {\n event: NonNullable;\n matched: true;\n target: Extract;\n threadId: string;\n} | {\n matched: true;\n target: Extract;\n thread: ThreadGetResult;\n threadId: string;\n};\ninterface ThreadInteractionsArea {\n get(args: ThreadInteractionGetArgs): Promise;\n list(args: ThreadInteractionListArgs): Promise;\n resolve(args: ThreadInteractionResolveArgs): Promise;\n}\ninterface ThreadEventsArea {\n list(args: ThreadEventsListArgs): Promise;\n wait(args: ThreadEventWaitArgs): Promise;\n}\ninterface ThreadTerminalsArea {\n close(args: ThreadTerminalCloseArgs): Promise;\n create(args: ThreadTerminalCreateArgs): Promise;\n input(args: ThreadTerminalInputArgs): Promise;\n list(args: ThreadTerminalListArgs): Promise;\n output(args: ThreadTerminalOutputArgs): Promise;\n resize(args: ThreadTerminalResizeArgs): Promise;\n update(args: ThreadTerminalUpdateArgs): Promise;\n}\ninterface ThreadsArea {\n archive(args: ThreadStatusArgs): Promise;\n delete(args: ThreadDeleteArgs): Promise;\n events: ThreadEventsArea;\n get(args: ThreadGetArgs): Promise;\n interactions: ThreadInteractionsArea;\n list(args?: ThreadListArgs): Promise;\n open(args: ThreadOpenArgs): Promise;\n output(args: ThreadOutputArgs): Promise;\n pin(args: ThreadStatusArgs): Promise;\n send(args: ThreadSendArgs): Promise;\n spawn(args: ThreadSpawnArgs): Promise;\n stop(args: ThreadStatusArgs): Promise;\n terminals: ThreadTerminalsArea;\n timeline(args: ThreadTimelineArgs): Promise;\n unarchive(args: ThreadStatusArgs): Promise;\n unpin(args: ThreadStatusArgs): Promise;\n update(args: ThreadUpdateArgs): Promise;\n wait(args: ThreadWaitArgs): Promise;\n}\ndeclare function createThreadsArea(args: CreateSdkAreaArgs): ThreadsArea;\n\ninterface BbSdk extends BbRealtime {\n automations: ReturnType;\n environments: ReturnType;\n files: ReturnType;\n guide: ReturnType;\n hosts: ReturnType;\n projects: ReturnType;\n providers: ReturnType;\n status: ReturnType;\n theme: ReturnType;\n threads: ReturnType;\n}\n\n/**\n * The backend plugin API contract — the `bb` object handed to a plugin's\n * `server.ts` factory (`export default function plugin(bb: BbPluginApi)`).\n *\n * Types only: the implementation lives in the BB server\n * (apps/server/src/services/plugins/plugin-api.ts), which imports these\n * shapes so the contract and the implementation cannot drift. Plugin authors\n * import them type-only (`import type { BbPluginApi } from\n * \"@bb/plugin-sdk\"`); the import is erased when BB loads the file.\n *\n * Runtime classes stay host-side. NeedsConfigurationError in particular is\n * matched by NAME, so plugin code needs no runtime import:\n * `throw Object.assign(new Error(msg), { name: \"NeedsConfigurationError\" })`.\n */\ninterface PluginLogger {\n debug(message: string): void;\n info(message: string): void;\n warn(message: string): void;\n error(message: string): void;\n}\n/**\n * Declarative settings descriptors (`bb.settings.define`). Deliberately plain\n * data — not zod — so the host can render settings forms and the CLI can\n * parse values without executing plugin code.\n */\ntype PluginSettingDescriptor = {\n type: \"string\";\n label: string;\n description?: string;\n /** Stored in a 0600 file under /plugins//secrets/, never in the db or sent to the frontend. */\n secret?: true;\n default?: string;\n} | {\n type: \"boolean\";\n label: string;\n description?: string;\n default?: boolean;\n} | {\n type: \"select\";\n label: string;\n description?: string;\n options: string[];\n default?: string;\n} | {\n type: \"project\";\n label: string;\n description?: string;\n default?: string;\n};\ntype PluginSettingDescriptors = Record;\ntype PluginSettingValue = string | boolean;\n/** `default` present → non-optional value; absent → `T | undefined`. */\ntype PluginSettingsValues> = {\n [K in keyof Ds]: Ds[K] extends {\n default: string | boolean;\n } ? PluginSettingValueOf : PluginSettingValueOf | undefined;\n};\ntype PluginSettingValueOf = D extends {\n type: \"boolean\";\n} ? boolean : string;\ninterface PluginSettingsHandle> {\n /** Load-safe: callable inside the factory. */\n get(): Promise>;\n /** Fires after values change through the settings route/CLI. */\n onChange(listener: (next: PluginSettingsValues, prev: PluginSettingsValues) => void): void;\n}\ninterface PluginSettings {\n define>(descriptors: Ds): PluginSettingsHandle;\n}\ninterface PluginKvStorage {\n get(key: string): Promise;\n set(key: string, value: unknown): Promise;\n delete(key: string): Promise;\n list(prefix?: string): Promise;\n}\ninterface PluginStorage {\n /** Namespaced JSON key-value rows in bb.db; values ≤256KB each. */\n kv: PluginKvStorage;\n /**\n * Open (or reuse the path of) the plugin's own SQLite database at\n * /plugins//data.db — the server's better-sqlite3, WAL mode,\n * busy_timeout 5000. Handles are host-tracked and closed on\n * dispose/reload; a closed handle throws on use.\n */\n sqlite(): Database.Database;\n /**\n * Ordered-statement migration helper: statement index = migration id in a\n * `_bb_migrations` table; unapplied statements run in one transaction.\n * Append-only — never reorder or edit shipped statements.\n */\n migrate(db: Database.Database, statements: string[]): void;\n}\n/**\n * Thread lifecycle events a plugin can observe (design §4.5). Observe-only:\n * handlers run fire-and-forget after the transition is applied and can never\n * block or veto it. `thread` is the same public DTO GET /threads/:id serves.\n */\ninterface PluginThreadEventPayloads {\n \"thread.created\": {\n thread: ThreadResponse;\n };\n /** Fired when a thread transitions into `idle`. `lastAssistantText` is\n * assembled the same way GET /threads/:id/output is. */\n \"thread.idle\": {\n thread: ThreadResponse;\n lastAssistantText: string | null;\n };\n /** Fired when a thread transitions into `error`. `error` is the latest\n * system/error event message, when one exists. */\n \"thread.failed\": {\n thread: ThreadResponse;\n error: string | null;\n };\n}\ntype PluginThreadEventName = keyof PluginThreadEventPayloads;\ntype PluginThreadEventHandler = (payload: PluginThreadEventPayloads[E]) => void | Promise;\ntype PluginHttpAuthMode = \"local\" | \"token\" | \"none\";\ntype PluginHttpHandler = (context: Context) => Response | Promise;\ninterface PluginHttp {\n /**\n * Register an HTTP route, mounted at\n * `/api/v1/plugins//http/`. Auth modes (default \"local\"):\n * - \"local\": Origin/Host must be a local BB app origin; non-GET requires\n * content-type application/json (forces a CORS preflight).\n * - \"token\": requires the per-plugin token (`bb plugin token `) via\n * the x-bb-plugin-token header or ?token=.\n * - \"none\": no checks — only for signature-verified webhooks.\n */\n route(method: string, path: string, handler: PluginHttpHandler, opts?: {\n auth?: PluginHttpAuthMode;\n }): void;\n}\ninterface PluginRpc {\n /**\n * Register rpc methods, served at POST\n * `/api/v1/plugins//rpc/` with \"local\" auth semantics. The\n * JSON request body is the input; the response is\n * `{ ok: true, result }` or `{ ok: false, error }`. Inputs and outputs\n * must survive a JSON round-trip — results are serialized with\n * JSON.stringify and nothing else.\n */\n register(handlers: Record unknown>): void;\n}\ninterface PluginRealtime {\n /**\n * Broadcast an ephemeral `plugin-signal` WS message\n * `{ pluginId, channel, payload }` to every connected client (V1 has no\n * per-channel subscriptions). `payload` must be JSON-serializable;\n * `undefined` is normalized to `null`. Nothing is persisted.\n */\n publish(channel: string, payload: unknown): void;\n}\ninterface PluginBackground {\n /**\n * Register a long-lived background service. `start` runs after the\n * factory completes and should resolve when `signal` aborts\n * (dispose/reload/disable/shutdown). A crash restarts it with capped\n * exponential backoff; throwing NeedsConfigurationError marks the plugin\n * `needs-configuration` and stops restarting until the next load.\n */\n service(name: string, service: {\n start(signal: AbortSignal): void | Promise;\n }): void;\n /**\n * Register a cron schedule (5-field expression, server-local time). The\n * durable row keyed (pluginId, name) is upserted at load; the periodic\n * sweep claims due rows with a CAS on next_run_at, but only while this\n * plugin is loaded. Failures land in last_status/last_error, visible in\n * `bb plugin list`.\n */\n schedule(name: string, cron: string, fn: () => void | Promise): void;\n}\ninterface PluginCliCommandInfo {\n name: string;\n summary: string;\n usage: string;\n}\n/** Context forwarded from the invoking CLI when known; all fields optional. */\ninterface PluginCliContext {\n cwd?: string;\n threadId?: string;\n projectId?: string;\n}\ninterface PluginCliResult {\n exitCode: number;\n stdout?: string;\n stderr?: string;\n}\ninterface PluginCliRegistration {\n /** Top-level command name (`bb …`): lowercase [a-z0-9-]+, and not\n * a core bb command (see RESERVED_BB_CLI_COMMANDS in the server). */\n name: string;\n summary: string;\n /** Subcommand metadata rendered in help and the plugin-commands skill\n * without executing plugin code. Parsing argv is plugin-owned. */\n commands?: PluginCliCommandInfo[];\n run(argv: string[], ctx: PluginCliContext): PluginCliResult | Promise;\n}\ninterface PluginCli {\n /**\n * Register this plugin's `bb` subcommand. One registration per plugin —\n * a second call replaces the first. Core bb commands always win name\n * collisions; reserved names are rejected at registration.\n */\n register(registration: PluginCliRegistration): void;\n}\n/** Per-turn context handed to bb.agents context providers (design §4.4). */\n/** MCP-style content parts a native tool may return (design §4.4). */\ntype PluginAgentToolContentPart = {\n type: \"text\";\n text: string;\n} | {\n type: \"image\";\n data: string;\n mimeType: string;\n};\ntype PluginAgentToolResult = string | {\n content: PluginAgentToolContentPart[];\n isError?: boolean;\n};\n/** Per-call context handed to a native tool's execute (design §4.4). */\ninterface PluginAgentToolContext {\n threadId: string;\n projectId: string;\n /** The tool-call request's abort signal (aborts if the daemon round-trip\n * is torn down mid-call). */\n signal: AbortSignal;\n}\ninterface PluginAgentToolRegistrationBase {\n /** Tool name shown to the model: [a-zA-Z0-9_-]+, unique across plugins,\n * and not a built-in dynamic tool (see RESERVED_AGENT_TOOL_NAMES in the\n * server). */\n name: string;\n description: string;\n /**\n * Optional usage snippet appended to the thread instructions whenever\n * this tool is in the session's tool set (mirrors the built-in\n * update_environment_directory guidance). Keep it short.\n */\n instructions?: string;\n}\ninterface PluginAgents {\n /**\n * Register a native dynamic tool (design §4.4). `parameters` is either a\n * zod schema (validated per call; execute receives the parsed value) or a\n * plain JSON-schema object (no validation; execute receives the raw\n * arguments as `unknown`). Tool-set changes apply on the NEXT session\n * start — a tool registered mid-session is not hot-added to running\n * provider sessions. A second registration of the same name within this\n * plugin replaces the first; a name already registered by another plugin\n * is rejected and surfaced as this plugin's status detail.\n */\n registerTool(tool: PluginAgentToolRegistrationBase & {\n parameters: Schema;\n execute(params: z.output, ctx: PluginAgentToolContext): PluginAgentToolResult | Promise;\n }): void;\n registerTool(tool: PluginAgentToolRegistrationBase & {\n /** Raw JSON-schema escape hatch; params arrive unvalidated. */\n parameters: Record;\n execute(params: unknown, ctx: PluginAgentToolContext): PluginAgentToolResult | Promise;\n }): void;\n}\ninterface PluginThreadActionContext {\n threadId: string;\n projectId: string;\n}\ninterface PluginThreadActionToast {\n kind: \"success\" | \"error\" | \"info\";\n message: string;\n}\ntype PluginThreadActionResult = void | {\n toast?: PluginThreadActionToast;\n};\ninterface PluginThreadActionRegistration {\n /** Unique within this plugin: [a-zA-Z0-9_-]+ (becomes a URL segment). */\n id: string;\n /** Button label rendered in the thread header. */\n title: string;\n /** Optional icon name; the host falls back to a generic icon. */\n icon?: string;\n /** Optional confirmation prompt the host shows before running. */\n confirm?: string;\n /**\n * Runs server-side when the user clicks the action. The host shows a\n * pending state while in flight, the returned toast on completion, and an\n * automatic error toast when this throws.\n */\n run(ctx: PluginThreadActionContext): PluginThreadActionResult | Promise;\n}\n/** Search context handed to a mention provider (design §4.9). `projectId`/\n * `threadId` are null when the composer has not committed one yet. */\ninterface PluginMentionSearchContext {\n query: string;\n projectId: string | null;\n threadId: string | null;\n}\n/** One row a mention provider returns from `search`. `id` is the provider's\n * own item id — the host namespaces it before it reaches the wire. */\ninterface PluginMentionItem {\n id: string;\n title: string;\n subtitle?: string;\n icon?: string;\n}\ninterface PluginMentionProviderRegistration {\n /** Unique within this plugin: [a-zA-Z0-9_-]+ (no \":\" — the host composes\n * wire item ids as \":\"). */\n id: string;\n /** Section label shown above this provider's rows in the mention menu. */\n label: string;\n /**\n * Runs server-side as the user types after `@` in the composer. Each call\n * is time-boxed (2s) and failure-isolated: a slow or throwing provider\n * contributes an empty list — it can never break the mention menu.\n */\n search(ctx: PluginMentionSearchContext): PluginMentionItem[] | Promise;\n /**\n * Resolves one picked item into agent context, called once per unique\n * item at message send time. The returned `context` is attached to the\n * message as an agent-visible (user-hidden) prompt input. Throwing blocks\n * the send with a visible error.\n */\n resolve(itemId: string): {\n context: string;\n } | Promise<{\n context: string;\n }>;\n}\ninterface PluginUi {\n /**\n * Register a thread action rendered in the shipped app's thread header\n * (design §4.9). Multiple actions per plugin; ids must be unique within\n * the plugin. Invoked via POST /plugins/:id/actions/:actionId.\n */\n registerThreadAction(action: PluginThreadActionRegistration): void;\n /**\n * Register an `@`-mention provider for the shipped app's composer\n * (design §4.9). Items group under `label` in the mention menu; a picked\n * item becomes a `{ kind: \"plugin\" }` mention resource whose context is\n * resolved once at send time. Multiple providers per plugin; ids must be\n * unique within the plugin.\n */\n registerMentionProvider(provider: PluginMentionProviderRegistration): void;\n}\ninterface PluginStatusApi {\n /**\n * Mark this plugin `needs-configuration` (with a message shown in\n * `bb plugin list` and the UI) instead of failing — e.g. a factory or\n * service that finds no API key configured. Cleared on the next load;\n * saving settings does not auto-reload in V1, so ask the user to\n * `bb plugin reload ` after configuring.\n */\n needsConfiguration(message: string): void;\n}\n/**\n * The API object handed to a plugin's factory (design §4). Implemented by\n * the BB server; this contract is what plugin `server.ts` files compile\n * against.\n */\ninterface BbPluginApi {\n /** The plugin's own id (namespaces storage, routes, commands). */\n readonly pluginId: string;\n /** Leveled, plugin-scoped logger. */\n readonly log: PluginLogger;\n /** Declarative settings (design §4.2). */\n readonly settings: PluginSettings;\n /** Namespaced KV + per-plugin SQLite (design §4.3). */\n readonly storage: PluginStorage;\n /** HTTP routes under /api/v1/plugins//http/* (design §4.6). */\n readonly http: PluginHttp;\n /** RPC methods under /api/v1/plugins//rpc/ (design §4.6). */\n readonly rpc: PluginRpc;\n /** Ephemeral push to connected frontends (design §4.7). */\n readonly realtime: PluginRealtime;\n /** Long-lived services + cron schedules (design §4.8). */\n readonly background: PluginBackground;\n /** Agent-facing `bb` CLI subcommand (design §4.4). */\n readonly cli: PluginCli;\n /** Per-turn agent context contributions (design §4.4). */\n readonly agents: PluginAgents;\n /** Host-rendered UI contributions (design §4.9). */\n readonly ui: PluginUi;\n /** Plugin-reported status (needs-configuration). */\n readonly status: PluginStatusApi;\n /**\n * The full BB SDK, bound to this server over loopback (design §4.1).\n * Bind-gated: reading this before the host binds the SDK throws. The real\n * server binds it before loading plugins, so it is available from the\n * moment factories run there — but isolated harnesses may not, so prefer\n * using it from handlers, services, and timers for portability.\n * `threads.spawn` defaults `origin` to \"plugin\" and `originPluginId` to\n * this plugin's id so spawned threads are attributed automatically.\n */\n readonly sdk: BbSdk;\n /**\n * Observe thread lifecycle events (design §4.5). Load-safe registration;\n * handlers run async after the transition and never affect it. Errors are\n * caught, logged, and counted against this plugin's handler stats.\n */\n on(event: E, handler: PluginThreadEventHandler): void;\n /**\n * Register cleanup to run on reload/disable/shutdown. Hooks run LIFO.\n * The sanctioned place to clear timers and close connections.\n */\n onDispose(hook: () => void | Promise): void;\n}\n\nexport { PLUGIN_SDK_APP_EXPORT_NAMES, PLUGIN_SLOT_ID_PATTERN };\nexport type { BbContext, BbNavigate, BbPluginApi, PluginAgentToolContentPart, PluginAgentToolContext, PluginAgentToolRegistrationBase, PluginAgentToolResult, PluginAgents, PluginAppBuilder, PluginAppDefinition, PluginAppSetup, PluginAppSlots, PluginBackground, PluginCli, PluginCliCommandInfo, PluginCliContext, PluginCliRegistration, PluginCliResult, PluginComposerAccessoryProps, PluginComposerAccessoryRegistration, PluginHomepageSectionProps, PluginHomepageSectionRegistration, PluginHttp, PluginHttpAuthMode, PluginHttpHandler, PluginKvStorage, PluginLogger, PluginMentionItem, PluginMentionProviderRegistration, PluginMentionSearchContext, PluginNavPanelProps, PluginNavPanelRegistration, PluginRealtime, PluginRpc, PluginRpcClient, PluginSdkApp, PluginSettingDescriptor, PluginSettingDescriptors, PluginSettingValue, PluginSettings, PluginSettingsHandle, PluginSettingsState, PluginSettingsValues, PluginStatusApi, PluginStorage, PluginThreadActionContext, PluginThreadActionRegistration, PluginThreadActionResult, PluginThreadActionToast, PluginThreadEventHandler, PluginThreadEventName, PluginThreadEventPayloads, PluginThreadPanelActionContext, PluginThreadPanelActionRegistration, PluginThreadPanelProps, PluginUi };\n"; -export const PLUGIN_SDK_APP_DTS = "// Bundled type declarations for `@bb/plugin-sdk`, shipped into scaffolded\n// plugins so they typecheck without the @bb/* workspace on disk.\n//\n// Confused by the API, or need a symbol that isn't here? Clone the BB repo\n// and read the real source: https://github.com/ymichael/bb\n\nimport { ComponentType } from 'react';\n\n/**\n * The `@bb/plugin-sdk/app` contract (plugin design §5.2) — pure types plus\n * the runtime export-name list, with no side effects. This module is what the\n * BB app imports to keep its real implementation in sync (`satisfies\n * PluginSdkApp`) and what `bb plugin build` imports to generate the shim's\n * named-export list. Plugin authors import the same shapes through\n * `@bb/plugin-sdk/app`.\n *\n * Per-slot props are versioned contracts: additive-only within an SDK major.\n */\n/** Props passed to a `homepageSection` component. */\ninterface PluginHomepageSectionProps {\n /** Project in view on the compose surface; null when none is selected. */\n projectId: string | null;\n}\n/** Props passed to a `navPanel` component (it owns its whole route). */\ninterface PluginNavPanelProps {\n}\n/** Props passed to a panel tab opened by a `threadPanelAction`. */\ninterface PluginThreadPanelProps {\n threadId: string;\n /**\n * The JSON value the action's `openPanel` call passed (round-tripped\n * through persistence, so the tab restores across reloads); null when the\n * action opened the panel without params.\n */\n params: unknown;\n}\n/** Props passed to a `composerAccessory` component. */\ninterface PluginComposerAccessoryProps {\n projectId: string | null;\n threadId: string | null;\n}\n/**\n * Slot/panel ids and nav-panel paths must match this pattern (letters,\n * digits, `-`, `_`): they ride URLs and persisted panel-tab keys.\n */\ndeclare const PLUGIN_SLOT_ID_PATTERN: RegExp;\ninterface PluginHomepageSectionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n component: ComponentType;\n}\ninterface PluginNavPanelRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n /** Icon hint (BB icon name); unknown names fall back to a generic icon. */\n icon: string;\n /** URL segment under `/plugins//`; letters, digits, `-`, `_`. */\n path: string;\n component: ComponentType;\n /**\n * Panel chrome (default \"page\"): \"page\" renders the host title bar (plugin\n * logo + `title` + your `headerContent`) above a full-width padded body;\n * \"none\" hands the ENTIRE panel area to `component` — no host padding, no\n * title bar (`headerContent` is ignored) — only the per-plugin error\n * boundary remains.\n */\n chrome?: \"page\" | \"none\";\n /**\n * Optional component rendered on the right side of the \"page\" title bar\n * (e.g. a sync button or a count). Contained separately from the body: a\n * throwing headerContent is hidden without breaking the title bar.\n */\n headerContent?: ComponentType;\n}\n/** Context handed to a `threadPanelAction`'s `run`. */\ninterface PluginThreadPanelActionContext {\n /** The thread whose panel launcher invoked the action. */\n threadId: string;\n /**\n * Open a tab in the thread's side panel rendering this action's\n * `component`. `title` labels the tab (default: the action's `title`);\n * `params` must be JSON-serializable — it is persisted with the tab and\n * reaches the component as its `params` prop. Opening with params\n * identical to an already-open tab of this action focuses that tab\n * (updating its title) instead of duplicating it. May be called more than\n * once (different params ⇒ multiple tabs) or not at all.\n */\n openPanel(options?: {\n title?: string;\n params?: unknown;\n }): void;\n}\ninterface PluginThreadPanelActionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n /** Label of the action row in the panel's new-tab launcher. */\n title: string;\n /**\n * Icon hint (BB icon name) used when the plugin ships no logo; the\n * launcher row and opened tabs prefer the plugin's logo.\n */\n icon?: string;\n /** Rendered inside every panel tab this action opens. */\n component: ComponentType;\n /**\n * Runs when the user activates the action: call your RPC methods, show a\n * toast, and/or open panel tabs via `context.openPanel`. Omitted =\n * immediately open a panel tab with defaults. Errors (sync or async) are\n * contained and logged; they never break the launcher.\n */\n run?(context: PluginThreadPanelActionContext): void | Promise;\n}\ninterface PluginComposerAccessoryRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n component: ComponentType;\n}\ninterface PluginAppSlots {\n homepageSection(registration: PluginHomepageSectionRegistration): void;\n navPanel(registration: PluginNavPanelRegistration): void;\n threadPanelAction(registration: PluginThreadPanelActionRegistration): void;\n composerAccessory(registration: PluginComposerAccessoryRegistration): void;\n}\ninterface PluginAppBuilder {\n slots: PluginAppSlots;\n}\ntype PluginAppSetup = (app: PluginAppBuilder) => void;\n/**\n * The opaque product of `definePluginApp` — a plugin's `app.tsx` default\n * export. The host re-runs `setup` against a fresh collector on every\n * (re)interpretation, replacing that plugin's registrations wholesale.\n */\ninterface PluginAppDefinition {\n /** Brand the host checks before interpreting a bundle's default export. */\n readonly __bbPluginApp: true;\n readonly setup: PluginAppSetup;\n}\ninterface PluginRpcClient {\n /**\n * Invoke one of the plugin's `bb.rpc` methods (POST\n * /api/v1/plugins/<id>/rpc/<method>). Resolves with the method's\n * result; rejects with an `Error` carrying the server's message when the\n * handler fails or the plugin is not running.\n */\n call(method: string, input?: unknown): Promise;\n}\ninterface PluginSettingsState {\n /**\n * Effective non-secret setting values (secret settings are excluded —\n * read them server-side). Undefined while loading or unavailable.\n */\n values: Record | undefined;\n isLoading: boolean;\n}\n/** Current app selection, derived from the route. */\ninterface BbContext {\n projectId: string | null;\n threadId: string | null;\n}\ninterface BbNavigate {\n toThread(threadId: string): void;\n toProject(projectId: string): void;\n /** Navigate to one of this plugin's own nav panels by its `path`. */\n toPluginPanel(path: string): void;\n}\n/**\n * Everything `@bb/plugin-sdk/app` resolves to at runtime. The BB app builds\n * the real implementation and `satisfies` this interface; `bb plugin build`\n * shims the specifier to that object on `globalThis.__bbPluginRuntime`.\n */\ninterface PluginSdkApp {\n definePluginApp(setup: PluginAppSetup): PluginAppDefinition;\n useRpc(): PluginRpcClient;\n useRealtime(channel: string, handler: (payload: unknown) => void): void;\n useSettings(): PluginSettingsState;\n useBbContext(): BbContext;\n useBbNavigate(): BbNavigate;\n}\n/**\n * Named runtime exports of `@bb/plugin-sdk/app`, in sorted order. Single\n * source of truth for the build shim's export list and the app's\n * implementation-key test — adding a surface member without updating this\n * list fails the type assertion below.\n */\ndeclare const PLUGIN_SDK_APP_EXPORT_NAMES: readonly [\"definePluginApp\", \"useBbContext\", \"useBbNavigate\", \"useRealtime\", \"useRpc\", \"useSettings\"];\n\ndeclare const definePluginApp: (setup: PluginAppSetup) => PluginAppDefinition;\ndeclare const useRpc: () => PluginRpcClient;\ndeclare const useRealtime: (channel: string, handler: (payload: unknown) => void) => void;\ndeclare const useSettings: () => PluginSettingsState;\ndeclare const useBbContext: () => BbContext;\ndeclare const useBbNavigate: () => BbNavigate;\n\nexport { PLUGIN_SDK_APP_EXPORT_NAMES, PLUGIN_SLOT_ID_PATTERN, definePluginApp, useBbContext, useBbNavigate, useRealtime, useRpc, useSettings };\nexport type { BbContext, BbNavigate, PluginAppBuilder, PluginAppDefinition, PluginAppSetup, PluginAppSlots, PluginComposerAccessoryProps, PluginComposerAccessoryRegistration, PluginHomepageSectionProps, PluginHomepageSectionRegistration, PluginNavPanelProps, PluginNavPanelRegistration, PluginRpcClient, PluginSdkApp, PluginSettingsState, PluginThreadPanelActionContext, PluginThreadPanelActionRegistration, PluginThreadPanelProps };\n"; +export const PLUGIN_SDK_APP_DTS = "// Bundled type declarations for `@bb/plugin-sdk`, shipped into scaffolded\n// plugins so they typecheck without the @bb/* workspace on disk.\n//\n// Confused by the API, or need a symbol that isn't here? Clone the BB repo\n// and read the real source: https://github.com/ymichael/bb\n\nimport { ComponentType } from 'react';\n\n/**\n * The `@bb/plugin-sdk/app` contract (plugin design §5.2) — pure types plus\n * the runtime export-name list, with no side effects. This module is what the\n * BB app imports to keep its real implementation in sync (`satisfies\n * PluginSdkApp`) and what `bb plugin build` imports to generate the shim's\n * named-export list. Plugin authors import the same shapes through\n * `@bb/plugin-sdk/app`.\n *\n * Per-slot props are versioned contracts: additive-only within an SDK major.\n */\n/** Props passed to a `homepageSection` component. */\ninterface PluginHomepageSectionProps {\n /** Project in view on the compose surface; null when none is selected. */\n projectId: string | null;\n}\n/** Props passed to a `navPanel` component (it owns its whole route). */\ninterface PluginNavPanelProps {\n /**\n * The route remainder after the panel root, \"\" at the root. The panel's\n * route is `/plugins///*`, so a deep link like\n * `/plugins/notes/notes/work/ideas.md` renders the panel with\n * `subPath: \"work/ideas.md\"`. Navigate within the panel via\n * `useBbNavigate().toPluginPanel(path, { subPath })` — browser\n * back/forward then walks panel-internal history.\n */\n subPath: string;\n}\n/** Props passed to a panel tab opened by a `threadPanelAction`. */\ninterface PluginThreadPanelProps {\n threadId: string;\n /**\n * The JSON value the action's `openPanel` call passed (round-tripped\n * through persistence, so the tab restores across reloads); null when the\n * action opened the panel without params.\n */\n params: unknown;\n}\n/** Props passed to a `composerAccessory` component. */\ninterface PluginComposerAccessoryProps {\n projectId: string | null;\n threadId: string | null;\n}\n/**\n * Slot/panel ids and nav-panel paths must match this pattern (letters,\n * digits, `-`, `_`): they ride URLs and persisted panel-tab keys.\n */\ndeclare const PLUGIN_SLOT_ID_PATTERN: RegExp;\ninterface PluginHomepageSectionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n component: ComponentType;\n}\ninterface PluginNavPanelRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n /** Icon hint (BB icon name); unknown names fall back to a generic icon. */\n icon: string;\n /** URL segment under `/plugins//`; letters, digits, `-`, `_`. */\n path: string;\n component: ComponentType;\n /**\n * Panel chrome (default \"page\"): \"page\" renders the host title bar (plugin\n * logo + `title` + your `headerContent`) above a full-width padded body;\n * \"none\" hands the ENTIRE panel area to `component` — no host padding, no\n * title bar (`headerContent` is ignored) — only the per-plugin error\n * boundary remains.\n */\n chrome?: \"page\" | \"none\";\n /**\n * Optional component rendered on the right side of the \"page\" title bar\n * (e.g. a sync button or a count). Contained separately from the body: a\n * throwing headerContent is hidden without breaking the title bar.\n */\n headerContent?: ComponentType;\n}\n/** Context handed to a `threadPanelAction`'s `run`. */\ninterface PluginThreadPanelActionContext {\n /** The thread whose panel launcher invoked the action. */\n threadId: string;\n /**\n * Open a tab in the thread's side panel rendering this action's\n * `component`. `title` labels the tab (default: the action's `title`);\n * `params` must be JSON-serializable — it is persisted with the tab and\n * reaches the component as its `params` prop. Opening with params\n * identical to an already-open tab of this action focuses that tab\n * (updating its title) instead of duplicating it. May be called more than\n * once (different params ⇒ multiple tabs) or not at all.\n */\n openPanel(options?: {\n title?: string;\n params?: unknown;\n }): void;\n}\ninterface PluginThreadPanelActionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n /** Label of the action row in the panel's new-tab launcher. */\n title: string;\n /**\n * Icon hint (BB icon name) used when the plugin ships no logo; the\n * launcher row and opened tabs prefer the plugin's logo.\n */\n icon?: string;\n /** Rendered inside every panel tab this action opens. */\n component: ComponentType;\n /**\n * Runs when the user activates the action: call your RPC methods, show a\n * toast, and/or open panel tabs via `context.openPanel`. Omitted =\n * immediately open a panel tab with defaults. Errors (sync or async) are\n * contained and logged; they never break the launcher.\n */\n run?(context: PluginThreadPanelActionContext): void | Promise;\n}\ninterface PluginComposerAccessoryRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n component: ComponentType;\n}\ninterface PluginAppSlots {\n homepageSection(registration: PluginHomepageSectionRegistration): void;\n navPanel(registration: PluginNavPanelRegistration): void;\n threadPanelAction(registration: PluginThreadPanelActionRegistration): void;\n composerAccessory(registration: PluginComposerAccessoryRegistration): void;\n}\ninterface PluginAppBuilder {\n slots: PluginAppSlots;\n}\ntype PluginAppSetup = (app: PluginAppBuilder) => void;\n/**\n * The opaque product of `definePluginApp` — a plugin's `app.tsx` default\n * export. The host re-runs `setup` against a fresh collector on every\n * (re)interpretation, replacing that plugin's registrations wholesale.\n */\ninterface PluginAppDefinition {\n /** Brand the host checks before interpreting a bundle's default export. */\n readonly __bbPluginApp: true;\n readonly setup: PluginAppSetup;\n}\ninterface PluginRpcClient {\n /**\n * Invoke one of the plugin's `bb.rpc` methods (POST\n * /api/v1/plugins/<id>/rpc/<method>). Resolves with the method's\n * result; rejects with an `Error` carrying the server's message when the\n * handler fails or the plugin is not running.\n */\n call(method: string, input?: unknown): Promise;\n}\ninterface PluginSettingsState {\n /**\n * Effective non-secret setting values (secret settings are excluded —\n * read them server-side). Undefined while loading or unavailable.\n */\n values: Record | undefined;\n isLoading: boolean;\n}\n/** Current app selection, derived from the route. */\ninterface BbContext {\n projectId: string | null;\n threadId: string | null;\n}\ninterface BbNavigate {\n toThread(threadId: string): void;\n toProject(projectId: string): void;\n /**\n * Navigate to one of this plugin's own nav panels by its `path`.\n * `subPath` targets a location inside the panel (the component's\n * `subPath` prop); `replace` swaps the current history entry instead of\n * pushing — use it for redirects so back does not bounce.\n */\n toPluginPanel(path: string, options?: {\n subPath?: string;\n replace?: boolean;\n }): void;\n}\n/**\n * Everything `@bb/plugin-sdk/app` resolves to at runtime. The BB app builds\n * the real implementation and `satisfies` this interface; `bb plugin build`\n * shims the specifier to that object on `globalThis.__bbPluginRuntime`.\n */\ninterface PluginSdkApp {\n definePluginApp(setup: PluginAppSetup): PluginAppDefinition;\n useRpc(): PluginRpcClient;\n useRealtime(channel: string, handler: (payload: unknown) => void): void;\n useSettings(): PluginSettingsState;\n useBbContext(): BbContext;\n useBbNavigate(): BbNavigate;\n}\n/**\n * Named runtime exports of `@bb/plugin-sdk/app`, in sorted order. Single\n * source of truth for the build shim's export list and the app's\n * implementation-key test — adding a surface member without updating this\n * list fails the type assertion below.\n */\ndeclare const PLUGIN_SDK_APP_EXPORT_NAMES: readonly [\"definePluginApp\", \"useBbContext\", \"useBbNavigate\", \"useRealtime\", \"useRpc\", \"useSettings\"];\n\ndeclare const definePluginApp: (setup: PluginAppSetup) => PluginAppDefinition;\ndeclare const useRpc: () => PluginRpcClient;\ndeclare const useRealtime: (channel: string, handler: (payload: unknown) => void) => void;\ndeclare const useSettings: () => PluginSettingsState;\ndeclare const useBbContext: () => BbContext;\ndeclare const useBbNavigate: () => BbNavigate;\n\nexport { PLUGIN_SDK_APP_EXPORT_NAMES, PLUGIN_SLOT_ID_PATTERN, definePluginApp, useBbContext, useBbNavigate, useRealtime, useRpc, useSettings };\nexport type { BbContext, BbNavigate, PluginAppBuilder, PluginAppDefinition, PluginAppSetup, PluginAppSlots, PluginComposerAccessoryProps, PluginComposerAccessoryRegistration, PluginHomepageSectionProps, PluginHomepageSectionRegistration, PluginNavPanelProps, PluginNavPanelRegistration, PluginRpcClient, PluginSdkApp, PluginSettingsState, PluginThreadPanelActionContext, PluginThreadPanelActionRegistration, PluginThreadPanelProps };\n"; diff --git a/packages/templates/src/generated/templates.generated.ts b/packages/templates/src/generated/templates.generated.ts index 1b11b72eb..2bcbcd138 100644 --- a/packages/templates/src/generated/templates.generated.ts +++ b/packages/templates/src/generated/templates.generated.ts @@ -73,7 +73,7 @@ export const templateDefinitions = [ }, { "id": "bbGuidePlugins", - "body": "Plugin commands\n\nA bb plugin is a TypeScript package that extends the bb server in-process:\nbackground services, cron schedules, HTTP/RPC endpoints, thread lifecycle\nhandlers, settings, storage — and `bb` CLI subcommands that agents and humans\nrun like any other command. Plugins are full-trust code inside the server.\n\nPlugins are an experiment, off by default: enable \"Plugins\" under Settings →\nExperiments first. Until then `bb plugin` commands report that plugins are\ndisabled. Plugin state lives under `/plugins//` (per-plugin\nSQLite file, secrets, logs).\n\n bb plugin install Install from a local path, git:@,\n or npm:@ (npm: needs npm on\n PATH; installs prompt — pass --yes to skip)\n bb plugin list Status, services, schedules, handler timings\n bb plugin enable|disable Load or unload an installed plugin\n bb plugin reload [id] Re-run factories against current sources\n bb plugin config [set | unset ]\n Show or change a plugin's declared settings\n bb plugin logs [-n N] [-f] Print (or follow) a plugin's bb.log output\n bb plugin run [args...] Run the plugin's CLI command explicitly\n bb plugin token [--rotate] Print the token for auth:\"token\" HTTP\n routes; --rotate generates a new token,\n invalidating the old one\n bb plugin remove Uninstall (managed git:/npm: files deleted)\n bb plugin new [--app] Scaffold a new plugin (no server required;\n --app adds a frontend entry, app.tsx, plus a\n typecheck-only tsconfig.json)\n bb plugin build [path] Compile the plugin into dist/ — the backend\n bundle (server.js, server.meta.json) and,\n when bb.app is declared, the frontend bundle\n (app.js, app.css, app.meta.json); no server\n required\n bb plugin dev [path] Watch a plugin's sources (default: cwd) and\n on every change rebuild its frontend bundle\n (if it declares bb.app) and reload the\n plugin; Ctrl+C to stop\n\nFrontend builds are automatic once installed: path and git installs compile\ndist/ at install time (a build failure fails the install), and the server\nrebuilds them at load after a bb upgrade. npm packages must publish a\nprebuilt dist/ (app.js + app.meta.json) or the install is refused.\n\nThe backend half is prebuilt too: when a git/npm install ships a\ndist/server.js built for the running SDK major, the server loads it instead\nof the TypeScript source — consumers never need npm or node_modules. Path\ninstalls always load server.ts from source, so `bb plugin dev`/reload see\nedits immediately.\n\n`bb plugin dev` is the edit loop: it requires the directory to already be\ninstalled as a plugin (`bb plugin install .` first), ignores dist/,\nnode_modules/, and .git/, batches saves, and prints one line per cycle. A\nbuild or reload failure prints the error and keeps watching (a failed build\nskips that cycle's reload). Reloads reach open app pages live — changed\nfrontend bundles re-import and their UI slots remount without a page\nrefresh.\n\nFrontend entries (app.tsx) default-export `definePluginApp` from\n`@bb/plugin-sdk/app` and register UI slots: homepageSection (root compose),\nnavPanel (own sidebar entry + /plugins// route), threadPanelAction\n(an entry in the thread right panel's new-tab Actions list whose run() can\nopen closable panel tabs with JSON params), composerAccessory (prompt box\nfooter). Hooks:\nuseRpc, useRealtime, useSettings (secrets excluded), useBbContext,\nuseBbNavigate. Components are vendored shadcn source the plugin owns (the\nshadcn model): `bb plugin new --app` pre-vendors a starter set into\ncomponents/ui/ and `npx shadcn add @bb/` pulls more from the BB\ncomponent registry (the full stock shadcn set, version-matched to the\nrunning BB via the pinned ref in components.json). `import { toast } from\n\"sonner\"` reaches the host toaster; react, the portaling radix families,\nsonner, vaul, and @pierre/diffs (the app's syntax-highlighted diff\nrenderer) are runtime-shimmed (never bundled), everything else\nbundles from the plugin's node_modules (`npm install` for authors;\nconsumers install prebuilt dist). A crashing slot collapses to a\n\"plugin crashed\" chip without\ntouching the rest of the app. Installed plugins and their declared settings\n(same data as `bb plugin config`) also appear under Settings → Plugins.\n\nPlugin CLI commands: a plugin can register one top-level subcommand (for\nexample `bb github …`). Unknown `bb` commands are looked up against installed\nplugins and proxied to the server, so plugin commands work exactly like core\ncommands; core command names always win. Inside agent threads the generated\n`plugin-commands` skill lists the available plugin commands.\n\nSettings changes do not auto-reload a plugin — run `bb plugin reload `\nafter configuring. Add --json to plugin commands for machine-readable output.\n\nAuthoring a plugin\n\nThe loop: `bb plugin new ` scaffolds `./bb-plugin-` (add --app\nfor a frontend entry); `bb plugin install .` registers it; `bb plugin dev`\nwatches and reloads on every save. The manifest is package.json: `bb.server`\n(backend entry, loaded as TypeScript — no build step), optional `bb.app`\n(frontend entry), optional `bb.skills` (skills directories auto-imported\ninto agent threads; default `skills/`), and `engines.bb` (supported bb\nrange). The plugin id is the package name minus `bb-plugin-`.\n\nLogos: drop a logo.svg (or logo.png / logo.webp) in the plugin root and bb\nshows it wherever the plugin's contributions appear — the sidebar entry,\npanel title bar, composer command and @-mention menus, thread action\nbuttons, and Settings → Plugins. Optional `bb.logo` in the manifest\nrelocates the file (svg/png/webp only). An optional dark-theme variant —\nlogo-dark.svg/png/webp at the root, or `bb.logoDark` — is preferred while\nthe app is in dark mode. Without a logo bb falls back to the contribution's\nnamed icon. Reload the plugin to pick up logo changes.\n\nThe backend entry default-exports a factory receiving the full plugin API:\n\n import type { BbPluginApi } from \"@bb/plugin-sdk\";\n export default async function plugin(bb: BbPluginApi) { ... }\n\nThe import is type-only and erased at load; the scaffold ships the full API\nas bundled .d.ts in types/ (tsconfig maps @bb/plugin-sdk to them), so\n`npm install && npx tsc --noEmit` typechecks anywhere — no bb checkout\nneeded. Confused, or need a symbol the types don't explain? Clone the repo:\nhttps://github.com/ymichael/bb. The API in\none line each — bb.log (plugin-scoped logger behind `bb plugin logs`);\nbb.settings.define (declarative settings incl. secrets, editable via\n`bb plugin config`); bb.storage.kv (JSON rows ≤256KB) and\nbb.storage.sqlite()+migrate (the plugin's own database); bb.sdk (the full\nbb SDK — handlers/services only, not the factory; spawned threads are\nattributed to the plugin); bb.on (observe thread.created/idle/failed);\nbb.http.route (routes under /api/v1/plugins//http/* with\nlocal/token/none auth); bb.rpc.register (the frontend data plane);\nbb.realtime.publish (ephemeral signals to open app pages);\nbb.background.service (long-lived, AbortSignal, restart w/ backoff) and\nbb.background.schedule (durable cron rows); bb.cli.register (a top-level\n`bb ` command agents run through bash); bb.agents.registerTool\n(native tools with\nzod or JSON-schema parameters); bb.ui.registerThreadAction /\nregisterMentionProvider (host-rendered UI — no\nfrontend bundle needed); bb.status.needsConfiguration (report\n\"unconfigured\" instead of crashing); bb.onDispose (LIFO cleanup on\nreload/disable/shutdown).\n\nFrontend entries register React slots (homepageSection, navPanel,\nthreadPanelAction, composerAccessory) via definePluginApp, use the hooks\nlisted above, and render vendored components; styling is Tailwind against\nthe host theme's tokens only (semantic classes like bg-background and\ntw-animate-css utilities compile in plugin builds).\n\nFor the complete authoring reference — exact signatures, working snippets\nfor every surface, the reload lifecycle, testing tips, and gotchas — use\nthe built-in `bb-plugin-authoring` skill (agents: it loads on demand;\nhumans: apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/\nin a checkout). The `examples/plugins/` directory of a bb checkout has four\nreference plugins: github (full-stack: gh-CLI-backed issue/PR browser on\nvendored shadcn components), slack-bot (webhook bot), agent-enrichment\n(agent surfaces), small-ux-pack (host-rendered UI).", + "body": "Plugin commands\n\nA bb plugin is a TypeScript package that extends the bb server in-process:\nbackground services, cron schedules, HTTP/RPC endpoints, thread lifecycle\nhandlers, settings, storage — and `bb` CLI subcommands that agents and humans\nrun like any other command. Plugins are full-trust code inside the server.\n\nPlugins are an experiment, off by default: enable \"Plugins\" under Settings →\nExperiments first. Until then `bb plugin` commands report that plugins are\ndisabled. Plugin state lives under `/plugins//` (per-plugin\nSQLite file, secrets, logs).\n\n bb plugin install Install from a local path, git:@,\n or npm:@ (npm: needs npm on\n PATH; installs prompt — pass --yes to skip)\n bb plugin list Status, services, schedules, handler timings\n bb plugin enable|disable Load or unload an installed plugin\n bb plugin reload [id] Re-run factories against current sources\n bb plugin config [set | unset ]\n Show or change a plugin's declared settings\n bb plugin logs [-n N] [-f] Print (or follow) a plugin's bb.log output\n bb plugin run [args...] Run the plugin's CLI command explicitly\n bb plugin token [--rotate] Print the token for auth:\"token\" HTTP\n routes; --rotate generates a new token,\n invalidating the old one\n bb plugin remove Uninstall (managed git:/npm: files deleted)\n bb plugin new [--app] Scaffold a new plugin (no server required;\n --app adds a frontend entry, app.tsx, plus a\n typecheck-only tsconfig.json)\n bb plugin build [path] Compile the plugin into dist/ — the backend\n bundle (server.js, server.meta.json) and,\n when bb.app is declared, the frontend bundle\n (app.js, app.css, app.meta.json); no server\n required\n bb plugin dev [path] Watch a plugin's sources (default: cwd) and\n on every change rebuild its frontend bundle\n (if it declares bb.app) and reload the\n plugin; Ctrl+C to stop\n\nFrontend builds are automatic once installed: path and git installs compile\ndist/ at install time (a build failure fails the install), and the server\nrebuilds them at load after a bb upgrade. npm packages must publish a\nprebuilt dist/ (app.js + app.meta.json) or the install is refused.\n\nThe backend half is prebuilt too: when a git/npm install ships a\ndist/server.js built for the running SDK major, the server loads it instead\nof the TypeScript source — consumers never need npm or node_modules. Path\ninstalls always load server.ts from source, so `bb plugin dev`/reload see\nedits immediately.\n\n`bb plugin dev` is the edit loop: it requires the directory to already be\ninstalled as a plugin (`bb plugin install .` first), ignores dist/,\nnode_modules/, and .git/, batches saves, and prints one line per cycle. A\nbuild or reload failure prints the error and keeps watching (a failed build\nskips that cycle's reload). Reloads reach open app pages live — changed\nfrontend bundles re-import and their UI slots remount without a page\nrefresh.\n\nFrontend entries (app.tsx) default-export `definePluginApp` from\n`@bb/plugin-sdk/app` and register UI slots: homepageSection (root compose),\nnavPanel (own sidebar entry + /plugins///* route; the remainder\narrives as the component's subPath prop for panel-internal deep links),\nthreadPanelAction\n(an entry in the thread right panel's new-tab Actions list whose run() can\nopen closable panel tabs with JSON params), composerAccessory (prompt box\nfooter). Hooks:\nuseRpc, useRealtime, useSettings (secrets excluded), useBbContext,\nuseBbNavigate. Components are vendored shadcn source the plugin owns (the\nshadcn model): `bb plugin new --app` pre-vendors a starter set into\ncomponents/ui/ and `npx shadcn add @bb/` pulls more from the BB\ncomponent registry (the full stock shadcn set, version-matched to the\nrunning BB via the pinned ref in components.json). `import { toast } from\n\"sonner\"` reaches the host toaster; react, the portaling radix families,\nsonner, vaul, and @pierre/diffs (the app's syntax-highlighted diff\nrenderer) are runtime-shimmed (never bundled), everything else\nbundles from the plugin's node_modules (`npm install` for authors;\nconsumers install prebuilt dist). A crashing slot collapses to a\n\"plugin crashed\" chip without\ntouching the rest of the app. Installed plugins and their declared settings\n(same data as `bb plugin config`) also appear under Settings → Plugins.\n\nPlugin CLI commands: a plugin can register one top-level subcommand (for\nexample `bb github …`). Unknown `bb` commands are looked up against installed\nplugins and proxied to the server, so plugin commands work exactly like core\ncommands; core command names always win. Inside agent threads the generated\n`plugin-commands` skill lists the available plugin commands.\n\nSettings changes do not auto-reload a plugin — run `bb plugin reload `\nafter configuring. Add --json to plugin commands for machine-readable output.\n\nAuthoring a plugin\n\nThe loop: `bb plugin new ` scaffolds `./bb-plugin-` (add --app\nfor a frontend entry); `bb plugin install .` registers it; `bb plugin dev`\nwatches and reloads on every save. The manifest is package.json: `bb.server`\n(backend entry, loaded as TypeScript — no build step), optional `bb.app`\n(frontend entry), optional `bb.skills` (skills directories auto-imported\ninto agent threads; default `skills/`), and `engines.bb` (supported bb\nrange). The plugin id is the package name minus `bb-plugin-`.\n\nLogos: drop a logo.svg (or logo.png / logo.webp) in the plugin root and bb\nshows it wherever the plugin's contributions appear — the sidebar entry,\npanel title bar, composer command and @-mention menus, thread action\nbuttons, and Settings → Plugins. Optional `bb.logo` in the manifest\nrelocates the file (svg/png/webp only). An optional dark-theme variant —\nlogo-dark.svg/png/webp at the root, or `bb.logoDark` — is preferred while\nthe app is in dark mode. Without a logo bb falls back to the contribution's\nnamed icon. Reload the plugin to pick up logo changes.\n\nThe backend entry default-exports a factory receiving the full plugin API:\n\n import type { BbPluginApi } from \"@bb/plugin-sdk\";\n export default async function plugin(bb: BbPluginApi) { ... }\n\nThe import is type-only and erased at load; the scaffold ships the full API\nas bundled .d.ts in types/ (tsconfig maps @bb/plugin-sdk to them), so\n`npm install && npx tsc --noEmit` typechecks anywhere — no bb checkout\nneeded. Confused, or need a symbol the types don't explain? Clone the repo:\nhttps://github.com/ymichael/bb. The API in\none line each — bb.log (plugin-scoped logger behind `bb plugin logs`);\nbb.settings.define (declarative settings incl. secrets, editable via\n`bb plugin config`); bb.storage.kv (JSON rows ≤256KB) and\nbb.storage.sqlite()+migrate (the plugin's own database); bb.sdk (the full\nbb SDK — handlers/services only, not the factory; spawned threads are\nattributed to the plugin); bb.on (observe thread.created/idle/failed);\nbb.http.route (routes under /api/v1/plugins//http/* with\nlocal/token/none auth); bb.rpc.register (the frontend data plane);\nbb.realtime.publish (ephemeral signals to open app pages);\nbb.background.service (long-lived, AbortSignal, restart w/ backoff) and\nbb.background.schedule (durable cron rows); bb.cli.register (a top-level\n`bb ` command agents run through bash); bb.agents.registerTool\n(native tools with\nzod or JSON-schema parameters); bb.ui.registerThreadAction /\nregisterMentionProvider (host-rendered UI — no\nfrontend bundle needed); bb.status.needsConfiguration (report\n\"unconfigured\" instead of crashing); bb.onDispose (LIFO cleanup on\nreload/disable/shutdown).\n\nFrontend entries register React slots (homepageSection, navPanel,\nthreadPanelAction, composerAccessory) via definePluginApp, use the hooks\nlisted above, and render vendored components; styling is Tailwind against\nthe host theme's tokens only (semantic classes like bg-background and\ntw-animate-css utilities compile in plugin builds).\n\nFor the complete authoring reference — exact signatures, working snippets\nfor every surface, the reload lifecycle, testing tips, and gotchas — use\nthe built-in `bb-plugin-authoring` skill (agents: it loads on demand;\nhumans: apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/\nin a checkout). The `examples/plugins/` directory of a bb checkout has four\nreference plugins: github (full-stack: gh-CLI-backed issue/PR browser on\nvendored shadcn components), slack-bot (webhook bot), agent-enrichment\n(agent surfaces), small-ux-pack (host-rendered UI).", "fileName": "bb-guide-plugins.md", "kind": "instruction", "title": "bb Guide — Plugins", diff --git a/packages/templates/src/templates/bb-guide-plugins.md b/packages/templates/src/templates/bb-guide-plugins.md index 54a9b9d90..6c7d2f647 100644 --- a/packages/templates/src/templates/bb-guide-plugins.md +++ b/packages/templates/src/templates/bb-guide-plugins.md @@ -65,7 +65,9 @@ refresh. Frontend entries (app.tsx) default-export `definePluginApp` from `@bb/plugin-sdk/app` and register UI slots: homepageSection (root compose), -navPanel (own sidebar entry + /plugins// route), threadPanelAction +navPanel (own sidebar entry + /plugins///* route; the remainder +arrives as the component's subPath prop for panel-internal deep links), +threadPanelAction (an entry in the thread right panel's new-tab Actions list whose run() can open closable panel tabs with JSON params), composerAccessory (prompt box footer). Hooks: From b174aecfc420de0768c29601bf328e2d8862b080 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Sat, 4 Jul 2026 08:53:16 -0700 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20useComposer()=20=E2=80=94=20plugin?= =?UTF-8?q?=20composer=20bridge=20(quotes,=20mention=20pills,=20focus)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New @bb/plugin-sdk/app hook over the shared prompt-draft store: addQuote appends a blockquote to the active draft (thread draft in thread context, new-thread draft elsewhere), insertMention appends an @-mention pill bound to the calling plugin's mention provider (resolved at send), focus rides a new composer-focus bus that ThreadDetailView and RootComposeView subscribe to by draft storage key. Co-Authored-By: Claude Fable 5 --- .../plugin/plugin-slot-mounts.test.tsx | 152 +++++++++++++++++- apps/app/src/lib/composer-focus-requests.ts | 40 +++++ apps/app/src/lib/plugin-sdk-app-impl.tsx | 2 + apps/app/src/lib/plugin-sdk-hooks.ts | 95 +++++++++++ apps/app/src/views/RootComposeView.tsx | 13 ++ .../views/thread-detail/ThreadDetailView.tsx | 10 ++ .../bb-plugin-authoring/SKILL.md | 10 ++ .../bundled-types/bb-plugin-sdk-app.d.ts | 46 +++++- .../bundled-types/bb-plugin-sdk.d.ts | 46 +++++- packages/plugin-sdk/src/app-contract.ts | 42 +++++ .../src/generated/plugin-sdk-dts.generated.ts | 4 +- .../src/generated/templates.generated.ts | 2 +- .../src/templates/bb-guide-plugins.md | 3 +- plans/plugin-system-design.md | 5 +- 14 files changed, 460 insertions(+), 10 deletions(-) create mode 100644 apps/app/src/lib/composer-focus-requests.ts diff --git a/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx b/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx index 6f5a71d22..3f0eaea50 100644 --- a/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx +++ b/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx @@ -2,7 +2,7 @@ import { MemoryRouter, Route, Routes } from "react-router-dom"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PERSONAL_PROJECT_ID } from "@bb/domain"; import type { PluginComposerAccessoryProps, @@ -30,6 +30,9 @@ import { resetAllCrashedPluginSlotsForTest } from "./PluginSlotMount"; import { PluginComposerAccessories } from "./PluginComposerAccessories"; import { PluginHomepageSections } from "./PluginHomepageSections"; import { PluginNavSidebarItems } from "./PluginNavSidebarItems"; +import { useComposer } from "@/lib/plugin-sdk-hooks"; +import { subscribeComposerFocusRequests } from "@/lib/composer-focus-requests"; +import { usePromptDraftStorage } from "@/hooks/usePromptDraftStorage"; import { PluginPanelTabContent, usePluginPanelActions, @@ -143,6 +146,153 @@ describe("PluginComposerAccessories", () => { }); }); + +function ThreadDraftViewer({ threadId }: { threadId: string }) { + const draft = usePromptDraftStorage({ + kind: "thread", + projectId: PERSONAL_PROJECT_ID, + threadId, + }); + return ( +
+
{draft.storageKey}
+
{draft.text}
+
{JSON.stringify(draft.mentions)}
+
+ ); +} + +function NewThreadDraftViewer() { + const draft = usePromptDraftStorage({ kind: "new-thread" }); + return ( +
+
{draft.storageKey}
+
{draft.text}
+
{JSON.stringify(draft.mentions)}
+
+ ); +} + +describe("useComposer", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + function registerComposerProbe(label: string) { + function ComposerProbe() { + const composer = useComposer(); + return ( +
+
scope: {composer.scope.kind}
+ + + +
+ ); + } + setPluginSlotRegistrations( + "demo", + registrationSet({ + composerAccessories: [{ id: "probe", component: ComposerProbe }], + }), + ); + } + + it("writes quotes into the thread draft and fires the focus bus", () => { + registerComposerProbe("t"); + render( + + + + , + ); + expect(screen.getByText("scope: thread")).toBeDefined(); + + let focusRequests = 0; + const storageKey = screen.getByTestId("draft-key").textContent ?? ""; + const unsubscribe = subscribeComposerFocusRequests(storageKey, () => { + focusRequests += 1; + }); + fireEvent.click(screen.getByText("t-quote")); + expect(screen.getByTestId("draft-text").textContent).toBe( + "> picked text\n", + ); + expect(focusRequests).toBe(1); + unsubscribe(); + }); + + it("appends mention pills with offsets into the new-thread draft", () => { + registerComposerProbe("n"); + render( + + + + , + ); + expect(screen.getByText("scope: new-thread")).toBeDefined(); + + fireEvent.click(screen.getByText("n-mention")); + expect(screen.getByTestId("draft-text").textContent).toBe("ideas.md "); + const mentions = JSON.parse( + screen.getByTestId("draft-mentions").textContent ?? "[]", + ) as Array<{ start: number; end: number; resource: Record }>; + expect(mentions).toEqual([ + { + start: 0, + end: 8, + resource: { + kind: "plugin", + pluginId: "demo", + itemId: "notes:work/ideas.md", + label: "ideas.md", + }, + }, + ]); + + // A second mention lands after the first with a preserved gap. + fireEvent.click(screen.getByText("n-mention")); + expect(screen.getByTestId("draft-text").textContent).toBe( + "ideas.md ideas.md ", + ); + }); + + it("rejects provider ids containing ':' without touching the draft", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + registerComposerProbe("b"); + render( + + + + , + ); + fireEvent.click(screen.getByText("b-bad-mention")); + expect(screen.getByTestId("draft-text").textContent).toBe(""); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("invalid provider id"), + ); + }); +}); + describe("PluginNavSidebarItems + PluginPanelView", () => { function Board() { return
board panel body
; diff --git a/apps/app/src/lib/composer-focus-requests.ts b/apps/app/src/lib/composer-focus-requests.ts new file mode 100644 index 000000000..61734ff70 --- /dev/null +++ b/apps/app/src/lib/composer-focus-requests.ts @@ -0,0 +1,40 @@ +/** + * Focus-request bus between plugin composer writes and the composer mounts. + * The draft store (usePromptDraftStorage) is module-level and shared, but + * "focus the caret" is per-view state (ThreadDetailView's focus nonce, the + * root compose prompt box ref) — this bus carries the request across that + * gap, keyed by the same draft storage key both sides already share. + */ +type ComposerFocusListener = () => void; + +const listenersByStorageKey = new Map>(); + +export function subscribeComposerFocusRequests( + storageKey: string | null, + listener: ComposerFocusListener, +): () => void { + // Null = the draft scope is incomplete (matching the draft store's own + // null storage keys); there is nothing to focus. + if (storageKey === null) return () => {}; + let listeners = listenersByStorageKey.get(storageKey); + if (!listeners) { + listeners = new Set(); + listenersByStorageKey.set(storageKey, listeners); + } + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + listenersByStorageKey.delete(storageKey); + } + }; +} + +export function requestComposerFocus(storageKey: string | null): void { + if (storageKey === null) return; + const listeners = listenersByStorageKey.get(storageKey); + if (!listeners) return; + for (const listener of [...listeners]) { + listener(); + } +} diff --git a/apps/app/src/lib/plugin-sdk-app-impl.tsx b/apps/app/src/lib/plugin-sdk-app-impl.tsx index 0bf2b2209..e05177b15 100644 --- a/apps/app/src/lib/plugin-sdk-app-impl.tsx +++ b/apps/app/src/lib/plugin-sdk-app-impl.tsx @@ -3,6 +3,7 @@ import { definePluginApp } from "./plugin-app-definition"; import { useBbContext, useBbNavigate, + useComposer, useRealtime, useRpc, useSettings, @@ -27,6 +28,7 @@ export const pluginSdkAppImplementation = { definePluginApp, useBbContext, useBbNavigate, + useComposer, useRealtime, useRpc, useSettings, diff --git a/apps/app/src/lib/plugin-sdk-hooks.ts b/apps/app/src/lib/plugin-sdk-hooks.ts index 5a7d0531d..8c2c187cc 100644 --- a/apps/app/src/lib/plugin-sdk-hooks.ts +++ b/apps/app/src/lib/plugin-sdk-hooks.ts @@ -4,11 +4,18 @@ import { useNavigate } from "react-router-dom"; import type { BbContext, BbNavigate, + PluginComposerApi, + PluginComposerMention, PluginRpcClient, PluginSettingsState, } from "@bb/plugin-sdk"; import { usePluginId } from "@/components/plugin/plugin-context"; import { getThread } from "@/lib/api"; +import { requestComposerFocus } from "@/lib/composer-focus-requests"; +import { + usePromptDraftStorage, + type PromptDraftScope, +} from "@/hooks/usePromptDraftStorage"; import { getPluginPanelRoutePath, getProjectComposeRoutePath, @@ -197,3 +204,91 @@ export function useBbNavigate(): BbNavigate { [toThread, toProject, toPluginPanel], ); } + +/** + * Programmatic composer-draft access (plugin design §5.2): the same shared + * localStorage-backed draft store the built-in "Add to chat" affordances + * write to. Thread context → that thread's draft; anywhere else → the + * new-thread draft. Focus requests ride the composer-focus bus, which the + * composer hosts (ThreadDetailView / RootComposeView) subscribe to by + * draft storage key. + */ +export function useComposer(): PluginComposerApi { + const pluginId = usePluginId(); + const { projectId, threadId } = useRouteState(); + const scope: PromptDraftScope = useMemo( + () => + threadId !== undefined && projectId !== undefined + ? { kind: "thread", projectId, threadId } + : { kind: "new-thread" }, + [projectId, threadId], + ); + const draft = usePromptDraftStorage(scope); + const { addQuote: addDraftQuote, getCurrent, setDraft, storageKey } = draft; + + const addQuote = useCallback( + (text: string) => { + addDraftQuote(text); + requestComposerFocus(storageKey); + }, + [addDraftQuote, storageKey], + ); + + const insertMention = useCallback( + (mention: PluginComposerMention) => { + const provider = mention.provider.trim(); + const label = mention.label.trim() || mention.id; + if (provider.length === 0 || provider.includes(":")) { + // Provider ids exclude ":" (enforced at registration) — a bad id + // would corrupt the composite itemId the server splits at send. + console.warn( + `[plugin:${pluginId}] useComposer().insertMention: invalid provider id "${mention.provider}"`, + ); + return; + } + const current = getCurrent(); + // Append at the END so existing mention offsets stay valid (the same + // invariant addQuote relies on). + const separator = + current.text.length === 0 || /\s$/u.test(current.text) ? "" : " "; + const start = current.text.length + separator.length; + const end = start + label.length; + setDraft({ + ...current, + text: `${current.text}${separator}${label} `, + mentions: [ + ...current.mentions, + { + start, + end, + resource: { + kind: "plugin", + pluginId, + itemId: `${provider}:${mention.id}`, + label, + }, + }, + ], + }); + requestComposerFocus(storageKey); + }, + [getCurrent, pluginId, setDraft, storageKey], + ); + + const focus = useCallback(() => { + requestComposerFocus(storageKey); + }, [storageKey]); + + return useMemo( + () => ({ + scope: + threadId !== undefined + ? { kind: "thread", threadId } + : { kind: "new-thread", projectId: projectId ?? null }, + addQuote, + insertMention, + focus, + }), + [addQuote, focus, insertMention, projectId, threadId], + ); +} diff --git a/apps/app/src/views/RootComposeView.tsx b/apps/app/src/views/RootComposeView.tsx index db7c804a3..5655f8658 100644 --- a/apps/app/src/views/RootComposeView.tsx +++ b/apps/app/src/views/RootComposeView.tsx @@ -95,6 +95,7 @@ import { useHostDaemon } from "@/hooks/useHostDaemon"; import { useLocalOpenTargets } from "@/hooks/useLocalOpenTargets"; import { useHosts } from "@/hooks/queries/host-queries"; import { usePromptDraftStorage } from "@/hooks/usePromptDraftStorage"; +import { subscribeComposerFocusRequests } from "@/lib/composer-focus-requests"; import { useEscapeToHide } from "@/hooks/useEscapeToHide"; import { usePromptMentions } from "@/hooks/usePromptMentions"; import type { PromptMentionLinkResolver } from "@/components/promptbox/editor/prompt-mention-link"; @@ -927,6 +928,18 @@ export function RootComposeView(props: RootComposeViewProps) { const primaryHostId = primaryHost?.id ?? null; const uploadPromptAttachment = useUploadPromptAttachment(); const promptDraft = usePromptDraftStorage({ kind: "new-thread" }); + // Plugin useComposer() writes (from nav panels / homepage sections) target + // the new-thread draft; surface + focus the composer when they ask. + useEffect( + () => + subscribeComposerFocusRequests(promptDraft.storageKey, () => { + setStartedComposing(true); + window.requestAnimationFrame(() => { + promptBoxRef.current?.focusEnd(); + }); + }), + [promptDraft.storageKey], + ); const handleRootPanelSelectionAddToChat = useCallback( (text: string, attachments?: readonly PromptDraftAttachment[]) => { promptDraft.addQuote(text, attachments); diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index b68462996..2038f3dea 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -49,6 +49,7 @@ import { } from "../../hooks/queries/thread-queries"; import { isTransientReadError } from "@/hooks/queries/query-helpers"; import { usePromptDraftStorage } from "@/hooks/usePromptDraftStorage"; +import { subscribeComposerFocusRequests } from "@/lib/composer-focus-requests"; import { ThreadGitActionDialog } from "@/components/dialogs/ThreadGitActionDialog"; import { PageShell } from "@/components/ui/page-shell.js"; import { HEADER_ICON_BUTTON_CLASS } from "@/components/layout/AppPageHeader"; @@ -826,6 +827,15 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { // sharing the localStorage draft) can focus its caret at the end, ready for // the reply under the quote. const [composerFocusRequestNonce, setComposerFocusRequestNonce] = useState(0); + // Plugin useComposer() writes ride the focus bus (they can't reach this + // view's local nonce); same storage key = same draft the composer shows. + useEffect( + () => + subscribeComposerFocusRequests(selectionPromptDraft.storageKey, () => + setComposerFocusRequestNonce((nonce) => nonce + 1), + ), + [selectionPromptDraft.storageKey], + ); const handleSelectionAddToChat = useCallback( (text: string, attachments?: readonly PromptDraftAttachment[]) => { addQuoteToComposer(text, attachments); diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md index a007f77bd..1f2c7dc81 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md @@ -452,6 +452,16 @@ Hooks: (secret settings are excluded; read them server-side only). - `useBbContext()` → `{ projectId, threadId }` from the current route. - `useBbNavigate()` → `{ toThread(id), toProject(id), toPluginPanel(path, { subPath?, replace? }?) }`. +- `useComposer()` → programmatic access to the chat composer draft (the + same one the built-in "Add to chat" affordances write to): + `addQuote(text)` appends the text as a `> ` blockquote block and focuses + the composer — the "reference this selection in chat" primitive; + `insertMention({ provider, id, label })` inserts an @-mention pill bound + to one of YOUR `bb.ui.registerMentionProvider` providers, resolved to + fresh context at send time; `focus()` focuses the caret; `scope` reports + where writes land (`{ kind: "thread", threadId }` inside a thread + context, `{ kind: "new-thread", projectId }` from nav panels and + homepage sections — those seed the composer the user lands on next). UI components — **vendored shadcn source you own** (the shadcn model; the old host-provided component kit is REMOVED — `@bb/plugin-sdk/app` exports diff --git a/packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts b/packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts index 1c45d0c9f..1a821a6f7 100644 --- a/packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts +++ b/packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts @@ -163,6 +163,47 @@ interface PluginSettingsState { values: Record | undefined; isLoading: boolean; } +/** Where `useComposer()` writes: the active thread's draft or the new-thread draft. */ +type PluginComposerScope = { + kind: "thread"; + threadId: string; +} | { + kind: "new-thread"; + projectId: string | null; +}; +/** An @-mention pill bound to one of the calling plugin's mention providers. */ +interface PluginComposerMention { + /** Mention provider id registered by THIS plugin via `bb.ui.registerMentionProvider`. */ + provider: string; + /** Item id your provider's `resolve` will receive at send time. */ + id: string; + /** Pill text shown in the composer. */ + label: string; +} +/** + * Programmatic access to the chat composer draft — the same shared draft the + * built-in "Add to chat" affordances (file preview, diff, terminal selections) + * write to. Inside a thread context writes land in that thread's draft; + * anywhere else (nav panel, homepage section) they seed the new-thread + * composer draft, which persists until the user sends or clears it. + */ +interface PluginComposerApi { + scope: PluginComposerScope; + /** + * Append text to the draft as a `> ` blockquote block and focus the + * composer. Blank text is a no-op. This is the "reference this selection + * in chat" primitive. + */ + addQuote(text: string): void; + /** + * Insert an @-mention pill that resolves through this plugin's mention + * provider at send time — the durable way to reference an entity whose + * content should be fetched fresh when the message is sent. + */ + insertMention(mention: PluginComposerMention): void; + /** Focus the composer caret at the end of the draft. */ + focus(): void; +} /** Current app selection, derived from the route. */ interface BbContext { projectId: string | null; @@ -194,6 +235,7 @@ interface PluginSdkApp { useSettings(): PluginSettingsState; useBbContext(): BbContext; useBbNavigate(): BbNavigate; + useComposer(): PluginComposerApi; } /** * Named runtime exports of `@bb/plugin-sdk/app`, in sorted order. Single @@ -201,7 +243,7 @@ interface PluginSdkApp { * implementation-key test — adding a surface member without updating this * list fails the type assertion below. */ -declare const PLUGIN_SDK_APP_EXPORT_NAMES: readonly ["definePluginApp", "useBbContext", "useBbNavigate", "useRealtime", "useRpc", "useSettings"]; +declare const PLUGIN_SDK_APP_EXPORT_NAMES: readonly ["definePluginApp", "useBbContext", "useBbNavigate", "useComposer", "useRealtime", "useRpc", "useSettings"]; declare const definePluginApp: (setup: PluginAppSetup) => PluginAppDefinition; declare const useRpc: () => PluginRpcClient; @@ -211,4 +253,4 @@ declare const useBbContext: () => BbContext; declare const useBbNavigate: () => BbNavigate; export { PLUGIN_SDK_APP_EXPORT_NAMES, PLUGIN_SLOT_ID_PATTERN, definePluginApp, useBbContext, useBbNavigate, useRealtime, useRpc, useSettings }; -export type { BbContext, BbNavigate, PluginAppBuilder, PluginAppDefinition, PluginAppSetup, PluginAppSlots, PluginComposerAccessoryProps, PluginComposerAccessoryRegistration, PluginHomepageSectionProps, PluginHomepageSectionRegistration, PluginNavPanelProps, PluginNavPanelRegistration, PluginRpcClient, PluginSdkApp, PluginSettingsState, PluginThreadPanelActionContext, PluginThreadPanelActionRegistration, PluginThreadPanelProps }; +export type { BbContext, BbNavigate, PluginAppBuilder, PluginAppDefinition, PluginAppSetup, PluginAppSlots, PluginComposerAccessoryProps, PluginComposerAccessoryRegistration, PluginComposerApi, PluginComposerMention, PluginComposerScope, PluginHomepageSectionProps, PluginHomepageSectionRegistration, PluginNavPanelProps, PluginNavPanelRegistration, PluginRpcClient, PluginSdkApp, PluginSettingsState, PluginThreadPanelActionContext, PluginThreadPanelActionRegistration, PluginThreadPanelProps }; diff --git a/packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts b/packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts index 54c1531fd..fa1900510 100644 --- a/packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts +++ b/packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts @@ -167,6 +167,47 @@ interface PluginSettingsState { values: Record | undefined; isLoading: boolean; } +/** Where `useComposer()` writes: the active thread's draft or the new-thread draft. */ +type PluginComposerScope = { + kind: "thread"; + threadId: string; +} | { + kind: "new-thread"; + projectId: string | null; +}; +/** An @-mention pill bound to one of the calling plugin's mention providers. */ +interface PluginComposerMention { + /** Mention provider id registered by THIS plugin via `bb.ui.registerMentionProvider`. */ + provider: string; + /** Item id your provider's `resolve` will receive at send time. */ + id: string; + /** Pill text shown in the composer. */ + label: string; +} +/** + * Programmatic access to the chat composer draft — the same shared draft the + * built-in "Add to chat" affordances (file preview, diff, terminal selections) + * write to. Inside a thread context writes land in that thread's draft; + * anywhere else (nav panel, homepage section) they seed the new-thread + * composer draft, which persists until the user sends or clears it. + */ +interface PluginComposerApi { + scope: PluginComposerScope; + /** + * Append text to the draft as a `> ` blockquote block and focus the + * composer. Blank text is a no-op. This is the "reference this selection + * in chat" primitive. + */ + addQuote(text: string): void; + /** + * Insert an @-mention pill that resolves through this plugin's mention + * provider at send time — the durable way to reference an entity whose + * content should be fetched fresh when the message is sent. + */ + insertMention(mention: PluginComposerMention): void; + /** Focus the composer caret at the end of the draft. */ + focus(): void; +} /** Current app selection, derived from the route. */ interface BbContext { projectId: string | null; @@ -198,6 +239,7 @@ interface PluginSdkApp { useSettings(): PluginSettingsState; useBbContext(): BbContext; useBbNavigate(): BbNavigate; + useComposer(): PluginComposerApi; } /** * Named runtime exports of `@bb/plugin-sdk/app`, in sorted order. Single @@ -205,7 +247,7 @@ interface PluginSdkApp { * implementation-key test — adding a surface member without updating this * list fails the type assertion below. */ -declare const PLUGIN_SDK_APP_EXPORT_NAMES: readonly ["definePluginApp", "useBbContext", "useBbNavigate", "useRealtime", "useRpc", "useSettings"]; +declare const PLUGIN_SDK_APP_EXPORT_NAMES: readonly ["definePluginApp", "useBbContext", "useBbNavigate", "useComposer", "useRealtime", "useRpc", "useSettings"]; declare const appThemeSchema: z$1.ZodObject<{ themeId: z$1.ZodString; @@ -2100,4 +2142,4 @@ interface BbPluginApi { } export { PLUGIN_SDK_APP_EXPORT_NAMES, PLUGIN_SLOT_ID_PATTERN }; -export type { BbContext, BbNavigate, BbPluginApi, PluginAgentToolContentPart, PluginAgentToolContext, PluginAgentToolRegistrationBase, PluginAgentToolResult, PluginAgents, PluginAppBuilder, PluginAppDefinition, PluginAppSetup, PluginAppSlots, PluginBackground, PluginCli, PluginCliCommandInfo, PluginCliContext, PluginCliRegistration, PluginCliResult, PluginComposerAccessoryProps, PluginComposerAccessoryRegistration, PluginHomepageSectionProps, PluginHomepageSectionRegistration, PluginHttp, PluginHttpAuthMode, PluginHttpHandler, PluginKvStorage, PluginLogger, PluginMentionItem, PluginMentionProviderRegistration, PluginMentionSearchContext, PluginNavPanelProps, PluginNavPanelRegistration, PluginRealtime, PluginRpc, PluginRpcClient, PluginSdkApp, PluginSettingDescriptor, PluginSettingDescriptors, PluginSettingValue, PluginSettings, PluginSettingsHandle, PluginSettingsState, PluginSettingsValues, PluginStatusApi, PluginStorage, PluginThreadActionContext, PluginThreadActionRegistration, PluginThreadActionResult, PluginThreadActionToast, PluginThreadEventHandler, PluginThreadEventName, PluginThreadEventPayloads, PluginThreadPanelActionContext, PluginThreadPanelActionRegistration, PluginThreadPanelProps, PluginUi }; +export type { BbContext, BbNavigate, BbPluginApi, PluginAgentToolContentPart, PluginAgentToolContext, PluginAgentToolRegistrationBase, PluginAgentToolResult, PluginAgents, PluginAppBuilder, PluginAppDefinition, PluginAppSetup, PluginAppSlots, PluginBackground, PluginCli, PluginCliCommandInfo, PluginCliContext, PluginCliRegistration, PluginCliResult, PluginComposerAccessoryProps, PluginComposerAccessoryRegistration, PluginComposerApi, PluginComposerMention, PluginComposerScope, PluginHomepageSectionProps, PluginHomepageSectionRegistration, PluginHttp, PluginHttpAuthMode, PluginHttpHandler, PluginKvStorage, PluginLogger, PluginMentionItem, PluginMentionProviderRegistration, PluginMentionSearchContext, PluginNavPanelProps, PluginNavPanelRegistration, PluginRealtime, PluginRpc, PluginRpcClient, PluginSdkApp, PluginSettingDescriptor, PluginSettingDescriptors, PluginSettingValue, PluginSettings, PluginSettingsHandle, PluginSettingsState, PluginSettingsValues, PluginStatusApi, PluginStorage, PluginThreadActionContext, PluginThreadActionRegistration, PluginThreadActionResult, PluginThreadActionToast, PluginThreadEventHandler, PluginThreadEventName, PluginThreadEventPayloads, PluginThreadPanelActionContext, PluginThreadPanelActionRegistration, PluginThreadPanelProps, PluginUi }; diff --git a/packages/plugin-sdk/src/app-contract.ts b/packages/plugin-sdk/src/app-contract.ts index 407b9003c..4fec093fe 100644 --- a/packages/plugin-sdk/src/app-contract.ts +++ b/packages/plugin-sdk/src/app-contract.ts @@ -189,6 +189,46 @@ export interface PluginSettingsState { isLoading: boolean; } +/** Where `useComposer()` writes: the active thread's draft or the new-thread draft. */ +export type PluginComposerScope = + | { kind: "thread"; threadId: string } + | { kind: "new-thread"; projectId: string | null }; + +/** An @-mention pill bound to one of the calling plugin's mention providers. */ +export interface PluginComposerMention { + /** Mention provider id registered by THIS plugin via `bb.ui.registerMentionProvider`. */ + provider: string; + /** Item id your provider's `resolve` will receive at send time. */ + id: string; + /** Pill text shown in the composer. */ + label: string; +} + +/** + * Programmatic access to the chat composer draft — the same shared draft the + * built-in "Add to chat" affordances (file preview, diff, terminal selections) + * write to. Inside a thread context writes land in that thread's draft; + * anywhere else (nav panel, homepage section) they seed the new-thread + * composer draft, which persists until the user sends or clears it. + */ +export interface PluginComposerApi { + scope: PluginComposerScope; + /** + * Append text to the draft as a `> ` blockquote block and focus the + * composer. Blank text is a no-op. This is the "reference this selection + * in chat" primitive. + */ + addQuote(text: string): void; + /** + * Insert an @-mention pill that resolves through this plugin's mention + * provider at send time — the durable way to reference an entity whose + * content should be fetched fresh when the message is sent. + */ + insertMention(mention: PluginComposerMention): void; + /** Focus the composer caret at the end of the draft. */ + focus(): void; +} + /** Current app selection, derived from the route. */ export interface BbContext { projectId: string | null; @@ -233,6 +273,7 @@ export interface PluginSdkApp { useSettings(): PluginSettingsState; useBbContext(): BbContext; useBbNavigate(): BbNavigate; + useComposer(): PluginComposerApi; } /** @@ -245,6 +286,7 @@ export const PLUGIN_SDK_APP_EXPORT_NAMES = [ "definePluginApp", "useBbContext", "useBbNavigate", + "useComposer", "useRealtime", "useRpc", "useSettings", diff --git a/packages/templates/src/generated/plugin-sdk-dts.generated.ts b/packages/templates/src/generated/plugin-sdk-dts.generated.ts index e642505d5..d136d7092 100644 --- a/packages/templates/src/generated/plugin-sdk-dts.generated.ts +++ b/packages/templates/src/generated/plugin-sdk-dts.generated.ts @@ -2,6 +2,6 @@ // Generated by packages/templates/scripts/generate-templates.mjs from // @bb/plugin-sdk/bundled-types. Do not edit directly. -export const PLUGIN_SDK_DTS = "// Bundled type declarations for `@bb/plugin-sdk`, shipped into scaffolded\n// plugins so they typecheck without the @bb/* workspace on disk.\n//\n// Confused by the API, or need a symbol that isn't here? Clone the BB repo\n// and read the real source: https://github.com/ymichael/bb\n\nimport { ComponentType } from 'react';\nimport Database from 'better-sqlite3';\nimport { Context } from 'hono';\nimport * as z from 'zod';\nimport { z as z$1 } from 'zod';\n\n/**\n * The `@bb/plugin-sdk/app` contract (plugin design §5.2) — pure types plus\n * the runtime export-name list, with no side effects. This module is what the\n * BB app imports to keep its real implementation in sync (`satisfies\n * PluginSdkApp`) and what `bb plugin build` imports to generate the shim's\n * named-export list. Plugin authors import the same shapes through\n * `@bb/plugin-sdk/app`.\n *\n * Per-slot props are versioned contracts: additive-only within an SDK major.\n */\n/** Props passed to a `homepageSection` component. */\ninterface PluginHomepageSectionProps {\n /** Project in view on the compose surface; null when none is selected. */\n projectId: string | null;\n}\n/** Props passed to a `navPanel` component (it owns its whole route). */\ninterface PluginNavPanelProps {\n /**\n * The route remainder after the panel root, \"\" at the root. The panel's\n * route is `/plugins///*`, so a deep link like\n * `/plugins/notes/notes/work/ideas.md` renders the panel with\n * `subPath: \"work/ideas.md\"`. Navigate within the panel via\n * `useBbNavigate().toPluginPanel(path, { subPath })` — browser\n * back/forward then walks panel-internal history.\n */\n subPath: string;\n}\n/** Props passed to a panel tab opened by a `threadPanelAction`. */\ninterface PluginThreadPanelProps {\n threadId: string;\n /**\n * The JSON value the action's `openPanel` call passed (round-tripped\n * through persistence, so the tab restores across reloads); null when the\n * action opened the panel without params.\n */\n params: unknown;\n}\n/** Props passed to a `composerAccessory` component. */\ninterface PluginComposerAccessoryProps {\n projectId: string | null;\n threadId: string | null;\n}\n/**\n * Slot/panel ids and nav-panel paths must match this pattern (letters,\n * digits, `-`, `_`): they ride URLs and persisted panel-tab keys.\n */\ndeclare const PLUGIN_SLOT_ID_PATTERN: RegExp;\ninterface PluginHomepageSectionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n component: ComponentType;\n}\ninterface PluginNavPanelRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n /** Icon hint (BB icon name); unknown names fall back to a generic icon. */\n icon: string;\n /** URL segment under `/plugins//`; letters, digits, `-`, `_`. */\n path: string;\n component: ComponentType;\n /**\n * Panel chrome (default \"page\"): \"page\" renders the host title bar (plugin\n * logo + `title` + your `headerContent`) above a full-width padded body;\n * \"none\" hands the ENTIRE panel area to `component` — no host padding, no\n * title bar (`headerContent` is ignored) — only the per-plugin error\n * boundary remains.\n */\n chrome?: \"page\" | \"none\";\n /**\n * Optional component rendered on the right side of the \"page\" title bar\n * (e.g. a sync button or a count). Contained separately from the body: a\n * throwing headerContent is hidden without breaking the title bar.\n */\n headerContent?: ComponentType;\n}\n/** Context handed to a `threadPanelAction`'s `run`. */\ninterface PluginThreadPanelActionContext {\n /** The thread whose panel launcher invoked the action. */\n threadId: string;\n /**\n * Open a tab in the thread's side panel rendering this action's\n * `component`. `title` labels the tab (default: the action's `title`);\n * `params` must be JSON-serializable — it is persisted with the tab and\n * reaches the component as its `params` prop. Opening with params\n * identical to an already-open tab of this action focuses that tab\n * (updating its title) instead of duplicating it. May be called more than\n * once (different params ⇒ multiple tabs) or not at all.\n */\n openPanel(options?: {\n title?: string;\n params?: unknown;\n }): void;\n}\ninterface PluginThreadPanelActionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n /** Label of the action row in the panel's new-tab launcher. */\n title: string;\n /**\n * Icon hint (BB icon name) used when the plugin ships no logo; the\n * launcher row and opened tabs prefer the plugin's logo.\n */\n icon?: string;\n /** Rendered inside every panel tab this action opens. */\n component: ComponentType;\n /**\n * Runs when the user activates the action: call your RPC methods, show a\n * toast, and/or open panel tabs via `context.openPanel`. Omitted =\n * immediately open a panel tab with defaults. Errors (sync or async) are\n * contained and logged; they never break the launcher.\n */\n run?(context: PluginThreadPanelActionContext): void | Promise;\n}\ninterface PluginComposerAccessoryRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n component: ComponentType;\n}\ninterface PluginAppSlots {\n homepageSection(registration: PluginHomepageSectionRegistration): void;\n navPanel(registration: PluginNavPanelRegistration): void;\n threadPanelAction(registration: PluginThreadPanelActionRegistration): void;\n composerAccessory(registration: PluginComposerAccessoryRegistration): void;\n}\ninterface PluginAppBuilder {\n slots: PluginAppSlots;\n}\ntype PluginAppSetup = (app: PluginAppBuilder) => void;\n/**\n * The opaque product of `definePluginApp` — a plugin's `app.tsx` default\n * export. The host re-runs `setup` against a fresh collector on every\n * (re)interpretation, replacing that plugin's registrations wholesale.\n */\ninterface PluginAppDefinition {\n /** Brand the host checks before interpreting a bundle's default export. */\n readonly __bbPluginApp: true;\n readonly setup: PluginAppSetup;\n}\ninterface PluginRpcClient {\n /**\n * Invoke one of the plugin's `bb.rpc` methods (POST\n * /api/v1/plugins/<id>/rpc/<method>). Resolves with the method's\n * result; rejects with an `Error` carrying the server's message when the\n * handler fails or the plugin is not running.\n */\n call(method: string, input?: unknown): Promise;\n}\ninterface PluginSettingsState {\n /**\n * Effective non-secret setting values (secret settings are excluded —\n * read them server-side). Undefined while loading or unavailable.\n */\n values: Record | undefined;\n isLoading: boolean;\n}\n/** Current app selection, derived from the route. */\ninterface BbContext {\n projectId: string | null;\n threadId: string | null;\n}\ninterface BbNavigate {\n toThread(threadId: string): void;\n toProject(projectId: string): void;\n /**\n * Navigate to one of this plugin's own nav panels by its `path`.\n * `subPath` targets a location inside the panel (the component's\n * `subPath` prop); `replace` swaps the current history entry instead of\n * pushing — use it for redirects so back does not bounce.\n */\n toPluginPanel(path: string, options?: {\n subPath?: string;\n replace?: boolean;\n }): void;\n}\n/**\n * Everything `@bb/plugin-sdk/app` resolves to at runtime. The BB app builds\n * the real implementation and `satisfies` this interface; `bb plugin build`\n * shims the specifier to that object on `globalThis.__bbPluginRuntime`.\n */\ninterface PluginSdkApp {\n definePluginApp(setup: PluginAppSetup): PluginAppDefinition;\n useRpc(): PluginRpcClient;\n useRealtime(channel: string, handler: (payload: unknown) => void): void;\n useSettings(): PluginSettingsState;\n useBbContext(): BbContext;\n useBbNavigate(): BbNavigate;\n}\n/**\n * Named runtime exports of `@bb/plugin-sdk/app`, in sorted order. Single\n * source of truth for the build shim's export list and the app's\n * implementation-key test — adding a surface member without updating this\n * list fails the type assertion below.\n */\ndeclare const PLUGIN_SDK_APP_EXPORT_NAMES: readonly [\"definePluginApp\", \"useBbContext\", \"useBbNavigate\", \"useRealtime\", \"useRpc\", \"useSettings\"];\n\ndeclare const appThemeSchema: z$1.ZodObject<{\n themeId: z$1.ZodString;\n customCss: z$1.ZodNullable;\n faviconColor: z$1.ZodEnum<{\n default: \"default\";\n red: \"red\";\n orange: \"orange\";\n yellow: \"yellow\";\n green: \"green\";\n teal: \"teal\";\n blue: \"blue\";\n purple: \"purple\";\n pink: \"pink\";\n }>;\n}, z$1.core.$strip>;\ntype AppTheme = z$1.infer;\n\ndeclare const changedMessageSchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"thread\">;\n id: z$1.ZodOptional;\n metadata: z$1.ZodOptional;\n eventTypes: z$1.ZodOptional>>>>;\n hasPendingInteraction: z$1.ZodOptional;\n projectId: z$1.ZodOptional;\n }, z$1.core.$strict>>;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"project\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"environment\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"host\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"system\">;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>], \"entity\">;\ntype ChangedMessage = z$1.infer;\n\ndeclare const pendingInteractionResolutionSchema: z$1.ZodUnion;\n grantedPermissions: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n fileSystem: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n decision: z$1.ZodLiteral<\"allow_for_session\">;\n grantedPermissions: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n fileSystem: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n decision: z$1.ZodLiteral<\"deny\">;\n}, z$1.core.$strip>], \"decision\">, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"user_answer\">;\n answers: z$1.ZodRecord;\n freeText: z$1.ZodOptional;\n }, z$1.core.$strip>>;\n}, z$1.core.$strip>]>;\ntype PendingInteractionResolution = z$1.infer;\n\ndeclare const threadStatusSchema: z$1.ZodEnum<{\n error: \"error\";\n active: \"active\";\n starting: \"starting\";\n idle: \"idle\";\n stopping: \"stopping\";\n}>;\ntype ThreadStatus = z$1.infer;\n\ndeclare const threadTimelinePendingTodosSchema: z$1.ZodObject<{\n sourceSeq: z$1.ZodNumber;\n updatedAt: z$1.ZodNumber;\n items: z$1.ZodArray;\n }, z$1.core.$strip>>;\n}, z$1.core.$strip>;\ntype ThreadTimelinePendingTodos = z$1.infer;\n\ndeclare const createAutomationRequestSchema: z$1.ZodObject<{\n name: z$1.ZodString;\n enabled: z$1.ZodDefault;\n trigger: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n triggerType: z$1.ZodLiteral<\"schedule\">;\n cron: z$1.ZodString;\n timezone: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n triggerType: z$1.ZodLiteral<\"once\">;\n runAt: z$1.ZodNumber;\n }, z$1.core.$strip>], \"triggerType\">;\n execution: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"agent\">;\n prompt: z$1.ZodString;\n providerId: z$1.ZodString;\n model: z$1.ZodString;\n permissionMode: z$1.ZodEnum<{\n readonly: \"readonly\";\n full: \"full\";\n \"workspace-write\": \"workspace-write\";\n }>;\n targetThreadId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"script\">;\n script: z$1.ZodOptional;\n scriptFile: z$1.ZodOptional;\n interpreter: z$1.ZodOptional>;\n timeoutMs: z$1.ZodDefault;\n env: z$1.ZodOptional>;\n }, z$1.core.$strip>], \"mode\">;\n environment: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"reuse\">;\n environmentId: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"host\">;\n hostId: z$1.ZodOptional;\n workspace: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"unmanaged\">;\n path: z$1.ZodNullable;\n branch: z$1.ZodOptional;\n name: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"new\">;\n baseBranch: z$1.ZodString;\n }, z$1.core.$strict>], \"kind\">>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"managed-worktree\">;\n baseBranch: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"named\">;\n name: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"default\">;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"personal\">;\n }, z$1.core.$strip>], \"type\">;\n }, z$1.core.$strip>], \"type\">;\n autoArchive: z$1.ZodDefault;\n origin: z$1.ZodEnum<{\n agent: \"agent\";\n human: \"human\";\n app: \"app\";\n }>;\n createdByThreadId: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype CreateAutomationRequest = z$1.input;\ndeclare const updateAutomationRequestSchema: z$1.ZodObject<{\n name: z$1.ZodOptional;\n trigger: z$1.ZodOptional;\n cron: z$1.ZodString;\n timezone: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n triggerType: z$1.ZodLiteral<\"once\">;\n runAt: z$1.ZodNumber;\n }, z$1.core.$strip>], \"triggerType\">>;\n execution: z$1.ZodOptional;\n prompt: z$1.ZodString;\n providerId: z$1.ZodString;\n model: z$1.ZodString;\n permissionMode: z$1.ZodEnum<{\n readonly: \"readonly\";\n full: \"full\";\n \"workspace-write\": \"workspace-write\";\n }>;\n targetThreadId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"script\">;\n script: z$1.ZodOptional;\n scriptFile: z$1.ZodOptional;\n interpreter: z$1.ZodOptional>;\n timeoutMs: z$1.ZodDefault;\n env: z$1.ZodOptional>;\n }, z$1.core.$strip>], \"mode\">>;\n environment: z$1.ZodOptional;\n environmentId: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"host\">;\n hostId: z$1.ZodOptional;\n workspace: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"unmanaged\">;\n path: z$1.ZodNullable;\n branch: z$1.ZodOptional;\n name: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"new\">;\n baseBranch: z$1.ZodString;\n }, z$1.core.$strict>], \"kind\">>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"managed-worktree\">;\n baseBranch: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"named\">;\n name: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"default\">;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"personal\">;\n }, z$1.core.$strip>], \"type\">;\n }, z$1.core.$strip>], \"type\">>;\n autoArchive: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype UpdateAutomationRequest = z$1.infer;\ndeclare const runAutomationRequestSchema: z$1.ZodObject<{\n idempotencyKey: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype RunAutomationRequest = z$1.infer;\n\ndeclare const createProjectSourceRequestSchema: z$1.ZodObject<{\n hostId: z$1.ZodString;\n type: z$1.ZodLiteral<\"local_path\">;\n path: z$1.ZodPipe>;\n}, z$1.core.$strict>;\ntype CreateProjectSourceRequest = z$1.infer;\ndeclare const createProjectRequestSchema: z$1.ZodObject<{\n name: z$1.ZodString;\n source: z$1.ZodObject<{\n hostId: z$1.ZodString;\n type: z$1.ZodLiteral<\"local_path\">;\n path: z$1.ZodPipe>;\n }, z$1.core.$strict>;\n}, z$1.core.$strip>;\ntype CreateProjectRequest = z$1.infer;\ndeclare const projectListQuerySchema: z$1.ZodObject<{\n include: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype ProjectListQuery = z$1.infer;\ndeclare const updateProjectRequestSchema: z$1.ZodObject<{\n name: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype UpdateProjectRequest = z$1.infer;\ndeclare const updateProjectSourceRequestSchema: z$1.ZodObject<{\n type: z$1.ZodLiteral<\"local_path\">;\n path: z$1.ZodOptional>>;\n isDefault: z$1.ZodOptional>;\n}, z$1.core.$strict>;\ntype UpdateProjectSourceRequest = z$1.infer;\n\ndeclare const updateEnvironmentRequestSchema: z$1.ZodObject<{\n mergeBaseBranch: z$1.ZodOptional>;\n name: z$1.ZodOptional>;\n}, z$1.core.$strip>;\ntype UpdateEnvironmentRequest = z$1.infer;\ndeclare const environmentDiffBranchesQuerySchema: z$1.ZodObject<{\n query: z$1.ZodOptional;\n limit: z$1.ZodOptional;\n selectedBranch: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype EnvironmentDiffBranchesQuery = z$1.infer;\ndeclare const environmentStatusQuerySchema: z$1.ZodObject<{\n mergeBaseBranch: z$1.ZodOptional>;\n}, z$1.core.$strip>;\ntype EnvironmentStatusQuery = z$1.infer;\ndeclare const environmentDiffQuerySchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n target: z$1.ZodLiteral<\"uncommitted\">;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"branch_committed\">;\n mergeBaseBranch: z$1.ZodPipe;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"all\">;\n mergeBaseBranch: z$1.ZodPipe;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"commit\">;\n sha: z$1.ZodString;\n}, z$1.core.$strip>], \"target\">;\ntype EnvironmentDiffQuery = z$1.infer;\n/**\n * Query for fetching a single file's contents at one side of a diff target.\n * Used by the diff card to reparse the card's patch with full old/new contents\n * so `@pierre/diffs` can render expand-context buttons between hunks.\n *\n * For `branch_committed` / `all`, callers pass the resolved merge-base SHA\n * (`mergeBaseRef`, surfaced by `workspace.diff`) rather than the branch name\n * — the diff itself was computed against that SHA, so reading the old side\n * from the same SHA keeps the file content aligned with the hunk line\n * numbers. Reading from the branch tip is wrong whenever the branch has\n * moved past the merge-base since the file existed there.\n */\ndeclare const environmentDiffFileQuerySchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n target: z$1.ZodLiteral<\"uncommitted\">;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"branch_committed\">;\n mergeBaseRef: z$1.ZodString;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"all\">;\n mergeBaseRef: z$1.ZodString;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"commit\">;\n sha: z$1.ZodString;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>], \"target\">;\ntype EnvironmentDiffFileQuery = z$1.infer;\n\ndeclare const systemExecutionOptionsQuerySchema: z$1.ZodObject<{\n providerId: z$1.ZodOptional;\n hostId: z$1.ZodOptional;\n environmentId: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype SystemExecutionOptionsQuery = z$1.infer;\n/**\n * Theme catalog: the on-disk custom-theme directory plus the discovered custom\n * themes and the active palette. Drives `bb theme list` / `bb theme dir`.\n */\ndeclare const themeCatalogResponseSchema: z$1.ZodObject<{\n dir: z$1.ZodString;\n custom: z$1.ZodArray;\n active: z$1.ZodObject<{\n themeId: z$1.ZodString;\n customCss: z$1.ZodNullable;\n faviconColor: z$1.ZodEnum<{\n default: \"default\";\n red: \"red\";\n orange: \"orange\";\n yellow: \"yellow\";\n green: \"green\";\n teal: \"teal\";\n blue: \"blue\";\n purple: \"purple\";\n pink: \"pink\";\n }>;\n }, z$1.core.$strip>;\n}, z$1.core.$strip>;\ntype ThemeCatalogResponse = z$1.infer;\n\ndeclare const createTerminalRequestSchema: z$1.ZodObject<{\n cols: z$1.ZodNumber;\n rows: z$1.ZodNumber;\n start: z$1.ZodOptional;\n }, z$1.core.$strict>, z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"command\">;\n command: z$1.ZodString;\n }, z$1.core.$strict>], \"mode\">>;\n target: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"thread\">;\n threadId: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"environment\">;\n environmentId: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"host_path\">;\n hostId: z$1.ZodString;\n cwd: z$1.ZodNullable;\n }, z$1.core.$strict>], \"kind\">;\n title: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype CreateTerminalRequest = z$1.infer;\ndeclare const closeTerminalRequestSchema: z$1.ZodObject<{\n mode: z$1.ZodEnum<{\n force: \"force\";\n \"if-clean\": \"if-clean\";\n }>;\n reason: z$1.ZodLiteral<\"user\">;\n}, z$1.core.$strict>;\ntype CloseTerminalRequest = z$1.infer;\ndeclare const updateTerminalRequestSchema: z$1.ZodObject<{\n title: z$1.ZodString;\n}, z$1.core.$strict>;\ntype UpdateTerminalRequest = z$1.infer;\ndeclare const terminalInputRequestSchema: z$1.ZodObject<{\n dataBase64: z$1.ZodString;\n}, z$1.core.$strict>;\ntype TerminalInputRequest = z$1.infer;\ndeclare const terminalResizeRequestSchema: z$1.ZodObject<{\n cols: z$1.ZodNumber;\n rows: z$1.ZodNumber;\n}, z$1.core.$strict>;\ntype TerminalResizeRequest = z$1.infer;\ndeclare const terminalOutputQuerySchema: z$1.ZodObject<{\n sinceSeq: z$1.ZodOptional>;\n tailBytes: z$1.ZodOptional>;\n limitChunks: z$1.ZodOptional>;\n}, z$1.core.$strict>;\ntype TerminalOutputQuery = z$1.infer;\n\ndeclare const createThreadRequestSchema: z$1.ZodObject<{\n projectId: z$1.ZodString;\n providerId: z$1.ZodOptional;\n origin: z$1.ZodEnum<{\n plugin: \"plugin\";\n app: \"app\";\n automation: \"automation\";\n cli: \"cli\";\n sdk: \"sdk\";\n }>;\n originPluginId: z$1.ZodOptional;\n title: z$1.ZodOptional;\n input: z$1.ZodArray>;\n type: z$1.ZodLiteral<\"text\">;\n text: z$1.ZodString;\n mentions: z$1.ZodDefault;\n threadId: z$1.ZodString;\n projectId: z$1.ZodOptional;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"project\">;\n projectId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"path\">;\n source: z$1.ZodEnum<{\n workspace: \"workspace\";\n \"thread-storage\": \"thread-storage\";\n }>;\n entryKind: z$1.ZodEnum<{\n file: \"file\";\n directory: \"directory\";\n }>;\n path: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"command\">;\n trigger: z$1.ZodEnum<{\n \"/\": \"/\";\n }>;\n name: z$1.ZodString;\n source: z$1.ZodEnum<{\n command: \"command\";\n skill: \"skill\";\n }>;\n origin: z$1.ZodEnum<{\n user: \"user\";\n project: \"project\";\n builtin: \"builtin\";\n }>;\n label: z$1.ZodString;\n argumentHint: z$1.ZodNullable;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"plugin\">;\n pluginId: z$1.ZodString;\n itemId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>>>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"image\">;\n url: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localImage\">;\n path: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localFile\">;\n path: z$1.ZodString;\n name: z$1.ZodOptional;\n sizeBytes: z$1.ZodOptional;\n mimeType: z$1.ZodOptional;\n }, z$1.core.$strip>], \"type\">>;\n model: z$1.ZodOptional;\n serviceTier: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>;\n permissionMode: z$1.ZodOptional>;\n executionInputSources: z$1.ZodOptional>;\n model: z$1.ZodOptional>;\n serviceTier: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>;\n permissionMode: z$1.ZodOptional>;\n }, z$1.core.$strict>>;\n environment: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"reuse\">;\n environmentId: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"host\">;\n hostId: z$1.ZodOptional;\n workspace: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"unmanaged\">;\n path: z$1.ZodNullable;\n branch: z$1.ZodOptional;\n name: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"new\">;\n baseBranch: z$1.ZodString;\n }, z$1.core.$strict>], \"kind\">>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"managed-worktree\">;\n baseBranch: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"named\">;\n name: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"default\">;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"personal\">;\n }, z$1.core.$strip>], \"type\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"project-default\">;\n }, z$1.core.$strip>], \"type\">;\n parentThreadId: z$1.ZodOptional;\n folderId: z$1.ZodOptional>;\n sourceThreadId: z$1.ZodOptional;\n sourceSeqEnd: z$1.ZodOptional;\n startedOnBehalfOf: z$1.ZodDefault;\n senderThreadId: z$1.ZodString;\n }, z$1.core.$strip>>>;\n originKind: z$1.ZodDefault>>;\n childOrigin: z$1.ZodDefault>>;\n}, z$1.core.$strip>;\ntype CreateThreadRequest = z$1.infer;\ndeclare const sendMessageRequestSchema: z$1.ZodObject<{\n input: z$1.ZodArray>;\n type: z$1.ZodLiteral<\"text\">;\n text: z$1.ZodString;\n mentions: z$1.ZodDefault;\n threadId: z$1.ZodString;\n projectId: z$1.ZodOptional;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"project\">;\n projectId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"path\">;\n source: z$1.ZodEnum<{\n workspace: \"workspace\";\n \"thread-storage\": \"thread-storage\";\n }>;\n entryKind: z$1.ZodEnum<{\n file: \"file\";\n directory: \"directory\";\n }>;\n path: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"command\">;\n trigger: z$1.ZodEnum<{\n \"/\": \"/\";\n }>;\n name: z$1.ZodString;\n source: z$1.ZodEnum<{\n command: \"command\";\n skill: \"skill\";\n }>;\n origin: z$1.ZodEnum<{\n user: \"user\";\n project: \"project\";\n builtin: \"builtin\";\n }>;\n label: z$1.ZodString;\n argumentHint: z$1.ZodNullable;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"plugin\">;\n pluginId: z$1.ZodString;\n itemId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>>>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"image\">;\n url: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localImage\">;\n path: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localFile\">;\n path: z$1.ZodString;\n name: z$1.ZodOptional;\n sizeBytes: z$1.ZodOptional;\n mimeType: z$1.ZodOptional;\n }, z$1.core.$strip>], \"type\">>;\n model: z$1.ZodOptional;\n serviceTier: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>;\n permissionMode: z$1.ZodOptional>;\n executionInputSources: z$1.ZodOptional>;\n serviceTier: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>;\n permissionMode: z$1.ZodOptional>;\n }, z$1.core.$strict>>;\n mode: z$1.ZodEnum<{\n steer: \"steer\";\n start: \"start\";\n auto: \"auto\";\n \"queue-if-active\": \"queue-if-active\";\n \"steer-if-active\": \"steer-if-active\";\n }>;\n senderThreadId: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype SendMessageRequest = z$1.infer;\ndeclare const threadResponseSchema: z$1.ZodObject<{\n id: z$1.ZodString;\n projectId: z$1.ZodString;\n environmentId: z$1.ZodNullable;\n providerId: z$1.ZodString;\n title: z$1.ZodNullable;\n titleFallback: z$1.ZodNullable;\n folderId: z$1.ZodNullable;\n status: z$1.ZodEnum<{\n error: \"error\";\n stopping: \"stopping\";\n idle: \"idle\";\n starting: \"starting\";\n active: \"active\";\n }>;\n parentThreadId: z$1.ZodNullable;\n sourceThreadId: z$1.ZodNullable;\n originKind: z$1.ZodNullable>;\n childOrigin: z$1.ZodNullable>;\n originPluginId: z$1.ZodNullable;\n archivedAt: z$1.ZodNullable;\n pinnedAt: z$1.ZodNullable;\n deletedAt: z$1.ZodNullable;\n lastReadAt: z$1.ZodNullable;\n latestAttentionAt: z$1.ZodNumber;\n createdAt: z$1.ZodNumber;\n updatedAt: z$1.ZodNumber;\n runtime: z$1.ZodObject<{\n displayStatus: z$1.ZodEnum<{\n error: \"error\";\n provisioning: \"provisioning\";\n stopping: \"stopping\";\n idle: \"idle\";\n starting: \"starting\";\n active: \"active\";\n \"host-reconnecting\": \"host-reconnecting\";\n \"waiting-for-host\": \"waiting-for-host\";\n }>;\n hostReconnectGraceExpiresAt: z$1.ZodNullable;\n }, z$1.core.$strip>;\n canSpawnChild: z$1.ZodBoolean;\n}, z$1.core.$strip>;\ntype ThreadResponse = z$1.infer;\ndeclare const threadGetQuerySchema: z$1.ZodObject<{\n include: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype ThreadGetQuery = z$1.infer;\ndeclare const deleteThreadRequestSchema: z$1.ZodObject<{\n childThreadsConfirmed: z$1.ZodBoolean;\n}, z$1.core.$strip>;\ntype DeleteThreadRequest = z$1.infer;\ndeclare const updateThreadRequestSchema: z$1.ZodObject<{\n title: z$1.ZodOptional>;\n folderId: z$1.ZodOptional>;\n parentThreadId: z$1.ZodOptional>;\n model: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>>;\n}, z$1.core.$strip>;\ntype UpdateThreadRequest = z$1.infer;\n/** Which root a secondary-panel file path is relative to. */\ndeclare const panelFileSourceSchema: z$1.ZodEnum<{\n workspace: \"workspace\";\n \"thread-storage\": \"thread-storage\";\n}>;\ntype PanelFileSource = z$1.infer;\ndeclare const threadTimelineQuerySchema: z$1.ZodObject<{\n includeNestedRows: z$1.ZodOptional>;\n segmentLimit: z$1.ZodOptional;\n beforeAnchorSeq: z$1.ZodOptional;\n beforeAnchorId: z$1.ZodOptional;\n summaryOnly: z$1.ZodOptional>;\n afterSequence: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype ThreadTimelineQuery = z$1.infer;\n\ntype PublicApiSchema = unknown;\ntype ApiClient = unknown;\n\ntype FetchImplementation = typeof fetch;\ntype JsonBodyOf = TResponse extends {\n json(): Promise;\n} ? TBody : never;\n\ntype BbSdkRuntime = \"node\" | \"browser\";\ninterface BbSdkTransport {\n api: ApiClient[\"api\"];\n baseUrl: string;\n fetch: FetchImplementation;\n realtimeUrl?: string;\n runtime: BbSdkRuntime;\n readJson(response: Promise): Promise>;\n readVoid(response: Promise): Promise;\n resolve(response: Promise): Promise;\n websocket?: BbRealtimeSocketFactory;\n}\n/**\n * Raw socket payload. Treated as opaque until decoded — realtime ignores\n * non-string frames.\n */\ninterface BbRealtimeSocketMessageEvent {\n data: unknown;\n}\n/**\n * Minimal runtime-agnostic socket shape consumed by the realtime client.\n * Default factories adapt the environment's WebSocket (browser/Node global,\n * or the `ws` package on older Node) to this interface; custom `websocket`\n * factories can wrap any implementation the same way.\n */\ninterface BbRealtimeSocket {\n close(): void;\n onclose: (() => void) | null;\n onerror: (() => void) | null;\n onmessage: ((event: BbRealtimeSocketMessageEvent) => void) | null;\n onopen: (() => void) | null;\n readyState: number;\n send(data: string): void;\n}\ntype BbRealtimeSocketFactory = (url: string) => BbRealtimeSocket;\ninterface BbSdkContext {\n}\n\ninterface CreateSdkAreaArgs {\n context: BbSdkContext;\n transport: BbSdkTransport;\n}\ntype PublicApiEndpointOutput = TEndpoint extends {\n status: infer Status;\n output: infer Output;\n} ? Status extends SuccessfulHttpStatus ? Output : never : never;\ntype SuccessfulHttpStatus = 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226;\ntype PublicApiOutput = PublicApiEndpointOutput;\n\ninterface AutomationCreateArgs extends CreateAutomationRequest {\n projectId?: string;\n}\ninterface AutomationListArgs {\n projectId?: string;\n}\ninterface AutomationGetArgs {\n projectId?: string;\n automationId: string;\n}\ninterface AutomationUpdateArgs extends UpdateAutomationRequest {\n projectId?: string;\n automationId: string;\n}\ninterface AutomationActionArgs {\n projectId?: string;\n automationId: string;\n}\ninterface AutomationRunArgs extends RunAutomationRequest {\n projectId?: string;\n automationId: string;\n}\ninterface AutomationRunsArgs {\n projectId?: string;\n automationId: string;\n limit?: number;\n cursor?: string;\n}\ntype AutomationCreateResult = PublicApiOutput<\"/projects/:id/automations\", \"$post\">;\ntype AutomationListResult = PublicApiOutput<\"/projects/:id/automations\", \"$get\">;\ntype AutomationGetResult = PublicApiOutput<\"/projects/:id/automations/:automationId\", \"$get\">;\ntype AutomationUpdateResult = PublicApiOutput<\"/projects/:id/automations/:automationId\", \"$patch\">;\ntype AutomationPauseResult = PublicApiOutput<\"/projects/:id/automations/:automationId/pause\", \"$post\">;\ntype AutomationResumeResult = PublicApiOutput<\"/projects/:id/automations/:automationId/resume\", \"$post\">;\ntype AutomationRunResult = PublicApiOutput<\"/projects/:id/automations/:automationId/run\", \"$post\">;\ntype AutomationRunsResult = PublicApiOutput<\"/projects/:id/automations/:automationId/runs\", \"$get\">;\ntype AutomationsOverviewResult = PublicApiOutput<\"/automations\", \"$get\">;\ninterface AutomationsArea {\n create(args: AutomationCreateArgs): Promise;\n delete(args: AutomationActionArgs): Promise<{\n ok: true;\n }>;\n get(args: AutomationGetArgs): Promise;\n list(args?: AutomationListArgs): Promise;\n overview(): Promise;\n pause(args: AutomationActionArgs): Promise;\n resume(args: AutomationActionArgs): Promise;\n run(args: AutomationRunArgs): Promise;\n runs(args: AutomationRunsArgs): Promise;\n update(args: AutomationUpdateArgs): Promise;\n}\ndeclare function createAutomationsArea(args: CreateSdkAreaArgs): AutomationsArea;\n\ninterface EnvironmentGetArgs {\n environmentId: string;\n}\ntype EnvironmentMergeBaseBranchUpdateValue = Exclude;\ntype EnvironmentNameUpdateValue = Exclude;\ninterface EnvironmentMergeBaseBranchUpdate {\n mergeBaseBranch: EnvironmentMergeBaseBranchUpdateValue;\n name?: EnvironmentNameUpdateValue;\n}\ninterface EnvironmentNameUpdate {\n mergeBaseBranch?: EnvironmentMergeBaseBranchUpdateValue;\n name: EnvironmentNameUpdateValue;\n}\ntype EnvironmentUpdateFields = EnvironmentMergeBaseBranchUpdate | EnvironmentNameUpdate;\ntype EnvironmentUpdateArgs = EnvironmentUpdateFields & {\n environmentId: string;\n};\ninterface EnvironmentStatusArgs extends EnvironmentStatusQuery {\n environmentId: string;\n}\ntype EnvironmentDiffArgs = EnvironmentDiffQuery & {\n environmentId: string;\n};\ntype EnvironmentDiffFileArgs = EnvironmentDiffFileQuery & {\n environmentId: string;\n};\ninterface EnvironmentDiffBranchesArgs extends EnvironmentDiffBranchesQuery {\n environmentId: string;\n}\ninterface EnvironmentCommitArgs {\n environmentId: string;\n}\ninterface EnvironmentSquashMergeArgs {\n environmentId: string;\n mergeBaseBranch: string;\n}\ntype EnvironmentActionResult = PublicApiOutput<\"/environments/:id/actions\", \"$post\">;\ntype EnvironmentCommitResult = Extract;\ntype EnvironmentDiffResult = PublicApiOutput<\"/environments/:id/diff\", \"$get\">;\ntype EnvironmentDiffBranchesResult = PublicApiOutput<\"/environments/:id/diff/branches\", \"$get\">;\ntype EnvironmentDiffFileResult = PublicApiOutput<\"/environments/:id/diff/file\", \"$get\">;\ntype EnvironmentGetResult = PublicApiOutput<\"/environments/:id\", \"$get\">;\ntype EnvironmentPullRequestResult = PublicApiOutput<\"/environments/:id/pull-request\", \"$get\">;\ntype EnvironmentSquashMergeResult = Extract;\ntype EnvironmentStatusResult = PublicApiOutput<\"/environments/:id/status\", \"$get\">;\ntype EnvironmentUpdateResult = PublicApiOutput<\"/environments/:id\", \"$patch\">;\ninterface EnvironmentsArea {\n commit(args: EnvironmentCommitArgs): Promise;\n diff(args: EnvironmentDiffArgs): Promise;\n diffBranches(args: EnvironmentDiffBranchesArgs): Promise;\n diffFile(args: EnvironmentDiffFileArgs): Promise;\n get(args: EnvironmentGetArgs): Promise;\n pullRequest(args: EnvironmentGetArgs): Promise;\n squashMerge(args: EnvironmentSquashMergeArgs): Promise;\n status(args: EnvironmentStatusArgs): Promise;\n update(args: EnvironmentUpdateArgs): Promise;\n}\ndeclare function createEnvironmentsArea(args: CreateSdkAreaArgs): EnvironmentsArea;\n\n/**\n * Host file primitives. `hostId` may be omitted to target the server's\n * primary (local) host. `rootPath`, when set, confines the target beneath\n * that absolute root on the host (symlink-safe).\n */\ninterface FileReadArgs {\n hostId?: string;\n path: string;\n rootPath?: string;\n}\ninterface FileWriteArgs {\n hostId?: string;\n path: string;\n rootPath?: string;\n content: string;\n /** Defaults to \"utf8\". */\n contentEncoding?: \"utf8\" | \"base64\";\n /** Defaults to false. */\n createParents?: boolean;\n /**\n * Optimistic-concurrency guard: omitted → unconditional write; a hash →\n * write only when the current content hashes to it (use `read().sha256`);\n * null → create-only. A failed guard resolves to the `conflict` outcome.\n */\n expectedSha256?: string | null;\n}\ninterface FileListArgs {\n hostId?: string;\n path: string;\n query?: string;\n limit?: number;\n}\ntype FileReadResult = PublicApiOutput<\"/files/read\", \"$post\">;\ntype FileWriteResult = PublicApiOutput<\"/files/write\", \"$post\">;\ntype FileListResult = PublicApiOutput<\"/files/list\", \"$post\">;\ninterface FilesArea {\n read(args: FileReadArgs): Promise;\n write(args: FileWriteArgs): Promise;\n list(args: FileListArgs): Promise;\n}\ndeclare function createFilesArea(args: CreateSdkAreaArgs): FilesArea;\n\ninterface GuideRenderArgs {\n chapter?: string;\n}\ninterface GuideRenderResult {\n chapter?: string;\n content: string;\n}\ninterface GuideArea {\n render(args?: GuideRenderArgs): GuideRenderResult;\n}\ndeclare function createGuideArea(): GuideArea;\n\ninterface HostGetArgs {\n hostId: string;\n}\ntype HostGetResult = PublicApiOutput<\"/hosts/:id\", \"$get\">;\ntype HostListResult = PublicApiOutput<\"/hosts\", \"$get\">;\ninterface HostsArea {\n get(args: HostGetArgs): Promise;\n list(): Promise;\n}\ndeclare function createHostsArea(args: CreateSdkAreaArgs): HostsArea;\n\ninterface ProjectListArgs extends ProjectListQuery {\n}\ninterface ProjectCreateArgs extends CreateProjectRequest {\n}\ninterface ProjectGetArgs {\n projectId: string;\n}\ninterface ProjectUpdateArgs extends UpdateProjectRequest {\n projectId: string;\n}\ninterface ProjectDeleteArgs {\n projectId: string;\n}\ninterface ProjectSourceAddArgs extends CreateProjectSourceRequest {\n projectId: string;\n}\ninterface ProjectSourceUpdateArgs extends UpdateProjectSourceRequest {\n projectId: string;\n sourceId: string;\n}\ninterface ProjectSourceDeleteArgs {\n projectId: string;\n sourceId: string;\n}\ntype ProjectCreateResult = PublicApiOutput<\"/projects\", \"$post\">;\ntype ProjectDeleteResult = PublicApiOutput<\"/projects/:id\", \"$delete\">;\ntype ProjectGetResult = PublicApiOutput<\"/projects/:id\", \"$get\">;\ntype ProjectListResult = PublicApiOutput<\"/projects\", \"$get\">;\ntype ProjectUpdateResult = PublicApiOutput<\"/projects/:id\", \"$patch\">;\ntype ProjectSourceAddResult = PublicApiOutput<\"/projects/:id/sources\", \"$post\">;\ntype ProjectSourceUpdateResult = PublicApiOutput<\"/projects/:id/sources/:sourceId\", \"$patch\">;\ntype ProjectSourceDeleteResult = PublicApiOutput<\"/projects/:id/sources/:sourceId\", \"$delete\">;\ninterface ProjectSourcesArea {\n add(args: ProjectSourceAddArgs): Promise;\n delete(args: ProjectSourceDeleteArgs): Promise;\n update(args: ProjectSourceUpdateArgs): Promise;\n}\ninterface ProjectsArea {\n create(args: ProjectCreateArgs): Promise;\n delete(args: ProjectDeleteArgs): Promise;\n get(args: ProjectGetArgs): Promise;\n list(args?: ProjectListArgs): Promise;\n sources: ProjectSourcesArea;\n update(args: ProjectUpdateArgs): Promise;\n}\ndeclare function createProjectsArea(args: CreateSdkAreaArgs): ProjectsArea;\n\ninterface ProviderModelsArgs extends SystemExecutionOptionsQuery {\n}\ntype ProviderListResult = PublicApiOutput<\"/system/providers\", \"$get\">;\ntype ProviderModelsResult = PublicApiOutput<\"/system/execution-options\", \"$get\">;\ninterface ProvidersArea {\n list(): Promise;\n models(args?: ProviderModelsArgs): Promise;\n}\ndeclare function createProvidersArea(args: CreateSdkAreaArgs): ProvidersArea;\n\ntype BbRealtimeUnsubscribe = () => void;\ntype BbRealtimeEventName = \"thread:changed\" | \"project:changed\" | \"environment:changed\" | \"host:changed\" | \"system:changed\" | \"system:config-changed\" | \"realtime:connection\";\ntype ThreadRealtimeEvent = Extract;\ntype ProjectRealtimeEvent = Extract;\ntype EnvironmentRealtimeEvent = Extract;\ntype HostRealtimeEvent = Extract;\ntype SystemRealtimeEvent = Extract;\ntype BbRealtimeConnectionState = \"connecting\" | \"connected\" | \"disconnected\";\ninterface BbRealtimeConnectionEvent {\n reconnectDelayMs: number | null;\n reconnected: boolean;\n state: BbRealtimeConnectionState;\n}\n/**\n * Entity-changed events are delivered as one shared object to every matching\n * listener; their payload types are readonly so a listener cannot mutate what\n * the next listener receives.\n */\ninterface BbRealtimeEventMap {\n \"thread:changed\": ThreadRealtimeEvent;\n \"project:changed\": ProjectRealtimeEvent;\n \"environment:changed\": EnvironmentRealtimeEvent;\n \"host:changed\": HostRealtimeEvent;\n \"system:changed\": SystemRealtimeEvent;\n \"system:config-changed\": SystemRealtimeEvent;\n \"realtime:connection\": BbRealtimeConnectionEvent;\n}\ntype BbRealtimeCallback = (event: BbRealtimeEventMap[TEventName]) => void;\ninterface ThreadRealtimeOnArgs {\n callback: BbRealtimeCallback<\"thread:changed\">;\n event: \"thread:changed\";\n threadId?: string;\n}\ninterface ProjectRealtimeOnArgs {\n callback: BbRealtimeCallback<\"project:changed\">;\n event: \"project:changed\";\n projectId?: string;\n}\ninterface EnvironmentRealtimeOnArgs {\n callback: BbRealtimeCallback<\"environment:changed\">;\n environmentId?: string;\n event: \"environment:changed\";\n}\ninterface HostRealtimeOnArgs {\n callback: BbRealtimeCallback<\"host:changed\">;\n event: \"host:changed\";\n hostId?: string;\n}\ninterface SystemRealtimeOnArgs {\n callback: BbRealtimeCallback<\"system:changed\">;\n event: \"system:changed\";\n}\ninterface SystemConfigRealtimeOnArgs {\n callback: BbRealtimeCallback<\"system:config-changed\">;\n event: \"system:config-changed\";\n}\n/**\n * Connection listeners are pure observers — they never open or hold the\n * socket. A listener registered while a socket already exists receives the\n * latest connection event as a snapshot on the next microtask, so a status\n * UI mounted after connect still learns the current state.\n */\ninterface RealtimeConnectionOnArgs {\n callback: BbRealtimeCallback<\"realtime:connection\">;\n event: \"realtime:connection\";\n}\ntype BbRealtimeOnArgsUnion = ThreadRealtimeOnArgs | ProjectRealtimeOnArgs | EnvironmentRealtimeOnArgs | HostRealtimeOnArgs | SystemRealtimeOnArgs | SystemConfigRealtimeOnArgs | RealtimeConnectionOnArgs;\ntype BbRealtimeOnArgs = Extract;\ninterface BbRealtime {\n on(args: BbRealtimeOnArgs): BbRealtimeUnsubscribe;\n}\n\ninterface StatusGetArgs {\n projectId?: string;\n threadId?: string;\n}\ninterface StatusThreadSummary {\n environmentId: string | null;\n id: string;\n parentThreadId: string | null;\n pinnedAt: number | null;\n projectId: string;\n status: ThreadStatus;\n title: string | null;\n}\ntype StatusProject = PublicApiOutput<\"/projects/:id\", \"$get\">;\ntype StatusChildThreads = PublicApiOutput<\"/threads\", \"$get\">;\ninterface StatusResult {\n childThreads: StatusChildThreads | null;\n pendingTodos: ThreadTimelinePendingTodos | null;\n project: StatusProject | null;\n thread: StatusThreadSummary | null;\n}\ninterface StatusArea {\n get(args?: StatusGetArgs): Promise;\n}\ndeclare function createStatusArea(args: CreateSdkAreaArgs): StatusArea;\n\ninterface ThemeArea {\n /** The active app palette, resolved server-side (built-in id or custom CSS). */\n get(): Promise;\n /** The custom-theme directory plus discovered themes and the active palette. */\n catalog(): Promise;\n /**\n * Activate a palette by id — a built-in id or a custom theme name that exists\n * under `/theme//theme.css`. Broadcasts to all open windows.\n */\n set(themeId: string): Promise;\n}\ndeclare function createThemeArea(args: CreateSdkAreaArgs): ThemeArea;\n\ninterface ThreadListArgs {\n archived?: boolean;\n hasParent?: boolean;\n parentThreadId?: string;\n projectId?: string;\n}\ninterface ThreadGetArgs {\n include?: ThreadGetQuery[\"include\"];\n threadId: string;\n}\ntype ThreadGetResult = PublicApiOutput<\"/threads/:id\", \"$get\">;\ntype ThreadListResult = PublicApiOutput<\"/threads\", \"$get\">;\ntype ThreadOutputResponse = PublicApiOutput<\"/threads/:id/output\", \"$get\">;\ntype ThreadMutationResult = PublicApiOutput<\"/threads/:id\", \"$patch\">;\ntype ThreadSpawnResult = PublicApiOutput<\"/threads\", \"$post\">;\ntype ThreadInteractionGetResult = PublicApiOutput<\"/threads/:id/interactions/:interactionId\", \"$get\">;\ntype ThreadInteractionListResult = PublicApiOutput<\"/threads/:id/interactions\", \"$get\">;\ntype ThreadInteractionResolveResult = PublicApiOutput<\"/threads/:id/interactions/:interactionId/resolve\", \"$post\">;\ntype ThreadEventsListResult = PublicApiOutput<\"/threads/:id/events\", \"$get\">;\ntype ThreadEventWaitResult = PublicApiOutput<\"/threads/:id/events/wait\", \"$get\">;\ntype ThreadTimelineResult = PublicApiOutput<\"/threads/:id/timeline\", \"$get\">;\ntype ThreadArchiveResult = PublicApiOutput<\"/threads/:id/archive\", \"$post\">;\ntype ThreadOpenResult = PublicApiOutput<\"/threads/:id/open\", \"$post\">;\ntype ThreadDeleteResult = PublicApiOutput<\"/threads/:id\", \"$delete\">;\ntype ThreadSendResult = PublicApiOutput<\"/threads/:id/send\", \"$post\">;\ntype ThreadStopResult = PublicApiOutput<\"/threads/:id/stop\", \"$post\">;\ntype ThreadTerminalCloseResult = PublicApiOutput<\"/terminals/:terminalId/close\", \"$post\">;\ntype ThreadTerminalCreateResult = PublicApiOutput<\"/terminals\", \"$post\">;\ntype ThreadTerminalInputResult = PublicApiOutput<\"/terminals/:terminalId/input\", \"$post\">;\ntype ThreadTerminalListResult = PublicApiOutput<\"/terminals\", \"$get\">;\ntype ThreadTerminalOutputResult = PublicApiOutput<\"/terminals/:terminalId/output\", \"$get\">;\ntype ThreadTerminalResizeResult = PublicApiOutput<\"/terminals/:terminalId/resize\", \"$post\">;\ntype ThreadTerminalUpdateResult = PublicApiOutput<\"/terminals/:terminalId\", \"$patch\">;\ntype ThreadUnarchiveResult = PublicApiOutput<\"/threads/:id/unarchive\", \"$post\">;\ninterface ThreadSpawnBaseArgs extends Omit {\n childOrigin?: CreateThreadRequest[\"childOrigin\"];\n origin?: CreateThreadRequest[\"origin\"];\n originKind?: CreateThreadRequest[\"originKind\"];\n startedOnBehalfOf?: CreateThreadRequest[\"startedOnBehalfOf\"];\n}\ntype ThreadSpawnArgs = ThreadSpawnBaseArgs & ({\n input: CreateThreadRequest[\"input\"];\n prompt?: never;\n} | {\n input?: never;\n prompt: string;\n});\ninterface ThreadUpdateArgs extends UpdateThreadRequest {\n threadId: string;\n}\ninterface ThreadDeleteArgs extends DeleteThreadRequest {\n threadId: string;\n}\ninterface ThreadSendArgs extends SendMessageRequest {\n threadId: string;\n}\ninterface ThreadStatusArgs {\n threadId: string;\n}\ninterface ThreadOpenArgs {\n threadId: string;\n source: PanelFileSource;\n path: string;\n lineNumber: number | null;\n}\ninterface ThreadEventsListArgs {\n afterSeq?: string;\n limit?: string;\n threadId: string;\n}\ninterface ThreadEventWaitArgs {\n afterSeq?: string;\n threadId: string;\n type: string;\n waitMs: string;\n}\ninterface ThreadTimelineArgs extends ThreadTimelineQuery {\n threadId: string;\n}\ninterface ThreadOutputArgs {\n threadId: string;\n}\ninterface ThreadTerminalListArgs {\n threadId: string;\n}\ninterface ThreadTerminalCreateArgs extends Omit {\n threadId: string;\n}\ninterface ThreadTerminalTargetArgs {\n terminalId: string;\n threadId: string;\n}\ninterface ThreadTerminalUpdateArgs extends ThreadTerminalTargetArgs, UpdateTerminalRequest {\n}\ninterface ThreadTerminalCloseArgs extends ThreadTerminalTargetArgs, CloseTerminalRequest {\n}\ninterface ThreadTerminalInputArgs extends ThreadTerminalTargetArgs, TerminalInputRequest {\n}\ninterface ThreadTerminalResizeArgs extends ThreadTerminalTargetArgs, TerminalResizeRequest {\n}\ninterface ThreadTerminalOutputArgs extends ThreadTerminalTargetArgs, TerminalOutputQuery {\n}\ninterface ThreadInteractionListArgs {\n threadId: string;\n}\ninterface ThreadInteractionGetArgs extends ThreadInteractionListArgs {\n interactionId: string;\n}\ninterface ThreadInteractionResolveArgs extends ThreadInteractionGetArgs {\n resolution: PendingInteractionResolution;\n}\ntype ThreadWaitTarget = {\n kind: \"status\";\n status: ThreadStatus;\n} | {\n kind: \"event\";\n eventType: string;\n};\ninterface ThreadWaitArgs {\n event?: string;\n pollIntervalMs?: number;\n status?: ThreadStatus;\n threadId: string;\n timeoutMs?: number;\n}\ntype ThreadWaitResult = {\n event: NonNullable;\n matched: true;\n target: Extract;\n threadId: string;\n} | {\n matched: true;\n target: Extract;\n thread: ThreadGetResult;\n threadId: string;\n};\ninterface ThreadInteractionsArea {\n get(args: ThreadInteractionGetArgs): Promise;\n list(args: ThreadInteractionListArgs): Promise;\n resolve(args: ThreadInteractionResolveArgs): Promise;\n}\ninterface ThreadEventsArea {\n list(args: ThreadEventsListArgs): Promise;\n wait(args: ThreadEventWaitArgs): Promise;\n}\ninterface ThreadTerminalsArea {\n close(args: ThreadTerminalCloseArgs): Promise;\n create(args: ThreadTerminalCreateArgs): Promise;\n input(args: ThreadTerminalInputArgs): Promise;\n list(args: ThreadTerminalListArgs): Promise;\n output(args: ThreadTerminalOutputArgs): Promise;\n resize(args: ThreadTerminalResizeArgs): Promise;\n update(args: ThreadTerminalUpdateArgs): Promise;\n}\ninterface ThreadsArea {\n archive(args: ThreadStatusArgs): Promise;\n delete(args: ThreadDeleteArgs): Promise;\n events: ThreadEventsArea;\n get(args: ThreadGetArgs): Promise;\n interactions: ThreadInteractionsArea;\n list(args?: ThreadListArgs): Promise;\n open(args: ThreadOpenArgs): Promise;\n output(args: ThreadOutputArgs): Promise;\n pin(args: ThreadStatusArgs): Promise;\n send(args: ThreadSendArgs): Promise;\n spawn(args: ThreadSpawnArgs): Promise;\n stop(args: ThreadStatusArgs): Promise;\n terminals: ThreadTerminalsArea;\n timeline(args: ThreadTimelineArgs): Promise;\n unarchive(args: ThreadStatusArgs): Promise;\n unpin(args: ThreadStatusArgs): Promise;\n update(args: ThreadUpdateArgs): Promise;\n wait(args: ThreadWaitArgs): Promise;\n}\ndeclare function createThreadsArea(args: CreateSdkAreaArgs): ThreadsArea;\n\ninterface BbSdk extends BbRealtime {\n automations: ReturnType;\n environments: ReturnType;\n files: ReturnType;\n guide: ReturnType;\n hosts: ReturnType;\n projects: ReturnType;\n providers: ReturnType;\n status: ReturnType;\n theme: ReturnType;\n threads: ReturnType;\n}\n\n/**\n * The backend plugin API contract — the `bb` object handed to a plugin's\n * `server.ts` factory (`export default function plugin(bb: BbPluginApi)`).\n *\n * Types only: the implementation lives in the BB server\n * (apps/server/src/services/plugins/plugin-api.ts), which imports these\n * shapes so the contract and the implementation cannot drift. Plugin authors\n * import them type-only (`import type { BbPluginApi } from\n * \"@bb/plugin-sdk\"`); the import is erased when BB loads the file.\n *\n * Runtime classes stay host-side. NeedsConfigurationError in particular is\n * matched by NAME, so plugin code needs no runtime import:\n * `throw Object.assign(new Error(msg), { name: \"NeedsConfigurationError\" })`.\n */\ninterface PluginLogger {\n debug(message: string): void;\n info(message: string): void;\n warn(message: string): void;\n error(message: string): void;\n}\n/**\n * Declarative settings descriptors (`bb.settings.define`). Deliberately plain\n * data — not zod — so the host can render settings forms and the CLI can\n * parse values without executing plugin code.\n */\ntype PluginSettingDescriptor = {\n type: \"string\";\n label: string;\n description?: string;\n /** Stored in a 0600 file under /plugins//secrets/, never in the db or sent to the frontend. */\n secret?: true;\n default?: string;\n} | {\n type: \"boolean\";\n label: string;\n description?: string;\n default?: boolean;\n} | {\n type: \"select\";\n label: string;\n description?: string;\n options: string[];\n default?: string;\n} | {\n type: \"project\";\n label: string;\n description?: string;\n default?: string;\n};\ntype PluginSettingDescriptors = Record;\ntype PluginSettingValue = string | boolean;\n/** `default` present → non-optional value; absent → `T | undefined`. */\ntype PluginSettingsValues> = {\n [K in keyof Ds]: Ds[K] extends {\n default: string | boolean;\n } ? PluginSettingValueOf : PluginSettingValueOf | undefined;\n};\ntype PluginSettingValueOf = D extends {\n type: \"boolean\";\n} ? boolean : string;\ninterface PluginSettingsHandle> {\n /** Load-safe: callable inside the factory. */\n get(): Promise>;\n /** Fires after values change through the settings route/CLI. */\n onChange(listener: (next: PluginSettingsValues, prev: PluginSettingsValues) => void): void;\n}\ninterface PluginSettings {\n define>(descriptors: Ds): PluginSettingsHandle;\n}\ninterface PluginKvStorage {\n get(key: string): Promise;\n set(key: string, value: unknown): Promise;\n delete(key: string): Promise;\n list(prefix?: string): Promise;\n}\ninterface PluginStorage {\n /** Namespaced JSON key-value rows in bb.db; values ≤256KB each. */\n kv: PluginKvStorage;\n /**\n * Open (or reuse the path of) the plugin's own SQLite database at\n * /plugins//data.db — the server's better-sqlite3, WAL mode,\n * busy_timeout 5000. Handles are host-tracked and closed on\n * dispose/reload; a closed handle throws on use.\n */\n sqlite(): Database.Database;\n /**\n * Ordered-statement migration helper: statement index = migration id in a\n * `_bb_migrations` table; unapplied statements run in one transaction.\n * Append-only — never reorder or edit shipped statements.\n */\n migrate(db: Database.Database, statements: string[]): void;\n}\n/**\n * Thread lifecycle events a plugin can observe (design §4.5). Observe-only:\n * handlers run fire-and-forget after the transition is applied and can never\n * block or veto it. `thread` is the same public DTO GET /threads/:id serves.\n */\ninterface PluginThreadEventPayloads {\n \"thread.created\": {\n thread: ThreadResponse;\n };\n /** Fired when a thread transitions into `idle`. `lastAssistantText` is\n * assembled the same way GET /threads/:id/output is. */\n \"thread.idle\": {\n thread: ThreadResponse;\n lastAssistantText: string | null;\n };\n /** Fired when a thread transitions into `error`. `error` is the latest\n * system/error event message, when one exists. */\n \"thread.failed\": {\n thread: ThreadResponse;\n error: string | null;\n };\n}\ntype PluginThreadEventName = keyof PluginThreadEventPayloads;\ntype PluginThreadEventHandler = (payload: PluginThreadEventPayloads[E]) => void | Promise;\ntype PluginHttpAuthMode = \"local\" | \"token\" | \"none\";\ntype PluginHttpHandler = (context: Context) => Response | Promise;\ninterface PluginHttp {\n /**\n * Register an HTTP route, mounted at\n * `/api/v1/plugins//http/`. Auth modes (default \"local\"):\n * - \"local\": Origin/Host must be a local BB app origin; non-GET requires\n * content-type application/json (forces a CORS preflight).\n * - \"token\": requires the per-plugin token (`bb plugin token `) via\n * the x-bb-plugin-token header or ?token=.\n * - \"none\": no checks — only for signature-verified webhooks.\n */\n route(method: string, path: string, handler: PluginHttpHandler, opts?: {\n auth?: PluginHttpAuthMode;\n }): void;\n}\ninterface PluginRpc {\n /**\n * Register rpc methods, served at POST\n * `/api/v1/plugins//rpc/` with \"local\" auth semantics. The\n * JSON request body is the input; the response is\n * `{ ok: true, result }` or `{ ok: false, error }`. Inputs and outputs\n * must survive a JSON round-trip — results are serialized with\n * JSON.stringify and nothing else.\n */\n register(handlers: Record unknown>): void;\n}\ninterface PluginRealtime {\n /**\n * Broadcast an ephemeral `plugin-signal` WS message\n * `{ pluginId, channel, payload }` to every connected client (V1 has no\n * per-channel subscriptions). `payload` must be JSON-serializable;\n * `undefined` is normalized to `null`. Nothing is persisted.\n */\n publish(channel: string, payload: unknown): void;\n}\ninterface PluginBackground {\n /**\n * Register a long-lived background service. `start` runs after the\n * factory completes and should resolve when `signal` aborts\n * (dispose/reload/disable/shutdown). A crash restarts it with capped\n * exponential backoff; throwing NeedsConfigurationError marks the plugin\n * `needs-configuration` and stops restarting until the next load.\n */\n service(name: string, service: {\n start(signal: AbortSignal): void | Promise;\n }): void;\n /**\n * Register a cron schedule (5-field expression, server-local time). The\n * durable row keyed (pluginId, name) is upserted at load; the periodic\n * sweep claims due rows with a CAS on next_run_at, but only while this\n * plugin is loaded. Failures land in last_status/last_error, visible in\n * `bb plugin list`.\n */\n schedule(name: string, cron: string, fn: () => void | Promise): void;\n}\ninterface PluginCliCommandInfo {\n name: string;\n summary: string;\n usage: string;\n}\n/** Context forwarded from the invoking CLI when known; all fields optional. */\ninterface PluginCliContext {\n cwd?: string;\n threadId?: string;\n projectId?: string;\n}\ninterface PluginCliResult {\n exitCode: number;\n stdout?: string;\n stderr?: string;\n}\ninterface PluginCliRegistration {\n /** Top-level command name (`bb …`): lowercase [a-z0-9-]+, and not\n * a core bb command (see RESERVED_BB_CLI_COMMANDS in the server). */\n name: string;\n summary: string;\n /** Subcommand metadata rendered in help and the plugin-commands skill\n * without executing plugin code. Parsing argv is plugin-owned. */\n commands?: PluginCliCommandInfo[];\n run(argv: string[], ctx: PluginCliContext): PluginCliResult | Promise;\n}\ninterface PluginCli {\n /**\n * Register this plugin's `bb` subcommand. One registration per plugin —\n * a second call replaces the first. Core bb commands always win name\n * collisions; reserved names are rejected at registration.\n */\n register(registration: PluginCliRegistration): void;\n}\n/** Per-turn context handed to bb.agents context providers (design §4.4). */\n/** MCP-style content parts a native tool may return (design §4.4). */\ntype PluginAgentToolContentPart = {\n type: \"text\";\n text: string;\n} | {\n type: \"image\";\n data: string;\n mimeType: string;\n};\ntype PluginAgentToolResult = string | {\n content: PluginAgentToolContentPart[];\n isError?: boolean;\n};\n/** Per-call context handed to a native tool's execute (design §4.4). */\ninterface PluginAgentToolContext {\n threadId: string;\n projectId: string;\n /** The tool-call request's abort signal (aborts if the daemon round-trip\n * is torn down mid-call). */\n signal: AbortSignal;\n}\ninterface PluginAgentToolRegistrationBase {\n /** Tool name shown to the model: [a-zA-Z0-9_-]+, unique across plugins,\n * and not a built-in dynamic tool (see RESERVED_AGENT_TOOL_NAMES in the\n * server). */\n name: string;\n description: string;\n /**\n * Optional usage snippet appended to the thread instructions whenever\n * this tool is in the session's tool set (mirrors the built-in\n * update_environment_directory guidance). Keep it short.\n */\n instructions?: string;\n}\ninterface PluginAgents {\n /**\n * Register a native dynamic tool (design §4.4). `parameters` is either a\n * zod schema (validated per call; execute receives the parsed value) or a\n * plain JSON-schema object (no validation; execute receives the raw\n * arguments as `unknown`). Tool-set changes apply on the NEXT session\n * start — a tool registered mid-session is not hot-added to running\n * provider sessions. A second registration of the same name within this\n * plugin replaces the first; a name already registered by another plugin\n * is rejected and surfaced as this plugin's status detail.\n */\n registerTool(tool: PluginAgentToolRegistrationBase & {\n parameters: Schema;\n execute(params: z.output, ctx: PluginAgentToolContext): PluginAgentToolResult | Promise;\n }): void;\n registerTool(tool: PluginAgentToolRegistrationBase & {\n /** Raw JSON-schema escape hatch; params arrive unvalidated. */\n parameters: Record;\n execute(params: unknown, ctx: PluginAgentToolContext): PluginAgentToolResult | Promise;\n }): void;\n}\ninterface PluginThreadActionContext {\n threadId: string;\n projectId: string;\n}\ninterface PluginThreadActionToast {\n kind: \"success\" | \"error\" | \"info\";\n message: string;\n}\ntype PluginThreadActionResult = void | {\n toast?: PluginThreadActionToast;\n};\ninterface PluginThreadActionRegistration {\n /** Unique within this plugin: [a-zA-Z0-9_-]+ (becomes a URL segment). */\n id: string;\n /** Button label rendered in the thread header. */\n title: string;\n /** Optional icon name; the host falls back to a generic icon. */\n icon?: string;\n /** Optional confirmation prompt the host shows before running. */\n confirm?: string;\n /**\n * Runs server-side when the user clicks the action. The host shows a\n * pending state while in flight, the returned toast on completion, and an\n * automatic error toast when this throws.\n */\n run(ctx: PluginThreadActionContext): PluginThreadActionResult | Promise;\n}\n/** Search context handed to a mention provider (design §4.9). `projectId`/\n * `threadId` are null when the composer has not committed one yet. */\ninterface PluginMentionSearchContext {\n query: string;\n projectId: string | null;\n threadId: string | null;\n}\n/** One row a mention provider returns from `search`. `id` is the provider's\n * own item id — the host namespaces it before it reaches the wire. */\ninterface PluginMentionItem {\n id: string;\n title: string;\n subtitle?: string;\n icon?: string;\n}\ninterface PluginMentionProviderRegistration {\n /** Unique within this plugin: [a-zA-Z0-9_-]+ (no \":\" — the host composes\n * wire item ids as \":\"). */\n id: string;\n /** Section label shown above this provider's rows in the mention menu. */\n label: string;\n /**\n * Runs server-side as the user types after `@` in the composer. Each call\n * is time-boxed (2s) and failure-isolated: a slow or throwing provider\n * contributes an empty list — it can never break the mention menu.\n */\n search(ctx: PluginMentionSearchContext): PluginMentionItem[] | Promise;\n /**\n * Resolves one picked item into agent context, called once per unique\n * item at message send time. The returned `context` is attached to the\n * message as an agent-visible (user-hidden) prompt input. Throwing blocks\n * the send with a visible error.\n */\n resolve(itemId: string): {\n context: string;\n } | Promise<{\n context: string;\n }>;\n}\ninterface PluginUi {\n /**\n * Register a thread action rendered in the shipped app's thread header\n * (design §4.9). Multiple actions per plugin; ids must be unique within\n * the plugin. Invoked via POST /plugins/:id/actions/:actionId.\n */\n registerThreadAction(action: PluginThreadActionRegistration): void;\n /**\n * Register an `@`-mention provider for the shipped app's composer\n * (design §4.9). Items group under `label` in the mention menu; a picked\n * item becomes a `{ kind: \"plugin\" }` mention resource whose context is\n * resolved once at send time. Multiple providers per plugin; ids must be\n * unique within the plugin.\n */\n registerMentionProvider(provider: PluginMentionProviderRegistration): void;\n}\ninterface PluginStatusApi {\n /**\n * Mark this plugin `needs-configuration` (with a message shown in\n * `bb plugin list` and the UI) instead of failing — e.g. a factory or\n * service that finds no API key configured. Cleared on the next load;\n * saving settings does not auto-reload in V1, so ask the user to\n * `bb plugin reload ` after configuring.\n */\n needsConfiguration(message: string): void;\n}\n/**\n * The API object handed to a plugin's factory (design §4). Implemented by\n * the BB server; this contract is what plugin `server.ts` files compile\n * against.\n */\ninterface BbPluginApi {\n /** The plugin's own id (namespaces storage, routes, commands). */\n readonly pluginId: string;\n /** Leveled, plugin-scoped logger. */\n readonly log: PluginLogger;\n /** Declarative settings (design §4.2). */\n readonly settings: PluginSettings;\n /** Namespaced KV + per-plugin SQLite (design §4.3). */\n readonly storage: PluginStorage;\n /** HTTP routes under /api/v1/plugins//http/* (design §4.6). */\n readonly http: PluginHttp;\n /** RPC methods under /api/v1/plugins//rpc/ (design §4.6). */\n readonly rpc: PluginRpc;\n /** Ephemeral push to connected frontends (design §4.7). */\n readonly realtime: PluginRealtime;\n /** Long-lived services + cron schedules (design §4.8). */\n readonly background: PluginBackground;\n /** Agent-facing `bb` CLI subcommand (design §4.4). */\n readonly cli: PluginCli;\n /** Per-turn agent context contributions (design §4.4). */\n readonly agents: PluginAgents;\n /** Host-rendered UI contributions (design §4.9). */\n readonly ui: PluginUi;\n /** Plugin-reported status (needs-configuration). */\n readonly status: PluginStatusApi;\n /**\n * The full BB SDK, bound to this server over loopback (design §4.1).\n * Bind-gated: reading this before the host binds the SDK throws. The real\n * server binds it before loading plugins, so it is available from the\n * moment factories run there — but isolated harnesses may not, so prefer\n * using it from handlers, services, and timers for portability.\n * `threads.spawn` defaults `origin` to \"plugin\" and `originPluginId` to\n * this plugin's id so spawned threads are attributed automatically.\n */\n readonly sdk: BbSdk;\n /**\n * Observe thread lifecycle events (design §4.5). Load-safe registration;\n * handlers run async after the transition and never affect it. Errors are\n * caught, logged, and counted against this plugin's handler stats.\n */\n on(event: E, handler: PluginThreadEventHandler): void;\n /**\n * Register cleanup to run on reload/disable/shutdown. Hooks run LIFO.\n * The sanctioned place to clear timers and close connections.\n */\n onDispose(hook: () => void | Promise): void;\n}\n\nexport { PLUGIN_SDK_APP_EXPORT_NAMES, PLUGIN_SLOT_ID_PATTERN };\nexport type { BbContext, BbNavigate, BbPluginApi, PluginAgentToolContentPart, PluginAgentToolContext, PluginAgentToolRegistrationBase, PluginAgentToolResult, PluginAgents, PluginAppBuilder, PluginAppDefinition, PluginAppSetup, PluginAppSlots, PluginBackground, PluginCli, PluginCliCommandInfo, PluginCliContext, PluginCliRegistration, PluginCliResult, PluginComposerAccessoryProps, PluginComposerAccessoryRegistration, PluginHomepageSectionProps, PluginHomepageSectionRegistration, PluginHttp, PluginHttpAuthMode, PluginHttpHandler, PluginKvStorage, PluginLogger, PluginMentionItem, PluginMentionProviderRegistration, PluginMentionSearchContext, PluginNavPanelProps, PluginNavPanelRegistration, PluginRealtime, PluginRpc, PluginRpcClient, PluginSdkApp, PluginSettingDescriptor, PluginSettingDescriptors, PluginSettingValue, PluginSettings, PluginSettingsHandle, PluginSettingsState, PluginSettingsValues, PluginStatusApi, PluginStorage, PluginThreadActionContext, PluginThreadActionRegistration, PluginThreadActionResult, PluginThreadActionToast, PluginThreadEventHandler, PluginThreadEventName, PluginThreadEventPayloads, PluginThreadPanelActionContext, PluginThreadPanelActionRegistration, PluginThreadPanelProps, PluginUi };\n"; +export const PLUGIN_SDK_DTS = "// Bundled type declarations for `@bb/plugin-sdk`, shipped into scaffolded\n// plugins so they typecheck without the @bb/* workspace on disk.\n//\n// Confused by the API, or need a symbol that isn't here? Clone the BB repo\n// and read the real source: https://github.com/ymichael/bb\n\nimport { ComponentType } from 'react';\nimport Database from 'better-sqlite3';\nimport { Context } from 'hono';\nimport * as z from 'zod';\nimport { z as z$1 } from 'zod';\n\n/**\n * The `@bb/plugin-sdk/app` contract (plugin design §5.2) — pure types plus\n * the runtime export-name list, with no side effects. This module is what the\n * BB app imports to keep its real implementation in sync (`satisfies\n * PluginSdkApp`) and what `bb plugin build` imports to generate the shim's\n * named-export list. Plugin authors import the same shapes through\n * `@bb/plugin-sdk/app`.\n *\n * Per-slot props are versioned contracts: additive-only within an SDK major.\n */\n/** Props passed to a `homepageSection` component. */\ninterface PluginHomepageSectionProps {\n /** Project in view on the compose surface; null when none is selected. */\n projectId: string | null;\n}\n/** Props passed to a `navPanel` component (it owns its whole route). */\ninterface PluginNavPanelProps {\n /**\n * The route remainder after the panel root, \"\" at the root. The panel's\n * route is `/plugins///*`, so a deep link like\n * `/plugins/notes/notes/work/ideas.md` renders the panel with\n * `subPath: \"work/ideas.md\"`. Navigate within the panel via\n * `useBbNavigate().toPluginPanel(path, { subPath })` — browser\n * back/forward then walks panel-internal history.\n */\n subPath: string;\n}\n/** Props passed to a panel tab opened by a `threadPanelAction`. */\ninterface PluginThreadPanelProps {\n threadId: string;\n /**\n * The JSON value the action's `openPanel` call passed (round-tripped\n * through persistence, so the tab restores across reloads); null when the\n * action opened the panel without params.\n */\n params: unknown;\n}\n/** Props passed to a `composerAccessory` component. */\ninterface PluginComposerAccessoryProps {\n projectId: string | null;\n threadId: string | null;\n}\n/**\n * Slot/panel ids and nav-panel paths must match this pattern (letters,\n * digits, `-`, `_`): they ride URLs and persisted panel-tab keys.\n */\ndeclare const PLUGIN_SLOT_ID_PATTERN: RegExp;\ninterface PluginHomepageSectionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n component: ComponentType;\n}\ninterface PluginNavPanelRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n /** Icon hint (BB icon name); unknown names fall back to a generic icon. */\n icon: string;\n /** URL segment under `/plugins//`; letters, digits, `-`, `_`. */\n path: string;\n component: ComponentType;\n /**\n * Panel chrome (default \"page\"): \"page\" renders the host title bar (plugin\n * logo + `title` + your `headerContent`) above a full-width padded body;\n * \"none\" hands the ENTIRE panel area to `component` — no host padding, no\n * title bar (`headerContent` is ignored) — only the per-plugin error\n * boundary remains.\n */\n chrome?: \"page\" | \"none\";\n /**\n * Optional component rendered on the right side of the \"page\" title bar\n * (e.g. a sync button or a count). Contained separately from the body: a\n * throwing headerContent is hidden without breaking the title bar.\n */\n headerContent?: ComponentType;\n}\n/** Context handed to a `threadPanelAction`'s `run`. */\ninterface PluginThreadPanelActionContext {\n /** The thread whose panel launcher invoked the action. */\n threadId: string;\n /**\n * Open a tab in the thread's side panel rendering this action's\n * `component`. `title` labels the tab (default: the action's `title`);\n * `params` must be JSON-serializable — it is persisted with the tab and\n * reaches the component as its `params` prop. Opening with params\n * identical to an already-open tab of this action focuses that tab\n * (updating its title) instead of duplicating it. May be called more than\n * once (different params ⇒ multiple tabs) or not at all.\n */\n openPanel(options?: {\n title?: string;\n params?: unknown;\n }): void;\n}\ninterface PluginThreadPanelActionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n /** Label of the action row in the panel's new-tab launcher. */\n title: string;\n /**\n * Icon hint (BB icon name) used when the plugin ships no logo; the\n * launcher row and opened tabs prefer the plugin's logo.\n */\n icon?: string;\n /** Rendered inside every panel tab this action opens. */\n component: ComponentType;\n /**\n * Runs when the user activates the action: call your RPC methods, show a\n * toast, and/or open panel tabs via `context.openPanel`. Omitted =\n * immediately open a panel tab with defaults. Errors (sync or async) are\n * contained and logged; they never break the launcher.\n */\n run?(context: PluginThreadPanelActionContext): void | Promise;\n}\ninterface PluginComposerAccessoryRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n component: ComponentType;\n}\ninterface PluginAppSlots {\n homepageSection(registration: PluginHomepageSectionRegistration): void;\n navPanel(registration: PluginNavPanelRegistration): void;\n threadPanelAction(registration: PluginThreadPanelActionRegistration): void;\n composerAccessory(registration: PluginComposerAccessoryRegistration): void;\n}\ninterface PluginAppBuilder {\n slots: PluginAppSlots;\n}\ntype PluginAppSetup = (app: PluginAppBuilder) => void;\n/**\n * The opaque product of `definePluginApp` — a plugin's `app.tsx` default\n * export. The host re-runs `setup` against a fresh collector on every\n * (re)interpretation, replacing that plugin's registrations wholesale.\n */\ninterface PluginAppDefinition {\n /** Brand the host checks before interpreting a bundle's default export. */\n readonly __bbPluginApp: true;\n readonly setup: PluginAppSetup;\n}\ninterface PluginRpcClient {\n /**\n * Invoke one of the plugin's `bb.rpc` methods (POST\n * /api/v1/plugins/<id>/rpc/<method>). Resolves with the method's\n * result; rejects with an `Error` carrying the server's message when the\n * handler fails or the plugin is not running.\n */\n call(method: string, input?: unknown): Promise;\n}\ninterface PluginSettingsState {\n /**\n * Effective non-secret setting values (secret settings are excluded —\n * read them server-side). Undefined while loading or unavailable.\n */\n values: Record | undefined;\n isLoading: boolean;\n}\n/** Where `useComposer()` writes: the active thread's draft or the new-thread draft. */\ntype PluginComposerScope = {\n kind: \"thread\";\n threadId: string;\n} | {\n kind: \"new-thread\";\n projectId: string | null;\n};\n/** An @-mention pill bound to one of the calling plugin's mention providers. */\ninterface PluginComposerMention {\n /** Mention provider id registered by THIS plugin via `bb.ui.registerMentionProvider`. */\n provider: string;\n /** Item id your provider's `resolve` will receive at send time. */\n id: string;\n /** Pill text shown in the composer. */\n label: string;\n}\n/**\n * Programmatic access to the chat composer draft — the same shared draft the\n * built-in \"Add to chat\" affordances (file preview, diff, terminal selections)\n * write to. Inside a thread context writes land in that thread's draft;\n * anywhere else (nav panel, homepage section) they seed the new-thread\n * composer draft, which persists until the user sends or clears it.\n */\ninterface PluginComposerApi {\n scope: PluginComposerScope;\n /**\n * Append text to the draft as a `> ` blockquote block and focus the\n * composer. Blank text is a no-op. This is the \"reference this selection\n * in chat\" primitive.\n */\n addQuote(text: string): void;\n /**\n * Insert an @-mention pill that resolves through this plugin's mention\n * provider at send time — the durable way to reference an entity whose\n * content should be fetched fresh when the message is sent.\n */\n insertMention(mention: PluginComposerMention): void;\n /** Focus the composer caret at the end of the draft. */\n focus(): void;\n}\n/** Current app selection, derived from the route. */\ninterface BbContext {\n projectId: string | null;\n threadId: string | null;\n}\ninterface BbNavigate {\n toThread(threadId: string): void;\n toProject(projectId: string): void;\n /**\n * Navigate to one of this plugin's own nav panels by its `path`.\n * `subPath` targets a location inside the panel (the component's\n * `subPath` prop); `replace` swaps the current history entry instead of\n * pushing — use it for redirects so back does not bounce.\n */\n toPluginPanel(path: string, options?: {\n subPath?: string;\n replace?: boolean;\n }): void;\n}\n/**\n * Everything `@bb/plugin-sdk/app` resolves to at runtime. The BB app builds\n * the real implementation and `satisfies` this interface; `bb plugin build`\n * shims the specifier to that object on `globalThis.__bbPluginRuntime`.\n */\ninterface PluginSdkApp {\n definePluginApp(setup: PluginAppSetup): PluginAppDefinition;\n useRpc(): PluginRpcClient;\n useRealtime(channel: string, handler: (payload: unknown) => void): void;\n useSettings(): PluginSettingsState;\n useBbContext(): BbContext;\n useBbNavigate(): BbNavigate;\n useComposer(): PluginComposerApi;\n}\n/**\n * Named runtime exports of `@bb/plugin-sdk/app`, in sorted order. Single\n * source of truth for the build shim's export list and the app's\n * implementation-key test — adding a surface member without updating this\n * list fails the type assertion below.\n */\ndeclare const PLUGIN_SDK_APP_EXPORT_NAMES: readonly [\"definePluginApp\", \"useBbContext\", \"useBbNavigate\", \"useComposer\", \"useRealtime\", \"useRpc\", \"useSettings\"];\n\ndeclare const appThemeSchema: z$1.ZodObject<{\n themeId: z$1.ZodString;\n customCss: z$1.ZodNullable;\n faviconColor: z$1.ZodEnum<{\n default: \"default\";\n red: \"red\";\n orange: \"orange\";\n yellow: \"yellow\";\n green: \"green\";\n teal: \"teal\";\n blue: \"blue\";\n purple: \"purple\";\n pink: \"pink\";\n }>;\n}, z$1.core.$strip>;\ntype AppTheme = z$1.infer;\n\ndeclare const changedMessageSchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"thread\">;\n id: z$1.ZodOptional;\n metadata: z$1.ZodOptional;\n eventTypes: z$1.ZodOptional>>>>;\n hasPendingInteraction: z$1.ZodOptional;\n projectId: z$1.ZodOptional;\n }, z$1.core.$strict>>;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"project\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"environment\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"host\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"system\">;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>], \"entity\">;\ntype ChangedMessage = z$1.infer;\n\ndeclare const pendingInteractionResolutionSchema: z$1.ZodUnion;\n grantedPermissions: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n fileSystem: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n decision: z$1.ZodLiteral<\"allow_for_session\">;\n grantedPermissions: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n fileSystem: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n decision: z$1.ZodLiteral<\"deny\">;\n}, z$1.core.$strip>], \"decision\">, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"user_answer\">;\n answers: z$1.ZodRecord;\n freeText: z$1.ZodOptional;\n }, z$1.core.$strip>>;\n}, z$1.core.$strip>]>;\ntype PendingInteractionResolution = z$1.infer;\n\ndeclare const threadStatusSchema: z$1.ZodEnum<{\n error: \"error\";\n active: \"active\";\n starting: \"starting\";\n idle: \"idle\";\n stopping: \"stopping\";\n}>;\ntype ThreadStatus = z$1.infer;\n\ndeclare const threadTimelinePendingTodosSchema: z$1.ZodObject<{\n sourceSeq: z$1.ZodNumber;\n updatedAt: z$1.ZodNumber;\n items: z$1.ZodArray;\n }, z$1.core.$strip>>;\n}, z$1.core.$strip>;\ntype ThreadTimelinePendingTodos = z$1.infer;\n\ndeclare const createAutomationRequestSchema: z$1.ZodObject<{\n name: z$1.ZodString;\n enabled: z$1.ZodDefault;\n trigger: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n triggerType: z$1.ZodLiteral<\"schedule\">;\n cron: z$1.ZodString;\n timezone: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n triggerType: z$1.ZodLiteral<\"once\">;\n runAt: z$1.ZodNumber;\n }, z$1.core.$strip>], \"triggerType\">;\n execution: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"agent\">;\n prompt: z$1.ZodString;\n providerId: z$1.ZodString;\n model: z$1.ZodString;\n permissionMode: z$1.ZodEnum<{\n readonly: \"readonly\";\n full: \"full\";\n \"workspace-write\": \"workspace-write\";\n }>;\n targetThreadId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"script\">;\n script: z$1.ZodOptional;\n scriptFile: z$1.ZodOptional;\n interpreter: z$1.ZodOptional>;\n timeoutMs: z$1.ZodDefault;\n env: z$1.ZodOptional>;\n }, z$1.core.$strip>], \"mode\">;\n environment: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"reuse\">;\n environmentId: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"host\">;\n hostId: z$1.ZodOptional;\n workspace: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"unmanaged\">;\n path: z$1.ZodNullable;\n branch: z$1.ZodOptional;\n name: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"new\">;\n baseBranch: z$1.ZodString;\n }, z$1.core.$strict>], \"kind\">>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"managed-worktree\">;\n baseBranch: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"named\">;\n name: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"default\">;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"personal\">;\n }, z$1.core.$strip>], \"type\">;\n }, z$1.core.$strip>], \"type\">;\n autoArchive: z$1.ZodDefault;\n origin: z$1.ZodEnum<{\n agent: \"agent\";\n human: \"human\";\n app: \"app\";\n }>;\n createdByThreadId: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype CreateAutomationRequest = z$1.input;\ndeclare const updateAutomationRequestSchema: z$1.ZodObject<{\n name: z$1.ZodOptional;\n trigger: z$1.ZodOptional;\n cron: z$1.ZodString;\n timezone: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n triggerType: z$1.ZodLiteral<\"once\">;\n runAt: z$1.ZodNumber;\n }, z$1.core.$strip>], \"triggerType\">>;\n execution: z$1.ZodOptional;\n prompt: z$1.ZodString;\n providerId: z$1.ZodString;\n model: z$1.ZodString;\n permissionMode: z$1.ZodEnum<{\n readonly: \"readonly\";\n full: \"full\";\n \"workspace-write\": \"workspace-write\";\n }>;\n targetThreadId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"script\">;\n script: z$1.ZodOptional;\n scriptFile: z$1.ZodOptional;\n interpreter: z$1.ZodOptional>;\n timeoutMs: z$1.ZodDefault;\n env: z$1.ZodOptional>;\n }, z$1.core.$strip>], \"mode\">>;\n environment: z$1.ZodOptional;\n environmentId: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"host\">;\n hostId: z$1.ZodOptional;\n workspace: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"unmanaged\">;\n path: z$1.ZodNullable;\n branch: z$1.ZodOptional;\n name: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"new\">;\n baseBranch: z$1.ZodString;\n }, z$1.core.$strict>], \"kind\">>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"managed-worktree\">;\n baseBranch: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"named\">;\n name: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"default\">;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"personal\">;\n }, z$1.core.$strip>], \"type\">;\n }, z$1.core.$strip>], \"type\">>;\n autoArchive: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype UpdateAutomationRequest = z$1.infer;\ndeclare const runAutomationRequestSchema: z$1.ZodObject<{\n idempotencyKey: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype RunAutomationRequest = z$1.infer;\n\ndeclare const createProjectSourceRequestSchema: z$1.ZodObject<{\n hostId: z$1.ZodString;\n type: z$1.ZodLiteral<\"local_path\">;\n path: z$1.ZodPipe>;\n}, z$1.core.$strict>;\ntype CreateProjectSourceRequest = z$1.infer;\ndeclare const createProjectRequestSchema: z$1.ZodObject<{\n name: z$1.ZodString;\n source: z$1.ZodObject<{\n hostId: z$1.ZodString;\n type: z$1.ZodLiteral<\"local_path\">;\n path: z$1.ZodPipe>;\n }, z$1.core.$strict>;\n}, z$1.core.$strip>;\ntype CreateProjectRequest = z$1.infer;\ndeclare const projectListQuerySchema: z$1.ZodObject<{\n include: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype ProjectListQuery = z$1.infer;\ndeclare const updateProjectRequestSchema: z$1.ZodObject<{\n name: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype UpdateProjectRequest = z$1.infer;\ndeclare const updateProjectSourceRequestSchema: z$1.ZodObject<{\n type: z$1.ZodLiteral<\"local_path\">;\n path: z$1.ZodOptional>>;\n isDefault: z$1.ZodOptional>;\n}, z$1.core.$strict>;\ntype UpdateProjectSourceRequest = z$1.infer;\n\ndeclare const updateEnvironmentRequestSchema: z$1.ZodObject<{\n mergeBaseBranch: z$1.ZodOptional>;\n name: z$1.ZodOptional>;\n}, z$1.core.$strip>;\ntype UpdateEnvironmentRequest = z$1.infer;\ndeclare const environmentDiffBranchesQuerySchema: z$1.ZodObject<{\n query: z$1.ZodOptional;\n limit: z$1.ZodOptional;\n selectedBranch: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype EnvironmentDiffBranchesQuery = z$1.infer;\ndeclare const environmentStatusQuerySchema: z$1.ZodObject<{\n mergeBaseBranch: z$1.ZodOptional>;\n}, z$1.core.$strip>;\ntype EnvironmentStatusQuery = z$1.infer;\ndeclare const environmentDiffQuerySchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n target: z$1.ZodLiteral<\"uncommitted\">;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"branch_committed\">;\n mergeBaseBranch: z$1.ZodPipe;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"all\">;\n mergeBaseBranch: z$1.ZodPipe;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"commit\">;\n sha: z$1.ZodString;\n}, z$1.core.$strip>], \"target\">;\ntype EnvironmentDiffQuery = z$1.infer;\n/**\n * Query for fetching a single file's contents at one side of a diff target.\n * Used by the diff card to reparse the card's patch with full old/new contents\n * so `@pierre/diffs` can render expand-context buttons between hunks.\n *\n * For `branch_committed` / `all`, callers pass the resolved merge-base SHA\n * (`mergeBaseRef`, surfaced by `workspace.diff`) rather than the branch name\n * — the diff itself was computed against that SHA, so reading the old side\n * from the same SHA keeps the file content aligned with the hunk line\n * numbers. Reading from the branch tip is wrong whenever the branch has\n * moved past the merge-base since the file existed there.\n */\ndeclare const environmentDiffFileQuerySchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n target: z$1.ZodLiteral<\"uncommitted\">;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"branch_committed\">;\n mergeBaseRef: z$1.ZodString;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"all\">;\n mergeBaseRef: z$1.ZodString;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"commit\">;\n sha: z$1.ZodString;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>], \"target\">;\ntype EnvironmentDiffFileQuery = z$1.infer;\n\ndeclare const systemExecutionOptionsQuerySchema: z$1.ZodObject<{\n providerId: z$1.ZodOptional;\n hostId: z$1.ZodOptional;\n environmentId: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype SystemExecutionOptionsQuery = z$1.infer;\n/**\n * Theme catalog: the on-disk custom-theme directory plus the discovered custom\n * themes and the active palette. Drives `bb theme list` / `bb theme dir`.\n */\ndeclare const themeCatalogResponseSchema: z$1.ZodObject<{\n dir: z$1.ZodString;\n custom: z$1.ZodArray;\n active: z$1.ZodObject<{\n themeId: z$1.ZodString;\n customCss: z$1.ZodNullable;\n faviconColor: z$1.ZodEnum<{\n default: \"default\";\n red: \"red\";\n orange: \"orange\";\n yellow: \"yellow\";\n green: \"green\";\n teal: \"teal\";\n blue: \"blue\";\n purple: \"purple\";\n pink: \"pink\";\n }>;\n }, z$1.core.$strip>;\n}, z$1.core.$strip>;\ntype ThemeCatalogResponse = z$1.infer;\n\ndeclare const createTerminalRequestSchema: z$1.ZodObject<{\n cols: z$1.ZodNumber;\n rows: z$1.ZodNumber;\n start: z$1.ZodOptional;\n }, z$1.core.$strict>, z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"command\">;\n command: z$1.ZodString;\n }, z$1.core.$strict>], \"mode\">>;\n target: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"thread\">;\n threadId: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"environment\">;\n environmentId: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"host_path\">;\n hostId: z$1.ZodString;\n cwd: z$1.ZodNullable;\n }, z$1.core.$strict>], \"kind\">;\n title: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype CreateTerminalRequest = z$1.infer;\ndeclare const closeTerminalRequestSchema: z$1.ZodObject<{\n mode: z$1.ZodEnum<{\n force: \"force\";\n \"if-clean\": \"if-clean\";\n }>;\n reason: z$1.ZodLiteral<\"user\">;\n}, z$1.core.$strict>;\ntype CloseTerminalRequest = z$1.infer;\ndeclare const updateTerminalRequestSchema: z$1.ZodObject<{\n title: z$1.ZodString;\n}, z$1.core.$strict>;\ntype UpdateTerminalRequest = z$1.infer;\ndeclare const terminalInputRequestSchema: z$1.ZodObject<{\n dataBase64: z$1.ZodString;\n}, z$1.core.$strict>;\ntype TerminalInputRequest = z$1.infer;\ndeclare const terminalResizeRequestSchema: z$1.ZodObject<{\n cols: z$1.ZodNumber;\n rows: z$1.ZodNumber;\n}, z$1.core.$strict>;\ntype TerminalResizeRequest = z$1.infer;\ndeclare const terminalOutputQuerySchema: z$1.ZodObject<{\n sinceSeq: z$1.ZodOptional>;\n tailBytes: z$1.ZodOptional>;\n limitChunks: z$1.ZodOptional>;\n}, z$1.core.$strict>;\ntype TerminalOutputQuery = z$1.infer;\n\ndeclare const createThreadRequestSchema: z$1.ZodObject<{\n projectId: z$1.ZodString;\n providerId: z$1.ZodOptional;\n origin: z$1.ZodEnum<{\n plugin: \"plugin\";\n app: \"app\";\n automation: \"automation\";\n cli: \"cli\";\n sdk: \"sdk\";\n }>;\n originPluginId: z$1.ZodOptional;\n title: z$1.ZodOptional;\n input: z$1.ZodArray>;\n type: z$1.ZodLiteral<\"text\">;\n text: z$1.ZodString;\n mentions: z$1.ZodDefault;\n threadId: z$1.ZodString;\n projectId: z$1.ZodOptional;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"project\">;\n projectId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"path\">;\n source: z$1.ZodEnum<{\n workspace: \"workspace\";\n \"thread-storage\": \"thread-storage\";\n }>;\n entryKind: z$1.ZodEnum<{\n file: \"file\";\n directory: \"directory\";\n }>;\n path: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"command\">;\n trigger: z$1.ZodEnum<{\n \"/\": \"/\";\n }>;\n name: z$1.ZodString;\n source: z$1.ZodEnum<{\n command: \"command\";\n skill: \"skill\";\n }>;\n origin: z$1.ZodEnum<{\n user: \"user\";\n project: \"project\";\n builtin: \"builtin\";\n }>;\n label: z$1.ZodString;\n argumentHint: z$1.ZodNullable;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"plugin\">;\n pluginId: z$1.ZodString;\n itemId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>>>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"image\">;\n url: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localImage\">;\n path: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localFile\">;\n path: z$1.ZodString;\n name: z$1.ZodOptional;\n sizeBytes: z$1.ZodOptional;\n mimeType: z$1.ZodOptional;\n }, z$1.core.$strip>], \"type\">>;\n model: z$1.ZodOptional;\n serviceTier: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>;\n permissionMode: z$1.ZodOptional>;\n executionInputSources: z$1.ZodOptional>;\n model: z$1.ZodOptional>;\n serviceTier: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>;\n permissionMode: z$1.ZodOptional>;\n }, z$1.core.$strict>>;\n environment: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"reuse\">;\n environmentId: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"host\">;\n hostId: z$1.ZodOptional;\n workspace: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"unmanaged\">;\n path: z$1.ZodNullable;\n branch: z$1.ZodOptional;\n name: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"new\">;\n baseBranch: z$1.ZodString;\n }, z$1.core.$strict>], \"kind\">>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"managed-worktree\">;\n baseBranch: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"named\">;\n name: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"default\">;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"personal\">;\n }, z$1.core.$strip>], \"type\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"project-default\">;\n }, z$1.core.$strip>], \"type\">;\n parentThreadId: z$1.ZodOptional;\n folderId: z$1.ZodOptional>;\n sourceThreadId: z$1.ZodOptional;\n sourceSeqEnd: z$1.ZodOptional;\n startedOnBehalfOf: z$1.ZodDefault;\n senderThreadId: z$1.ZodString;\n }, z$1.core.$strip>>>;\n originKind: z$1.ZodDefault>>;\n childOrigin: z$1.ZodDefault>>;\n}, z$1.core.$strip>;\ntype CreateThreadRequest = z$1.infer;\ndeclare const sendMessageRequestSchema: z$1.ZodObject<{\n input: z$1.ZodArray>;\n type: z$1.ZodLiteral<\"text\">;\n text: z$1.ZodString;\n mentions: z$1.ZodDefault;\n threadId: z$1.ZodString;\n projectId: z$1.ZodOptional;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"project\">;\n projectId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"path\">;\n source: z$1.ZodEnum<{\n workspace: \"workspace\";\n \"thread-storage\": \"thread-storage\";\n }>;\n entryKind: z$1.ZodEnum<{\n file: \"file\";\n directory: \"directory\";\n }>;\n path: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"command\">;\n trigger: z$1.ZodEnum<{\n \"/\": \"/\";\n }>;\n name: z$1.ZodString;\n source: z$1.ZodEnum<{\n command: \"command\";\n skill: \"skill\";\n }>;\n origin: z$1.ZodEnum<{\n user: \"user\";\n project: \"project\";\n builtin: \"builtin\";\n }>;\n label: z$1.ZodString;\n argumentHint: z$1.ZodNullable;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"plugin\">;\n pluginId: z$1.ZodString;\n itemId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>>>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"image\">;\n url: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localImage\">;\n path: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localFile\">;\n path: z$1.ZodString;\n name: z$1.ZodOptional;\n sizeBytes: z$1.ZodOptional;\n mimeType: z$1.ZodOptional;\n }, z$1.core.$strip>], \"type\">>;\n model: z$1.ZodOptional;\n serviceTier: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>;\n permissionMode: z$1.ZodOptional>;\n executionInputSources: z$1.ZodOptional>;\n serviceTier: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>;\n permissionMode: z$1.ZodOptional>;\n }, z$1.core.$strict>>;\n mode: z$1.ZodEnum<{\n steer: \"steer\";\n start: \"start\";\n auto: \"auto\";\n \"queue-if-active\": \"queue-if-active\";\n \"steer-if-active\": \"steer-if-active\";\n }>;\n senderThreadId: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype SendMessageRequest = z$1.infer;\ndeclare const threadResponseSchema: z$1.ZodObject<{\n id: z$1.ZodString;\n projectId: z$1.ZodString;\n environmentId: z$1.ZodNullable;\n providerId: z$1.ZodString;\n title: z$1.ZodNullable;\n titleFallback: z$1.ZodNullable;\n folderId: z$1.ZodNullable;\n status: z$1.ZodEnum<{\n error: \"error\";\n stopping: \"stopping\";\n idle: \"idle\";\n starting: \"starting\";\n active: \"active\";\n }>;\n parentThreadId: z$1.ZodNullable;\n sourceThreadId: z$1.ZodNullable;\n originKind: z$1.ZodNullable>;\n childOrigin: z$1.ZodNullable>;\n originPluginId: z$1.ZodNullable;\n archivedAt: z$1.ZodNullable;\n pinnedAt: z$1.ZodNullable;\n deletedAt: z$1.ZodNullable;\n lastReadAt: z$1.ZodNullable;\n latestAttentionAt: z$1.ZodNumber;\n createdAt: z$1.ZodNumber;\n updatedAt: z$1.ZodNumber;\n runtime: z$1.ZodObject<{\n displayStatus: z$1.ZodEnum<{\n error: \"error\";\n provisioning: \"provisioning\";\n stopping: \"stopping\";\n idle: \"idle\";\n starting: \"starting\";\n active: \"active\";\n \"host-reconnecting\": \"host-reconnecting\";\n \"waiting-for-host\": \"waiting-for-host\";\n }>;\n hostReconnectGraceExpiresAt: z$1.ZodNullable;\n }, z$1.core.$strip>;\n canSpawnChild: z$1.ZodBoolean;\n}, z$1.core.$strip>;\ntype ThreadResponse = z$1.infer;\ndeclare const threadGetQuerySchema: z$1.ZodObject<{\n include: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype ThreadGetQuery = z$1.infer;\ndeclare const deleteThreadRequestSchema: z$1.ZodObject<{\n childThreadsConfirmed: z$1.ZodBoolean;\n}, z$1.core.$strip>;\ntype DeleteThreadRequest = z$1.infer;\ndeclare const updateThreadRequestSchema: z$1.ZodObject<{\n title: z$1.ZodOptional>;\n folderId: z$1.ZodOptional>;\n parentThreadId: z$1.ZodOptional>;\n model: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>>;\n}, z$1.core.$strip>;\ntype UpdateThreadRequest = z$1.infer;\n/** Which root a secondary-panel file path is relative to. */\ndeclare const panelFileSourceSchema: z$1.ZodEnum<{\n workspace: \"workspace\";\n \"thread-storage\": \"thread-storage\";\n}>;\ntype PanelFileSource = z$1.infer;\ndeclare const threadTimelineQuerySchema: z$1.ZodObject<{\n includeNestedRows: z$1.ZodOptional>;\n segmentLimit: z$1.ZodOptional;\n beforeAnchorSeq: z$1.ZodOptional;\n beforeAnchorId: z$1.ZodOptional;\n summaryOnly: z$1.ZodOptional>;\n afterSequence: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype ThreadTimelineQuery = z$1.infer;\n\ntype PublicApiSchema = unknown;\ntype ApiClient = unknown;\n\ntype FetchImplementation = typeof fetch;\ntype JsonBodyOf = TResponse extends {\n json(): Promise;\n} ? TBody : never;\n\ntype BbSdkRuntime = \"node\" | \"browser\";\ninterface BbSdkTransport {\n api: ApiClient[\"api\"];\n baseUrl: string;\n fetch: FetchImplementation;\n realtimeUrl?: string;\n runtime: BbSdkRuntime;\n readJson(response: Promise): Promise>;\n readVoid(response: Promise): Promise;\n resolve(response: Promise): Promise;\n websocket?: BbRealtimeSocketFactory;\n}\n/**\n * Raw socket payload. Treated as opaque until decoded — realtime ignores\n * non-string frames.\n */\ninterface BbRealtimeSocketMessageEvent {\n data: unknown;\n}\n/**\n * Minimal runtime-agnostic socket shape consumed by the realtime client.\n * Default factories adapt the environment's WebSocket (browser/Node global,\n * or the `ws` package on older Node) to this interface; custom `websocket`\n * factories can wrap any implementation the same way.\n */\ninterface BbRealtimeSocket {\n close(): void;\n onclose: (() => void) | null;\n onerror: (() => void) | null;\n onmessage: ((event: BbRealtimeSocketMessageEvent) => void) | null;\n onopen: (() => void) | null;\n readyState: number;\n send(data: string): void;\n}\ntype BbRealtimeSocketFactory = (url: string) => BbRealtimeSocket;\ninterface BbSdkContext {\n}\n\ninterface CreateSdkAreaArgs {\n context: BbSdkContext;\n transport: BbSdkTransport;\n}\ntype PublicApiEndpointOutput = TEndpoint extends {\n status: infer Status;\n output: infer Output;\n} ? Status extends SuccessfulHttpStatus ? Output : never : never;\ntype SuccessfulHttpStatus = 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226;\ntype PublicApiOutput = PublicApiEndpointOutput;\n\ninterface AutomationCreateArgs extends CreateAutomationRequest {\n projectId?: string;\n}\ninterface AutomationListArgs {\n projectId?: string;\n}\ninterface AutomationGetArgs {\n projectId?: string;\n automationId: string;\n}\ninterface AutomationUpdateArgs extends UpdateAutomationRequest {\n projectId?: string;\n automationId: string;\n}\ninterface AutomationActionArgs {\n projectId?: string;\n automationId: string;\n}\ninterface AutomationRunArgs extends RunAutomationRequest {\n projectId?: string;\n automationId: string;\n}\ninterface AutomationRunsArgs {\n projectId?: string;\n automationId: string;\n limit?: number;\n cursor?: string;\n}\ntype AutomationCreateResult = PublicApiOutput<\"/projects/:id/automations\", \"$post\">;\ntype AutomationListResult = PublicApiOutput<\"/projects/:id/automations\", \"$get\">;\ntype AutomationGetResult = PublicApiOutput<\"/projects/:id/automations/:automationId\", \"$get\">;\ntype AutomationUpdateResult = PublicApiOutput<\"/projects/:id/automations/:automationId\", \"$patch\">;\ntype AutomationPauseResult = PublicApiOutput<\"/projects/:id/automations/:automationId/pause\", \"$post\">;\ntype AutomationResumeResult = PublicApiOutput<\"/projects/:id/automations/:automationId/resume\", \"$post\">;\ntype AutomationRunResult = PublicApiOutput<\"/projects/:id/automations/:automationId/run\", \"$post\">;\ntype AutomationRunsResult = PublicApiOutput<\"/projects/:id/automations/:automationId/runs\", \"$get\">;\ntype AutomationsOverviewResult = PublicApiOutput<\"/automations\", \"$get\">;\ninterface AutomationsArea {\n create(args: AutomationCreateArgs): Promise;\n delete(args: AutomationActionArgs): Promise<{\n ok: true;\n }>;\n get(args: AutomationGetArgs): Promise;\n list(args?: AutomationListArgs): Promise;\n overview(): Promise;\n pause(args: AutomationActionArgs): Promise;\n resume(args: AutomationActionArgs): Promise;\n run(args: AutomationRunArgs): Promise;\n runs(args: AutomationRunsArgs): Promise;\n update(args: AutomationUpdateArgs): Promise;\n}\ndeclare function createAutomationsArea(args: CreateSdkAreaArgs): AutomationsArea;\n\ninterface EnvironmentGetArgs {\n environmentId: string;\n}\ntype EnvironmentMergeBaseBranchUpdateValue = Exclude;\ntype EnvironmentNameUpdateValue = Exclude;\ninterface EnvironmentMergeBaseBranchUpdate {\n mergeBaseBranch: EnvironmentMergeBaseBranchUpdateValue;\n name?: EnvironmentNameUpdateValue;\n}\ninterface EnvironmentNameUpdate {\n mergeBaseBranch?: EnvironmentMergeBaseBranchUpdateValue;\n name: EnvironmentNameUpdateValue;\n}\ntype EnvironmentUpdateFields = EnvironmentMergeBaseBranchUpdate | EnvironmentNameUpdate;\ntype EnvironmentUpdateArgs = EnvironmentUpdateFields & {\n environmentId: string;\n};\ninterface EnvironmentStatusArgs extends EnvironmentStatusQuery {\n environmentId: string;\n}\ntype EnvironmentDiffArgs = EnvironmentDiffQuery & {\n environmentId: string;\n};\ntype EnvironmentDiffFileArgs = EnvironmentDiffFileQuery & {\n environmentId: string;\n};\ninterface EnvironmentDiffBranchesArgs extends EnvironmentDiffBranchesQuery {\n environmentId: string;\n}\ninterface EnvironmentCommitArgs {\n environmentId: string;\n}\ninterface EnvironmentSquashMergeArgs {\n environmentId: string;\n mergeBaseBranch: string;\n}\ntype EnvironmentActionResult = PublicApiOutput<\"/environments/:id/actions\", \"$post\">;\ntype EnvironmentCommitResult = Extract;\ntype EnvironmentDiffResult = PublicApiOutput<\"/environments/:id/diff\", \"$get\">;\ntype EnvironmentDiffBranchesResult = PublicApiOutput<\"/environments/:id/diff/branches\", \"$get\">;\ntype EnvironmentDiffFileResult = PublicApiOutput<\"/environments/:id/diff/file\", \"$get\">;\ntype EnvironmentGetResult = PublicApiOutput<\"/environments/:id\", \"$get\">;\ntype EnvironmentPullRequestResult = PublicApiOutput<\"/environments/:id/pull-request\", \"$get\">;\ntype EnvironmentSquashMergeResult = Extract;\ntype EnvironmentStatusResult = PublicApiOutput<\"/environments/:id/status\", \"$get\">;\ntype EnvironmentUpdateResult = PublicApiOutput<\"/environments/:id\", \"$patch\">;\ninterface EnvironmentsArea {\n commit(args: EnvironmentCommitArgs): Promise;\n diff(args: EnvironmentDiffArgs): Promise;\n diffBranches(args: EnvironmentDiffBranchesArgs): Promise;\n diffFile(args: EnvironmentDiffFileArgs): Promise;\n get(args: EnvironmentGetArgs): Promise;\n pullRequest(args: EnvironmentGetArgs): Promise;\n squashMerge(args: EnvironmentSquashMergeArgs): Promise;\n status(args: EnvironmentStatusArgs): Promise;\n update(args: EnvironmentUpdateArgs): Promise;\n}\ndeclare function createEnvironmentsArea(args: CreateSdkAreaArgs): EnvironmentsArea;\n\n/**\n * Host file primitives. `hostId` may be omitted to target the server's\n * primary (local) host. `rootPath`, when set, confines the target beneath\n * that absolute root on the host (symlink-safe).\n */\ninterface FileReadArgs {\n hostId?: string;\n path: string;\n rootPath?: string;\n}\ninterface FileWriteArgs {\n hostId?: string;\n path: string;\n rootPath?: string;\n content: string;\n /** Defaults to \"utf8\". */\n contentEncoding?: \"utf8\" | \"base64\";\n /** Defaults to false. */\n createParents?: boolean;\n /**\n * Optimistic-concurrency guard: omitted → unconditional write; a hash →\n * write only when the current content hashes to it (use `read().sha256`);\n * null → create-only. A failed guard resolves to the `conflict` outcome.\n */\n expectedSha256?: string | null;\n}\ninterface FileListArgs {\n hostId?: string;\n path: string;\n query?: string;\n limit?: number;\n}\ntype FileReadResult = PublicApiOutput<\"/files/read\", \"$post\">;\ntype FileWriteResult = PublicApiOutput<\"/files/write\", \"$post\">;\ntype FileListResult = PublicApiOutput<\"/files/list\", \"$post\">;\ninterface FilesArea {\n read(args: FileReadArgs): Promise;\n write(args: FileWriteArgs): Promise;\n list(args: FileListArgs): Promise;\n}\ndeclare function createFilesArea(args: CreateSdkAreaArgs): FilesArea;\n\ninterface GuideRenderArgs {\n chapter?: string;\n}\ninterface GuideRenderResult {\n chapter?: string;\n content: string;\n}\ninterface GuideArea {\n render(args?: GuideRenderArgs): GuideRenderResult;\n}\ndeclare function createGuideArea(): GuideArea;\n\ninterface HostGetArgs {\n hostId: string;\n}\ntype HostGetResult = PublicApiOutput<\"/hosts/:id\", \"$get\">;\ntype HostListResult = PublicApiOutput<\"/hosts\", \"$get\">;\ninterface HostsArea {\n get(args: HostGetArgs): Promise;\n list(): Promise;\n}\ndeclare function createHostsArea(args: CreateSdkAreaArgs): HostsArea;\n\ninterface ProjectListArgs extends ProjectListQuery {\n}\ninterface ProjectCreateArgs extends CreateProjectRequest {\n}\ninterface ProjectGetArgs {\n projectId: string;\n}\ninterface ProjectUpdateArgs extends UpdateProjectRequest {\n projectId: string;\n}\ninterface ProjectDeleteArgs {\n projectId: string;\n}\ninterface ProjectSourceAddArgs extends CreateProjectSourceRequest {\n projectId: string;\n}\ninterface ProjectSourceUpdateArgs extends UpdateProjectSourceRequest {\n projectId: string;\n sourceId: string;\n}\ninterface ProjectSourceDeleteArgs {\n projectId: string;\n sourceId: string;\n}\ntype ProjectCreateResult = PublicApiOutput<\"/projects\", \"$post\">;\ntype ProjectDeleteResult = PublicApiOutput<\"/projects/:id\", \"$delete\">;\ntype ProjectGetResult = PublicApiOutput<\"/projects/:id\", \"$get\">;\ntype ProjectListResult = PublicApiOutput<\"/projects\", \"$get\">;\ntype ProjectUpdateResult = PublicApiOutput<\"/projects/:id\", \"$patch\">;\ntype ProjectSourceAddResult = PublicApiOutput<\"/projects/:id/sources\", \"$post\">;\ntype ProjectSourceUpdateResult = PublicApiOutput<\"/projects/:id/sources/:sourceId\", \"$patch\">;\ntype ProjectSourceDeleteResult = PublicApiOutput<\"/projects/:id/sources/:sourceId\", \"$delete\">;\ninterface ProjectSourcesArea {\n add(args: ProjectSourceAddArgs): Promise;\n delete(args: ProjectSourceDeleteArgs): Promise;\n update(args: ProjectSourceUpdateArgs): Promise;\n}\ninterface ProjectsArea {\n create(args: ProjectCreateArgs): Promise;\n delete(args: ProjectDeleteArgs): Promise;\n get(args: ProjectGetArgs): Promise;\n list(args?: ProjectListArgs): Promise;\n sources: ProjectSourcesArea;\n update(args: ProjectUpdateArgs): Promise;\n}\ndeclare function createProjectsArea(args: CreateSdkAreaArgs): ProjectsArea;\n\ninterface ProviderModelsArgs extends SystemExecutionOptionsQuery {\n}\ntype ProviderListResult = PublicApiOutput<\"/system/providers\", \"$get\">;\ntype ProviderModelsResult = PublicApiOutput<\"/system/execution-options\", \"$get\">;\ninterface ProvidersArea {\n list(): Promise;\n models(args?: ProviderModelsArgs): Promise;\n}\ndeclare function createProvidersArea(args: CreateSdkAreaArgs): ProvidersArea;\n\ntype BbRealtimeUnsubscribe = () => void;\ntype BbRealtimeEventName = \"thread:changed\" | \"project:changed\" | \"environment:changed\" | \"host:changed\" | \"system:changed\" | \"system:config-changed\" | \"realtime:connection\";\ntype ThreadRealtimeEvent = Extract;\ntype ProjectRealtimeEvent = Extract;\ntype EnvironmentRealtimeEvent = Extract;\ntype HostRealtimeEvent = Extract;\ntype SystemRealtimeEvent = Extract;\ntype BbRealtimeConnectionState = \"connecting\" | \"connected\" | \"disconnected\";\ninterface BbRealtimeConnectionEvent {\n reconnectDelayMs: number | null;\n reconnected: boolean;\n state: BbRealtimeConnectionState;\n}\n/**\n * Entity-changed events are delivered as one shared object to every matching\n * listener; their payload types are readonly so a listener cannot mutate what\n * the next listener receives.\n */\ninterface BbRealtimeEventMap {\n \"thread:changed\": ThreadRealtimeEvent;\n \"project:changed\": ProjectRealtimeEvent;\n \"environment:changed\": EnvironmentRealtimeEvent;\n \"host:changed\": HostRealtimeEvent;\n \"system:changed\": SystemRealtimeEvent;\n \"system:config-changed\": SystemRealtimeEvent;\n \"realtime:connection\": BbRealtimeConnectionEvent;\n}\ntype BbRealtimeCallback = (event: BbRealtimeEventMap[TEventName]) => void;\ninterface ThreadRealtimeOnArgs {\n callback: BbRealtimeCallback<\"thread:changed\">;\n event: \"thread:changed\";\n threadId?: string;\n}\ninterface ProjectRealtimeOnArgs {\n callback: BbRealtimeCallback<\"project:changed\">;\n event: \"project:changed\";\n projectId?: string;\n}\ninterface EnvironmentRealtimeOnArgs {\n callback: BbRealtimeCallback<\"environment:changed\">;\n environmentId?: string;\n event: \"environment:changed\";\n}\ninterface HostRealtimeOnArgs {\n callback: BbRealtimeCallback<\"host:changed\">;\n event: \"host:changed\";\n hostId?: string;\n}\ninterface SystemRealtimeOnArgs {\n callback: BbRealtimeCallback<\"system:changed\">;\n event: \"system:changed\";\n}\ninterface SystemConfigRealtimeOnArgs {\n callback: BbRealtimeCallback<\"system:config-changed\">;\n event: \"system:config-changed\";\n}\n/**\n * Connection listeners are pure observers — they never open or hold the\n * socket. A listener registered while a socket already exists receives the\n * latest connection event as a snapshot on the next microtask, so a status\n * UI mounted after connect still learns the current state.\n */\ninterface RealtimeConnectionOnArgs {\n callback: BbRealtimeCallback<\"realtime:connection\">;\n event: \"realtime:connection\";\n}\ntype BbRealtimeOnArgsUnion = ThreadRealtimeOnArgs | ProjectRealtimeOnArgs | EnvironmentRealtimeOnArgs | HostRealtimeOnArgs | SystemRealtimeOnArgs | SystemConfigRealtimeOnArgs | RealtimeConnectionOnArgs;\ntype BbRealtimeOnArgs = Extract;\ninterface BbRealtime {\n on(args: BbRealtimeOnArgs): BbRealtimeUnsubscribe;\n}\n\ninterface StatusGetArgs {\n projectId?: string;\n threadId?: string;\n}\ninterface StatusThreadSummary {\n environmentId: string | null;\n id: string;\n parentThreadId: string | null;\n pinnedAt: number | null;\n projectId: string;\n status: ThreadStatus;\n title: string | null;\n}\ntype StatusProject = PublicApiOutput<\"/projects/:id\", \"$get\">;\ntype StatusChildThreads = PublicApiOutput<\"/threads\", \"$get\">;\ninterface StatusResult {\n childThreads: StatusChildThreads | null;\n pendingTodos: ThreadTimelinePendingTodos | null;\n project: StatusProject | null;\n thread: StatusThreadSummary | null;\n}\ninterface StatusArea {\n get(args?: StatusGetArgs): Promise;\n}\ndeclare function createStatusArea(args: CreateSdkAreaArgs): StatusArea;\n\ninterface ThemeArea {\n /** The active app palette, resolved server-side (built-in id or custom CSS). */\n get(): Promise;\n /** The custom-theme directory plus discovered themes and the active palette. */\n catalog(): Promise;\n /**\n * Activate a palette by id — a built-in id or a custom theme name that exists\n * under `/theme//theme.css`. Broadcasts to all open windows.\n */\n set(themeId: string): Promise;\n}\ndeclare function createThemeArea(args: CreateSdkAreaArgs): ThemeArea;\n\ninterface ThreadListArgs {\n archived?: boolean;\n hasParent?: boolean;\n parentThreadId?: string;\n projectId?: string;\n}\ninterface ThreadGetArgs {\n include?: ThreadGetQuery[\"include\"];\n threadId: string;\n}\ntype ThreadGetResult = PublicApiOutput<\"/threads/:id\", \"$get\">;\ntype ThreadListResult = PublicApiOutput<\"/threads\", \"$get\">;\ntype ThreadOutputResponse = PublicApiOutput<\"/threads/:id/output\", \"$get\">;\ntype ThreadMutationResult = PublicApiOutput<\"/threads/:id\", \"$patch\">;\ntype ThreadSpawnResult = PublicApiOutput<\"/threads\", \"$post\">;\ntype ThreadInteractionGetResult = PublicApiOutput<\"/threads/:id/interactions/:interactionId\", \"$get\">;\ntype ThreadInteractionListResult = PublicApiOutput<\"/threads/:id/interactions\", \"$get\">;\ntype ThreadInteractionResolveResult = PublicApiOutput<\"/threads/:id/interactions/:interactionId/resolve\", \"$post\">;\ntype ThreadEventsListResult = PublicApiOutput<\"/threads/:id/events\", \"$get\">;\ntype ThreadEventWaitResult = PublicApiOutput<\"/threads/:id/events/wait\", \"$get\">;\ntype ThreadTimelineResult = PublicApiOutput<\"/threads/:id/timeline\", \"$get\">;\ntype ThreadArchiveResult = PublicApiOutput<\"/threads/:id/archive\", \"$post\">;\ntype ThreadOpenResult = PublicApiOutput<\"/threads/:id/open\", \"$post\">;\ntype ThreadDeleteResult = PublicApiOutput<\"/threads/:id\", \"$delete\">;\ntype ThreadSendResult = PublicApiOutput<\"/threads/:id/send\", \"$post\">;\ntype ThreadStopResult = PublicApiOutput<\"/threads/:id/stop\", \"$post\">;\ntype ThreadTerminalCloseResult = PublicApiOutput<\"/terminals/:terminalId/close\", \"$post\">;\ntype ThreadTerminalCreateResult = PublicApiOutput<\"/terminals\", \"$post\">;\ntype ThreadTerminalInputResult = PublicApiOutput<\"/terminals/:terminalId/input\", \"$post\">;\ntype ThreadTerminalListResult = PublicApiOutput<\"/terminals\", \"$get\">;\ntype ThreadTerminalOutputResult = PublicApiOutput<\"/terminals/:terminalId/output\", \"$get\">;\ntype ThreadTerminalResizeResult = PublicApiOutput<\"/terminals/:terminalId/resize\", \"$post\">;\ntype ThreadTerminalUpdateResult = PublicApiOutput<\"/terminals/:terminalId\", \"$patch\">;\ntype ThreadUnarchiveResult = PublicApiOutput<\"/threads/:id/unarchive\", \"$post\">;\ninterface ThreadSpawnBaseArgs extends Omit {\n childOrigin?: CreateThreadRequest[\"childOrigin\"];\n origin?: CreateThreadRequest[\"origin\"];\n originKind?: CreateThreadRequest[\"originKind\"];\n startedOnBehalfOf?: CreateThreadRequest[\"startedOnBehalfOf\"];\n}\ntype ThreadSpawnArgs = ThreadSpawnBaseArgs & ({\n input: CreateThreadRequest[\"input\"];\n prompt?: never;\n} | {\n input?: never;\n prompt: string;\n});\ninterface ThreadUpdateArgs extends UpdateThreadRequest {\n threadId: string;\n}\ninterface ThreadDeleteArgs extends DeleteThreadRequest {\n threadId: string;\n}\ninterface ThreadSendArgs extends SendMessageRequest {\n threadId: string;\n}\ninterface ThreadStatusArgs {\n threadId: string;\n}\ninterface ThreadOpenArgs {\n threadId: string;\n source: PanelFileSource;\n path: string;\n lineNumber: number | null;\n}\ninterface ThreadEventsListArgs {\n afterSeq?: string;\n limit?: string;\n threadId: string;\n}\ninterface ThreadEventWaitArgs {\n afterSeq?: string;\n threadId: string;\n type: string;\n waitMs: string;\n}\ninterface ThreadTimelineArgs extends ThreadTimelineQuery {\n threadId: string;\n}\ninterface ThreadOutputArgs {\n threadId: string;\n}\ninterface ThreadTerminalListArgs {\n threadId: string;\n}\ninterface ThreadTerminalCreateArgs extends Omit {\n threadId: string;\n}\ninterface ThreadTerminalTargetArgs {\n terminalId: string;\n threadId: string;\n}\ninterface ThreadTerminalUpdateArgs extends ThreadTerminalTargetArgs, UpdateTerminalRequest {\n}\ninterface ThreadTerminalCloseArgs extends ThreadTerminalTargetArgs, CloseTerminalRequest {\n}\ninterface ThreadTerminalInputArgs extends ThreadTerminalTargetArgs, TerminalInputRequest {\n}\ninterface ThreadTerminalResizeArgs extends ThreadTerminalTargetArgs, TerminalResizeRequest {\n}\ninterface ThreadTerminalOutputArgs extends ThreadTerminalTargetArgs, TerminalOutputQuery {\n}\ninterface ThreadInteractionListArgs {\n threadId: string;\n}\ninterface ThreadInteractionGetArgs extends ThreadInteractionListArgs {\n interactionId: string;\n}\ninterface ThreadInteractionResolveArgs extends ThreadInteractionGetArgs {\n resolution: PendingInteractionResolution;\n}\ntype ThreadWaitTarget = {\n kind: \"status\";\n status: ThreadStatus;\n} | {\n kind: \"event\";\n eventType: string;\n};\ninterface ThreadWaitArgs {\n event?: string;\n pollIntervalMs?: number;\n status?: ThreadStatus;\n threadId: string;\n timeoutMs?: number;\n}\ntype ThreadWaitResult = {\n event: NonNullable;\n matched: true;\n target: Extract;\n threadId: string;\n} | {\n matched: true;\n target: Extract;\n thread: ThreadGetResult;\n threadId: string;\n};\ninterface ThreadInteractionsArea {\n get(args: ThreadInteractionGetArgs): Promise;\n list(args: ThreadInteractionListArgs): Promise;\n resolve(args: ThreadInteractionResolveArgs): Promise;\n}\ninterface ThreadEventsArea {\n list(args: ThreadEventsListArgs): Promise;\n wait(args: ThreadEventWaitArgs): Promise;\n}\ninterface ThreadTerminalsArea {\n close(args: ThreadTerminalCloseArgs): Promise;\n create(args: ThreadTerminalCreateArgs): Promise;\n input(args: ThreadTerminalInputArgs): Promise;\n list(args: ThreadTerminalListArgs): Promise;\n output(args: ThreadTerminalOutputArgs): Promise;\n resize(args: ThreadTerminalResizeArgs): Promise;\n update(args: ThreadTerminalUpdateArgs): Promise;\n}\ninterface ThreadsArea {\n archive(args: ThreadStatusArgs): Promise;\n delete(args: ThreadDeleteArgs): Promise;\n events: ThreadEventsArea;\n get(args: ThreadGetArgs): Promise;\n interactions: ThreadInteractionsArea;\n list(args?: ThreadListArgs): Promise;\n open(args: ThreadOpenArgs): Promise;\n output(args: ThreadOutputArgs): Promise;\n pin(args: ThreadStatusArgs): Promise;\n send(args: ThreadSendArgs): Promise;\n spawn(args: ThreadSpawnArgs): Promise;\n stop(args: ThreadStatusArgs): Promise;\n terminals: ThreadTerminalsArea;\n timeline(args: ThreadTimelineArgs): Promise;\n unarchive(args: ThreadStatusArgs): Promise;\n unpin(args: ThreadStatusArgs): Promise;\n update(args: ThreadUpdateArgs): Promise;\n wait(args: ThreadWaitArgs): Promise;\n}\ndeclare function createThreadsArea(args: CreateSdkAreaArgs): ThreadsArea;\n\ninterface BbSdk extends BbRealtime {\n automations: ReturnType;\n environments: ReturnType;\n files: ReturnType;\n guide: ReturnType;\n hosts: ReturnType;\n projects: ReturnType;\n providers: ReturnType;\n status: ReturnType;\n theme: ReturnType;\n threads: ReturnType;\n}\n\n/**\n * The backend plugin API contract — the `bb` object handed to a plugin's\n * `server.ts` factory (`export default function plugin(bb: BbPluginApi)`).\n *\n * Types only: the implementation lives in the BB server\n * (apps/server/src/services/plugins/plugin-api.ts), which imports these\n * shapes so the contract and the implementation cannot drift. Plugin authors\n * import them type-only (`import type { BbPluginApi } from\n * \"@bb/plugin-sdk\"`); the import is erased when BB loads the file.\n *\n * Runtime classes stay host-side. NeedsConfigurationError in particular is\n * matched by NAME, so plugin code needs no runtime import:\n * `throw Object.assign(new Error(msg), { name: \"NeedsConfigurationError\" })`.\n */\ninterface PluginLogger {\n debug(message: string): void;\n info(message: string): void;\n warn(message: string): void;\n error(message: string): void;\n}\n/**\n * Declarative settings descriptors (`bb.settings.define`). Deliberately plain\n * data — not zod — so the host can render settings forms and the CLI can\n * parse values without executing plugin code.\n */\ntype PluginSettingDescriptor = {\n type: \"string\";\n label: string;\n description?: string;\n /** Stored in a 0600 file under /plugins//secrets/, never in the db or sent to the frontend. */\n secret?: true;\n default?: string;\n} | {\n type: \"boolean\";\n label: string;\n description?: string;\n default?: boolean;\n} | {\n type: \"select\";\n label: string;\n description?: string;\n options: string[];\n default?: string;\n} | {\n type: \"project\";\n label: string;\n description?: string;\n default?: string;\n};\ntype PluginSettingDescriptors = Record;\ntype PluginSettingValue = string | boolean;\n/** `default` present → non-optional value; absent → `T | undefined`. */\ntype PluginSettingsValues> = {\n [K in keyof Ds]: Ds[K] extends {\n default: string | boolean;\n } ? PluginSettingValueOf : PluginSettingValueOf | undefined;\n};\ntype PluginSettingValueOf = D extends {\n type: \"boolean\";\n} ? boolean : string;\ninterface PluginSettingsHandle> {\n /** Load-safe: callable inside the factory. */\n get(): Promise>;\n /** Fires after values change through the settings route/CLI. */\n onChange(listener: (next: PluginSettingsValues, prev: PluginSettingsValues) => void): void;\n}\ninterface PluginSettings {\n define>(descriptors: Ds): PluginSettingsHandle;\n}\ninterface PluginKvStorage {\n get(key: string): Promise;\n set(key: string, value: unknown): Promise;\n delete(key: string): Promise;\n list(prefix?: string): Promise;\n}\ninterface PluginStorage {\n /** Namespaced JSON key-value rows in bb.db; values ≤256KB each. */\n kv: PluginKvStorage;\n /**\n * Open (or reuse the path of) the plugin's own SQLite database at\n * /plugins//data.db — the server's better-sqlite3, WAL mode,\n * busy_timeout 5000. Handles are host-tracked and closed on\n * dispose/reload; a closed handle throws on use.\n */\n sqlite(): Database.Database;\n /**\n * Ordered-statement migration helper: statement index = migration id in a\n * `_bb_migrations` table; unapplied statements run in one transaction.\n * Append-only — never reorder or edit shipped statements.\n */\n migrate(db: Database.Database, statements: string[]): void;\n}\n/**\n * Thread lifecycle events a plugin can observe (design §4.5). Observe-only:\n * handlers run fire-and-forget after the transition is applied and can never\n * block or veto it. `thread` is the same public DTO GET /threads/:id serves.\n */\ninterface PluginThreadEventPayloads {\n \"thread.created\": {\n thread: ThreadResponse;\n };\n /** Fired when a thread transitions into `idle`. `lastAssistantText` is\n * assembled the same way GET /threads/:id/output is. */\n \"thread.idle\": {\n thread: ThreadResponse;\n lastAssistantText: string | null;\n };\n /** Fired when a thread transitions into `error`. `error` is the latest\n * system/error event message, when one exists. */\n \"thread.failed\": {\n thread: ThreadResponse;\n error: string | null;\n };\n}\ntype PluginThreadEventName = keyof PluginThreadEventPayloads;\ntype PluginThreadEventHandler = (payload: PluginThreadEventPayloads[E]) => void | Promise;\ntype PluginHttpAuthMode = \"local\" | \"token\" | \"none\";\ntype PluginHttpHandler = (context: Context) => Response | Promise;\ninterface PluginHttp {\n /**\n * Register an HTTP route, mounted at\n * `/api/v1/plugins//http/`. Auth modes (default \"local\"):\n * - \"local\": Origin/Host must be a local BB app origin; non-GET requires\n * content-type application/json (forces a CORS preflight).\n * - \"token\": requires the per-plugin token (`bb plugin token `) via\n * the x-bb-plugin-token header or ?token=.\n * - \"none\": no checks — only for signature-verified webhooks.\n */\n route(method: string, path: string, handler: PluginHttpHandler, opts?: {\n auth?: PluginHttpAuthMode;\n }): void;\n}\ninterface PluginRpc {\n /**\n * Register rpc methods, served at POST\n * `/api/v1/plugins//rpc/` with \"local\" auth semantics. The\n * JSON request body is the input; the response is\n * `{ ok: true, result }` or `{ ok: false, error }`. Inputs and outputs\n * must survive a JSON round-trip — results are serialized with\n * JSON.stringify and nothing else.\n */\n register(handlers: Record unknown>): void;\n}\ninterface PluginRealtime {\n /**\n * Broadcast an ephemeral `plugin-signal` WS message\n * `{ pluginId, channel, payload }` to every connected client (V1 has no\n * per-channel subscriptions). `payload` must be JSON-serializable;\n * `undefined` is normalized to `null`. Nothing is persisted.\n */\n publish(channel: string, payload: unknown): void;\n}\ninterface PluginBackground {\n /**\n * Register a long-lived background service. `start` runs after the\n * factory completes and should resolve when `signal` aborts\n * (dispose/reload/disable/shutdown). A crash restarts it with capped\n * exponential backoff; throwing NeedsConfigurationError marks the plugin\n * `needs-configuration` and stops restarting until the next load.\n */\n service(name: string, service: {\n start(signal: AbortSignal): void | Promise;\n }): void;\n /**\n * Register a cron schedule (5-field expression, server-local time). The\n * durable row keyed (pluginId, name) is upserted at load; the periodic\n * sweep claims due rows with a CAS on next_run_at, but only while this\n * plugin is loaded. Failures land in last_status/last_error, visible in\n * `bb plugin list`.\n */\n schedule(name: string, cron: string, fn: () => void | Promise): void;\n}\ninterface PluginCliCommandInfo {\n name: string;\n summary: string;\n usage: string;\n}\n/** Context forwarded from the invoking CLI when known; all fields optional. */\ninterface PluginCliContext {\n cwd?: string;\n threadId?: string;\n projectId?: string;\n}\ninterface PluginCliResult {\n exitCode: number;\n stdout?: string;\n stderr?: string;\n}\ninterface PluginCliRegistration {\n /** Top-level command name (`bb …`): lowercase [a-z0-9-]+, and not\n * a core bb command (see RESERVED_BB_CLI_COMMANDS in the server). */\n name: string;\n summary: string;\n /** Subcommand metadata rendered in help and the plugin-commands skill\n * without executing plugin code. Parsing argv is plugin-owned. */\n commands?: PluginCliCommandInfo[];\n run(argv: string[], ctx: PluginCliContext): PluginCliResult | Promise;\n}\ninterface PluginCli {\n /**\n * Register this plugin's `bb` subcommand. One registration per plugin —\n * a second call replaces the first. Core bb commands always win name\n * collisions; reserved names are rejected at registration.\n */\n register(registration: PluginCliRegistration): void;\n}\n/** Per-turn context handed to bb.agents context providers (design §4.4). */\n/** MCP-style content parts a native tool may return (design §4.4). */\ntype PluginAgentToolContentPart = {\n type: \"text\";\n text: string;\n} | {\n type: \"image\";\n data: string;\n mimeType: string;\n};\ntype PluginAgentToolResult = string | {\n content: PluginAgentToolContentPart[];\n isError?: boolean;\n};\n/** Per-call context handed to a native tool's execute (design §4.4). */\ninterface PluginAgentToolContext {\n threadId: string;\n projectId: string;\n /** The tool-call request's abort signal (aborts if the daemon round-trip\n * is torn down mid-call). */\n signal: AbortSignal;\n}\ninterface PluginAgentToolRegistrationBase {\n /** Tool name shown to the model: [a-zA-Z0-9_-]+, unique across plugins,\n * and not a built-in dynamic tool (see RESERVED_AGENT_TOOL_NAMES in the\n * server). */\n name: string;\n description: string;\n /**\n * Optional usage snippet appended to the thread instructions whenever\n * this tool is in the session's tool set (mirrors the built-in\n * update_environment_directory guidance). Keep it short.\n */\n instructions?: string;\n}\ninterface PluginAgents {\n /**\n * Register a native dynamic tool (design §4.4). `parameters` is either a\n * zod schema (validated per call; execute receives the parsed value) or a\n * plain JSON-schema object (no validation; execute receives the raw\n * arguments as `unknown`). Tool-set changes apply on the NEXT session\n * start — a tool registered mid-session is not hot-added to running\n * provider sessions. A second registration of the same name within this\n * plugin replaces the first; a name already registered by another plugin\n * is rejected and surfaced as this plugin's status detail.\n */\n registerTool(tool: PluginAgentToolRegistrationBase & {\n parameters: Schema;\n execute(params: z.output, ctx: PluginAgentToolContext): PluginAgentToolResult | Promise;\n }): void;\n registerTool(tool: PluginAgentToolRegistrationBase & {\n /** Raw JSON-schema escape hatch; params arrive unvalidated. */\n parameters: Record;\n execute(params: unknown, ctx: PluginAgentToolContext): PluginAgentToolResult | Promise;\n }): void;\n}\ninterface PluginThreadActionContext {\n threadId: string;\n projectId: string;\n}\ninterface PluginThreadActionToast {\n kind: \"success\" | \"error\" | \"info\";\n message: string;\n}\ntype PluginThreadActionResult = void | {\n toast?: PluginThreadActionToast;\n};\ninterface PluginThreadActionRegistration {\n /** Unique within this plugin: [a-zA-Z0-9_-]+ (becomes a URL segment). */\n id: string;\n /** Button label rendered in the thread header. */\n title: string;\n /** Optional icon name; the host falls back to a generic icon. */\n icon?: string;\n /** Optional confirmation prompt the host shows before running. */\n confirm?: string;\n /**\n * Runs server-side when the user clicks the action. The host shows a\n * pending state while in flight, the returned toast on completion, and an\n * automatic error toast when this throws.\n */\n run(ctx: PluginThreadActionContext): PluginThreadActionResult | Promise;\n}\n/** Search context handed to a mention provider (design §4.9). `projectId`/\n * `threadId` are null when the composer has not committed one yet. */\ninterface PluginMentionSearchContext {\n query: string;\n projectId: string | null;\n threadId: string | null;\n}\n/** One row a mention provider returns from `search`. `id` is the provider's\n * own item id — the host namespaces it before it reaches the wire. */\ninterface PluginMentionItem {\n id: string;\n title: string;\n subtitle?: string;\n icon?: string;\n}\ninterface PluginMentionProviderRegistration {\n /** Unique within this plugin: [a-zA-Z0-9_-]+ (no \":\" — the host composes\n * wire item ids as \":\"). */\n id: string;\n /** Section label shown above this provider's rows in the mention menu. */\n label: string;\n /**\n * Runs server-side as the user types after `@` in the composer. Each call\n * is time-boxed (2s) and failure-isolated: a slow or throwing provider\n * contributes an empty list — it can never break the mention menu.\n */\n search(ctx: PluginMentionSearchContext): PluginMentionItem[] | Promise;\n /**\n * Resolves one picked item into agent context, called once per unique\n * item at message send time. The returned `context` is attached to the\n * message as an agent-visible (user-hidden) prompt input. Throwing blocks\n * the send with a visible error.\n */\n resolve(itemId: string): {\n context: string;\n } | Promise<{\n context: string;\n }>;\n}\ninterface PluginUi {\n /**\n * Register a thread action rendered in the shipped app's thread header\n * (design §4.9). Multiple actions per plugin; ids must be unique within\n * the plugin. Invoked via POST /plugins/:id/actions/:actionId.\n */\n registerThreadAction(action: PluginThreadActionRegistration): void;\n /**\n * Register an `@`-mention provider for the shipped app's composer\n * (design §4.9). Items group under `label` in the mention menu; a picked\n * item becomes a `{ kind: \"plugin\" }` mention resource whose context is\n * resolved once at send time. Multiple providers per plugin; ids must be\n * unique within the plugin.\n */\n registerMentionProvider(provider: PluginMentionProviderRegistration): void;\n}\ninterface PluginStatusApi {\n /**\n * Mark this plugin `needs-configuration` (with a message shown in\n * `bb plugin list` and the UI) instead of failing — e.g. a factory or\n * service that finds no API key configured. Cleared on the next load;\n * saving settings does not auto-reload in V1, so ask the user to\n * `bb plugin reload ` after configuring.\n */\n needsConfiguration(message: string): void;\n}\n/**\n * The API object handed to a plugin's factory (design §4). Implemented by\n * the BB server; this contract is what plugin `server.ts` files compile\n * against.\n */\ninterface BbPluginApi {\n /** The plugin's own id (namespaces storage, routes, commands). */\n readonly pluginId: string;\n /** Leveled, plugin-scoped logger. */\n readonly log: PluginLogger;\n /** Declarative settings (design §4.2). */\n readonly settings: PluginSettings;\n /** Namespaced KV + per-plugin SQLite (design §4.3). */\n readonly storage: PluginStorage;\n /** HTTP routes under /api/v1/plugins//http/* (design §4.6). */\n readonly http: PluginHttp;\n /** RPC methods under /api/v1/plugins//rpc/ (design §4.6). */\n readonly rpc: PluginRpc;\n /** Ephemeral push to connected frontends (design §4.7). */\n readonly realtime: PluginRealtime;\n /** Long-lived services + cron schedules (design §4.8). */\n readonly background: PluginBackground;\n /** Agent-facing `bb` CLI subcommand (design §4.4). */\n readonly cli: PluginCli;\n /** Per-turn agent context contributions (design §4.4). */\n readonly agents: PluginAgents;\n /** Host-rendered UI contributions (design §4.9). */\n readonly ui: PluginUi;\n /** Plugin-reported status (needs-configuration). */\n readonly status: PluginStatusApi;\n /**\n * The full BB SDK, bound to this server over loopback (design §4.1).\n * Bind-gated: reading this before the host binds the SDK throws. The real\n * server binds it before loading plugins, so it is available from the\n * moment factories run there — but isolated harnesses may not, so prefer\n * using it from handlers, services, and timers for portability.\n * `threads.spawn` defaults `origin` to \"plugin\" and `originPluginId` to\n * this plugin's id so spawned threads are attributed automatically.\n */\n readonly sdk: BbSdk;\n /**\n * Observe thread lifecycle events (design §4.5). Load-safe registration;\n * handlers run async after the transition and never affect it. Errors are\n * caught, logged, and counted against this plugin's handler stats.\n */\n on(event: E, handler: PluginThreadEventHandler): void;\n /**\n * Register cleanup to run on reload/disable/shutdown. Hooks run LIFO.\n * The sanctioned place to clear timers and close connections.\n */\n onDispose(hook: () => void | Promise): void;\n}\n\nexport { PLUGIN_SDK_APP_EXPORT_NAMES, PLUGIN_SLOT_ID_PATTERN };\nexport type { BbContext, BbNavigate, BbPluginApi, PluginAgentToolContentPart, PluginAgentToolContext, PluginAgentToolRegistrationBase, PluginAgentToolResult, PluginAgents, PluginAppBuilder, PluginAppDefinition, PluginAppSetup, PluginAppSlots, PluginBackground, PluginCli, PluginCliCommandInfo, PluginCliContext, PluginCliRegistration, PluginCliResult, PluginComposerAccessoryProps, PluginComposerAccessoryRegistration, PluginComposerApi, PluginComposerMention, PluginComposerScope, PluginHomepageSectionProps, PluginHomepageSectionRegistration, PluginHttp, PluginHttpAuthMode, PluginHttpHandler, PluginKvStorage, PluginLogger, PluginMentionItem, PluginMentionProviderRegistration, PluginMentionSearchContext, PluginNavPanelProps, PluginNavPanelRegistration, PluginRealtime, PluginRpc, PluginRpcClient, PluginSdkApp, PluginSettingDescriptor, PluginSettingDescriptors, PluginSettingValue, PluginSettings, PluginSettingsHandle, PluginSettingsState, PluginSettingsValues, PluginStatusApi, PluginStorage, PluginThreadActionContext, PluginThreadActionRegistration, PluginThreadActionResult, PluginThreadActionToast, PluginThreadEventHandler, PluginThreadEventName, PluginThreadEventPayloads, PluginThreadPanelActionContext, PluginThreadPanelActionRegistration, PluginThreadPanelProps, PluginUi };\n"; -export const PLUGIN_SDK_APP_DTS = "// Bundled type declarations for `@bb/plugin-sdk`, shipped into scaffolded\n// plugins so they typecheck without the @bb/* workspace on disk.\n//\n// Confused by the API, or need a symbol that isn't here? Clone the BB repo\n// and read the real source: https://github.com/ymichael/bb\n\nimport { ComponentType } from 'react';\n\n/**\n * The `@bb/plugin-sdk/app` contract (plugin design §5.2) — pure types plus\n * the runtime export-name list, with no side effects. This module is what the\n * BB app imports to keep its real implementation in sync (`satisfies\n * PluginSdkApp`) and what `bb plugin build` imports to generate the shim's\n * named-export list. Plugin authors import the same shapes through\n * `@bb/plugin-sdk/app`.\n *\n * Per-slot props are versioned contracts: additive-only within an SDK major.\n */\n/** Props passed to a `homepageSection` component. */\ninterface PluginHomepageSectionProps {\n /** Project in view on the compose surface; null when none is selected. */\n projectId: string | null;\n}\n/** Props passed to a `navPanel` component (it owns its whole route). */\ninterface PluginNavPanelProps {\n /**\n * The route remainder after the panel root, \"\" at the root. The panel's\n * route is `/plugins///*`, so a deep link like\n * `/plugins/notes/notes/work/ideas.md` renders the panel with\n * `subPath: \"work/ideas.md\"`. Navigate within the panel via\n * `useBbNavigate().toPluginPanel(path, { subPath })` — browser\n * back/forward then walks panel-internal history.\n */\n subPath: string;\n}\n/** Props passed to a panel tab opened by a `threadPanelAction`. */\ninterface PluginThreadPanelProps {\n threadId: string;\n /**\n * The JSON value the action's `openPanel` call passed (round-tripped\n * through persistence, so the tab restores across reloads); null when the\n * action opened the panel without params.\n */\n params: unknown;\n}\n/** Props passed to a `composerAccessory` component. */\ninterface PluginComposerAccessoryProps {\n projectId: string | null;\n threadId: string | null;\n}\n/**\n * Slot/panel ids and nav-panel paths must match this pattern (letters,\n * digits, `-`, `_`): they ride URLs and persisted panel-tab keys.\n */\ndeclare const PLUGIN_SLOT_ID_PATTERN: RegExp;\ninterface PluginHomepageSectionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n component: ComponentType;\n}\ninterface PluginNavPanelRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n /** Icon hint (BB icon name); unknown names fall back to a generic icon. */\n icon: string;\n /** URL segment under `/plugins//`; letters, digits, `-`, `_`. */\n path: string;\n component: ComponentType;\n /**\n * Panel chrome (default \"page\"): \"page\" renders the host title bar (plugin\n * logo + `title` + your `headerContent`) above a full-width padded body;\n * \"none\" hands the ENTIRE panel area to `component` — no host padding, no\n * title bar (`headerContent` is ignored) — only the per-plugin error\n * boundary remains.\n */\n chrome?: \"page\" | \"none\";\n /**\n * Optional component rendered on the right side of the \"page\" title bar\n * (e.g. a sync button or a count). Contained separately from the body: a\n * throwing headerContent is hidden without breaking the title bar.\n */\n headerContent?: ComponentType;\n}\n/** Context handed to a `threadPanelAction`'s `run`. */\ninterface PluginThreadPanelActionContext {\n /** The thread whose panel launcher invoked the action. */\n threadId: string;\n /**\n * Open a tab in the thread's side panel rendering this action's\n * `component`. `title` labels the tab (default: the action's `title`);\n * `params` must be JSON-serializable — it is persisted with the tab and\n * reaches the component as its `params` prop. Opening with params\n * identical to an already-open tab of this action focuses that tab\n * (updating its title) instead of duplicating it. May be called more than\n * once (different params ⇒ multiple tabs) or not at all.\n */\n openPanel(options?: {\n title?: string;\n params?: unknown;\n }): void;\n}\ninterface PluginThreadPanelActionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n /** Label of the action row in the panel's new-tab launcher. */\n title: string;\n /**\n * Icon hint (BB icon name) used when the plugin ships no logo; the\n * launcher row and opened tabs prefer the plugin's logo.\n */\n icon?: string;\n /** Rendered inside every panel tab this action opens. */\n component: ComponentType;\n /**\n * Runs when the user activates the action: call your RPC methods, show a\n * toast, and/or open panel tabs via `context.openPanel`. Omitted =\n * immediately open a panel tab with defaults. Errors (sync or async) are\n * contained and logged; they never break the launcher.\n */\n run?(context: PluginThreadPanelActionContext): void | Promise;\n}\ninterface PluginComposerAccessoryRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n component: ComponentType;\n}\ninterface PluginAppSlots {\n homepageSection(registration: PluginHomepageSectionRegistration): void;\n navPanel(registration: PluginNavPanelRegistration): void;\n threadPanelAction(registration: PluginThreadPanelActionRegistration): void;\n composerAccessory(registration: PluginComposerAccessoryRegistration): void;\n}\ninterface PluginAppBuilder {\n slots: PluginAppSlots;\n}\ntype PluginAppSetup = (app: PluginAppBuilder) => void;\n/**\n * The opaque product of `definePluginApp` — a plugin's `app.tsx` default\n * export. The host re-runs `setup` against a fresh collector on every\n * (re)interpretation, replacing that plugin's registrations wholesale.\n */\ninterface PluginAppDefinition {\n /** Brand the host checks before interpreting a bundle's default export. */\n readonly __bbPluginApp: true;\n readonly setup: PluginAppSetup;\n}\ninterface PluginRpcClient {\n /**\n * Invoke one of the plugin's `bb.rpc` methods (POST\n * /api/v1/plugins/<id>/rpc/<method>). Resolves with the method's\n * result; rejects with an `Error` carrying the server's message when the\n * handler fails or the plugin is not running.\n */\n call(method: string, input?: unknown): Promise;\n}\ninterface PluginSettingsState {\n /**\n * Effective non-secret setting values (secret settings are excluded —\n * read them server-side). Undefined while loading or unavailable.\n */\n values: Record | undefined;\n isLoading: boolean;\n}\n/** Current app selection, derived from the route. */\ninterface BbContext {\n projectId: string | null;\n threadId: string | null;\n}\ninterface BbNavigate {\n toThread(threadId: string): void;\n toProject(projectId: string): void;\n /**\n * Navigate to one of this plugin's own nav panels by its `path`.\n * `subPath` targets a location inside the panel (the component's\n * `subPath` prop); `replace` swaps the current history entry instead of\n * pushing — use it for redirects so back does not bounce.\n */\n toPluginPanel(path: string, options?: {\n subPath?: string;\n replace?: boolean;\n }): void;\n}\n/**\n * Everything `@bb/plugin-sdk/app` resolves to at runtime. The BB app builds\n * the real implementation and `satisfies` this interface; `bb plugin build`\n * shims the specifier to that object on `globalThis.__bbPluginRuntime`.\n */\ninterface PluginSdkApp {\n definePluginApp(setup: PluginAppSetup): PluginAppDefinition;\n useRpc(): PluginRpcClient;\n useRealtime(channel: string, handler: (payload: unknown) => void): void;\n useSettings(): PluginSettingsState;\n useBbContext(): BbContext;\n useBbNavigate(): BbNavigate;\n}\n/**\n * Named runtime exports of `@bb/plugin-sdk/app`, in sorted order. Single\n * source of truth for the build shim's export list and the app's\n * implementation-key test — adding a surface member without updating this\n * list fails the type assertion below.\n */\ndeclare const PLUGIN_SDK_APP_EXPORT_NAMES: readonly [\"definePluginApp\", \"useBbContext\", \"useBbNavigate\", \"useRealtime\", \"useRpc\", \"useSettings\"];\n\ndeclare const definePluginApp: (setup: PluginAppSetup) => PluginAppDefinition;\ndeclare const useRpc: () => PluginRpcClient;\ndeclare const useRealtime: (channel: string, handler: (payload: unknown) => void) => void;\ndeclare const useSettings: () => PluginSettingsState;\ndeclare const useBbContext: () => BbContext;\ndeclare const useBbNavigate: () => BbNavigate;\n\nexport { PLUGIN_SDK_APP_EXPORT_NAMES, PLUGIN_SLOT_ID_PATTERN, definePluginApp, useBbContext, useBbNavigate, useRealtime, useRpc, useSettings };\nexport type { BbContext, BbNavigate, PluginAppBuilder, PluginAppDefinition, PluginAppSetup, PluginAppSlots, PluginComposerAccessoryProps, PluginComposerAccessoryRegistration, PluginHomepageSectionProps, PluginHomepageSectionRegistration, PluginNavPanelProps, PluginNavPanelRegistration, PluginRpcClient, PluginSdkApp, PluginSettingsState, PluginThreadPanelActionContext, PluginThreadPanelActionRegistration, PluginThreadPanelProps };\n"; +export const PLUGIN_SDK_APP_DTS = "// Bundled type declarations for `@bb/plugin-sdk`, shipped into scaffolded\n// plugins so they typecheck without the @bb/* workspace on disk.\n//\n// Confused by the API, or need a symbol that isn't here? Clone the BB repo\n// and read the real source: https://github.com/ymichael/bb\n\nimport { ComponentType } from 'react';\n\n/**\n * The `@bb/plugin-sdk/app` contract (plugin design §5.2) — pure types plus\n * the runtime export-name list, with no side effects. This module is what the\n * BB app imports to keep its real implementation in sync (`satisfies\n * PluginSdkApp`) and what `bb plugin build` imports to generate the shim's\n * named-export list. Plugin authors import the same shapes through\n * `@bb/plugin-sdk/app`.\n *\n * Per-slot props are versioned contracts: additive-only within an SDK major.\n */\n/** Props passed to a `homepageSection` component. */\ninterface PluginHomepageSectionProps {\n /** Project in view on the compose surface; null when none is selected. */\n projectId: string | null;\n}\n/** Props passed to a `navPanel` component (it owns its whole route). */\ninterface PluginNavPanelProps {\n /**\n * The route remainder after the panel root, \"\" at the root. The panel's\n * route is `/plugins///*`, so a deep link like\n * `/plugins/notes/notes/work/ideas.md` renders the panel with\n * `subPath: \"work/ideas.md\"`. Navigate within the panel via\n * `useBbNavigate().toPluginPanel(path, { subPath })` — browser\n * back/forward then walks panel-internal history.\n */\n subPath: string;\n}\n/** Props passed to a panel tab opened by a `threadPanelAction`. */\ninterface PluginThreadPanelProps {\n threadId: string;\n /**\n * The JSON value the action's `openPanel` call passed (round-tripped\n * through persistence, so the tab restores across reloads); null when the\n * action opened the panel without params.\n */\n params: unknown;\n}\n/** Props passed to a `composerAccessory` component. */\ninterface PluginComposerAccessoryProps {\n projectId: string | null;\n threadId: string | null;\n}\n/**\n * Slot/panel ids and nav-panel paths must match this pattern (letters,\n * digits, `-`, `_`): they ride URLs and persisted panel-tab keys.\n */\ndeclare const PLUGIN_SLOT_ID_PATTERN: RegExp;\ninterface PluginHomepageSectionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n component: ComponentType;\n}\ninterface PluginNavPanelRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n /** Icon hint (BB icon name); unknown names fall back to a generic icon. */\n icon: string;\n /** URL segment under `/plugins//`; letters, digits, `-`, `_`. */\n path: string;\n component: ComponentType;\n /**\n * Panel chrome (default \"page\"): \"page\" renders the host title bar (plugin\n * logo + `title` + your `headerContent`) above a full-width padded body;\n * \"none\" hands the ENTIRE panel area to `component` — no host padding, no\n * title bar (`headerContent` is ignored) — only the per-plugin error\n * boundary remains.\n */\n chrome?: \"page\" | \"none\";\n /**\n * Optional component rendered on the right side of the \"page\" title bar\n * (e.g. a sync button or a count). Contained separately from the body: a\n * throwing headerContent is hidden without breaking the title bar.\n */\n headerContent?: ComponentType;\n}\n/** Context handed to a `threadPanelAction`'s `run`. */\ninterface PluginThreadPanelActionContext {\n /** The thread whose panel launcher invoked the action. */\n threadId: string;\n /**\n * Open a tab in the thread's side panel rendering this action's\n * `component`. `title` labels the tab (default: the action's `title`);\n * `params` must be JSON-serializable — it is persisted with the tab and\n * reaches the component as its `params` prop. Opening with params\n * identical to an already-open tab of this action focuses that tab\n * (updating its title) instead of duplicating it. May be called more than\n * once (different params ⇒ multiple tabs) or not at all.\n */\n openPanel(options?: {\n title?: string;\n params?: unknown;\n }): void;\n}\ninterface PluginThreadPanelActionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n /** Label of the action row in the panel's new-tab launcher. */\n title: string;\n /**\n * Icon hint (BB icon name) used when the plugin ships no logo; the\n * launcher row and opened tabs prefer the plugin's logo.\n */\n icon?: string;\n /** Rendered inside every panel tab this action opens. */\n component: ComponentType;\n /**\n * Runs when the user activates the action: call your RPC methods, show a\n * toast, and/or open panel tabs via `context.openPanel`. Omitted =\n * immediately open a panel tab with defaults. Errors (sync or async) are\n * contained and logged; they never break the launcher.\n */\n run?(context: PluginThreadPanelActionContext): void | Promise;\n}\ninterface PluginComposerAccessoryRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n component: ComponentType;\n}\ninterface PluginAppSlots {\n homepageSection(registration: PluginHomepageSectionRegistration): void;\n navPanel(registration: PluginNavPanelRegistration): void;\n threadPanelAction(registration: PluginThreadPanelActionRegistration): void;\n composerAccessory(registration: PluginComposerAccessoryRegistration): void;\n}\ninterface PluginAppBuilder {\n slots: PluginAppSlots;\n}\ntype PluginAppSetup = (app: PluginAppBuilder) => void;\n/**\n * The opaque product of `definePluginApp` — a plugin's `app.tsx` default\n * export. The host re-runs `setup` against a fresh collector on every\n * (re)interpretation, replacing that plugin's registrations wholesale.\n */\ninterface PluginAppDefinition {\n /** Brand the host checks before interpreting a bundle's default export. */\n readonly __bbPluginApp: true;\n readonly setup: PluginAppSetup;\n}\ninterface PluginRpcClient {\n /**\n * Invoke one of the plugin's `bb.rpc` methods (POST\n * /api/v1/plugins/<id>/rpc/<method>). Resolves with the method's\n * result; rejects with an `Error` carrying the server's message when the\n * handler fails or the plugin is not running.\n */\n call(method: string, input?: unknown): Promise;\n}\ninterface PluginSettingsState {\n /**\n * Effective non-secret setting values (secret settings are excluded —\n * read them server-side). Undefined while loading or unavailable.\n */\n values: Record | undefined;\n isLoading: boolean;\n}\n/** Where `useComposer()` writes: the active thread's draft or the new-thread draft. */\ntype PluginComposerScope = {\n kind: \"thread\";\n threadId: string;\n} | {\n kind: \"new-thread\";\n projectId: string | null;\n};\n/** An @-mention pill bound to one of the calling plugin's mention providers. */\ninterface PluginComposerMention {\n /** Mention provider id registered by THIS plugin via `bb.ui.registerMentionProvider`. */\n provider: string;\n /** Item id your provider's `resolve` will receive at send time. */\n id: string;\n /** Pill text shown in the composer. */\n label: string;\n}\n/**\n * Programmatic access to the chat composer draft — the same shared draft the\n * built-in \"Add to chat\" affordances (file preview, diff, terminal selections)\n * write to. Inside a thread context writes land in that thread's draft;\n * anywhere else (nav panel, homepage section) they seed the new-thread\n * composer draft, which persists until the user sends or clears it.\n */\ninterface PluginComposerApi {\n scope: PluginComposerScope;\n /**\n * Append text to the draft as a `> ` blockquote block and focus the\n * composer. Blank text is a no-op. This is the \"reference this selection\n * in chat\" primitive.\n */\n addQuote(text: string): void;\n /**\n * Insert an @-mention pill that resolves through this plugin's mention\n * provider at send time — the durable way to reference an entity whose\n * content should be fetched fresh when the message is sent.\n */\n insertMention(mention: PluginComposerMention): void;\n /** Focus the composer caret at the end of the draft. */\n focus(): void;\n}\n/** Current app selection, derived from the route. */\ninterface BbContext {\n projectId: string | null;\n threadId: string | null;\n}\ninterface BbNavigate {\n toThread(threadId: string): void;\n toProject(projectId: string): void;\n /**\n * Navigate to one of this plugin's own nav panels by its `path`.\n * `subPath` targets a location inside the panel (the component's\n * `subPath` prop); `replace` swaps the current history entry instead of\n * pushing — use it for redirects so back does not bounce.\n */\n toPluginPanel(path: string, options?: {\n subPath?: string;\n replace?: boolean;\n }): void;\n}\n/**\n * Everything `@bb/plugin-sdk/app` resolves to at runtime. The BB app builds\n * the real implementation and `satisfies` this interface; `bb plugin build`\n * shims the specifier to that object on `globalThis.__bbPluginRuntime`.\n */\ninterface PluginSdkApp {\n definePluginApp(setup: PluginAppSetup): PluginAppDefinition;\n useRpc(): PluginRpcClient;\n useRealtime(channel: string, handler: (payload: unknown) => void): void;\n useSettings(): PluginSettingsState;\n useBbContext(): BbContext;\n useBbNavigate(): BbNavigate;\n useComposer(): PluginComposerApi;\n}\n/**\n * Named runtime exports of `@bb/plugin-sdk/app`, in sorted order. Single\n * source of truth for the build shim's export list and the app's\n * implementation-key test — adding a surface member without updating this\n * list fails the type assertion below.\n */\ndeclare const PLUGIN_SDK_APP_EXPORT_NAMES: readonly [\"definePluginApp\", \"useBbContext\", \"useBbNavigate\", \"useComposer\", \"useRealtime\", \"useRpc\", \"useSettings\"];\n\ndeclare const definePluginApp: (setup: PluginAppSetup) => PluginAppDefinition;\ndeclare const useRpc: () => PluginRpcClient;\ndeclare const useRealtime: (channel: string, handler: (payload: unknown) => void) => void;\ndeclare const useSettings: () => PluginSettingsState;\ndeclare const useBbContext: () => BbContext;\ndeclare const useBbNavigate: () => BbNavigate;\n\nexport { PLUGIN_SDK_APP_EXPORT_NAMES, PLUGIN_SLOT_ID_PATTERN, definePluginApp, useBbContext, useBbNavigate, useRealtime, useRpc, useSettings };\nexport type { BbContext, BbNavigate, PluginAppBuilder, PluginAppDefinition, PluginAppSetup, PluginAppSlots, PluginComposerAccessoryProps, PluginComposerAccessoryRegistration, PluginComposerApi, PluginComposerMention, PluginComposerScope, PluginHomepageSectionProps, PluginHomepageSectionRegistration, PluginNavPanelProps, PluginNavPanelRegistration, PluginRpcClient, PluginSdkApp, PluginSettingsState, PluginThreadPanelActionContext, PluginThreadPanelActionRegistration, PluginThreadPanelProps };\n"; diff --git a/packages/templates/src/generated/templates.generated.ts b/packages/templates/src/generated/templates.generated.ts index 2bcbcd138..2318734c8 100644 --- a/packages/templates/src/generated/templates.generated.ts +++ b/packages/templates/src/generated/templates.generated.ts @@ -73,7 +73,7 @@ export const templateDefinitions = [ }, { "id": "bbGuidePlugins", - "body": "Plugin commands\n\nA bb plugin is a TypeScript package that extends the bb server in-process:\nbackground services, cron schedules, HTTP/RPC endpoints, thread lifecycle\nhandlers, settings, storage — and `bb` CLI subcommands that agents and humans\nrun like any other command. Plugins are full-trust code inside the server.\n\nPlugins are an experiment, off by default: enable \"Plugins\" under Settings →\nExperiments first. Until then `bb plugin` commands report that plugins are\ndisabled. Plugin state lives under `/plugins//` (per-plugin\nSQLite file, secrets, logs).\n\n bb plugin install Install from a local path, git:@,\n or npm:@ (npm: needs npm on\n PATH; installs prompt — pass --yes to skip)\n bb plugin list Status, services, schedules, handler timings\n bb plugin enable|disable Load or unload an installed plugin\n bb plugin reload [id] Re-run factories against current sources\n bb plugin config [set | unset ]\n Show or change a plugin's declared settings\n bb plugin logs [-n N] [-f] Print (or follow) a plugin's bb.log output\n bb plugin run [args...] Run the plugin's CLI command explicitly\n bb plugin token [--rotate] Print the token for auth:\"token\" HTTP\n routes; --rotate generates a new token,\n invalidating the old one\n bb plugin remove Uninstall (managed git:/npm: files deleted)\n bb plugin new [--app] Scaffold a new plugin (no server required;\n --app adds a frontend entry, app.tsx, plus a\n typecheck-only tsconfig.json)\n bb plugin build [path] Compile the plugin into dist/ — the backend\n bundle (server.js, server.meta.json) and,\n when bb.app is declared, the frontend bundle\n (app.js, app.css, app.meta.json); no server\n required\n bb plugin dev [path] Watch a plugin's sources (default: cwd) and\n on every change rebuild its frontend bundle\n (if it declares bb.app) and reload the\n plugin; Ctrl+C to stop\n\nFrontend builds are automatic once installed: path and git installs compile\ndist/ at install time (a build failure fails the install), and the server\nrebuilds them at load after a bb upgrade. npm packages must publish a\nprebuilt dist/ (app.js + app.meta.json) or the install is refused.\n\nThe backend half is prebuilt too: when a git/npm install ships a\ndist/server.js built for the running SDK major, the server loads it instead\nof the TypeScript source — consumers never need npm or node_modules. Path\ninstalls always load server.ts from source, so `bb plugin dev`/reload see\nedits immediately.\n\n`bb plugin dev` is the edit loop: it requires the directory to already be\ninstalled as a plugin (`bb plugin install .` first), ignores dist/,\nnode_modules/, and .git/, batches saves, and prints one line per cycle. A\nbuild or reload failure prints the error and keeps watching (a failed build\nskips that cycle's reload). Reloads reach open app pages live — changed\nfrontend bundles re-import and their UI slots remount without a page\nrefresh.\n\nFrontend entries (app.tsx) default-export `definePluginApp` from\n`@bb/plugin-sdk/app` and register UI slots: homepageSection (root compose),\nnavPanel (own sidebar entry + /plugins///* route; the remainder\narrives as the component's subPath prop for panel-internal deep links),\nthreadPanelAction\n(an entry in the thread right panel's new-tab Actions list whose run() can\nopen closable panel tabs with JSON params), composerAccessory (prompt box\nfooter). Hooks:\nuseRpc, useRealtime, useSettings (secrets excluded), useBbContext,\nuseBbNavigate. Components are vendored shadcn source the plugin owns (the\nshadcn model): `bb plugin new --app` pre-vendors a starter set into\ncomponents/ui/ and `npx shadcn add @bb/` pulls more from the BB\ncomponent registry (the full stock shadcn set, version-matched to the\nrunning BB via the pinned ref in components.json). `import { toast } from\n\"sonner\"` reaches the host toaster; react, the portaling radix families,\nsonner, vaul, and @pierre/diffs (the app's syntax-highlighted diff\nrenderer) are runtime-shimmed (never bundled), everything else\nbundles from the plugin's node_modules (`npm install` for authors;\nconsumers install prebuilt dist). A crashing slot collapses to a\n\"plugin crashed\" chip without\ntouching the rest of the app. Installed plugins and their declared settings\n(same data as `bb plugin config`) also appear under Settings → Plugins.\n\nPlugin CLI commands: a plugin can register one top-level subcommand (for\nexample `bb github …`). Unknown `bb` commands are looked up against installed\nplugins and proxied to the server, so plugin commands work exactly like core\ncommands; core command names always win. Inside agent threads the generated\n`plugin-commands` skill lists the available plugin commands.\n\nSettings changes do not auto-reload a plugin — run `bb plugin reload `\nafter configuring. Add --json to plugin commands for machine-readable output.\n\nAuthoring a plugin\n\nThe loop: `bb plugin new ` scaffolds `./bb-plugin-` (add --app\nfor a frontend entry); `bb plugin install .` registers it; `bb plugin dev`\nwatches and reloads on every save. The manifest is package.json: `bb.server`\n(backend entry, loaded as TypeScript — no build step), optional `bb.app`\n(frontend entry), optional `bb.skills` (skills directories auto-imported\ninto agent threads; default `skills/`), and `engines.bb` (supported bb\nrange). The plugin id is the package name minus `bb-plugin-`.\n\nLogos: drop a logo.svg (or logo.png / logo.webp) in the plugin root and bb\nshows it wherever the plugin's contributions appear — the sidebar entry,\npanel title bar, composer command and @-mention menus, thread action\nbuttons, and Settings → Plugins. Optional `bb.logo` in the manifest\nrelocates the file (svg/png/webp only). An optional dark-theme variant —\nlogo-dark.svg/png/webp at the root, or `bb.logoDark` — is preferred while\nthe app is in dark mode. Without a logo bb falls back to the contribution's\nnamed icon. Reload the plugin to pick up logo changes.\n\nThe backend entry default-exports a factory receiving the full plugin API:\n\n import type { BbPluginApi } from \"@bb/plugin-sdk\";\n export default async function plugin(bb: BbPluginApi) { ... }\n\nThe import is type-only and erased at load; the scaffold ships the full API\nas bundled .d.ts in types/ (tsconfig maps @bb/plugin-sdk to them), so\n`npm install && npx tsc --noEmit` typechecks anywhere — no bb checkout\nneeded. Confused, or need a symbol the types don't explain? Clone the repo:\nhttps://github.com/ymichael/bb. The API in\none line each — bb.log (plugin-scoped logger behind `bb plugin logs`);\nbb.settings.define (declarative settings incl. secrets, editable via\n`bb plugin config`); bb.storage.kv (JSON rows ≤256KB) and\nbb.storage.sqlite()+migrate (the plugin's own database); bb.sdk (the full\nbb SDK — handlers/services only, not the factory; spawned threads are\nattributed to the plugin); bb.on (observe thread.created/idle/failed);\nbb.http.route (routes under /api/v1/plugins//http/* with\nlocal/token/none auth); bb.rpc.register (the frontend data plane);\nbb.realtime.publish (ephemeral signals to open app pages);\nbb.background.service (long-lived, AbortSignal, restart w/ backoff) and\nbb.background.schedule (durable cron rows); bb.cli.register (a top-level\n`bb ` command agents run through bash); bb.agents.registerTool\n(native tools with\nzod or JSON-schema parameters); bb.ui.registerThreadAction /\nregisterMentionProvider (host-rendered UI — no\nfrontend bundle needed); bb.status.needsConfiguration (report\n\"unconfigured\" instead of crashing); bb.onDispose (LIFO cleanup on\nreload/disable/shutdown).\n\nFrontend entries register React slots (homepageSection, navPanel,\nthreadPanelAction, composerAccessory) via definePluginApp, use the hooks\nlisted above, and render vendored components; styling is Tailwind against\nthe host theme's tokens only (semantic classes like bg-background and\ntw-animate-css utilities compile in plugin builds).\n\nFor the complete authoring reference — exact signatures, working snippets\nfor every surface, the reload lifecycle, testing tips, and gotchas — use\nthe built-in `bb-plugin-authoring` skill (agents: it loads on demand;\nhumans: apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/\nin a checkout). The `examples/plugins/` directory of a bb checkout has four\nreference plugins: github (full-stack: gh-CLI-backed issue/PR browser on\nvendored shadcn components), slack-bot (webhook bot), agent-enrichment\n(agent surfaces), small-ux-pack (host-rendered UI).", + "body": "Plugin commands\n\nA bb plugin is a TypeScript package that extends the bb server in-process:\nbackground services, cron schedules, HTTP/RPC endpoints, thread lifecycle\nhandlers, settings, storage — and `bb` CLI subcommands that agents and humans\nrun like any other command. Plugins are full-trust code inside the server.\n\nPlugins are an experiment, off by default: enable \"Plugins\" under Settings →\nExperiments first. Until then `bb plugin` commands report that plugins are\ndisabled. Plugin state lives under `/plugins//` (per-plugin\nSQLite file, secrets, logs).\n\n bb plugin install Install from a local path, git:@,\n or npm:@ (npm: needs npm on\n PATH; installs prompt — pass --yes to skip)\n bb plugin list Status, services, schedules, handler timings\n bb plugin enable|disable Load or unload an installed plugin\n bb plugin reload [id] Re-run factories against current sources\n bb plugin config [set | unset ]\n Show or change a plugin's declared settings\n bb plugin logs [-n N] [-f] Print (or follow) a plugin's bb.log output\n bb plugin run [args...] Run the plugin's CLI command explicitly\n bb plugin token [--rotate] Print the token for auth:\"token\" HTTP\n routes; --rotate generates a new token,\n invalidating the old one\n bb plugin remove Uninstall (managed git:/npm: files deleted)\n bb plugin new [--app] Scaffold a new plugin (no server required;\n --app adds a frontend entry, app.tsx, plus a\n typecheck-only tsconfig.json)\n bb plugin build [path] Compile the plugin into dist/ — the backend\n bundle (server.js, server.meta.json) and,\n when bb.app is declared, the frontend bundle\n (app.js, app.css, app.meta.json); no server\n required\n bb plugin dev [path] Watch a plugin's sources (default: cwd) and\n on every change rebuild its frontend bundle\n (if it declares bb.app) and reload the\n plugin; Ctrl+C to stop\n\nFrontend builds are automatic once installed: path and git installs compile\ndist/ at install time (a build failure fails the install), and the server\nrebuilds them at load after a bb upgrade. npm packages must publish a\nprebuilt dist/ (app.js + app.meta.json) or the install is refused.\n\nThe backend half is prebuilt too: when a git/npm install ships a\ndist/server.js built for the running SDK major, the server loads it instead\nof the TypeScript source — consumers never need npm or node_modules. Path\ninstalls always load server.ts from source, so `bb plugin dev`/reload see\nedits immediately.\n\n`bb plugin dev` is the edit loop: it requires the directory to already be\ninstalled as a plugin (`bb plugin install .` first), ignores dist/,\nnode_modules/, and .git/, batches saves, and prints one line per cycle. A\nbuild or reload failure prints the error and keeps watching (a failed build\nskips that cycle's reload). Reloads reach open app pages live — changed\nfrontend bundles re-import and their UI slots remount without a page\nrefresh.\n\nFrontend entries (app.tsx) default-export `definePluginApp` from\n`@bb/plugin-sdk/app` and register UI slots: homepageSection (root compose),\nnavPanel (own sidebar entry + /plugins///* route; the remainder\narrives as the component's subPath prop for panel-internal deep links),\nthreadPanelAction\n(an entry in the thread right panel's new-tab Actions list whose run() can\nopen closable panel tabs with JSON params), composerAccessory (prompt box\nfooter). Hooks:\nuseRpc, useRealtime, useSettings (secrets excluded), useBbContext,\nuseBbNavigate, and useComposer (quote selections / insert mention pills\ninto the chat composer draft). Components are vendored shadcn source the plugin owns (the\nshadcn model): `bb plugin new --app` pre-vendors a starter set into\ncomponents/ui/ and `npx shadcn add @bb/` pulls more from the BB\ncomponent registry (the full stock shadcn set, version-matched to the\nrunning BB via the pinned ref in components.json). `import { toast } from\n\"sonner\"` reaches the host toaster; react, the portaling radix families,\nsonner, vaul, and @pierre/diffs (the app's syntax-highlighted diff\nrenderer) are runtime-shimmed (never bundled), everything else\nbundles from the plugin's node_modules (`npm install` for authors;\nconsumers install prebuilt dist). A crashing slot collapses to a\n\"plugin crashed\" chip without\ntouching the rest of the app. Installed plugins and their declared settings\n(same data as `bb plugin config`) also appear under Settings → Plugins.\n\nPlugin CLI commands: a plugin can register one top-level subcommand (for\nexample `bb github …`). Unknown `bb` commands are looked up against installed\nplugins and proxied to the server, so plugin commands work exactly like core\ncommands; core command names always win. Inside agent threads the generated\n`plugin-commands` skill lists the available plugin commands.\n\nSettings changes do not auto-reload a plugin — run `bb plugin reload `\nafter configuring. Add --json to plugin commands for machine-readable output.\n\nAuthoring a plugin\n\nThe loop: `bb plugin new ` scaffolds `./bb-plugin-` (add --app\nfor a frontend entry); `bb plugin install .` registers it; `bb plugin dev`\nwatches and reloads on every save. The manifest is package.json: `bb.server`\n(backend entry, loaded as TypeScript — no build step), optional `bb.app`\n(frontend entry), optional `bb.skills` (skills directories auto-imported\ninto agent threads; default `skills/`), and `engines.bb` (supported bb\nrange). The plugin id is the package name minus `bb-plugin-`.\n\nLogos: drop a logo.svg (or logo.png / logo.webp) in the plugin root and bb\nshows it wherever the plugin's contributions appear — the sidebar entry,\npanel title bar, composer command and @-mention menus, thread action\nbuttons, and Settings → Plugins. Optional `bb.logo` in the manifest\nrelocates the file (svg/png/webp only). An optional dark-theme variant —\nlogo-dark.svg/png/webp at the root, or `bb.logoDark` — is preferred while\nthe app is in dark mode. Without a logo bb falls back to the contribution's\nnamed icon. Reload the plugin to pick up logo changes.\n\nThe backend entry default-exports a factory receiving the full plugin API:\n\n import type { BbPluginApi } from \"@bb/plugin-sdk\";\n export default async function plugin(bb: BbPluginApi) { ... }\n\nThe import is type-only and erased at load; the scaffold ships the full API\nas bundled .d.ts in types/ (tsconfig maps @bb/plugin-sdk to them), so\n`npm install && npx tsc --noEmit` typechecks anywhere — no bb checkout\nneeded. Confused, or need a symbol the types don't explain? Clone the repo:\nhttps://github.com/ymichael/bb. The API in\none line each — bb.log (plugin-scoped logger behind `bb plugin logs`);\nbb.settings.define (declarative settings incl. secrets, editable via\n`bb plugin config`); bb.storage.kv (JSON rows ≤256KB) and\nbb.storage.sqlite()+migrate (the plugin's own database); bb.sdk (the full\nbb SDK — handlers/services only, not the factory; spawned threads are\nattributed to the plugin); bb.on (observe thread.created/idle/failed);\nbb.http.route (routes under /api/v1/plugins//http/* with\nlocal/token/none auth); bb.rpc.register (the frontend data plane);\nbb.realtime.publish (ephemeral signals to open app pages);\nbb.background.service (long-lived, AbortSignal, restart w/ backoff) and\nbb.background.schedule (durable cron rows); bb.cli.register (a top-level\n`bb ` command agents run through bash); bb.agents.registerTool\n(native tools with\nzod or JSON-schema parameters); bb.ui.registerThreadAction /\nregisterMentionProvider (host-rendered UI — no\nfrontend bundle needed); bb.status.needsConfiguration (report\n\"unconfigured\" instead of crashing); bb.onDispose (LIFO cleanup on\nreload/disable/shutdown).\n\nFrontend entries register React slots (homepageSection, navPanel,\nthreadPanelAction, composerAccessory) via definePluginApp, use the hooks\nlisted above, and render vendored components; styling is Tailwind against\nthe host theme's tokens only (semantic classes like bg-background and\ntw-animate-css utilities compile in plugin builds).\n\nFor the complete authoring reference — exact signatures, working snippets\nfor every surface, the reload lifecycle, testing tips, and gotchas — use\nthe built-in `bb-plugin-authoring` skill (agents: it loads on demand;\nhumans: apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/\nin a checkout). The `examples/plugins/` directory of a bb checkout has four\nreference plugins: github (full-stack: gh-CLI-backed issue/PR browser on\nvendored shadcn components), slack-bot (webhook bot), agent-enrichment\n(agent surfaces), small-ux-pack (host-rendered UI).", "fileName": "bb-guide-plugins.md", "kind": "instruction", "title": "bb Guide — Plugins", diff --git a/packages/templates/src/templates/bb-guide-plugins.md b/packages/templates/src/templates/bb-guide-plugins.md index 6c7d2f647..29d18d902 100644 --- a/packages/templates/src/templates/bb-guide-plugins.md +++ b/packages/templates/src/templates/bb-guide-plugins.md @@ -72,7 +72,8 @@ threadPanelAction open closable panel tabs with JSON params), composerAccessory (prompt box footer). Hooks: useRpc, useRealtime, useSettings (secrets excluded), useBbContext, -useBbNavigate. Components are vendored shadcn source the plugin owns (the +useBbNavigate, and useComposer (quote selections / insert mention pills +into the chat composer draft). Components are vendored shadcn source the plugin owns (the shadcn model): `bb plugin new --app` pre-vendors a starter set into components/ui/ and `npx shadcn add @bb/` pulls more from the BB component registry (the full stock shadcn set, version-matched to the diff --git a/plans/plugin-system-design.md b/plans/plugin-system-design.md index 42038f35f..67f13b745 100644 --- a/plans/plugin-system-design.md +++ b/plans/plugin-system-design.md @@ -563,7 +563,10 @@ V1 slot set with **versioned per-slot props contracts** (additive-only within a Hooks from `@bb/plugin-sdk/app`: `useRpc()`, `useRealtime()`, `useSettings()` (secrets excluded), `useBbContext()` (current project/thread selection), -and `useBbNavigate()` with **typed helpers** (`toThread(id)`, `toPluginPanel(path)`) — no +and `useBbNavigate()` with **typed helpers** (`toThread(id)`, `toPluginPanel(path, +{ subPath?, replace? })`) — plus `useComposer()` *(added 2026-07-04)* for programmatic +composer-draft writes (addQuote / insertMention / focus, scope-resolved to the thread or +new-thread draft) — no guessed URL schemes. *(A host-provided UI kit — 65 shadcn-shaped component re-exports — shipped with Phase 3 and was REMOVED by decision 2026-07-03: it froze every component's props into a pinned compatibility surface, so any app component evolution became a From bd5cb58bf74131ba1536cf21cbe57d318cde2978 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Sat, 4 Jul 2026 09:11:29 -0700 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20fileOpener=20slot=20=E2=80=94=20plu?= =?UTF-8?q?ggable=20per-extension=20file=20openers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugins register app.slots.fileOpener({ id, title, extensions, component }); useThreadFileTabs.openTab diverts matching live-content opens (working tree, host, thread storage — never git-ref snapshots or deleted files) to a plugin-panel tab (file-opener: action ids, params persisted). A right-click "Open with" context menu on file tab pills switches viewers in place and pins a per-extension default (localStorage, builtin fallback when the opener is gone). Co-Authored-By: Claude Fable 5 --- .../components/plugin/PluginPanelActions.tsx | 68 +++++ .../src/components/plugin/file-opener-tabs.ts | 176 +++++++++++++ .../plugin/plugin-slot-mounts.test.tsx | 87 +++++++ .../components/plugin/useOpenWithTabMenu.ts | 233 ++++++++++++++++++ .../SecondaryPanelTabStrip.tsx | 36 ++- .../secondary-panel/secondaryPanelFileTab.ts | 11 + .../secondary-panel/useThreadFileTabs.test.ts | 190 ++++++++++++++ .../secondary-panel/useThreadFileTabs.ts | 46 +++- apps/app/src/lib/file-opener-preference.ts | 77 ++++++ .../app/src/lib/plugin-app-definition.test.ts | 40 +++ apps/app/src/lib/plugin-app-definition.ts | 32 +++ apps/app/src/lib/plugin-slots.test.ts | 1 + apps/app/src/lib/plugin-slots.ts | 11 + apps/app/src/views/RootComposeView.tsx | 15 +- .../views/thread-detail/ThreadDetailView.tsx | 10 + .../bb-plugin-authoring/SKILL.md | 14 ++ .../plugins/plugin-authoring-docs.test.ts | 3 + .../bundled-types/bb-plugin-sdk-app.d.ts | 38 ++- .../bundled-types/bb-plugin-sdk.d.ts | 40 ++- packages/plugin-sdk/src/app-contract.ts | 39 +++ .../src/generated/plugin-sdk-dts.generated.ts | 4 +- .../src/generated/templates.generated.ts | 2 +- .../src/templates/bb-guide-plugins.md | 3 +- plans/plugin-system-design.md | 1 + 24 files changed, 1165 insertions(+), 12 deletions(-) create mode 100644 apps/app/src/components/plugin/file-opener-tabs.ts create mode 100644 apps/app/src/components/plugin/useOpenWithTabMenu.ts create mode 100644 apps/app/src/lib/file-opener-preference.ts diff --git a/apps/app/src/components/plugin/PluginPanelActions.tsx b/apps/app/src/components/plugin/PluginPanelActions.tsx index f7a355744..ddea42b94 100644 --- a/apps/app/src/components/plugin/PluginPanelActions.tsx +++ b/apps/app/src/components/plugin/PluginPanelActions.tsx @@ -5,6 +5,10 @@ import { type PluginThreadPanelActionSlot, } from "@/lib/plugin-slots"; import type { PluginPanelFixedPanelTab } from "@/lib/fixed-panel-tabs-state"; +import { + fileOpenerIdFromActionId, + parseFileOpenerParams, +} from "./file-opener-tabs"; import { PluginSlotMount } from "./PluginSlotMount"; /** @@ -124,6 +128,20 @@ export function PluginPanelTabContent({ }: { tab: PluginPanelFixedPanelTab; threadId: string | null | undefined; +}) { + const openerId = fileOpenerIdFromActionId(tab.actionId); + if (openerId !== null) { + return ; + } + return ; +} + +function ActionTabContent({ + tab, + threadId, +}: { + tab: PluginPanelFixedPanelTab; + threadId: string | null | undefined; }) { const { threadPanelActions } = usePluginSlots(); const action = @@ -169,3 +187,53 @@ export function PluginPanelTabContent({ ); } + +/** + * A file diverted to a plugin `fileOpener` (see file-opener-tabs.ts). Same + * degrade rules as action tabs: missing opener/plugin or unparsable params + * render a placeholder, never a crash. + */ +function FileOpenerTabContent({ + openerId, + tab, +}: { + openerId: string; + tab: PluginPanelFixedPanelTab; +}) { + const { fileOpeners } = usePluginSlots(); + const opener = + fileOpeners.find( + (candidate) => + candidate.pluginId === tab.pluginId && candidate.id === openerId, + ) ?? null; + const file = useMemo( + () => parseFileOpenerParams(tab.paramsJson), + [tab.paramsJson], + ); + if (opener === null || file === null) { + return ( +
+ + This file opener is not available. The plugin may still be loading, + or it has been disabled or removed — reopen the file to use the + built-in preview. + +
+ ); + } + return ( +
+ + + +
+ ); +} diff --git a/apps/app/src/components/plugin/file-opener-tabs.ts b/apps/app/src/components/plugin/file-opener-tabs.ts new file mode 100644 index 000000000..94d500dc9 --- /dev/null +++ b/apps/app/src/components/plugin/file-opener-tabs.ts @@ -0,0 +1,176 @@ +import type { PluginFileOpenerProps, PluginFileOpenerSource } from "@bb/plugin-sdk"; +import { + createPluginPanelFixedPanelTab, + type PluginPanelFixedPanelTab, +} from "@/lib/fixed-panel-tabs-state"; +import { + resolvePreferredFileOpener, + type FileOpenerPreferenceMap, +} from "@/lib/file-opener-preference"; +import type { PluginFileOpenerSlot } from "@/lib/plugin-slots"; +import type { OpenSecondaryPanelTabRequest } from "@/components/secondary-panel/useThreadFileTabs"; + +/** + * Plugin file-opener tabs ride the existing `plugin-panel` tab kind: the + * action id carries this prefix + the opener's id, and `paramsJson` persists + * the opened file (`PluginFileOpenerProps`). Same identity semantics as + * action tabs — same opener + same file focuses the existing tab. + */ +export const FILE_OPENER_ACTION_ID_PREFIX = "file-opener:"; + +export function isFileOpenerPanelTab(tab: PluginPanelFixedPanelTab): boolean { + return tab.actionId.startsWith(FILE_OPENER_ACTION_ID_PREFIX); +} + +export function fileOpenerIdFromActionId(actionId: string): string | null { + return actionId.startsWith(FILE_OPENER_ACTION_ID_PREFIX) + ? actionId.slice(FILE_OPENER_ACTION_ID_PREFIX.length) + : null; +} + +export function buildFileOpenerPanelTab( + opener: Pick, + file: PluginFileOpenerProps, +): PluginPanelFixedPanelTab { + return createPluginPanelFixedPanelTab({ + actionId: `${FILE_OPENER_ACTION_ID_PREFIX}${opener.id}`, + paramsJson: JSON.stringify({ path: file.path, source: file.source }), + pluginId: opener.pluginId, + title: file.path.split("/").at(-1) ?? file.path, + }); +} + +/** Parse a persisted opener tab's params; null on any mismatch (degrade). */ +export function parseFileOpenerParams( + paramsJson: string | null, +): PluginFileOpenerProps | null { + if (paramsJson === null) return null; + let parsed: unknown; + try { + parsed = JSON.parse(paramsJson); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const { path, source } = parsed as { path?: unknown; source?: unknown }; + if (typeof path !== "string" || path.length === 0) return null; + if (typeof source !== "object" || source === null) return null; + const { kind, threadId, environmentId, projectId } = source as { + kind?: unknown; + threadId?: unknown; + environmentId?: unknown; + projectId?: unknown; + }; + if (kind !== "workspace" && kind !== "host" && kind !== "thread-storage") { + return null; + } + return { + path, + source: { + kind, + threadId: typeof threadId === "string" ? threadId : null, + environmentId: typeof environmentId === "string" ? environmentId : null, + projectId: typeof projectId === "string" ? projectId : null, + }, + }; +} + +export interface CreateFileOpenerTabForRequestArgs { + fileOpeners: readonly PluginFileOpenerSlot[]; + preference: FileOpenerPreferenceMap; + projectId: string | null; + request: OpenSecondaryPanelTabRequest; + resolvedEnvironmentId: string | null | undefined; + threadId: string | null | undefined; +} + +/** + * The plugin-opener tab a file-open request should divert to, or null for + * the built-in path. Diversion applies only to live file content — working + * tree, host, and thread-storage previews; git-ref snapshots and deleted + * files always use the built-in preview. + */ +export function createFileOpenerTabForRequest({ + fileOpeners, + preference, + projectId, + request, + resolvedEnvironmentId, + threadId, +}: CreateFileOpenerTabForRequestArgs): PluginPanelFixedPanelTab | null { + const file = fileForOpenRequest({ + projectId, + request, + resolvedEnvironmentId, + threadId, + }); + if (file === null) return null; + const opener = resolvePreferredFileOpener({ + openers: fileOpeners, + preference, + path: file.path, + }); + if (opener === null) return null; + return buildFileOpenerPanelTab(opener, file); +} + +function fileForOpenRequest({ + projectId, + request, + resolvedEnvironmentId, + threadId, +}: Omit): + | PluginFileOpenerProps + | null { + switch (request.kind) { + case "workspace-file-preview": { + // Same guard as the built-in path, plus live-content-only rules. + if (resolvedEnvironmentId === undefined) return null; + if (request.tab.source.kind !== "working-tree") return null; + if (request.tab.statusLabel === "deleted") return null; + return { + path: request.tab.path, + source: buildSource("workspace", { + environmentId: resolvedEnvironmentId, + projectId: resolvedEnvironmentId === null ? projectId : null, + threadId: threadId ?? null, + }), + }; + } + case "host-file-preview": { + if (!threadId || !resolvedEnvironmentId) return null; + return { + path: request.tab.path, + source: buildSource("host", { + environmentId: resolvedEnvironmentId, + projectId: null, + threadId, + }), + }; + } + case "thread-storage-file-preview": { + if (!threadId) return null; + return { + path: request.tab.path, + source: buildSource("thread-storage", { + environmentId: resolvedEnvironmentId ?? null, + projectId: null, + threadId, + }), + }; + } + default: + return null; + } +} + +function buildSource( + kind: PluginFileOpenerSource["kind"], + fields: { + environmentId: string | null; + projectId: string | null; + threadId: string | null; + }, +): PluginFileOpenerSource { + return { kind, ...fields }; +} diff --git a/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx b/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx index 3f0eaea50..debcbb6aa 100644 --- a/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx +++ b/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx @@ -47,6 +47,7 @@ function registrationSet( navPanels: [], threadPanelActions: [], composerAccessories: [], + fileOpeners: [], ...overrides, }; } @@ -683,3 +684,89 @@ describe("plugin thread panel actions", () => { expect(screen.getByText(/This plugin tab is not available/)).toBeDefined(); }); }); + +describe("plugin file opener tabs", () => { + function MarkdownEditorProbe({ + path, + source, + }: { + path: string; + source: { kind: string; environmentId: string | null }; + }) { + return ( +
+ editor {path} @ {source.kind}:{String(source.environmentId)} +
+ ); + } + + it("renders the opener component with parsed path + source", () => { + setPluginSlotRegistrations( + "notes", + registrationSet({ + fileOpeners: [ + { + id: "editor", + title: "Notes editor", + extensions: ["md"], + component: MarkdownEditorProbe, + }, + ], + }), + ); + const tab = createPluginPanelFixedPanelTab({ + actionId: "file-opener:editor", + paramsJson: JSON.stringify({ + path: "notes/todo.md", + source: { + kind: "workspace", + threadId: null, + environmentId: "env_1", + projectId: null, + }, + }), + pluginId: "notes", + title: "todo.md", + }); + render(); + expect( + screen.getByText("editor notes/todo.md @ workspace:env_1"), + ).toBeDefined(); + }); + + it("degrades to a placeholder when the opener is gone or params are junk", () => { + const orphanTab = createPluginPanelFixedPanelTab({ + actionId: "file-opener:gone", + paramsJson: JSON.stringify({ path: "a.md", source: { kind: "workspace" } }), + pluginId: "ghost", + title: "a.md", + }); + const { unmount } = render( + , + ); + expect(screen.getByText(/file opener is not available/)).toBeDefined(); + unmount(); + + setPluginSlotRegistrations( + "notes", + registrationSet({ + fileOpeners: [ + { + id: "editor", + title: "Notes editor", + extensions: ["md"], + component: MarkdownEditorProbe, + }, + ], + }), + ); + const junkParamsTab = createPluginPanelFixedPanelTab({ + actionId: "file-opener:editor", + paramsJson: "not json", + pluginId: "notes", + title: "junk", + }); + render(); + expect(screen.getByText(/file opener is not available/)).toBeDefined(); + }); +}); diff --git a/apps/app/src/components/plugin/useOpenWithTabMenu.ts b/apps/app/src/components/plugin/useOpenWithTabMenu.ts new file mode 100644 index 000000000..5a2e5fb45 --- /dev/null +++ b/apps/app/src/components/plugin/useOpenWithTabMenu.ts @@ -0,0 +1,233 @@ +import { useCallback } from "react"; +import type { PluginFileOpenerProps } from "@bb/plugin-sdk"; +import { + createHostFilePreviewFixedPanelTab, + createThreadStorageFilePreviewFixedPanelTab, + createWorkspaceFilePreviewFixedPanelTab, + type FixedPanelTab, +} from "@/lib/fixed-panel-tabs-state"; +import { + buildFileOpenerRef, + findFileOpenersForPath, + getFileExtension, + resolvePreferredFileOpener, + useFileOpenerPreference, +} from "@/lib/file-opener-preference"; +import { usePluginSlots } from "@/lib/plugin-slots"; +import type { SecondaryPanelTabMenuItem } from "@/components/secondary-panel/secondaryPanelFileTab"; +import { + buildFileOpenerPanelTab, + fileOpenerIdFromActionId, + parseFileOpenerParams, +} from "./file-opener-tabs"; + +/** + * The "Open with" context menu for file tabs: switch the tab between the + * built-in preview and registered plugin `fileOpener`s, and pin the current + * viewer as the extension's default. Returns undefined (no menu) for tabs + * that aren't live file content or have no alternative viewer. + */ + +interface OpenWithFile { + file: PluginFileOpenerProps; + /** null = the built-in preview. */ + currentOpenerRef: string | null; +} + +function fileFromTab(tab: FixedPanelTab): OpenWithFile | null { + switch (tab.kind) { + case "workspace-file-preview": + // Live working-tree content only — ref snapshots and deleted files + // stay on the built-in preview (nothing on disk to edit). + if (tab.source.kind !== "working-tree" || tab.statusLabel === "deleted") { + return null; + } + return { + currentOpenerRef: null, + file: { + path: tab.path, + source: { + kind: "workspace", + threadId: null, + environmentId: tab.environmentId, + projectId: tab.projectId, + }, + }, + }; + case "host-file-preview": + return { + currentOpenerRef: null, + file: { + path: tab.path, + source: { + kind: "host", + threadId: tab.threadId, + environmentId: tab.environmentId, + projectId: null, + }, + }, + }; + case "thread-storage-file-preview": + if (tab.isPinned) return null; + return { + currentOpenerRef: null, + file: { + path: tab.path, + source: { + kind: "thread-storage", + threadId: tab.threadId, + environmentId: tab.environmentId, + projectId: null, + }, + }, + }; + case "plugin-panel": { + const openerId = fileOpenerIdFromActionId(tab.actionId); + if (openerId === null) return null; + const file = parseFileOpenerParams(tab.paramsJson); + if (file === null) return null; + return { + currentOpenerRef: buildFileOpenerRef({ + pluginId: tab.pluginId, + id: openerId, + }), + file, + }; + } + default: + return null; + } +} + +function buildBuiltinTab(file: PluginFileOpenerProps): FixedPanelTab | null { + const { path, source } = file; + switch (source.kind) { + case "workspace": + return createWorkspaceFilePreviewFixedPanelTab({ + environmentId: source.environmentId, + projectId: source.projectId, + tab: { + lineRange: null, + path, + source: { kind: "working-tree" }, + statusLabel: null, + }, + }); + case "host": + if (source.threadId === null || source.environmentId === null) { + return null; + } + return createHostFilePreviewFixedPanelTab({ + environmentId: source.environmentId, + tab: { lineRange: null, path }, + threadId: source.threadId, + }); + case "thread-storage": + if (source.threadId === null) return null; + return createThreadStorageFilePreviewFixedPanelTab({ + environmentId: source.environmentId, + isPinned: false, + tab: { lineRange: null, path }, + threadId: source.threadId, + }); + } +} + +export type BuildTabMenuItems = ( + tab: FixedPanelTab, +) => readonly SecondaryPanelTabMenuItem[] | undefined; + +export function useOpenWithTabMenu({ + replaceTab, +}: { + replaceTab: (args: { fromTabId: string; toTab: FixedPanelTab }) => void; +}): BuildTabMenuItems { + const { fileOpeners } = usePluginSlots(); + const [preference, setPreference] = useFileOpenerPreference(); + + return useCallback( + (tab) => { + const resolved = fileFromTab(tab); + if (resolved === null) return undefined; + const { file, currentOpenerRef } = resolved; + const matchingOpeners = findFileOpenersForPath(fileOpeners, file.path); + // No menu when the built-in preview is the only possible viewer. + if (matchingOpeners.length === 0 && currentOpenerRef === null) { + return undefined; + } + + const items: SecondaryPanelTabMenuItem[] = []; + if (currentOpenerRef !== null) { + const builtinTab = buildBuiltinTab(file); + if (builtinTab !== null) { + items.push({ + id: "open-with:builtin", + label: "Open with built-in preview", + onSelect: () => + replaceTab({ fromTabId: tab.id, toTab: builtinTab }), + }); + } + } + for (const opener of matchingOpeners) { + const openerRef = buildFileOpenerRef(opener); + if (openerRef === currentOpenerRef) continue; + items.push({ + id: `open-with:${openerRef}`, + label: `Open with ${opener.title}`, + onSelect: () => + replaceTab({ + fromTabId: tab.id, + toTab: buildFileOpenerPanelTab(opener, file), + }), + }); + } + + const extension = getFileExtension(file.path); + if (extension !== null) { + const defaultOpener = resolvePreferredFileOpener({ + openers: fileOpeners, + preference, + path: file.path, + }); + const defaultRef = + defaultOpener === null ? null : buildFileOpenerRef(defaultOpener); + const currentOpener = + currentOpenerRef === null + ? null + : (matchingOpeners.find( + (opener) => buildFileOpenerRef(opener) === currentOpenerRef, + ) ?? null); + const currentTitle = + currentOpenerRef === null + ? "built-in preview" + : (currentOpener?.title ?? "this opener"); + // Only offer pinning viewers that are actually registered. + if (currentOpenerRef === null || currentOpener !== null) { + items.push({ + id: "open-with:set-default", + label: `Always open .${extension} with ${currentTitle}`, + checked: defaultRef === currentOpenerRef, + onSelect: () => + setPreference((previous) => { + const next = { ...previous }; + if ( + currentOpenerRef === null || + defaultRef === currentOpenerRef + ) { + // Built-in is the implicit default; unchecking an opener + // default also reverts to it. + delete next[extension]; + } else { + next[extension] = currentOpenerRef; + } + return next; + }), + }); + } + } + + return items.length > 0 ? items : undefined; + }, + [fileOpeners, preference, replaceTab, setPreference], + ); +} diff --git a/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.tsx b/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.tsx index ebdd53d1a..3a3bf76c9 100644 --- a/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.tsx +++ b/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.tsx @@ -10,6 +10,13 @@ import { useState, } from "react"; import { createPortal } from "react-dom"; +import { + ContextMenu, + ContextMenuCheckboxItem, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; import { closestCenter, DndContext, @@ -538,7 +545,7 @@ function FileTab({ tab }: { tab: SecondaryPanelFileTab }) { tab.statusLabel === null ? tab.filename : `${tab.filename} (${tab.statusLabel})`; - return ( + const pill = ( ); + if (tab.menuItems === undefined || tab.menuItems.length === 0) { + return pill; + } + return ( + + +
{pill}
+
+ + {tab.menuItems.map((item) => + item.checked === undefined ? ( + + {item.label} + + ) : ( + + {item.label} + + ), + )} + +
+ ); } diff --git a/apps/app/src/components/secondary-panel/secondaryPanelFileTab.ts b/apps/app/src/components/secondary-panel/secondaryPanelFileTab.ts index d6bf7f1d3..bf6276ddb 100644 --- a/apps/app/src/components/secondary-panel/secondaryPanelFileTab.ts +++ b/apps/app/src/components/secondary-panel/secondaryPanelFileTab.ts @@ -9,6 +9,15 @@ export type SecondaryPanelTabReorderHandler = ( request: SecondaryPanelTabReorderRequest, ) => void; +/** One entry in a tab's right-click context menu (e.g. "Open with…"). */ +export interface SecondaryPanelTabMenuItem { + id: string; + label: string; + /** Renders a check-style item reflecting this state when set. */ + checked?: boolean; + onSelect: () => void; +} + /** * A single closable tab rendered in the right panel's scrolling tab strip. */ @@ -20,6 +29,8 @@ export interface SecondaryPanelFileTab { isPinned?: boolean; leadingVisual: ReactNode; statusLabel: string | null; + /** Right-click context menu; omitted = no menu. */ + menuItems?: readonly SecondaryPanelTabMenuItem[]; onSelect: () => void; onClose: () => void; } diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts index 336c42188..fc68f45c2 100644 --- a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts +++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts @@ -13,6 +13,10 @@ import { FIXED_PANEL_TABS_STATE_STORAGE_VERSION, } from "@/lib/fixed-panel-tabs-state"; import { useThreadFileTabs } from "./useThreadFileTabs"; +import { + resetPluginSlotStoreForTest, + setPluginSlotRegistrations, +} from "@/lib/plugin-slots"; type TerminalSessionOverrides = Partial; @@ -41,6 +45,7 @@ function terminalSession( afterEach(() => { cleanup(); window.localStorage.clear(); + resetPluginSlotStoreForTest(); }); describe("useThreadFileTabs terminal pruning", () => { @@ -338,3 +343,188 @@ describe("useThreadFileTabs plugin panel tabs", () => { ).toEqual(["plugin-panel"]); }); }); + +describe("useThreadFileTabs file opener diversion", () => { + function NotesEditor() { + return null; + } + + function registerNotesOpener() { + setPluginSlotRegistrations("notes", { + homepageSections: [], + navPanels: [], + threadPanelActions: [], + composerAccessories: [], + fileOpeners: [ + { + id: "editor", + title: "Notes editor", + extensions: ["md"], + component: NotesEditor, + }, + ], + }); + } + + function setDefaultOpener() { + window.localStorage.setItem( + "bb.fileOpenerByExtension", + JSON.stringify({ md: "notes:editor" }), + ); + } + + it("diverts working-tree markdown opens to the preferred opener tab", () => { + registerNotesOpener(); + setDefaultOpener(); + const { result } = renderHook(() => + useThreadFileTabs({ + threadId: "opener-divert", + environmentId: "env_1", + storageFiles: undefined, + terminalSessions: undefined, + }), + ); + + act(() => + result.current.openTab({ + kind: "workspace-file-preview", + tab: { + lineRange: null, + path: "notes/todo.md", + source: { kind: "working-tree" }, + statusLabel: null, + }, + }), + ); + + expect(result.current.activePluginPanelTab).toMatchObject({ + kind: "plugin-panel", + pluginId: "notes", + actionId: "file-opener:editor", + title: "todo.md", + }); + const params = JSON.parse( + result.current.activePluginPanelTab?.paramsJson ?? "null", + ) as { path: string; source: { kind: string; environmentId: string | null } }; + expect(params.path).toBe("notes/todo.md"); + expect(params.source).toMatchObject({ + kind: "workspace", + environmentId: "env_1", + }); + }); + + it("keeps the built-in preview for ref snapshots and unmatched extensions", () => { + registerNotesOpener(); + setDefaultOpener(); + const { result } = renderHook(() => + useThreadFileTabs({ + threadId: "opener-skip", + environmentId: "env_1", + storageFiles: undefined, + terminalSessions: undefined, + }), + ); + + // A git-ref snapshot never diverts, even for a matching extension. + act(() => + result.current.openTab({ + kind: "workspace-file-preview", + tab: { + lineRange: null, + path: "notes/todo.md", + source: { kind: "head" }, + statusLabel: null, + }, + }), + ); + expect(result.current.activePluginPanelTab).toBeNull(); + expect(result.current.activeWorkspaceFilePath).toBe("notes/todo.md"); + + // Unmatched extension stays built-in too. + act(() => + result.current.openTab({ + kind: "workspace-file-preview", + tab: { + lineRange: null, + path: "src/index.ts", + source: { kind: "working-tree" }, + statusLabel: null, + }, + }), + ); + expect(result.current.activePluginPanelTab).toBeNull(); + expect(result.current.activeWorkspaceFilePath).toBe("src/index.ts"); + }); + + it("falls back to the built-in preview when the preferred opener is gone", () => { + // Preference points at an opener that is not registered (plugin removed). + setDefaultOpener(); + const { result } = renderHook(() => + useThreadFileTabs({ + threadId: "opener-gone", + environmentId: "env_1", + storageFiles: undefined, + terminalSessions: undefined, + }), + ); + + act(() => + result.current.openTab({ + kind: "workspace-file-preview", + tab: { + lineRange: null, + path: "notes/todo.md", + source: { kind: "working-tree" }, + statusLabel: null, + }, + }), + ); + expect(result.current.activePluginPanelTab).toBeNull(); + expect(result.current.activeWorkspaceFilePath).toBe("notes/todo.md"); + }); + + it("replaces a tab in place for Open with switches", () => { + registerNotesOpener(); + const { result } = renderHook(() => + useThreadFileTabs({ + threadId: "opener-switch", + environmentId: "env_1", + storageFiles: undefined, + terminalSessions: undefined, + }), + ); + + // Open built-in (no default set), then switch to the opener tab. + act(() => + result.current.openTab({ + kind: "workspace-file-preview", + tab: { + lineRange: null, + path: "notes/todo.md", + source: { kind: "working-tree" }, + statusLabel: null, + }, + }), + ); + const builtinTabId = result.current.orderedSecondaryFileTabs[0]?.id; + expect(builtinTabId).toBeDefined(); + + act(() => + result.current.replaceSecondaryPanelTab({ + fromTabId: builtinTabId ?? "", + toTab: { + kind: "plugin-panel", + id: "fixed:plugin-panel:x:none", + pluginId: "notes", + actionId: "file-opener:editor", + title: "todo.md", + paramsJson: "{}", + }, + }), + ); + expect(result.current.orderedSecondaryFileTabs).toHaveLength(1); + expect(result.current.activePluginPanelTab?.actionId).toBe( + "file-opener:editor", + ); + }); +}); diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts index c7256380d..52b6def33 100644 --- a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts +++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts @@ -20,6 +20,9 @@ import { type ThreadStorageFilePreviewFixedPanelTab, type WorkspaceFilePreviewFixedPanelTab, } from "@/lib/fixed-panel-tabs-state"; +import { usePluginSlots } from "@/lib/plugin-slots"; +import { useFileOpenerPreferenceValue } from "@/lib/file-opener-preference"; +import { createFileOpenerTabForRequest } from "@/components/plugin/file-opener-tabs"; import type { OpenPluginPanelArgs } from "@/components/plugin/PluginPanelActions"; import type { HostFileTabState, @@ -405,14 +408,31 @@ export function useThreadFileTabs({ updateFixedPanelTabsState, ]); + const { fileOpeners } = usePluginSlots(); + const fileOpenerPreference = useFileOpenerPreferenceValue(); + const openTab = useCallback( (request: OpenSecondaryPanelTabRequest) => { - const tab = createTabForOpenRequest({ + // Default-opener diversion (plugin design §5.2): every file-open flow + // funnels through here (links, file search, `bb thread open`), so a + // preferred plugin opener applies uniformly. Falls through to the + // built-in tab when no opener matches. + const openerTab = createFileOpenerTabForRequest({ + fileOpeners, + preference: fileOpenerPreference, projectId, request, resolvedEnvironmentId, threadId: resolvedFileOwnerThreadId, }); + const tab = + openerTab ?? + createTabForOpenRequest({ + projectId, + request, + resolvedEnvironmentId, + threadId: resolvedFileOwnerThreadId, + }); if (tab === null) return; if ( @@ -433,6 +453,8 @@ export function useThreadFileTabs({ }); }, [ + fileOpenerPreference, + fileOpeners, recordRecentItem, projectId, resolvedEnvironmentId, @@ -441,6 +463,27 @@ export function useThreadFileTabs({ ], ); + // "Open with…" switches a file tab's viewer in place: activate/insert the + // replacement, then drop the original (identity-keyed tabs mean the + // replacement may already exist — it is focused instead of duplicated). + const replaceSecondaryPanelTab = useCallback( + ({ fromTabId, toTab }: { fromTabId: string; toTab: FixedPanelTab }) => { + updateFixedPanelTabsState((state) => { + const opened = openSecondaryPanelTabInState({ state, tab: toTab }); + const tabs = opened.secondary.tabs.filter( + (candidate) => candidate.id !== fromTabId || candidate.id === toTab.id, + ); + return setSecondaryPanelTabsInState({ + activeTabId: toTab.id, + isOpen: opened.secondary.isOpen, + state: opened, + tabs, + }); + }); + }, + [updateFixedPanelTabsState], + ); + const activateTab = useCallback( (tabId: string) => { updateFixedPanelTabsState((state) => @@ -722,6 +765,7 @@ export function useThreadFileTabs({ openExistingSideChatTab, openTab, orderedSecondaryFileTabs, + replaceSecondaryPanelTab, reorderFileTab, selectFileSearchResult, setSideChatThreadId, diff --git a/apps/app/src/lib/file-opener-preference.ts b/apps/app/src/lib/file-opener-preference.ts new file mode 100644 index 000000000..bd76fff41 --- /dev/null +++ b/apps/app/src/lib/file-opener-preference.ts @@ -0,0 +1,77 @@ +import { atomWithStorage } from "jotai/utils"; +import { useAtom, useAtomValue } from "jotai"; +import type { PluginFileOpenerSlot } from "./plugin-slots"; +import { createJsonLocalStorage } from "./browser-storage"; + +/** + * Default file opener per extension: `"" → ":"`. + * Extensions absent from the map (or pointing at an opener that is no longer + * registered) fall back to the built-in preview — a removed plugin can never + * dead-end file opening. Stored client-side like the other view preferences + * (see workspace-open-target-preference.ts). + */ +export type FileOpenerPreferenceMap = Record; + +const FILE_OPENER_PREFERENCE_STORAGE_KEY = "bb.fileOpenerByExtension"; + +const fileOpenerPreferenceAtom = atomWithStorage( + FILE_OPENER_PREFERENCE_STORAGE_KEY, + {}, + createJsonLocalStorage(), + { getOnInit: true }, +); + +export function useFileOpenerPreference() { + return useAtom(fileOpenerPreferenceAtom); +} + +export function useFileOpenerPreferenceValue(): FileOpenerPreferenceMap { + return useAtomValue(fileOpenerPreferenceAtom); +} + +/** Lowercased extension without the dot; null when the name has none. */ +export function getFileExtension(path: string): string | null { + const name = path.split("/").at(-1) ?? path; + const dotIndex = name.lastIndexOf("."); + if (dotIndex <= 0 || dotIndex === name.length - 1) return null; + return name.slice(dotIndex + 1).toLowerCase(); +} + +export function buildFileOpenerRef(opener: { + pluginId: string; + id: string; +}): string { + return `${opener.pluginId}:${opener.id}`; +} + +export function findFileOpenersForPath( + openers: readonly PluginFileOpenerSlot[], + path: string, +): PluginFileOpenerSlot[] { + const extension = getFileExtension(path); + if (extension === null) return []; + return openers.filter((opener) => opener.extensions.includes(extension)); +} + +/** + * The opener the given path should open with, or null for the built-in + * preview (no preference, an unknown extension, or a preferred opener that + * is no longer registered). + */ +export function resolvePreferredFileOpener(args: { + openers: readonly PluginFileOpenerSlot[]; + preference: FileOpenerPreferenceMap; + path: string; +}): PluginFileOpenerSlot | null { + const extension = getFileExtension(args.path); + if (extension === null) return null; + const preferredRef = args.preference[extension]; + if (preferredRef === undefined) return null; + return ( + args.openers.find( + (opener) => + buildFileOpenerRef(opener) === preferredRef && + opener.extensions.includes(extension), + ) ?? null + ); +} diff --git a/apps/app/src/lib/plugin-app-definition.test.ts b/apps/app/src/lib/plugin-app-definition.test.ts index d33bc6898..712579711 100644 --- a/apps/app/src/lib/plugin-app-definition.test.ts +++ b/apps/app/src/lib/plugin-app-definition.test.ts @@ -52,6 +52,12 @@ describe("collectPluginAppRegistrations", () => { run, }); app.slots.composerAccessory({ id: "picker", component: Component }); + app.slots.fileOpener({ + id: "editor", + title: "Notes editor", + extensions: ["md", "mdx"], + component: Component, + }); }); const registrations = collectPluginAppRegistrations(definition); @@ -75,6 +81,14 @@ describe("collectPluginAppRegistrations", () => { expect(registrations.composerAccessories).toEqual([ { id: "picker", component: Component }, ]); + expect(registrations.fileOpeners).toEqual([ + { + id: "editor", + title: "Notes editor", + extensions: ["md", "mdx"], + component: Component, + }, + ]); }); it.each([ @@ -113,6 +127,32 @@ describe("collectPluginAppRegistrations", () => { }), /"path" must match/, ], + [ + "file opener with no extensions", + () => + definePluginApp((app) => { + app.slots.fileOpener({ + id: "x", + title: "X", + extensions: [], + component: Component, + }); + }), + /"extensions" must be a non-empty array/, + ], + [ + "file opener with a dotted extension", + () => + definePluginApp((app) => { + app.slots.fileOpener({ + id: "x", + title: "X", + extensions: [".md"], + component: Component, + }); + }), + /extensions must be lowercase alphanumerics/, + ], [ "thread panel action with a non-function run", () => diff --git a/apps/app/src/lib/plugin-app-definition.ts b/apps/app/src/lib/plugin-app-definition.ts index a2ce5d20c..92dbffa2c 100644 --- a/apps/app/src/lib/plugin-app-definition.ts +++ b/apps/app/src/lib/plugin-app-definition.ts @@ -3,6 +3,7 @@ import { type PluginAppDefinition, type PluginAppSetup, type PluginComposerAccessoryRegistration, + type PluginFileOpenerRegistration, type PluginHomepageSectionRegistration, type PluginNavPanelRegistration, type PluginThreadPanelActionRegistration, @@ -84,11 +85,13 @@ export function collectPluginAppRegistrations( const navPanels: PluginNavPanelRegistration[] = []; const threadPanelActions: PluginThreadPanelActionRegistration[] = []; const composerAccessories: PluginComposerAccessoryRegistration[] = []; + const fileOpeners: PluginFileOpenerRegistration[] = []; const seenIds = { homepageSection: new Set(), navPanel: new Set(), threadPanelAction: new Set(), composerAccessory: new Set(), + fileOpener: new Set(), }; definition.setup({ @@ -171,6 +174,34 @@ export function collectPluginAppRegistrations( component: requireComponent(kind, registration.component), }); }, + fileOpener(registration) { + const kind = "slots.fileOpener"; + const id = requireSlotId(kind, registration?.id); + requireUniqueId(kind, seenIds.fileOpener, id); + const rawExtensions = registration?.extensions; + if (!Array.isArray(rawExtensions) || rawExtensions.length === 0) { + throw new Error( + `${kind}: "extensions" must be a non-empty array of lowercase extensions without the dot`, + ); + } + const extensions = rawExtensions.map((extension) => { + if ( + typeof extension !== "string" || + !/^[a-z0-9]+$/.test(extension) + ) { + throw new Error( + `${kind}: extensions must be lowercase alphanumerics without the dot, got ${JSON.stringify(extension)}`, + ); + } + return extension; + }); + fileOpeners.push({ + id, + title: requireNonEmptyString(kind, "title", registration.title), + extensions, + component: requireComponent(kind, registration.component), + }); + }, }, }); @@ -179,6 +210,7 @@ export function collectPluginAppRegistrations( navPanels, threadPanelActions, composerAccessories, + fileOpeners, }; } diff --git a/apps/app/src/lib/plugin-slots.test.ts b/apps/app/src/lib/plugin-slots.test.ts index 3608880a9..1e55d3bb8 100644 --- a/apps/app/src/lib/plugin-slots.test.ts +++ b/apps/app/src/lib/plugin-slots.test.ts @@ -24,6 +24,7 @@ function registrationSet( navPanels: [], threadPanelActions: [], composerAccessories: [], + fileOpeners: [], ...overrides, }; } diff --git a/apps/app/src/lib/plugin-slots.ts b/apps/app/src/lib/plugin-slots.ts index a5b5ba708..3b3fb8433 100644 --- a/apps/app/src/lib/plugin-slots.ts +++ b/apps/app/src/lib/plugin-slots.ts @@ -1,6 +1,7 @@ import { useSyncExternalStore } from "react"; import type { PluginComposerAccessoryRegistration, + PluginFileOpenerRegistration, PluginHomepageSectionRegistration, PluginNavPanelRegistration, PluginThreadPanelActionRegistration, @@ -19,6 +20,7 @@ export interface PluginRegistrationSet { navPanels: readonly PluginNavPanelRegistration[]; threadPanelActions: readonly PluginThreadPanelActionRegistration[]; composerAccessories: readonly PluginComposerAccessoryRegistration[]; + fileOpeners: readonly PluginFileOpenerRegistration[]; } interface PluginSlotBase { @@ -40,6 +42,8 @@ export interface PluginThreadPanelActionSlot extends PluginThreadPanelActionRegistration, PluginSlotBase {} export interface PluginComposerAccessorySlot extends PluginComposerAccessoryRegistration, PluginSlotBase {} +export interface PluginFileOpenerSlot + extends PluginFileOpenerRegistration, PluginSlotBase {} /** Flattened view across plugins, ordered by plugin id (deterministic). */ export interface PluginSlotSnapshot { @@ -47,6 +51,7 @@ export interface PluginSlotSnapshot { navPanels: readonly PluginNavPanelSlot[]; threadPanelActions: readonly PluginThreadPanelActionSlot[]; composerAccessories: readonly PluginComposerAccessorySlot[]; + fileOpeners: readonly PluginFileOpenerSlot[]; } export const EMPTY_PLUGIN_SLOT_SNAPSHOT: PluginSlotSnapshot = { @@ -54,6 +59,7 @@ export const EMPTY_PLUGIN_SLOT_SNAPSHOT: PluginSlotSnapshot = { navPanels: [], threadPanelActions: [], composerAccessories: [], + fileOpeners: [], }; const registrationsByPluginId = new Map(); @@ -68,11 +74,13 @@ function buildSnapshot(): PluginSlotSnapshot { navPanels: PluginNavPanelSlot[]; threadPanelActions: PluginThreadPanelActionSlot[]; composerAccessories: PluginComposerAccessorySlot[]; + fileOpeners: PluginFileOpenerSlot[]; } = { homepageSections: [], navPanels: [], threadPanelActions: [], composerAccessories: [], + fileOpeners: [], }; for (const pluginId of pluginIds) { const set = registrationsByPluginId.get(pluginId); @@ -90,6 +98,9 @@ function buildSnapshot(): PluginSlotSnapshot { for (const registration of set.composerAccessories) { next.composerAccessories.push({ ...registration, pluginId, generation }); } + for (const registration of set.fileOpeners) { + next.fileOpeners.push({ ...registration, pluginId, generation }); + } } return next; } diff --git a/apps/app/src/views/RootComposeView.tsx b/apps/app/src/views/RootComposeView.tsx index 5655f8658..6340442a3 100644 --- a/apps/app/src/views/RootComposeView.tsx +++ b/apps/app/src/views/RootComposeView.tsx @@ -68,6 +68,7 @@ import { usePointerCoarse } from "@/components/ui/hooks/use-pointer-coarse.js"; import { COARSE_POINTER_COMPACT_ICON_SIZE_CLASS } from "@/components/ui/coarse-pointer-sizing.js"; import { PluginIcon } from "@/components/plugin/PluginIcon"; import { PluginPanelTabContent } from "@/components/plugin/PluginPanelActions"; +import { useOpenWithTabMenu } from "@/components/plugin/useOpenWithTabMenu"; import { useUploadPromptAttachment } from "@/hooks/mutations/project-mutations"; import { useCreateThread } from "@/hooks/mutations/thread-runtime-mutations"; import { @@ -2034,6 +2035,7 @@ export function RootComposeView(props: RootComposeViewProps) { isNewTabActive, openTab, orderedSecondaryFileTabs, + replaceSecondaryPanelTab, reorderFileTab, selectFileSearchResult, updateBrowserTab, @@ -2048,6 +2050,9 @@ export function RootComposeView(props: RootComposeViewProps) { storageFiles: rootThreadStorageFiles?.files, terminalSessions: loadedTerminalSessions, }); + const buildTabMenuItems = useOpenWithTabMenu({ + replaceTab: replaceSecondaryPanelTab, + }); const activeRootHostFileThreadId = activeHostFileThreadId ?? (activeHostFilePath !== null ? rootPanelThreadId : null); @@ -2548,6 +2553,7 @@ export function RootComposeView(props: RootComposeViewProps) { isActive: tab.id === activeFixedSecondaryTabId, leadingVisual: , statusLabel: tab.statusLabel, + menuItems: buildTabMenuItems(tab), onSelect: () => handleActivateFileTab(tab.id), onClose: () => closeTab(tab.id), }; @@ -2558,6 +2564,7 @@ export function RootComposeView(props: RootComposeViewProps) { isActive: tab.id === activeFixedSecondaryTabId, leadingVisual: , statusLabel: null, + menuItems: buildTabMenuItems(tab), onSelect: () => handleActivateFileTab(tab.id), onClose: () => closeTab(tab.id), }; @@ -2569,6 +2576,7 @@ export function RootComposeView(props: RootComposeViewProps) { isPinned: tab.isPinned, leadingVisual: , statusLabel: null, + menuItems: buildTabMenuItems(tab), onSelect: () => handleActivateFileTab(tab.id), onClose: () => closeTab(tab.id), }; @@ -2606,9 +2614,9 @@ export function RootComposeView(props: RootComposeViewProps) { onClose: () => closeTab(tab.id), }; case "plugin-panel": - // Plugin panel tabs are opened from a thread's launcher; the root - // panel offers no plugin actions, but its persisted state must - // still render any tab kind without crashing. + // Plugin action tabs are opened from a thread's launcher; the + // root panel offers no plugin actions, but file-opener tabs open + // here too and persisted state must render any kind. return { id: tab.id, filename: tab.title, @@ -2621,6 +2629,7 @@ export function RootComposeView(props: RootComposeViewProps) { /> ), statusLabel: null, + menuItems: buildTabMenuItems(tab), onSelect: () => handleActivateFileTab(tab.id), onClose: () => closeTab(tab.id), }; diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index 2038f3dea..596317428 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -127,6 +127,7 @@ import { PluginPanelTabContent, usePluginPanelActions, } from "@/components/plugin/PluginPanelActions"; +import { useOpenWithTabMenu } from "@/components/plugin/useOpenWithTabMenu"; import { Icon } from "@/components/ui/icon.js"; import { getBbDesktopInfo, @@ -573,6 +574,7 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { openSideChat, openExistingSideChatTab, orderedSecondaryFileTabs, + replaceSecondaryPanelTab, reorderFileTab, selectFileSearchResult, setSideChatThreadId, @@ -589,6 +591,9 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { openPluginPanel, threadId, }); + const buildTabMenuItems = useOpenWithTabMenu({ + replaceTab: replaceSecondaryPanelTab, + }); useThreadOpenFileSignal({ threadId, environmentId: thread?.environmentId, @@ -1295,6 +1300,7 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { isActive: tab.id === activeFixedSecondaryTabId, leadingVisual: , statusLabel: tab.statusLabel, + menuItems: buildTabMenuItems(tab), onSelect: () => handleActivateFileTab(tab.id), onClose: () => closeTab(tab.id), }; @@ -1305,6 +1311,7 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { isActive: tab.id === activeFixedSecondaryTabId, leadingVisual: , statusLabel: null, + menuItems: buildTabMenuItems(tab), onSelect: () => handleActivateFileTab(tab.id), onClose: () => closeTab(tab.id), }; @@ -1316,6 +1323,7 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { isPinned: tab.isPinned, leadingVisual: , statusLabel: null, + menuItems: buildTabMenuItems(tab), onSelect: () => handleActivateFileTab(tab.id), onClose: () => closeTab(tab.id), }; @@ -1360,6 +1368,7 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { /> ), statusLabel: null, + menuItems: buildTabMenuItems(tab), onSelect: () => handleActivateFileTab(tab.id), onClose: () => closeTab(tab.id), }; @@ -1371,6 +1380,7 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { activateSideChatTab, activeFixedSecondaryTabId, activeSideChatTabId, + buildTabMenuItems, closeTab, closeSideChatTab, handleActivateFileTab, diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md index 1f2c7dc81..c68ab0f52 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md @@ -441,6 +441,20 @@ Slot props contracts (versioned, additive-only): or async) are contained and logged, never breaking the launcher. - `composerAccessory` → `{ projectId: string | null, threadId: string | null }` — rendered in the composer footer. Registration: `{ id, component }`. +- `fileOpener` → `{ path: string, source }` — register as a viewer/editor + for file extensions: `{ id, title, extensions: ["md"], component }`. + Users pick it (and can set it as the default per extension) from a file + tab's right-click "Open with" menu; matching files opened in the right + panel then render your component in a plugin tab instead of the built-in + preview — this includes links clicked in rendered markdown, the file + picker, and `bb thread open`. `source` is + `{ kind: "workspace" | "host" | "thread-storage", threadId, environmentId, + projectId }` (nullable fields) and `path` follows the source (workspace: + worktree-relative; host: absolute; thread-storage: storage-relative). + Applies only to live file content — git-ref snapshots and deleted files + always use the built-in preview, and a removed/disabled opener degrades + back to it. Pair with `bb.sdk.files` (rpc from your server) to load and + CAS-save the content. Hooks: 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 c55b524e9..50c97fb3d 100644 --- a/apps/server/test/services/plugins/plugin-authoring-docs.test.ts +++ b/apps/server/test/services/plugins/plugin-authoring-docs.test.ts @@ -6,6 +6,7 @@ import { type BbPluginApi, type PluginAppSlots, type PluginComposerAccessoryProps, + type PluginFileOpenerProps, type PluginHomepageSectionProps, type PluginHttpAuthMode, type PluginNavPanelProps, @@ -133,6 +134,7 @@ type SlotPropsByName = { navPanel: PluginNavPanelProps; threadPanelAction: PluginThreadPanelProps; composerAccessory: PluginComposerAccessoryProps; + fileOpener: PluginFileOpenerProps; }; type MissingSlot = Exclude; @@ -144,6 +146,7 @@ const FRONTEND_SLOT_PROP_FIELDS = { navPanel: ["subPath"], threadPanelAction: ["threadId", "params"], composerAccessory: ["projectId", "threadId"], + fileOpener: ["path", "source"], } as const satisfies { [S in keyof SlotPropsByName]: readonly (keyof SlotPropsByName[S])[]; }; diff --git a/packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts b/packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts index 1a821a6f7..cc0113f4a 100644 --- a/packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts +++ b/packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts @@ -48,6 +48,23 @@ interface PluginComposerAccessoryProps { projectId: string | null; threadId: string | null; } +/** + * Where a file being opened by a `fileOpener` lives. `path` semantics follow + * the source: workspace paths are relative to the environment's worktree, + * thread-storage paths are relative to the thread's storage root, host paths + * are absolute on the thread's host. + */ +interface PluginFileOpenerSource { + kind: "workspace" | "host" | "thread-storage"; + threadId: string | null; + environmentId: string | null; + projectId: string | null; +} +/** Props passed to a `fileOpener` component (rendered as a panel file tab). */ +interface PluginFileOpenerProps { + path: string; + source: PluginFileOpenerSource; +} /** * Slot/panel ids and nav-panel paths must match this pattern (letters, * digits, `-`, `_`): they ride URLs and persisted panel-tab keys. @@ -126,11 +143,30 @@ interface PluginComposerAccessoryRegistration { id: string; component: ComponentType; } +/** + * Register this plugin as a viewer/editor for file extensions. The user + * picks (and can set as default) an opener per extension via the file tab's + * "Open with" menu; matching files opened in the panel then render + * `component` in a plugin tab instead of the built-in preview. Applies to + * working-tree, host, and thread-storage files — never to git-ref snapshots + * (diff views always use the built-in preview). The built-in preview stays + * one menu click away, and a missing/disabled opener falls back to it. + */ +interface PluginFileOpenerRegistration { + /** Unique within the plugin; letters, digits, `-`, `_`. */ + id: string; + /** Label in the "Open with" menu (e.g. "Notes editor"). */ + title: string; + /** Lowercase extensions without the dot (e.g. ["md", "mdx"]). */ + extensions: readonly string[]; + component: ComponentType; +} interface PluginAppSlots { homepageSection(registration: PluginHomepageSectionRegistration): void; navPanel(registration: PluginNavPanelRegistration): void; threadPanelAction(registration: PluginThreadPanelActionRegistration): void; composerAccessory(registration: PluginComposerAccessoryRegistration): void; + fileOpener(registration: PluginFileOpenerRegistration): void; } interface PluginAppBuilder { slots: PluginAppSlots; @@ -253,4 +289,4 @@ declare const useBbContext: () => BbContext; declare const useBbNavigate: () => BbNavigate; export { PLUGIN_SDK_APP_EXPORT_NAMES, PLUGIN_SLOT_ID_PATTERN, definePluginApp, useBbContext, useBbNavigate, useRealtime, useRpc, useSettings }; -export type { BbContext, BbNavigate, PluginAppBuilder, PluginAppDefinition, PluginAppSetup, PluginAppSlots, PluginComposerAccessoryProps, PluginComposerAccessoryRegistration, PluginComposerApi, PluginComposerMention, PluginComposerScope, PluginHomepageSectionProps, PluginHomepageSectionRegistration, PluginNavPanelProps, PluginNavPanelRegistration, PluginRpcClient, PluginSdkApp, PluginSettingsState, PluginThreadPanelActionContext, PluginThreadPanelActionRegistration, PluginThreadPanelProps }; +export type { BbContext, BbNavigate, PluginAppBuilder, PluginAppDefinition, PluginAppSetup, PluginAppSlots, PluginComposerAccessoryProps, PluginComposerAccessoryRegistration, PluginComposerApi, PluginComposerMention, PluginComposerScope, PluginFileOpenerProps, PluginFileOpenerRegistration, PluginFileOpenerSource, PluginHomepageSectionProps, PluginHomepageSectionRegistration, PluginNavPanelProps, PluginNavPanelRegistration, PluginRpcClient, PluginSdkApp, PluginSettingsState, PluginThreadPanelActionContext, PluginThreadPanelActionRegistration, PluginThreadPanelProps }; diff --git a/packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts b/packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts index fa1900510..98b6e02c6 100644 --- a/packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts +++ b/packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts @@ -52,6 +52,23 @@ interface PluginComposerAccessoryProps { projectId: string | null; threadId: string | null; } +/** + * Where a file being opened by a `fileOpener` lives. `path` semantics follow + * the source: workspace paths are relative to the environment's worktree, + * thread-storage paths are relative to the thread's storage root, host paths + * are absolute on the thread's host. + */ +interface PluginFileOpenerSource { + kind: "workspace" | "host" | "thread-storage"; + threadId: string | null; + environmentId: string | null; + projectId: string | null; +} +/** Props passed to a `fileOpener` component (rendered as a panel file tab). */ +interface PluginFileOpenerProps { + path: string; + source: PluginFileOpenerSource; +} /** * Slot/panel ids and nav-panel paths must match this pattern (letters, * digits, `-`, `_`): they ride URLs and persisted panel-tab keys. @@ -130,11 +147,30 @@ interface PluginComposerAccessoryRegistration { id: string; component: ComponentType; } +/** + * Register this plugin as a viewer/editor for file extensions. The user + * picks (and can set as default) an opener per extension via the file tab's + * "Open with" menu; matching files opened in the panel then render + * `component` in a plugin tab instead of the built-in preview. Applies to + * working-tree, host, and thread-storage files — never to git-ref snapshots + * (diff views always use the built-in preview). The built-in preview stays + * one menu click away, and a missing/disabled opener falls back to it. + */ +interface PluginFileOpenerRegistration { + /** Unique within the plugin; letters, digits, `-`, `_`. */ + id: string; + /** Label in the "Open with" menu (e.g. "Notes editor"). */ + title: string; + /** Lowercase extensions without the dot (e.g. ["md", "mdx"]). */ + extensions: readonly string[]; + component: ComponentType; +} interface PluginAppSlots { homepageSection(registration: PluginHomepageSectionRegistration): void; navPanel(registration: PluginNavPanelRegistration): void; threadPanelAction(registration: PluginThreadPanelActionRegistration): void; composerAccessory(registration: PluginComposerAccessoryRegistration): void; + fileOpener(registration: PluginFileOpenerRegistration): void; } interface PluginAppBuilder { slots: PluginAppSlots; @@ -389,8 +425,8 @@ declare const threadTimelinePendingTodosSchema: z$1.ZodObject<{ text: z$1.ZodString; status: z$1.ZodEnum<{ pending: "pending"; - completed: "completed"; in_progress: "in_progress"; + completed: "completed"; }>; }, z$1.core.$strip>>; }, z$1.core.$strip>; @@ -2142,4 +2178,4 @@ interface BbPluginApi { } export { PLUGIN_SDK_APP_EXPORT_NAMES, PLUGIN_SLOT_ID_PATTERN }; -export type { BbContext, BbNavigate, BbPluginApi, PluginAgentToolContentPart, PluginAgentToolContext, PluginAgentToolRegistrationBase, PluginAgentToolResult, PluginAgents, PluginAppBuilder, PluginAppDefinition, PluginAppSetup, PluginAppSlots, PluginBackground, PluginCli, PluginCliCommandInfo, PluginCliContext, PluginCliRegistration, PluginCliResult, PluginComposerAccessoryProps, PluginComposerAccessoryRegistration, PluginComposerApi, PluginComposerMention, PluginComposerScope, PluginHomepageSectionProps, PluginHomepageSectionRegistration, PluginHttp, PluginHttpAuthMode, PluginHttpHandler, PluginKvStorage, PluginLogger, PluginMentionItem, PluginMentionProviderRegistration, PluginMentionSearchContext, PluginNavPanelProps, PluginNavPanelRegistration, PluginRealtime, PluginRpc, PluginRpcClient, PluginSdkApp, PluginSettingDescriptor, PluginSettingDescriptors, PluginSettingValue, PluginSettings, PluginSettingsHandle, PluginSettingsState, PluginSettingsValues, PluginStatusApi, PluginStorage, PluginThreadActionContext, PluginThreadActionRegistration, PluginThreadActionResult, PluginThreadActionToast, PluginThreadEventHandler, PluginThreadEventName, PluginThreadEventPayloads, PluginThreadPanelActionContext, PluginThreadPanelActionRegistration, PluginThreadPanelProps, PluginUi }; +export type { BbContext, BbNavigate, BbPluginApi, PluginAgentToolContentPart, PluginAgentToolContext, PluginAgentToolRegistrationBase, PluginAgentToolResult, PluginAgents, PluginAppBuilder, PluginAppDefinition, PluginAppSetup, PluginAppSlots, PluginBackground, PluginCli, PluginCliCommandInfo, PluginCliContext, PluginCliRegistration, PluginCliResult, PluginComposerAccessoryProps, PluginComposerAccessoryRegistration, PluginComposerApi, PluginComposerMention, PluginComposerScope, PluginFileOpenerProps, PluginFileOpenerRegistration, PluginFileOpenerSource, PluginHomepageSectionProps, PluginHomepageSectionRegistration, PluginHttp, PluginHttpAuthMode, PluginHttpHandler, PluginKvStorage, PluginLogger, PluginMentionItem, PluginMentionProviderRegistration, PluginMentionSearchContext, PluginNavPanelProps, PluginNavPanelRegistration, PluginRealtime, PluginRpc, PluginRpcClient, PluginSdkApp, PluginSettingDescriptor, PluginSettingDescriptors, PluginSettingValue, PluginSettings, PluginSettingsHandle, PluginSettingsState, PluginSettingsValues, PluginStatusApi, PluginStorage, PluginThreadActionContext, PluginThreadActionRegistration, PluginThreadActionResult, PluginThreadActionToast, PluginThreadEventHandler, PluginThreadEventName, PluginThreadEventPayloads, PluginThreadPanelActionContext, PluginThreadPanelActionRegistration, PluginThreadPanelProps, PluginUi }; diff --git a/packages/plugin-sdk/src/app-contract.ts b/packages/plugin-sdk/src/app-contract.ts index 4fec093fe..92081fe0a 100644 --- a/packages/plugin-sdk/src/app-contract.ts +++ b/packages/plugin-sdk/src/app-contract.ts @@ -53,6 +53,25 @@ export interface PluginComposerAccessoryProps { threadId: string | null; } +/** + * Where a file being opened by a `fileOpener` lives. `path` semantics follow + * the source: workspace paths are relative to the environment's worktree, + * thread-storage paths are relative to the thread's storage root, host paths + * are absolute on the thread's host. + */ +export interface PluginFileOpenerSource { + kind: "workspace" | "host" | "thread-storage"; + threadId: string | null; + environmentId: string | null; + projectId: string | null; +} + +/** Props passed to a `fileOpener` component (rendered as a panel file tab). */ +export interface PluginFileOpenerProps { + path: string; + source: PluginFileOpenerSource; +} + // --------------------------------------------------------------------------- // Slot registrations (the arguments to `app.slots.*`). // --------------------------------------------------------------------------- @@ -138,6 +157,25 @@ export interface PluginComposerAccessoryRegistration { component: ComponentType; } +/** + * Register this plugin as a viewer/editor for file extensions. The user + * picks (and can set as default) an opener per extension via the file tab's + * "Open with" menu; matching files opened in the panel then render + * `component` in a plugin tab instead of the built-in preview. Applies to + * working-tree, host, and thread-storage files — never to git-ref snapshots + * (diff views always use the built-in preview). The built-in preview stays + * one menu click away, and a missing/disabled opener falls back to it. + */ +export interface PluginFileOpenerRegistration { + /** Unique within the plugin; letters, digits, `-`, `_`. */ + id: string; + /** Label in the "Open with" menu (e.g. "Notes editor"). */ + title: string; + /** Lowercase extensions without the dot (e.g. ["md", "mdx"]). */ + extensions: readonly string[]; + component: ComponentType; +} + // --------------------------------------------------------------------------- // definePluginApp // --------------------------------------------------------------------------- @@ -147,6 +185,7 @@ export interface PluginAppSlots { navPanel(registration: PluginNavPanelRegistration): void; threadPanelAction(registration: PluginThreadPanelActionRegistration): void; composerAccessory(registration: PluginComposerAccessoryRegistration): void; + fileOpener(registration: PluginFileOpenerRegistration): void; } export interface PluginAppBuilder { diff --git a/packages/templates/src/generated/plugin-sdk-dts.generated.ts b/packages/templates/src/generated/plugin-sdk-dts.generated.ts index d136d7092..60bd055fa 100644 --- a/packages/templates/src/generated/plugin-sdk-dts.generated.ts +++ b/packages/templates/src/generated/plugin-sdk-dts.generated.ts @@ -2,6 +2,6 @@ // Generated by packages/templates/scripts/generate-templates.mjs from // @bb/plugin-sdk/bundled-types. Do not edit directly. -export const PLUGIN_SDK_DTS = "// Bundled type declarations for `@bb/plugin-sdk`, shipped into scaffolded\n// plugins so they typecheck without the @bb/* workspace on disk.\n//\n// Confused by the API, or need a symbol that isn't here? Clone the BB repo\n// and read the real source: https://github.com/ymichael/bb\n\nimport { ComponentType } from 'react';\nimport Database from 'better-sqlite3';\nimport { Context } from 'hono';\nimport * as z from 'zod';\nimport { z as z$1 } from 'zod';\n\n/**\n * The `@bb/plugin-sdk/app` contract (plugin design §5.2) — pure types plus\n * the runtime export-name list, with no side effects. This module is what the\n * BB app imports to keep its real implementation in sync (`satisfies\n * PluginSdkApp`) and what `bb plugin build` imports to generate the shim's\n * named-export list. Plugin authors import the same shapes through\n * `@bb/plugin-sdk/app`.\n *\n * Per-slot props are versioned contracts: additive-only within an SDK major.\n */\n/** Props passed to a `homepageSection` component. */\ninterface PluginHomepageSectionProps {\n /** Project in view on the compose surface; null when none is selected. */\n projectId: string | null;\n}\n/** Props passed to a `navPanel` component (it owns its whole route). */\ninterface PluginNavPanelProps {\n /**\n * The route remainder after the panel root, \"\" at the root. The panel's\n * route is `/plugins///*`, so a deep link like\n * `/plugins/notes/notes/work/ideas.md` renders the panel with\n * `subPath: \"work/ideas.md\"`. Navigate within the panel via\n * `useBbNavigate().toPluginPanel(path, { subPath })` — browser\n * back/forward then walks panel-internal history.\n */\n subPath: string;\n}\n/** Props passed to a panel tab opened by a `threadPanelAction`. */\ninterface PluginThreadPanelProps {\n threadId: string;\n /**\n * The JSON value the action's `openPanel` call passed (round-tripped\n * through persistence, so the tab restores across reloads); null when the\n * action opened the panel without params.\n */\n params: unknown;\n}\n/** Props passed to a `composerAccessory` component. */\ninterface PluginComposerAccessoryProps {\n projectId: string | null;\n threadId: string | null;\n}\n/**\n * Slot/panel ids and nav-panel paths must match this pattern (letters,\n * digits, `-`, `_`): they ride URLs and persisted panel-tab keys.\n */\ndeclare const PLUGIN_SLOT_ID_PATTERN: RegExp;\ninterface PluginHomepageSectionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n component: ComponentType;\n}\ninterface PluginNavPanelRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n /** Icon hint (BB icon name); unknown names fall back to a generic icon. */\n icon: string;\n /** URL segment under `/plugins//`; letters, digits, `-`, `_`. */\n path: string;\n component: ComponentType;\n /**\n * Panel chrome (default \"page\"): \"page\" renders the host title bar (plugin\n * logo + `title` + your `headerContent`) above a full-width padded body;\n * \"none\" hands the ENTIRE panel area to `component` — no host padding, no\n * title bar (`headerContent` is ignored) — only the per-plugin error\n * boundary remains.\n */\n chrome?: \"page\" | \"none\";\n /**\n * Optional component rendered on the right side of the \"page\" title bar\n * (e.g. a sync button or a count). Contained separately from the body: a\n * throwing headerContent is hidden without breaking the title bar.\n */\n headerContent?: ComponentType;\n}\n/** Context handed to a `threadPanelAction`'s `run`. */\ninterface PluginThreadPanelActionContext {\n /** The thread whose panel launcher invoked the action. */\n threadId: string;\n /**\n * Open a tab in the thread's side panel rendering this action's\n * `component`. `title` labels the tab (default: the action's `title`);\n * `params` must be JSON-serializable — it is persisted with the tab and\n * reaches the component as its `params` prop. Opening with params\n * identical to an already-open tab of this action focuses that tab\n * (updating its title) instead of duplicating it. May be called more than\n * once (different params ⇒ multiple tabs) or not at all.\n */\n openPanel(options?: {\n title?: string;\n params?: unknown;\n }): void;\n}\ninterface PluginThreadPanelActionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n /** Label of the action row in the panel's new-tab launcher. */\n title: string;\n /**\n * Icon hint (BB icon name) used when the plugin ships no logo; the\n * launcher row and opened tabs prefer the plugin's logo.\n */\n icon?: string;\n /** Rendered inside every panel tab this action opens. */\n component: ComponentType;\n /**\n * Runs when the user activates the action: call your RPC methods, show a\n * toast, and/or open panel tabs via `context.openPanel`. Omitted =\n * immediately open a panel tab with defaults. Errors (sync or async) are\n * contained and logged; they never break the launcher.\n */\n run?(context: PluginThreadPanelActionContext): void | Promise;\n}\ninterface PluginComposerAccessoryRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n component: ComponentType;\n}\ninterface PluginAppSlots {\n homepageSection(registration: PluginHomepageSectionRegistration): void;\n navPanel(registration: PluginNavPanelRegistration): void;\n threadPanelAction(registration: PluginThreadPanelActionRegistration): void;\n composerAccessory(registration: PluginComposerAccessoryRegistration): void;\n}\ninterface PluginAppBuilder {\n slots: PluginAppSlots;\n}\ntype PluginAppSetup = (app: PluginAppBuilder) => void;\n/**\n * The opaque product of `definePluginApp` — a plugin's `app.tsx` default\n * export. The host re-runs `setup` against a fresh collector on every\n * (re)interpretation, replacing that plugin's registrations wholesale.\n */\ninterface PluginAppDefinition {\n /** Brand the host checks before interpreting a bundle's default export. */\n readonly __bbPluginApp: true;\n readonly setup: PluginAppSetup;\n}\ninterface PluginRpcClient {\n /**\n * Invoke one of the plugin's `bb.rpc` methods (POST\n * /api/v1/plugins/<id>/rpc/<method>). Resolves with the method's\n * result; rejects with an `Error` carrying the server's message when the\n * handler fails or the plugin is not running.\n */\n call(method: string, input?: unknown): Promise;\n}\ninterface PluginSettingsState {\n /**\n * Effective non-secret setting values (secret settings are excluded —\n * read them server-side). Undefined while loading or unavailable.\n */\n values: Record | undefined;\n isLoading: boolean;\n}\n/** Where `useComposer()` writes: the active thread's draft or the new-thread draft. */\ntype PluginComposerScope = {\n kind: \"thread\";\n threadId: string;\n} | {\n kind: \"new-thread\";\n projectId: string | null;\n};\n/** An @-mention pill bound to one of the calling plugin's mention providers. */\ninterface PluginComposerMention {\n /** Mention provider id registered by THIS plugin via `bb.ui.registerMentionProvider`. */\n provider: string;\n /** Item id your provider's `resolve` will receive at send time. */\n id: string;\n /** Pill text shown in the composer. */\n label: string;\n}\n/**\n * Programmatic access to the chat composer draft — the same shared draft the\n * built-in \"Add to chat\" affordances (file preview, diff, terminal selections)\n * write to. Inside a thread context writes land in that thread's draft;\n * anywhere else (nav panel, homepage section) they seed the new-thread\n * composer draft, which persists until the user sends or clears it.\n */\ninterface PluginComposerApi {\n scope: PluginComposerScope;\n /**\n * Append text to the draft as a `> ` blockquote block and focus the\n * composer. Blank text is a no-op. This is the \"reference this selection\n * in chat\" primitive.\n */\n addQuote(text: string): void;\n /**\n * Insert an @-mention pill that resolves through this plugin's mention\n * provider at send time — the durable way to reference an entity whose\n * content should be fetched fresh when the message is sent.\n */\n insertMention(mention: PluginComposerMention): void;\n /** Focus the composer caret at the end of the draft. */\n focus(): void;\n}\n/** Current app selection, derived from the route. */\ninterface BbContext {\n projectId: string | null;\n threadId: string | null;\n}\ninterface BbNavigate {\n toThread(threadId: string): void;\n toProject(projectId: string): void;\n /**\n * Navigate to one of this plugin's own nav panels by its `path`.\n * `subPath` targets a location inside the panel (the component's\n * `subPath` prop); `replace` swaps the current history entry instead of\n * pushing — use it for redirects so back does not bounce.\n */\n toPluginPanel(path: string, options?: {\n subPath?: string;\n replace?: boolean;\n }): void;\n}\n/**\n * Everything `@bb/plugin-sdk/app` resolves to at runtime. The BB app builds\n * the real implementation and `satisfies` this interface; `bb plugin build`\n * shims the specifier to that object on `globalThis.__bbPluginRuntime`.\n */\ninterface PluginSdkApp {\n definePluginApp(setup: PluginAppSetup): PluginAppDefinition;\n useRpc(): PluginRpcClient;\n useRealtime(channel: string, handler: (payload: unknown) => void): void;\n useSettings(): PluginSettingsState;\n useBbContext(): BbContext;\n useBbNavigate(): BbNavigate;\n useComposer(): PluginComposerApi;\n}\n/**\n * Named runtime exports of `@bb/plugin-sdk/app`, in sorted order. Single\n * source of truth for the build shim's export list and the app's\n * implementation-key test — adding a surface member without updating this\n * list fails the type assertion below.\n */\ndeclare const PLUGIN_SDK_APP_EXPORT_NAMES: readonly [\"definePluginApp\", \"useBbContext\", \"useBbNavigate\", \"useComposer\", \"useRealtime\", \"useRpc\", \"useSettings\"];\n\ndeclare const appThemeSchema: z$1.ZodObject<{\n themeId: z$1.ZodString;\n customCss: z$1.ZodNullable;\n faviconColor: z$1.ZodEnum<{\n default: \"default\";\n red: \"red\";\n orange: \"orange\";\n yellow: \"yellow\";\n green: \"green\";\n teal: \"teal\";\n blue: \"blue\";\n purple: \"purple\";\n pink: \"pink\";\n }>;\n}, z$1.core.$strip>;\ntype AppTheme = z$1.infer;\n\ndeclare const changedMessageSchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"thread\">;\n id: z$1.ZodOptional;\n metadata: z$1.ZodOptional;\n eventTypes: z$1.ZodOptional>>>>;\n hasPendingInteraction: z$1.ZodOptional;\n projectId: z$1.ZodOptional;\n }, z$1.core.$strict>>;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"project\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"environment\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"host\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"system\">;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>], \"entity\">;\ntype ChangedMessage = z$1.infer;\n\ndeclare const pendingInteractionResolutionSchema: z$1.ZodUnion;\n grantedPermissions: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n fileSystem: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n decision: z$1.ZodLiteral<\"allow_for_session\">;\n grantedPermissions: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n fileSystem: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n decision: z$1.ZodLiteral<\"deny\">;\n}, z$1.core.$strip>], \"decision\">, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"user_answer\">;\n answers: z$1.ZodRecord;\n freeText: z$1.ZodOptional;\n }, z$1.core.$strip>>;\n}, z$1.core.$strip>]>;\ntype PendingInteractionResolution = z$1.infer;\n\ndeclare const threadStatusSchema: z$1.ZodEnum<{\n error: \"error\";\n active: \"active\";\n starting: \"starting\";\n idle: \"idle\";\n stopping: \"stopping\";\n}>;\ntype ThreadStatus = z$1.infer;\n\ndeclare const threadTimelinePendingTodosSchema: z$1.ZodObject<{\n sourceSeq: z$1.ZodNumber;\n updatedAt: z$1.ZodNumber;\n items: z$1.ZodArray;\n }, z$1.core.$strip>>;\n}, z$1.core.$strip>;\ntype ThreadTimelinePendingTodos = z$1.infer;\n\ndeclare const createAutomationRequestSchema: z$1.ZodObject<{\n name: z$1.ZodString;\n enabled: z$1.ZodDefault;\n trigger: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n triggerType: z$1.ZodLiteral<\"schedule\">;\n cron: z$1.ZodString;\n timezone: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n triggerType: z$1.ZodLiteral<\"once\">;\n runAt: z$1.ZodNumber;\n }, z$1.core.$strip>], \"triggerType\">;\n execution: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"agent\">;\n prompt: z$1.ZodString;\n providerId: z$1.ZodString;\n model: z$1.ZodString;\n permissionMode: z$1.ZodEnum<{\n readonly: \"readonly\";\n full: \"full\";\n \"workspace-write\": \"workspace-write\";\n }>;\n targetThreadId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"script\">;\n script: z$1.ZodOptional;\n scriptFile: z$1.ZodOptional;\n interpreter: z$1.ZodOptional>;\n timeoutMs: z$1.ZodDefault;\n env: z$1.ZodOptional>;\n }, z$1.core.$strip>], \"mode\">;\n environment: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"reuse\">;\n environmentId: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"host\">;\n hostId: z$1.ZodOptional;\n workspace: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"unmanaged\">;\n path: z$1.ZodNullable;\n branch: z$1.ZodOptional;\n name: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"new\">;\n baseBranch: z$1.ZodString;\n }, z$1.core.$strict>], \"kind\">>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"managed-worktree\">;\n baseBranch: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"named\">;\n name: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"default\">;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"personal\">;\n }, z$1.core.$strip>], \"type\">;\n }, z$1.core.$strip>], \"type\">;\n autoArchive: z$1.ZodDefault;\n origin: z$1.ZodEnum<{\n agent: \"agent\";\n human: \"human\";\n app: \"app\";\n }>;\n createdByThreadId: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype CreateAutomationRequest = z$1.input;\ndeclare const updateAutomationRequestSchema: z$1.ZodObject<{\n name: z$1.ZodOptional;\n trigger: z$1.ZodOptional;\n cron: z$1.ZodString;\n timezone: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n triggerType: z$1.ZodLiteral<\"once\">;\n runAt: z$1.ZodNumber;\n }, z$1.core.$strip>], \"triggerType\">>;\n execution: z$1.ZodOptional;\n prompt: z$1.ZodString;\n providerId: z$1.ZodString;\n model: z$1.ZodString;\n permissionMode: z$1.ZodEnum<{\n readonly: \"readonly\";\n full: \"full\";\n \"workspace-write\": \"workspace-write\";\n }>;\n targetThreadId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"script\">;\n script: z$1.ZodOptional;\n scriptFile: z$1.ZodOptional;\n interpreter: z$1.ZodOptional>;\n timeoutMs: z$1.ZodDefault;\n env: z$1.ZodOptional>;\n }, z$1.core.$strip>], \"mode\">>;\n environment: z$1.ZodOptional;\n environmentId: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"host\">;\n hostId: z$1.ZodOptional;\n workspace: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"unmanaged\">;\n path: z$1.ZodNullable;\n branch: z$1.ZodOptional;\n name: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"new\">;\n baseBranch: z$1.ZodString;\n }, z$1.core.$strict>], \"kind\">>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"managed-worktree\">;\n baseBranch: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"named\">;\n name: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"default\">;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"personal\">;\n }, z$1.core.$strip>], \"type\">;\n }, z$1.core.$strip>], \"type\">>;\n autoArchive: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype UpdateAutomationRequest = z$1.infer;\ndeclare const runAutomationRequestSchema: z$1.ZodObject<{\n idempotencyKey: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype RunAutomationRequest = z$1.infer;\n\ndeclare const createProjectSourceRequestSchema: z$1.ZodObject<{\n hostId: z$1.ZodString;\n type: z$1.ZodLiteral<\"local_path\">;\n path: z$1.ZodPipe>;\n}, z$1.core.$strict>;\ntype CreateProjectSourceRequest = z$1.infer;\ndeclare const createProjectRequestSchema: z$1.ZodObject<{\n name: z$1.ZodString;\n source: z$1.ZodObject<{\n hostId: z$1.ZodString;\n type: z$1.ZodLiteral<\"local_path\">;\n path: z$1.ZodPipe>;\n }, z$1.core.$strict>;\n}, z$1.core.$strip>;\ntype CreateProjectRequest = z$1.infer;\ndeclare const projectListQuerySchema: z$1.ZodObject<{\n include: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype ProjectListQuery = z$1.infer;\ndeclare const updateProjectRequestSchema: z$1.ZodObject<{\n name: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype UpdateProjectRequest = z$1.infer;\ndeclare const updateProjectSourceRequestSchema: z$1.ZodObject<{\n type: z$1.ZodLiteral<\"local_path\">;\n path: z$1.ZodOptional>>;\n isDefault: z$1.ZodOptional>;\n}, z$1.core.$strict>;\ntype UpdateProjectSourceRequest = z$1.infer;\n\ndeclare const updateEnvironmentRequestSchema: z$1.ZodObject<{\n mergeBaseBranch: z$1.ZodOptional>;\n name: z$1.ZodOptional>;\n}, z$1.core.$strip>;\ntype UpdateEnvironmentRequest = z$1.infer;\ndeclare const environmentDiffBranchesQuerySchema: z$1.ZodObject<{\n query: z$1.ZodOptional;\n limit: z$1.ZodOptional;\n selectedBranch: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype EnvironmentDiffBranchesQuery = z$1.infer;\ndeclare const environmentStatusQuerySchema: z$1.ZodObject<{\n mergeBaseBranch: z$1.ZodOptional>;\n}, z$1.core.$strip>;\ntype EnvironmentStatusQuery = z$1.infer;\ndeclare const environmentDiffQuerySchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n target: z$1.ZodLiteral<\"uncommitted\">;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"branch_committed\">;\n mergeBaseBranch: z$1.ZodPipe;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"all\">;\n mergeBaseBranch: z$1.ZodPipe;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"commit\">;\n sha: z$1.ZodString;\n}, z$1.core.$strip>], \"target\">;\ntype EnvironmentDiffQuery = z$1.infer;\n/**\n * Query for fetching a single file's contents at one side of a diff target.\n * Used by the diff card to reparse the card's patch with full old/new contents\n * so `@pierre/diffs` can render expand-context buttons between hunks.\n *\n * For `branch_committed` / `all`, callers pass the resolved merge-base SHA\n * (`mergeBaseRef`, surfaced by `workspace.diff`) rather than the branch name\n * — the diff itself was computed against that SHA, so reading the old side\n * from the same SHA keeps the file content aligned with the hunk line\n * numbers. Reading from the branch tip is wrong whenever the branch has\n * moved past the merge-base since the file existed there.\n */\ndeclare const environmentDiffFileQuerySchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n target: z$1.ZodLiteral<\"uncommitted\">;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"branch_committed\">;\n mergeBaseRef: z$1.ZodString;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"all\">;\n mergeBaseRef: z$1.ZodString;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"commit\">;\n sha: z$1.ZodString;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>], \"target\">;\ntype EnvironmentDiffFileQuery = z$1.infer;\n\ndeclare const systemExecutionOptionsQuerySchema: z$1.ZodObject<{\n providerId: z$1.ZodOptional;\n hostId: z$1.ZodOptional;\n environmentId: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype SystemExecutionOptionsQuery = z$1.infer;\n/**\n * Theme catalog: the on-disk custom-theme directory plus the discovered custom\n * themes and the active palette. Drives `bb theme list` / `bb theme dir`.\n */\ndeclare const themeCatalogResponseSchema: z$1.ZodObject<{\n dir: z$1.ZodString;\n custom: z$1.ZodArray;\n active: z$1.ZodObject<{\n themeId: z$1.ZodString;\n customCss: z$1.ZodNullable;\n faviconColor: z$1.ZodEnum<{\n default: \"default\";\n red: \"red\";\n orange: \"orange\";\n yellow: \"yellow\";\n green: \"green\";\n teal: \"teal\";\n blue: \"blue\";\n purple: \"purple\";\n pink: \"pink\";\n }>;\n }, z$1.core.$strip>;\n}, z$1.core.$strip>;\ntype ThemeCatalogResponse = z$1.infer;\n\ndeclare const createTerminalRequestSchema: z$1.ZodObject<{\n cols: z$1.ZodNumber;\n rows: z$1.ZodNumber;\n start: z$1.ZodOptional;\n }, z$1.core.$strict>, z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"command\">;\n command: z$1.ZodString;\n }, z$1.core.$strict>], \"mode\">>;\n target: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"thread\">;\n threadId: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"environment\">;\n environmentId: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"host_path\">;\n hostId: z$1.ZodString;\n cwd: z$1.ZodNullable;\n }, z$1.core.$strict>], \"kind\">;\n title: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype CreateTerminalRequest = z$1.infer;\ndeclare const closeTerminalRequestSchema: z$1.ZodObject<{\n mode: z$1.ZodEnum<{\n force: \"force\";\n \"if-clean\": \"if-clean\";\n }>;\n reason: z$1.ZodLiteral<\"user\">;\n}, z$1.core.$strict>;\ntype CloseTerminalRequest = z$1.infer;\ndeclare const updateTerminalRequestSchema: z$1.ZodObject<{\n title: z$1.ZodString;\n}, z$1.core.$strict>;\ntype UpdateTerminalRequest = z$1.infer;\ndeclare const terminalInputRequestSchema: z$1.ZodObject<{\n dataBase64: z$1.ZodString;\n}, z$1.core.$strict>;\ntype TerminalInputRequest = z$1.infer;\ndeclare const terminalResizeRequestSchema: z$1.ZodObject<{\n cols: z$1.ZodNumber;\n rows: z$1.ZodNumber;\n}, z$1.core.$strict>;\ntype TerminalResizeRequest = z$1.infer;\ndeclare const terminalOutputQuerySchema: z$1.ZodObject<{\n sinceSeq: z$1.ZodOptional>;\n tailBytes: z$1.ZodOptional>;\n limitChunks: z$1.ZodOptional>;\n}, z$1.core.$strict>;\ntype TerminalOutputQuery = z$1.infer;\n\ndeclare const createThreadRequestSchema: z$1.ZodObject<{\n projectId: z$1.ZodString;\n providerId: z$1.ZodOptional;\n origin: z$1.ZodEnum<{\n plugin: \"plugin\";\n app: \"app\";\n automation: \"automation\";\n cli: \"cli\";\n sdk: \"sdk\";\n }>;\n originPluginId: z$1.ZodOptional;\n title: z$1.ZodOptional;\n input: z$1.ZodArray>;\n type: z$1.ZodLiteral<\"text\">;\n text: z$1.ZodString;\n mentions: z$1.ZodDefault;\n threadId: z$1.ZodString;\n projectId: z$1.ZodOptional;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"project\">;\n projectId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"path\">;\n source: z$1.ZodEnum<{\n workspace: \"workspace\";\n \"thread-storage\": \"thread-storage\";\n }>;\n entryKind: z$1.ZodEnum<{\n file: \"file\";\n directory: \"directory\";\n }>;\n path: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"command\">;\n trigger: z$1.ZodEnum<{\n \"/\": \"/\";\n }>;\n name: z$1.ZodString;\n source: z$1.ZodEnum<{\n command: \"command\";\n skill: \"skill\";\n }>;\n origin: z$1.ZodEnum<{\n user: \"user\";\n project: \"project\";\n builtin: \"builtin\";\n }>;\n label: z$1.ZodString;\n argumentHint: z$1.ZodNullable;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"plugin\">;\n pluginId: z$1.ZodString;\n itemId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>>>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"image\">;\n url: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localImage\">;\n path: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localFile\">;\n path: z$1.ZodString;\n name: z$1.ZodOptional;\n sizeBytes: z$1.ZodOptional;\n mimeType: z$1.ZodOptional;\n }, z$1.core.$strip>], \"type\">>;\n model: z$1.ZodOptional;\n serviceTier: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>;\n permissionMode: z$1.ZodOptional>;\n executionInputSources: z$1.ZodOptional>;\n model: z$1.ZodOptional>;\n serviceTier: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>;\n permissionMode: z$1.ZodOptional>;\n }, z$1.core.$strict>>;\n environment: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"reuse\">;\n environmentId: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"host\">;\n hostId: z$1.ZodOptional;\n workspace: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"unmanaged\">;\n path: z$1.ZodNullable;\n branch: z$1.ZodOptional;\n name: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"new\">;\n baseBranch: z$1.ZodString;\n }, z$1.core.$strict>], \"kind\">>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"managed-worktree\">;\n baseBranch: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"named\">;\n name: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"default\">;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"personal\">;\n }, z$1.core.$strip>], \"type\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"project-default\">;\n }, z$1.core.$strip>], \"type\">;\n parentThreadId: z$1.ZodOptional;\n folderId: z$1.ZodOptional>;\n sourceThreadId: z$1.ZodOptional;\n sourceSeqEnd: z$1.ZodOptional;\n startedOnBehalfOf: z$1.ZodDefault;\n senderThreadId: z$1.ZodString;\n }, z$1.core.$strip>>>;\n originKind: z$1.ZodDefault>>;\n childOrigin: z$1.ZodDefault>>;\n}, z$1.core.$strip>;\ntype CreateThreadRequest = z$1.infer;\ndeclare const sendMessageRequestSchema: z$1.ZodObject<{\n input: z$1.ZodArray>;\n type: z$1.ZodLiteral<\"text\">;\n text: z$1.ZodString;\n mentions: z$1.ZodDefault;\n threadId: z$1.ZodString;\n projectId: z$1.ZodOptional;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"project\">;\n projectId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"path\">;\n source: z$1.ZodEnum<{\n workspace: \"workspace\";\n \"thread-storage\": \"thread-storage\";\n }>;\n entryKind: z$1.ZodEnum<{\n file: \"file\";\n directory: \"directory\";\n }>;\n path: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"command\">;\n trigger: z$1.ZodEnum<{\n \"/\": \"/\";\n }>;\n name: z$1.ZodString;\n source: z$1.ZodEnum<{\n command: \"command\";\n skill: \"skill\";\n }>;\n origin: z$1.ZodEnum<{\n user: \"user\";\n project: \"project\";\n builtin: \"builtin\";\n }>;\n label: z$1.ZodString;\n argumentHint: z$1.ZodNullable;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"plugin\">;\n pluginId: z$1.ZodString;\n itemId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>>>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"image\">;\n url: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localImage\">;\n path: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localFile\">;\n path: z$1.ZodString;\n name: z$1.ZodOptional;\n sizeBytes: z$1.ZodOptional;\n mimeType: z$1.ZodOptional;\n }, z$1.core.$strip>], \"type\">>;\n model: z$1.ZodOptional;\n serviceTier: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>;\n permissionMode: z$1.ZodOptional>;\n executionInputSources: z$1.ZodOptional>;\n serviceTier: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>;\n permissionMode: z$1.ZodOptional>;\n }, z$1.core.$strict>>;\n mode: z$1.ZodEnum<{\n steer: \"steer\";\n start: \"start\";\n auto: \"auto\";\n \"queue-if-active\": \"queue-if-active\";\n \"steer-if-active\": \"steer-if-active\";\n }>;\n senderThreadId: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype SendMessageRequest = z$1.infer;\ndeclare const threadResponseSchema: z$1.ZodObject<{\n id: z$1.ZodString;\n projectId: z$1.ZodString;\n environmentId: z$1.ZodNullable;\n providerId: z$1.ZodString;\n title: z$1.ZodNullable;\n titleFallback: z$1.ZodNullable;\n folderId: z$1.ZodNullable;\n status: z$1.ZodEnum<{\n error: \"error\";\n stopping: \"stopping\";\n idle: \"idle\";\n starting: \"starting\";\n active: \"active\";\n }>;\n parentThreadId: z$1.ZodNullable;\n sourceThreadId: z$1.ZodNullable;\n originKind: z$1.ZodNullable>;\n childOrigin: z$1.ZodNullable>;\n originPluginId: z$1.ZodNullable;\n archivedAt: z$1.ZodNullable;\n pinnedAt: z$1.ZodNullable;\n deletedAt: z$1.ZodNullable;\n lastReadAt: z$1.ZodNullable;\n latestAttentionAt: z$1.ZodNumber;\n createdAt: z$1.ZodNumber;\n updatedAt: z$1.ZodNumber;\n runtime: z$1.ZodObject<{\n displayStatus: z$1.ZodEnum<{\n error: \"error\";\n provisioning: \"provisioning\";\n stopping: \"stopping\";\n idle: \"idle\";\n starting: \"starting\";\n active: \"active\";\n \"host-reconnecting\": \"host-reconnecting\";\n \"waiting-for-host\": \"waiting-for-host\";\n }>;\n hostReconnectGraceExpiresAt: z$1.ZodNullable;\n }, z$1.core.$strip>;\n canSpawnChild: z$1.ZodBoolean;\n}, z$1.core.$strip>;\ntype ThreadResponse = z$1.infer;\ndeclare const threadGetQuerySchema: z$1.ZodObject<{\n include: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype ThreadGetQuery = z$1.infer;\ndeclare const deleteThreadRequestSchema: z$1.ZodObject<{\n childThreadsConfirmed: z$1.ZodBoolean;\n}, z$1.core.$strip>;\ntype DeleteThreadRequest = z$1.infer;\ndeclare const updateThreadRequestSchema: z$1.ZodObject<{\n title: z$1.ZodOptional>;\n folderId: z$1.ZodOptional>;\n parentThreadId: z$1.ZodOptional>;\n model: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>>;\n}, z$1.core.$strip>;\ntype UpdateThreadRequest = z$1.infer;\n/** Which root a secondary-panel file path is relative to. */\ndeclare const panelFileSourceSchema: z$1.ZodEnum<{\n workspace: \"workspace\";\n \"thread-storage\": \"thread-storage\";\n}>;\ntype PanelFileSource = z$1.infer;\ndeclare const threadTimelineQuerySchema: z$1.ZodObject<{\n includeNestedRows: z$1.ZodOptional>;\n segmentLimit: z$1.ZodOptional;\n beforeAnchorSeq: z$1.ZodOptional;\n beforeAnchorId: z$1.ZodOptional;\n summaryOnly: z$1.ZodOptional>;\n afterSequence: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype ThreadTimelineQuery = z$1.infer;\n\ntype PublicApiSchema = unknown;\ntype ApiClient = unknown;\n\ntype FetchImplementation = typeof fetch;\ntype JsonBodyOf = TResponse extends {\n json(): Promise;\n} ? TBody : never;\n\ntype BbSdkRuntime = \"node\" | \"browser\";\ninterface BbSdkTransport {\n api: ApiClient[\"api\"];\n baseUrl: string;\n fetch: FetchImplementation;\n realtimeUrl?: string;\n runtime: BbSdkRuntime;\n readJson(response: Promise): Promise>;\n readVoid(response: Promise): Promise;\n resolve(response: Promise): Promise;\n websocket?: BbRealtimeSocketFactory;\n}\n/**\n * Raw socket payload. Treated as opaque until decoded — realtime ignores\n * non-string frames.\n */\ninterface BbRealtimeSocketMessageEvent {\n data: unknown;\n}\n/**\n * Minimal runtime-agnostic socket shape consumed by the realtime client.\n * Default factories adapt the environment's WebSocket (browser/Node global,\n * or the `ws` package on older Node) to this interface; custom `websocket`\n * factories can wrap any implementation the same way.\n */\ninterface BbRealtimeSocket {\n close(): void;\n onclose: (() => void) | null;\n onerror: (() => void) | null;\n onmessage: ((event: BbRealtimeSocketMessageEvent) => void) | null;\n onopen: (() => void) | null;\n readyState: number;\n send(data: string): void;\n}\ntype BbRealtimeSocketFactory = (url: string) => BbRealtimeSocket;\ninterface BbSdkContext {\n}\n\ninterface CreateSdkAreaArgs {\n context: BbSdkContext;\n transport: BbSdkTransport;\n}\ntype PublicApiEndpointOutput = TEndpoint extends {\n status: infer Status;\n output: infer Output;\n} ? Status extends SuccessfulHttpStatus ? Output : never : never;\ntype SuccessfulHttpStatus = 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226;\ntype PublicApiOutput = PublicApiEndpointOutput;\n\ninterface AutomationCreateArgs extends CreateAutomationRequest {\n projectId?: string;\n}\ninterface AutomationListArgs {\n projectId?: string;\n}\ninterface AutomationGetArgs {\n projectId?: string;\n automationId: string;\n}\ninterface AutomationUpdateArgs extends UpdateAutomationRequest {\n projectId?: string;\n automationId: string;\n}\ninterface AutomationActionArgs {\n projectId?: string;\n automationId: string;\n}\ninterface AutomationRunArgs extends RunAutomationRequest {\n projectId?: string;\n automationId: string;\n}\ninterface AutomationRunsArgs {\n projectId?: string;\n automationId: string;\n limit?: number;\n cursor?: string;\n}\ntype AutomationCreateResult = PublicApiOutput<\"/projects/:id/automations\", \"$post\">;\ntype AutomationListResult = PublicApiOutput<\"/projects/:id/automations\", \"$get\">;\ntype AutomationGetResult = PublicApiOutput<\"/projects/:id/automations/:automationId\", \"$get\">;\ntype AutomationUpdateResult = PublicApiOutput<\"/projects/:id/automations/:automationId\", \"$patch\">;\ntype AutomationPauseResult = PublicApiOutput<\"/projects/:id/automations/:automationId/pause\", \"$post\">;\ntype AutomationResumeResult = PublicApiOutput<\"/projects/:id/automations/:automationId/resume\", \"$post\">;\ntype AutomationRunResult = PublicApiOutput<\"/projects/:id/automations/:automationId/run\", \"$post\">;\ntype AutomationRunsResult = PublicApiOutput<\"/projects/:id/automations/:automationId/runs\", \"$get\">;\ntype AutomationsOverviewResult = PublicApiOutput<\"/automations\", \"$get\">;\ninterface AutomationsArea {\n create(args: AutomationCreateArgs): Promise;\n delete(args: AutomationActionArgs): Promise<{\n ok: true;\n }>;\n get(args: AutomationGetArgs): Promise;\n list(args?: AutomationListArgs): Promise;\n overview(): Promise;\n pause(args: AutomationActionArgs): Promise;\n resume(args: AutomationActionArgs): Promise;\n run(args: AutomationRunArgs): Promise;\n runs(args: AutomationRunsArgs): Promise;\n update(args: AutomationUpdateArgs): Promise;\n}\ndeclare function createAutomationsArea(args: CreateSdkAreaArgs): AutomationsArea;\n\ninterface EnvironmentGetArgs {\n environmentId: string;\n}\ntype EnvironmentMergeBaseBranchUpdateValue = Exclude;\ntype EnvironmentNameUpdateValue = Exclude;\ninterface EnvironmentMergeBaseBranchUpdate {\n mergeBaseBranch: EnvironmentMergeBaseBranchUpdateValue;\n name?: EnvironmentNameUpdateValue;\n}\ninterface EnvironmentNameUpdate {\n mergeBaseBranch?: EnvironmentMergeBaseBranchUpdateValue;\n name: EnvironmentNameUpdateValue;\n}\ntype EnvironmentUpdateFields = EnvironmentMergeBaseBranchUpdate | EnvironmentNameUpdate;\ntype EnvironmentUpdateArgs = EnvironmentUpdateFields & {\n environmentId: string;\n};\ninterface EnvironmentStatusArgs extends EnvironmentStatusQuery {\n environmentId: string;\n}\ntype EnvironmentDiffArgs = EnvironmentDiffQuery & {\n environmentId: string;\n};\ntype EnvironmentDiffFileArgs = EnvironmentDiffFileQuery & {\n environmentId: string;\n};\ninterface EnvironmentDiffBranchesArgs extends EnvironmentDiffBranchesQuery {\n environmentId: string;\n}\ninterface EnvironmentCommitArgs {\n environmentId: string;\n}\ninterface EnvironmentSquashMergeArgs {\n environmentId: string;\n mergeBaseBranch: string;\n}\ntype EnvironmentActionResult = PublicApiOutput<\"/environments/:id/actions\", \"$post\">;\ntype EnvironmentCommitResult = Extract;\ntype EnvironmentDiffResult = PublicApiOutput<\"/environments/:id/diff\", \"$get\">;\ntype EnvironmentDiffBranchesResult = PublicApiOutput<\"/environments/:id/diff/branches\", \"$get\">;\ntype EnvironmentDiffFileResult = PublicApiOutput<\"/environments/:id/diff/file\", \"$get\">;\ntype EnvironmentGetResult = PublicApiOutput<\"/environments/:id\", \"$get\">;\ntype EnvironmentPullRequestResult = PublicApiOutput<\"/environments/:id/pull-request\", \"$get\">;\ntype EnvironmentSquashMergeResult = Extract;\ntype EnvironmentStatusResult = PublicApiOutput<\"/environments/:id/status\", \"$get\">;\ntype EnvironmentUpdateResult = PublicApiOutput<\"/environments/:id\", \"$patch\">;\ninterface EnvironmentsArea {\n commit(args: EnvironmentCommitArgs): Promise;\n diff(args: EnvironmentDiffArgs): Promise;\n diffBranches(args: EnvironmentDiffBranchesArgs): Promise;\n diffFile(args: EnvironmentDiffFileArgs): Promise;\n get(args: EnvironmentGetArgs): Promise;\n pullRequest(args: EnvironmentGetArgs): Promise;\n squashMerge(args: EnvironmentSquashMergeArgs): Promise;\n status(args: EnvironmentStatusArgs): Promise;\n update(args: EnvironmentUpdateArgs): Promise;\n}\ndeclare function createEnvironmentsArea(args: CreateSdkAreaArgs): EnvironmentsArea;\n\n/**\n * Host file primitives. `hostId` may be omitted to target the server's\n * primary (local) host. `rootPath`, when set, confines the target beneath\n * that absolute root on the host (symlink-safe).\n */\ninterface FileReadArgs {\n hostId?: string;\n path: string;\n rootPath?: string;\n}\ninterface FileWriteArgs {\n hostId?: string;\n path: string;\n rootPath?: string;\n content: string;\n /** Defaults to \"utf8\". */\n contentEncoding?: \"utf8\" | \"base64\";\n /** Defaults to false. */\n createParents?: boolean;\n /**\n * Optimistic-concurrency guard: omitted → unconditional write; a hash →\n * write only when the current content hashes to it (use `read().sha256`);\n * null → create-only. A failed guard resolves to the `conflict` outcome.\n */\n expectedSha256?: string | null;\n}\ninterface FileListArgs {\n hostId?: string;\n path: string;\n query?: string;\n limit?: number;\n}\ntype FileReadResult = PublicApiOutput<\"/files/read\", \"$post\">;\ntype FileWriteResult = PublicApiOutput<\"/files/write\", \"$post\">;\ntype FileListResult = PublicApiOutput<\"/files/list\", \"$post\">;\ninterface FilesArea {\n read(args: FileReadArgs): Promise;\n write(args: FileWriteArgs): Promise;\n list(args: FileListArgs): Promise;\n}\ndeclare function createFilesArea(args: CreateSdkAreaArgs): FilesArea;\n\ninterface GuideRenderArgs {\n chapter?: string;\n}\ninterface GuideRenderResult {\n chapter?: string;\n content: string;\n}\ninterface GuideArea {\n render(args?: GuideRenderArgs): GuideRenderResult;\n}\ndeclare function createGuideArea(): GuideArea;\n\ninterface HostGetArgs {\n hostId: string;\n}\ntype HostGetResult = PublicApiOutput<\"/hosts/:id\", \"$get\">;\ntype HostListResult = PublicApiOutput<\"/hosts\", \"$get\">;\ninterface HostsArea {\n get(args: HostGetArgs): Promise;\n list(): Promise;\n}\ndeclare function createHostsArea(args: CreateSdkAreaArgs): HostsArea;\n\ninterface ProjectListArgs extends ProjectListQuery {\n}\ninterface ProjectCreateArgs extends CreateProjectRequest {\n}\ninterface ProjectGetArgs {\n projectId: string;\n}\ninterface ProjectUpdateArgs extends UpdateProjectRequest {\n projectId: string;\n}\ninterface ProjectDeleteArgs {\n projectId: string;\n}\ninterface ProjectSourceAddArgs extends CreateProjectSourceRequest {\n projectId: string;\n}\ninterface ProjectSourceUpdateArgs extends UpdateProjectSourceRequest {\n projectId: string;\n sourceId: string;\n}\ninterface ProjectSourceDeleteArgs {\n projectId: string;\n sourceId: string;\n}\ntype ProjectCreateResult = PublicApiOutput<\"/projects\", \"$post\">;\ntype ProjectDeleteResult = PublicApiOutput<\"/projects/:id\", \"$delete\">;\ntype ProjectGetResult = PublicApiOutput<\"/projects/:id\", \"$get\">;\ntype ProjectListResult = PublicApiOutput<\"/projects\", \"$get\">;\ntype ProjectUpdateResult = PublicApiOutput<\"/projects/:id\", \"$patch\">;\ntype ProjectSourceAddResult = PublicApiOutput<\"/projects/:id/sources\", \"$post\">;\ntype ProjectSourceUpdateResult = PublicApiOutput<\"/projects/:id/sources/:sourceId\", \"$patch\">;\ntype ProjectSourceDeleteResult = PublicApiOutput<\"/projects/:id/sources/:sourceId\", \"$delete\">;\ninterface ProjectSourcesArea {\n add(args: ProjectSourceAddArgs): Promise;\n delete(args: ProjectSourceDeleteArgs): Promise;\n update(args: ProjectSourceUpdateArgs): Promise;\n}\ninterface ProjectsArea {\n create(args: ProjectCreateArgs): Promise;\n delete(args: ProjectDeleteArgs): Promise;\n get(args: ProjectGetArgs): Promise;\n list(args?: ProjectListArgs): Promise;\n sources: ProjectSourcesArea;\n update(args: ProjectUpdateArgs): Promise;\n}\ndeclare function createProjectsArea(args: CreateSdkAreaArgs): ProjectsArea;\n\ninterface ProviderModelsArgs extends SystemExecutionOptionsQuery {\n}\ntype ProviderListResult = PublicApiOutput<\"/system/providers\", \"$get\">;\ntype ProviderModelsResult = PublicApiOutput<\"/system/execution-options\", \"$get\">;\ninterface ProvidersArea {\n list(): Promise;\n models(args?: ProviderModelsArgs): Promise;\n}\ndeclare function createProvidersArea(args: CreateSdkAreaArgs): ProvidersArea;\n\ntype BbRealtimeUnsubscribe = () => void;\ntype BbRealtimeEventName = \"thread:changed\" | \"project:changed\" | \"environment:changed\" | \"host:changed\" | \"system:changed\" | \"system:config-changed\" | \"realtime:connection\";\ntype ThreadRealtimeEvent = Extract;\ntype ProjectRealtimeEvent = Extract;\ntype EnvironmentRealtimeEvent = Extract;\ntype HostRealtimeEvent = Extract;\ntype SystemRealtimeEvent = Extract;\ntype BbRealtimeConnectionState = \"connecting\" | \"connected\" | \"disconnected\";\ninterface BbRealtimeConnectionEvent {\n reconnectDelayMs: number | null;\n reconnected: boolean;\n state: BbRealtimeConnectionState;\n}\n/**\n * Entity-changed events are delivered as one shared object to every matching\n * listener; their payload types are readonly so a listener cannot mutate what\n * the next listener receives.\n */\ninterface BbRealtimeEventMap {\n \"thread:changed\": ThreadRealtimeEvent;\n \"project:changed\": ProjectRealtimeEvent;\n \"environment:changed\": EnvironmentRealtimeEvent;\n \"host:changed\": HostRealtimeEvent;\n \"system:changed\": SystemRealtimeEvent;\n \"system:config-changed\": SystemRealtimeEvent;\n \"realtime:connection\": BbRealtimeConnectionEvent;\n}\ntype BbRealtimeCallback = (event: BbRealtimeEventMap[TEventName]) => void;\ninterface ThreadRealtimeOnArgs {\n callback: BbRealtimeCallback<\"thread:changed\">;\n event: \"thread:changed\";\n threadId?: string;\n}\ninterface ProjectRealtimeOnArgs {\n callback: BbRealtimeCallback<\"project:changed\">;\n event: \"project:changed\";\n projectId?: string;\n}\ninterface EnvironmentRealtimeOnArgs {\n callback: BbRealtimeCallback<\"environment:changed\">;\n environmentId?: string;\n event: \"environment:changed\";\n}\ninterface HostRealtimeOnArgs {\n callback: BbRealtimeCallback<\"host:changed\">;\n event: \"host:changed\";\n hostId?: string;\n}\ninterface SystemRealtimeOnArgs {\n callback: BbRealtimeCallback<\"system:changed\">;\n event: \"system:changed\";\n}\ninterface SystemConfigRealtimeOnArgs {\n callback: BbRealtimeCallback<\"system:config-changed\">;\n event: \"system:config-changed\";\n}\n/**\n * Connection listeners are pure observers — they never open or hold the\n * socket. A listener registered while a socket already exists receives the\n * latest connection event as a snapshot on the next microtask, so a status\n * UI mounted after connect still learns the current state.\n */\ninterface RealtimeConnectionOnArgs {\n callback: BbRealtimeCallback<\"realtime:connection\">;\n event: \"realtime:connection\";\n}\ntype BbRealtimeOnArgsUnion = ThreadRealtimeOnArgs | ProjectRealtimeOnArgs | EnvironmentRealtimeOnArgs | HostRealtimeOnArgs | SystemRealtimeOnArgs | SystemConfigRealtimeOnArgs | RealtimeConnectionOnArgs;\ntype BbRealtimeOnArgs = Extract;\ninterface BbRealtime {\n on(args: BbRealtimeOnArgs): BbRealtimeUnsubscribe;\n}\n\ninterface StatusGetArgs {\n projectId?: string;\n threadId?: string;\n}\ninterface StatusThreadSummary {\n environmentId: string | null;\n id: string;\n parentThreadId: string | null;\n pinnedAt: number | null;\n projectId: string;\n status: ThreadStatus;\n title: string | null;\n}\ntype StatusProject = PublicApiOutput<\"/projects/:id\", \"$get\">;\ntype StatusChildThreads = PublicApiOutput<\"/threads\", \"$get\">;\ninterface StatusResult {\n childThreads: StatusChildThreads | null;\n pendingTodos: ThreadTimelinePendingTodos | null;\n project: StatusProject | null;\n thread: StatusThreadSummary | null;\n}\ninterface StatusArea {\n get(args?: StatusGetArgs): Promise;\n}\ndeclare function createStatusArea(args: CreateSdkAreaArgs): StatusArea;\n\ninterface ThemeArea {\n /** The active app palette, resolved server-side (built-in id or custom CSS). */\n get(): Promise;\n /** The custom-theme directory plus discovered themes and the active palette. */\n catalog(): Promise;\n /**\n * Activate a palette by id — a built-in id or a custom theme name that exists\n * under `/theme//theme.css`. Broadcasts to all open windows.\n */\n set(themeId: string): Promise;\n}\ndeclare function createThemeArea(args: CreateSdkAreaArgs): ThemeArea;\n\ninterface ThreadListArgs {\n archived?: boolean;\n hasParent?: boolean;\n parentThreadId?: string;\n projectId?: string;\n}\ninterface ThreadGetArgs {\n include?: ThreadGetQuery[\"include\"];\n threadId: string;\n}\ntype ThreadGetResult = PublicApiOutput<\"/threads/:id\", \"$get\">;\ntype ThreadListResult = PublicApiOutput<\"/threads\", \"$get\">;\ntype ThreadOutputResponse = PublicApiOutput<\"/threads/:id/output\", \"$get\">;\ntype ThreadMutationResult = PublicApiOutput<\"/threads/:id\", \"$patch\">;\ntype ThreadSpawnResult = PublicApiOutput<\"/threads\", \"$post\">;\ntype ThreadInteractionGetResult = PublicApiOutput<\"/threads/:id/interactions/:interactionId\", \"$get\">;\ntype ThreadInteractionListResult = PublicApiOutput<\"/threads/:id/interactions\", \"$get\">;\ntype ThreadInteractionResolveResult = PublicApiOutput<\"/threads/:id/interactions/:interactionId/resolve\", \"$post\">;\ntype ThreadEventsListResult = PublicApiOutput<\"/threads/:id/events\", \"$get\">;\ntype ThreadEventWaitResult = PublicApiOutput<\"/threads/:id/events/wait\", \"$get\">;\ntype ThreadTimelineResult = PublicApiOutput<\"/threads/:id/timeline\", \"$get\">;\ntype ThreadArchiveResult = PublicApiOutput<\"/threads/:id/archive\", \"$post\">;\ntype ThreadOpenResult = PublicApiOutput<\"/threads/:id/open\", \"$post\">;\ntype ThreadDeleteResult = PublicApiOutput<\"/threads/:id\", \"$delete\">;\ntype ThreadSendResult = PublicApiOutput<\"/threads/:id/send\", \"$post\">;\ntype ThreadStopResult = PublicApiOutput<\"/threads/:id/stop\", \"$post\">;\ntype ThreadTerminalCloseResult = PublicApiOutput<\"/terminals/:terminalId/close\", \"$post\">;\ntype ThreadTerminalCreateResult = PublicApiOutput<\"/terminals\", \"$post\">;\ntype ThreadTerminalInputResult = PublicApiOutput<\"/terminals/:terminalId/input\", \"$post\">;\ntype ThreadTerminalListResult = PublicApiOutput<\"/terminals\", \"$get\">;\ntype ThreadTerminalOutputResult = PublicApiOutput<\"/terminals/:terminalId/output\", \"$get\">;\ntype ThreadTerminalResizeResult = PublicApiOutput<\"/terminals/:terminalId/resize\", \"$post\">;\ntype ThreadTerminalUpdateResult = PublicApiOutput<\"/terminals/:terminalId\", \"$patch\">;\ntype ThreadUnarchiveResult = PublicApiOutput<\"/threads/:id/unarchive\", \"$post\">;\ninterface ThreadSpawnBaseArgs extends Omit {\n childOrigin?: CreateThreadRequest[\"childOrigin\"];\n origin?: CreateThreadRequest[\"origin\"];\n originKind?: CreateThreadRequest[\"originKind\"];\n startedOnBehalfOf?: CreateThreadRequest[\"startedOnBehalfOf\"];\n}\ntype ThreadSpawnArgs = ThreadSpawnBaseArgs & ({\n input: CreateThreadRequest[\"input\"];\n prompt?: never;\n} | {\n input?: never;\n prompt: string;\n});\ninterface ThreadUpdateArgs extends UpdateThreadRequest {\n threadId: string;\n}\ninterface ThreadDeleteArgs extends DeleteThreadRequest {\n threadId: string;\n}\ninterface ThreadSendArgs extends SendMessageRequest {\n threadId: string;\n}\ninterface ThreadStatusArgs {\n threadId: string;\n}\ninterface ThreadOpenArgs {\n threadId: string;\n source: PanelFileSource;\n path: string;\n lineNumber: number | null;\n}\ninterface ThreadEventsListArgs {\n afterSeq?: string;\n limit?: string;\n threadId: string;\n}\ninterface ThreadEventWaitArgs {\n afterSeq?: string;\n threadId: string;\n type: string;\n waitMs: string;\n}\ninterface ThreadTimelineArgs extends ThreadTimelineQuery {\n threadId: string;\n}\ninterface ThreadOutputArgs {\n threadId: string;\n}\ninterface ThreadTerminalListArgs {\n threadId: string;\n}\ninterface ThreadTerminalCreateArgs extends Omit {\n threadId: string;\n}\ninterface ThreadTerminalTargetArgs {\n terminalId: string;\n threadId: string;\n}\ninterface ThreadTerminalUpdateArgs extends ThreadTerminalTargetArgs, UpdateTerminalRequest {\n}\ninterface ThreadTerminalCloseArgs extends ThreadTerminalTargetArgs, CloseTerminalRequest {\n}\ninterface ThreadTerminalInputArgs extends ThreadTerminalTargetArgs, TerminalInputRequest {\n}\ninterface ThreadTerminalResizeArgs extends ThreadTerminalTargetArgs, TerminalResizeRequest {\n}\ninterface ThreadTerminalOutputArgs extends ThreadTerminalTargetArgs, TerminalOutputQuery {\n}\ninterface ThreadInteractionListArgs {\n threadId: string;\n}\ninterface ThreadInteractionGetArgs extends ThreadInteractionListArgs {\n interactionId: string;\n}\ninterface ThreadInteractionResolveArgs extends ThreadInteractionGetArgs {\n resolution: PendingInteractionResolution;\n}\ntype ThreadWaitTarget = {\n kind: \"status\";\n status: ThreadStatus;\n} | {\n kind: \"event\";\n eventType: string;\n};\ninterface ThreadWaitArgs {\n event?: string;\n pollIntervalMs?: number;\n status?: ThreadStatus;\n threadId: string;\n timeoutMs?: number;\n}\ntype ThreadWaitResult = {\n event: NonNullable;\n matched: true;\n target: Extract;\n threadId: string;\n} | {\n matched: true;\n target: Extract;\n thread: ThreadGetResult;\n threadId: string;\n};\ninterface ThreadInteractionsArea {\n get(args: ThreadInteractionGetArgs): Promise;\n list(args: ThreadInteractionListArgs): Promise;\n resolve(args: ThreadInteractionResolveArgs): Promise;\n}\ninterface ThreadEventsArea {\n list(args: ThreadEventsListArgs): Promise;\n wait(args: ThreadEventWaitArgs): Promise;\n}\ninterface ThreadTerminalsArea {\n close(args: ThreadTerminalCloseArgs): Promise;\n create(args: ThreadTerminalCreateArgs): Promise;\n input(args: ThreadTerminalInputArgs): Promise;\n list(args: ThreadTerminalListArgs): Promise;\n output(args: ThreadTerminalOutputArgs): Promise;\n resize(args: ThreadTerminalResizeArgs): Promise;\n update(args: ThreadTerminalUpdateArgs): Promise;\n}\ninterface ThreadsArea {\n archive(args: ThreadStatusArgs): Promise;\n delete(args: ThreadDeleteArgs): Promise;\n events: ThreadEventsArea;\n get(args: ThreadGetArgs): Promise;\n interactions: ThreadInteractionsArea;\n list(args?: ThreadListArgs): Promise;\n open(args: ThreadOpenArgs): Promise;\n output(args: ThreadOutputArgs): Promise;\n pin(args: ThreadStatusArgs): Promise;\n send(args: ThreadSendArgs): Promise;\n spawn(args: ThreadSpawnArgs): Promise;\n stop(args: ThreadStatusArgs): Promise;\n terminals: ThreadTerminalsArea;\n timeline(args: ThreadTimelineArgs): Promise;\n unarchive(args: ThreadStatusArgs): Promise;\n unpin(args: ThreadStatusArgs): Promise;\n update(args: ThreadUpdateArgs): Promise;\n wait(args: ThreadWaitArgs): Promise;\n}\ndeclare function createThreadsArea(args: CreateSdkAreaArgs): ThreadsArea;\n\ninterface BbSdk extends BbRealtime {\n automations: ReturnType;\n environments: ReturnType;\n files: ReturnType;\n guide: ReturnType;\n hosts: ReturnType;\n projects: ReturnType;\n providers: ReturnType;\n status: ReturnType;\n theme: ReturnType;\n threads: ReturnType;\n}\n\n/**\n * The backend plugin API contract — the `bb` object handed to a plugin's\n * `server.ts` factory (`export default function plugin(bb: BbPluginApi)`).\n *\n * Types only: the implementation lives in the BB server\n * (apps/server/src/services/plugins/plugin-api.ts), which imports these\n * shapes so the contract and the implementation cannot drift. Plugin authors\n * import them type-only (`import type { BbPluginApi } from\n * \"@bb/plugin-sdk\"`); the import is erased when BB loads the file.\n *\n * Runtime classes stay host-side. NeedsConfigurationError in particular is\n * matched by NAME, so plugin code needs no runtime import:\n * `throw Object.assign(new Error(msg), { name: \"NeedsConfigurationError\" })`.\n */\ninterface PluginLogger {\n debug(message: string): void;\n info(message: string): void;\n warn(message: string): void;\n error(message: string): void;\n}\n/**\n * Declarative settings descriptors (`bb.settings.define`). Deliberately plain\n * data — not zod — so the host can render settings forms and the CLI can\n * parse values without executing plugin code.\n */\ntype PluginSettingDescriptor = {\n type: \"string\";\n label: string;\n description?: string;\n /** Stored in a 0600 file under /plugins//secrets/, never in the db or sent to the frontend. */\n secret?: true;\n default?: string;\n} | {\n type: \"boolean\";\n label: string;\n description?: string;\n default?: boolean;\n} | {\n type: \"select\";\n label: string;\n description?: string;\n options: string[];\n default?: string;\n} | {\n type: \"project\";\n label: string;\n description?: string;\n default?: string;\n};\ntype PluginSettingDescriptors = Record;\ntype PluginSettingValue = string | boolean;\n/** `default` present → non-optional value; absent → `T | undefined`. */\ntype PluginSettingsValues> = {\n [K in keyof Ds]: Ds[K] extends {\n default: string | boolean;\n } ? PluginSettingValueOf : PluginSettingValueOf | undefined;\n};\ntype PluginSettingValueOf = D extends {\n type: \"boolean\";\n} ? boolean : string;\ninterface PluginSettingsHandle> {\n /** Load-safe: callable inside the factory. */\n get(): Promise>;\n /** Fires after values change through the settings route/CLI. */\n onChange(listener: (next: PluginSettingsValues, prev: PluginSettingsValues) => void): void;\n}\ninterface PluginSettings {\n define>(descriptors: Ds): PluginSettingsHandle;\n}\ninterface PluginKvStorage {\n get(key: string): Promise;\n set(key: string, value: unknown): Promise;\n delete(key: string): Promise;\n list(prefix?: string): Promise;\n}\ninterface PluginStorage {\n /** Namespaced JSON key-value rows in bb.db; values ≤256KB each. */\n kv: PluginKvStorage;\n /**\n * Open (or reuse the path of) the plugin's own SQLite database at\n * /plugins//data.db — the server's better-sqlite3, WAL mode,\n * busy_timeout 5000. Handles are host-tracked and closed on\n * dispose/reload; a closed handle throws on use.\n */\n sqlite(): Database.Database;\n /**\n * Ordered-statement migration helper: statement index = migration id in a\n * `_bb_migrations` table; unapplied statements run in one transaction.\n * Append-only — never reorder or edit shipped statements.\n */\n migrate(db: Database.Database, statements: string[]): void;\n}\n/**\n * Thread lifecycle events a plugin can observe (design §4.5). Observe-only:\n * handlers run fire-and-forget after the transition is applied and can never\n * block or veto it. `thread` is the same public DTO GET /threads/:id serves.\n */\ninterface PluginThreadEventPayloads {\n \"thread.created\": {\n thread: ThreadResponse;\n };\n /** Fired when a thread transitions into `idle`. `lastAssistantText` is\n * assembled the same way GET /threads/:id/output is. */\n \"thread.idle\": {\n thread: ThreadResponse;\n lastAssistantText: string | null;\n };\n /** Fired when a thread transitions into `error`. `error` is the latest\n * system/error event message, when one exists. */\n \"thread.failed\": {\n thread: ThreadResponse;\n error: string | null;\n };\n}\ntype PluginThreadEventName = keyof PluginThreadEventPayloads;\ntype PluginThreadEventHandler = (payload: PluginThreadEventPayloads[E]) => void | Promise;\ntype PluginHttpAuthMode = \"local\" | \"token\" | \"none\";\ntype PluginHttpHandler = (context: Context) => Response | Promise;\ninterface PluginHttp {\n /**\n * Register an HTTP route, mounted at\n * `/api/v1/plugins//http/`. Auth modes (default \"local\"):\n * - \"local\": Origin/Host must be a local BB app origin; non-GET requires\n * content-type application/json (forces a CORS preflight).\n * - \"token\": requires the per-plugin token (`bb plugin token `) via\n * the x-bb-plugin-token header or ?token=.\n * - \"none\": no checks — only for signature-verified webhooks.\n */\n route(method: string, path: string, handler: PluginHttpHandler, opts?: {\n auth?: PluginHttpAuthMode;\n }): void;\n}\ninterface PluginRpc {\n /**\n * Register rpc methods, served at POST\n * `/api/v1/plugins//rpc/` with \"local\" auth semantics. The\n * JSON request body is the input; the response is\n * `{ ok: true, result }` or `{ ok: false, error }`. Inputs and outputs\n * must survive a JSON round-trip — results are serialized with\n * JSON.stringify and nothing else.\n */\n register(handlers: Record unknown>): void;\n}\ninterface PluginRealtime {\n /**\n * Broadcast an ephemeral `plugin-signal` WS message\n * `{ pluginId, channel, payload }` to every connected client (V1 has no\n * per-channel subscriptions). `payload` must be JSON-serializable;\n * `undefined` is normalized to `null`. Nothing is persisted.\n */\n publish(channel: string, payload: unknown): void;\n}\ninterface PluginBackground {\n /**\n * Register a long-lived background service. `start` runs after the\n * factory completes and should resolve when `signal` aborts\n * (dispose/reload/disable/shutdown). A crash restarts it with capped\n * exponential backoff; throwing NeedsConfigurationError marks the plugin\n * `needs-configuration` and stops restarting until the next load.\n */\n service(name: string, service: {\n start(signal: AbortSignal): void | Promise;\n }): void;\n /**\n * Register a cron schedule (5-field expression, server-local time). The\n * durable row keyed (pluginId, name) is upserted at load; the periodic\n * sweep claims due rows with a CAS on next_run_at, but only while this\n * plugin is loaded. Failures land in last_status/last_error, visible in\n * `bb plugin list`.\n */\n schedule(name: string, cron: string, fn: () => void | Promise): void;\n}\ninterface PluginCliCommandInfo {\n name: string;\n summary: string;\n usage: string;\n}\n/** Context forwarded from the invoking CLI when known; all fields optional. */\ninterface PluginCliContext {\n cwd?: string;\n threadId?: string;\n projectId?: string;\n}\ninterface PluginCliResult {\n exitCode: number;\n stdout?: string;\n stderr?: string;\n}\ninterface PluginCliRegistration {\n /** Top-level command name (`bb …`): lowercase [a-z0-9-]+, and not\n * a core bb command (see RESERVED_BB_CLI_COMMANDS in the server). */\n name: string;\n summary: string;\n /** Subcommand metadata rendered in help and the plugin-commands skill\n * without executing plugin code. Parsing argv is plugin-owned. */\n commands?: PluginCliCommandInfo[];\n run(argv: string[], ctx: PluginCliContext): PluginCliResult | Promise;\n}\ninterface PluginCli {\n /**\n * Register this plugin's `bb` subcommand. One registration per plugin —\n * a second call replaces the first. Core bb commands always win name\n * collisions; reserved names are rejected at registration.\n */\n register(registration: PluginCliRegistration): void;\n}\n/** Per-turn context handed to bb.agents context providers (design §4.4). */\n/** MCP-style content parts a native tool may return (design §4.4). */\ntype PluginAgentToolContentPart = {\n type: \"text\";\n text: string;\n} | {\n type: \"image\";\n data: string;\n mimeType: string;\n};\ntype PluginAgentToolResult = string | {\n content: PluginAgentToolContentPart[];\n isError?: boolean;\n};\n/** Per-call context handed to a native tool's execute (design §4.4). */\ninterface PluginAgentToolContext {\n threadId: string;\n projectId: string;\n /** The tool-call request's abort signal (aborts if the daemon round-trip\n * is torn down mid-call). */\n signal: AbortSignal;\n}\ninterface PluginAgentToolRegistrationBase {\n /** Tool name shown to the model: [a-zA-Z0-9_-]+, unique across plugins,\n * and not a built-in dynamic tool (see RESERVED_AGENT_TOOL_NAMES in the\n * server). */\n name: string;\n description: string;\n /**\n * Optional usage snippet appended to the thread instructions whenever\n * this tool is in the session's tool set (mirrors the built-in\n * update_environment_directory guidance). Keep it short.\n */\n instructions?: string;\n}\ninterface PluginAgents {\n /**\n * Register a native dynamic tool (design §4.4). `parameters` is either a\n * zod schema (validated per call; execute receives the parsed value) or a\n * plain JSON-schema object (no validation; execute receives the raw\n * arguments as `unknown`). Tool-set changes apply on the NEXT session\n * start — a tool registered mid-session is not hot-added to running\n * provider sessions. A second registration of the same name within this\n * plugin replaces the first; a name already registered by another plugin\n * is rejected and surfaced as this plugin's status detail.\n */\n registerTool(tool: PluginAgentToolRegistrationBase & {\n parameters: Schema;\n execute(params: z.output, ctx: PluginAgentToolContext): PluginAgentToolResult | Promise;\n }): void;\n registerTool(tool: PluginAgentToolRegistrationBase & {\n /** Raw JSON-schema escape hatch; params arrive unvalidated. */\n parameters: Record;\n execute(params: unknown, ctx: PluginAgentToolContext): PluginAgentToolResult | Promise;\n }): void;\n}\ninterface PluginThreadActionContext {\n threadId: string;\n projectId: string;\n}\ninterface PluginThreadActionToast {\n kind: \"success\" | \"error\" | \"info\";\n message: string;\n}\ntype PluginThreadActionResult = void | {\n toast?: PluginThreadActionToast;\n};\ninterface PluginThreadActionRegistration {\n /** Unique within this plugin: [a-zA-Z0-9_-]+ (becomes a URL segment). */\n id: string;\n /** Button label rendered in the thread header. */\n title: string;\n /** Optional icon name; the host falls back to a generic icon. */\n icon?: string;\n /** Optional confirmation prompt the host shows before running. */\n confirm?: string;\n /**\n * Runs server-side when the user clicks the action. The host shows a\n * pending state while in flight, the returned toast on completion, and an\n * automatic error toast when this throws.\n */\n run(ctx: PluginThreadActionContext): PluginThreadActionResult | Promise;\n}\n/** Search context handed to a mention provider (design §4.9). `projectId`/\n * `threadId` are null when the composer has not committed one yet. */\ninterface PluginMentionSearchContext {\n query: string;\n projectId: string | null;\n threadId: string | null;\n}\n/** One row a mention provider returns from `search`. `id` is the provider's\n * own item id — the host namespaces it before it reaches the wire. */\ninterface PluginMentionItem {\n id: string;\n title: string;\n subtitle?: string;\n icon?: string;\n}\ninterface PluginMentionProviderRegistration {\n /** Unique within this plugin: [a-zA-Z0-9_-]+ (no \":\" — the host composes\n * wire item ids as \":\"). */\n id: string;\n /** Section label shown above this provider's rows in the mention menu. */\n label: string;\n /**\n * Runs server-side as the user types after `@` in the composer. Each call\n * is time-boxed (2s) and failure-isolated: a slow or throwing provider\n * contributes an empty list — it can never break the mention menu.\n */\n search(ctx: PluginMentionSearchContext): PluginMentionItem[] | Promise;\n /**\n * Resolves one picked item into agent context, called once per unique\n * item at message send time. The returned `context` is attached to the\n * message as an agent-visible (user-hidden) prompt input. Throwing blocks\n * the send with a visible error.\n */\n resolve(itemId: string): {\n context: string;\n } | Promise<{\n context: string;\n }>;\n}\ninterface PluginUi {\n /**\n * Register a thread action rendered in the shipped app's thread header\n * (design §4.9). Multiple actions per plugin; ids must be unique within\n * the plugin. Invoked via POST /plugins/:id/actions/:actionId.\n */\n registerThreadAction(action: PluginThreadActionRegistration): void;\n /**\n * Register an `@`-mention provider for the shipped app's composer\n * (design §4.9). Items group under `label` in the mention menu; a picked\n * item becomes a `{ kind: \"plugin\" }` mention resource whose context is\n * resolved once at send time. Multiple providers per plugin; ids must be\n * unique within the plugin.\n */\n registerMentionProvider(provider: PluginMentionProviderRegistration): void;\n}\ninterface PluginStatusApi {\n /**\n * Mark this plugin `needs-configuration` (with a message shown in\n * `bb plugin list` and the UI) instead of failing — e.g. a factory or\n * service that finds no API key configured. Cleared on the next load;\n * saving settings does not auto-reload in V1, so ask the user to\n * `bb plugin reload ` after configuring.\n */\n needsConfiguration(message: string): void;\n}\n/**\n * The API object handed to a plugin's factory (design §4). Implemented by\n * the BB server; this contract is what plugin `server.ts` files compile\n * against.\n */\ninterface BbPluginApi {\n /** The plugin's own id (namespaces storage, routes, commands). */\n readonly pluginId: string;\n /** Leveled, plugin-scoped logger. */\n readonly log: PluginLogger;\n /** Declarative settings (design §4.2). */\n readonly settings: PluginSettings;\n /** Namespaced KV + per-plugin SQLite (design §4.3). */\n readonly storage: PluginStorage;\n /** HTTP routes under /api/v1/plugins//http/* (design §4.6). */\n readonly http: PluginHttp;\n /** RPC methods under /api/v1/plugins//rpc/ (design §4.6). */\n readonly rpc: PluginRpc;\n /** Ephemeral push to connected frontends (design §4.7). */\n readonly realtime: PluginRealtime;\n /** Long-lived services + cron schedules (design §4.8). */\n readonly background: PluginBackground;\n /** Agent-facing `bb` CLI subcommand (design §4.4). */\n readonly cli: PluginCli;\n /** Per-turn agent context contributions (design §4.4). */\n readonly agents: PluginAgents;\n /** Host-rendered UI contributions (design §4.9). */\n readonly ui: PluginUi;\n /** Plugin-reported status (needs-configuration). */\n readonly status: PluginStatusApi;\n /**\n * The full BB SDK, bound to this server over loopback (design §4.1).\n * Bind-gated: reading this before the host binds the SDK throws. The real\n * server binds it before loading plugins, so it is available from the\n * moment factories run there — but isolated harnesses may not, so prefer\n * using it from handlers, services, and timers for portability.\n * `threads.spawn` defaults `origin` to \"plugin\" and `originPluginId` to\n * this plugin's id so spawned threads are attributed automatically.\n */\n readonly sdk: BbSdk;\n /**\n * Observe thread lifecycle events (design §4.5). Load-safe registration;\n * handlers run async after the transition and never affect it. Errors are\n * caught, logged, and counted against this plugin's handler stats.\n */\n on(event: E, handler: PluginThreadEventHandler): void;\n /**\n * Register cleanup to run on reload/disable/shutdown. Hooks run LIFO.\n * The sanctioned place to clear timers and close connections.\n */\n onDispose(hook: () => void | Promise): void;\n}\n\nexport { PLUGIN_SDK_APP_EXPORT_NAMES, PLUGIN_SLOT_ID_PATTERN };\nexport type { BbContext, BbNavigate, BbPluginApi, PluginAgentToolContentPart, PluginAgentToolContext, PluginAgentToolRegistrationBase, PluginAgentToolResult, PluginAgents, PluginAppBuilder, PluginAppDefinition, PluginAppSetup, PluginAppSlots, PluginBackground, PluginCli, PluginCliCommandInfo, PluginCliContext, PluginCliRegistration, PluginCliResult, PluginComposerAccessoryProps, PluginComposerAccessoryRegistration, PluginComposerApi, PluginComposerMention, PluginComposerScope, PluginHomepageSectionProps, PluginHomepageSectionRegistration, PluginHttp, PluginHttpAuthMode, PluginHttpHandler, PluginKvStorage, PluginLogger, PluginMentionItem, PluginMentionProviderRegistration, PluginMentionSearchContext, PluginNavPanelProps, PluginNavPanelRegistration, PluginRealtime, PluginRpc, PluginRpcClient, PluginSdkApp, PluginSettingDescriptor, PluginSettingDescriptors, PluginSettingValue, PluginSettings, PluginSettingsHandle, PluginSettingsState, PluginSettingsValues, PluginStatusApi, PluginStorage, PluginThreadActionContext, PluginThreadActionRegistration, PluginThreadActionResult, PluginThreadActionToast, PluginThreadEventHandler, PluginThreadEventName, PluginThreadEventPayloads, PluginThreadPanelActionContext, PluginThreadPanelActionRegistration, PluginThreadPanelProps, PluginUi };\n"; +export const PLUGIN_SDK_DTS = "// Bundled type declarations for `@bb/plugin-sdk`, shipped into scaffolded\n// plugins so they typecheck without the @bb/* workspace on disk.\n//\n// Confused by the API, or need a symbol that isn't here? Clone the BB repo\n// and read the real source: https://github.com/ymichael/bb\n\nimport { ComponentType } from 'react';\nimport Database from 'better-sqlite3';\nimport { Context } from 'hono';\nimport * as z from 'zod';\nimport { z as z$1 } from 'zod';\n\n/**\n * The `@bb/plugin-sdk/app` contract (plugin design §5.2) — pure types plus\n * the runtime export-name list, with no side effects. This module is what the\n * BB app imports to keep its real implementation in sync (`satisfies\n * PluginSdkApp`) and what `bb plugin build` imports to generate the shim's\n * named-export list. Plugin authors import the same shapes through\n * `@bb/plugin-sdk/app`.\n *\n * Per-slot props are versioned contracts: additive-only within an SDK major.\n */\n/** Props passed to a `homepageSection` component. */\ninterface PluginHomepageSectionProps {\n /** Project in view on the compose surface; null when none is selected. */\n projectId: string | null;\n}\n/** Props passed to a `navPanel` component (it owns its whole route). */\ninterface PluginNavPanelProps {\n /**\n * The route remainder after the panel root, \"\" at the root. The panel's\n * route is `/plugins///*`, so a deep link like\n * `/plugins/notes/notes/work/ideas.md` renders the panel with\n * `subPath: \"work/ideas.md\"`. Navigate within the panel via\n * `useBbNavigate().toPluginPanel(path, { subPath })` — browser\n * back/forward then walks panel-internal history.\n */\n subPath: string;\n}\n/** Props passed to a panel tab opened by a `threadPanelAction`. */\ninterface PluginThreadPanelProps {\n threadId: string;\n /**\n * The JSON value the action's `openPanel` call passed (round-tripped\n * through persistence, so the tab restores across reloads); null when the\n * action opened the panel without params.\n */\n params: unknown;\n}\n/** Props passed to a `composerAccessory` component. */\ninterface PluginComposerAccessoryProps {\n projectId: string | null;\n threadId: string | null;\n}\n/**\n * Where a file being opened by a `fileOpener` lives. `path` semantics follow\n * the source: workspace paths are relative to the environment's worktree,\n * thread-storage paths are relative to the thread's storage root, host paths\n * are absolute on the thread's host.\n */\ninterface PluginFileOpenerSource {\n kind: \"workspace\" | \"host\" | \"thread-storage\";\n threadId: string | null;\n environmentId: string | null;\n projectId: string | null;\n}\n/** Props passed to a `fileOpener` component (rendered as a panel file tab). */\ninterface PluginFileOpenerProps {\n path: string;\n source: PluginFileOpenerSource;\n}\n/**\n * Slot/panel ids and nav-panel paths must match this pattern (letters,\n * digits, `-`, `_`): they ride URLs and persisted panel-tab keys.\n */\ndeclare const PLUGIN_SLOT_ID_PATTERN: RegExp;\ninterface PluginHomepageSectionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n component: ComponentType;\n}\ninterface PluginNavPanelRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n /** Icon hint (BB icon name); unknown names fall back to a generic icon. */\n icon: string;\n /** URL segment under `/plugins//`; letters, digits, `-`, `_`. */\n path: string;\n component: ComponentType;\n /**\n * Panel chrome (default \"page\"): \"page\" renders the host title bar (plugin\n * logo + `title` + your `headerContent`) above a full-width padded body;\n * \"none\" hands the ENTIRE panel area to `component` — no host padding, no\n * title bar (`headerContent` is ignored) — only the per-plugin error\n * boundary remains.\n */\n chrome?: \"page\" | \"none\";\n /**\n * Optional component rendered on the right side of the \"page\" title bar\n * (e.g. a sync button or a count). Contained separately from the body: a\n * throwing headerContent is hidden without breaking the title bar.\n */\n headerContent?: ComponentType;\n}\n/** Context handed to a `threadPanelAction`'s `run`. */\ninterface PluginThreadPanelActionContext {\n /** The thread whose panel launcher invoked the action. */\n threadId: string;\n /**\n * Open a tab in the thread's side panel rendering this action's\n * `component`. `title` labels the tab (default: the action's `title`);\n * `params` must be JSON-serializable — it is persisted with the tab and\n * reaches the component as its `params` prop. Opening with params\n * identical to an already-open tab of this action focuses that tab\n * (updating its title) instead of duplicating it. May be called more than\n * once (different params ⇒ multiple tabs) or not at all.\n */\n openPanel(options?: {\n title?: string;\n params?: unknown;\n }): void;\n}\ninterface PluginThreadPanelActionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n /** Label of the action row in the panel's new-tab launcher. */\n title: string;\n /**\n * Icon hint (BB icon name) used when the plugin ships no logo; the\n * launcher row and opened tabs prefer the plugin's logo.\n */\n icon?: string;\n /** Rendered inside every panel tab this action opens. */\n component: ComponentType;\n /**\n * Runs when the user activates the action: call your RPC methods, show a\n * toast, and/or open panel tabs via `context.openPanel`. Omitted =\n * immediately open a panel tab with defaults. Errors (sync or async) are\n * contained and logged; they never break the launcher.\n */\n run?(context: PluginThreadPanelActionContext): void | Promise;\n}\ninterface PluginComposerAccessoryRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n component: ComponentType;\n}\n/**\n * Register this plugin as a viewer/editor for file extensions. The user\n * picks (and can set as default) an opener per extension via the file tab's\n * \"Open with\" menu; matching files opened in the panel then render\n * `component` in a plugin tab instead of the built-in preview. Applies to\n * working-tree, host, and thread-storage files — never to git-ref snapshots\n * (diff views always use the built-in preview). The built-in preview stays\n * one menu click away, and a missing/disabled opener falls back to it.\n */\ninterface PluginFileOpenerRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n /** Label in the \"Open with\" menu (e.g. \"Notes editor\"). */\n title: string;\n /** Lowercase extensions without the dot (e.g. [\"md\", \"mdx\"]). */\n extensions: readonly string[];\n component: ComponentType;\n}\ninterface PluginAppSlots {\n homepageSection(registration: PluginHomepageSectionRegistration): void;\n navPanel(registration: PluginNavPanelRegistration): void;\n threadPanelAction(registration: PluginThreadPanelActionRegistration): void;\n composerAccessory(registration: PluginComposerAccessoryRegistration): void;\n fileOpener(registration: PluginFileOpenerRegistration): void;\n}\ninterface PluginAppBuilder {\n slots: PluginAppSlots;\n}\ntype PluginAppSetup = (app: PluginAppBuilder) => void;\n/**\n * The opaque product of `definePluginApp` — a plugin's `app.tsx` default\n * export. The host re-runs `setup` against a fresh collector on every\n * (re)interpretation, replacing that plugin's registrations wholesale.\n */\ninterface PluginAppDefinition {\n /** Brand the host checks before interpreting a bundle's default export. */\n readonly __bbPluginApp: true;\n readonly setup: PluginAppSetup;\n}\ninterface PluginRpcClient {\n /**\n * Invoke one of the plugin's `bb.rpc` methods (POST\n * /api/v1/plugins/<id>/rpc/<method>). Resolves with the method's\n * result; rejects with an `Error` carrying the server's message when the\n * handler fails or the plugin is not running.\n */\n call(method: string, input?: unknown): Promise;\n}\ninterface PluginSettingsState {\n /**\n * Effective non-secret setting values (secret settings are excluded —\n * read them server-side). Undefined while loading or unavailable.\n */\n values: Record | undefined;\n isLoading: boolean;\n}\n/** Where `useComposer()` writes: the active thread's draft or the new-thread draft. */\ntype PluginComposerScope = {\n kind: \"thread\";\n threadId: string;\n} | {\n kind: \"new-thread\";\n projectId: string | null;\n};\n/** An @-mention pill bound to one of the calling plugin's mention providers. */\ninterface PluginComposerMention {\n /** Mention provider id registered by THIS plugin via `bb.ui.registerMentionProvider`. */\n provider: string;\n /** Item id your provider's `resolve` will receive at send time. */\n id: string;\n /** Pill text shown in the composer. */\n label: string;\n}\n/**\n * Programmatic access to the chat composer draft — the same shared draft the\n * built-in \"Add to chat\" affordances (file preview, diff, terminal selections)\n * write to. Inside a thread context writes land in that thread's draft;\n * anywhere else (nav panel, homepage section) they seed the new-thread\n * composer draft, which persists until the user sends or clears it.\n */\ninterface PluginComposerApi {\n scope: PluginComposerScope;\n /**\n * Append text to the draft as a `> ` blockquote block and focus the\n * composer. Blank text is a no-op. This is the \"reference this selection\n * in chat\" primitive.\n */\n addQuote(text: string): void;\n /**\n * Insert an @-mention pill that resolves through this plugin's mention\n * provider at send time — the durable way to reference an entity whose\n * content should be fetched fresh when the message is sent.\n */\n insertMention(mention: PluginComposerMention): void;\n /** Focus the composer caret at the end of the draft. */\n focus(): void;\n}\n/** Current app selection, derived from the route. */\ninterface BbContext {\n projectId: string | null;\n threadId: string | null;\n}\ninterface BbNavigate {\n toThread(threadId: string): void;\n toProject(projectId: string): void;\n /**\n * Navigate to one of this plugin's own nav panels by its `path`.\n * `subPath` targets a location inside the panel (the component's\n * `subPath` prop); `replace` swaps the current history entry instead of\n * pushing — use it for redirects so back does not bounce.\n */\n toPluginPanel(path: string, options?: {\n subPath?: string;\n replace?: boolean;\n }): void;\n}\n/**\n * Everything `@bb/plugin-sdk/app` resolves to at runtime. The BB app builds\n * the real implementation and `satisfies` this interface; `bb plugin build`\n * shims the specifier to that object on `globalThis.__bbPluginRuntime`.\n */\ninterface PluginSdkApp {\n definePluginApp(setup: PluginAppSetup): PluginAppDefinition;\n useRpc(): PluginRpcClient;\n useRealtime(channel: string, handler: (payload: unknown) => void): void;\n useSettings(): PluginSettingsState;\n useBbContext(): BbContext;\n useBbNavigate(): BbNavigate;\n useComposer(): PluginComposerApi;\n}\n/**\n * Named runtime exports of `@bb/plugin-sdk/app`, in sorted order. Single\n * source of truth for the build shim's export list and the app's\n * implementation-key test — adding a surface member without updating this\n * list fails the type assertion below.\n */\ndeclare const PLUGIN_SDK_APP_EXPORT_NAMES: readonly [\"definePluginApp\", \"useBbContext\", \"useBbNavigate\", \"useComposer\", \"useRealtime\", \"useRpc\", \"useSettings\"];\n\ndeclare const appThemeSchema: z$1.ZodObject<{\n themeId: z$1.ZodString;\n customCss: z$1.ZodNullable;\n faviconColor: z$1.ZodEnum<{\n default: \"default\";\n red: \"red\";\n orange: \"orange\";\n yellow: \"yellow\";\n green: \"green\";\n teal: \"teal\";\n blue: \"blue\";\n purple: \"purple\";\n pink: \"pink\";\n }>;\n}, z$1.core.$strip>;\ntype AppTheme = z$1.infer;\n\ndeclare const changedMessageSchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"thread\">;\n id: z$1.ZodOptional;\n metadata: z$1.ZodOptional;\n eventTypes: z$1.ZodOptional>>>>;\n hasPendingInteraction: z$1.ZodOptional;\n projectId: z$1.ZodOptional;\n }, z$1.core.$strict>>;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"project\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"environment\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"host\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"system\">;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>], \"entity\">;\ntype ChangedMessage = z$1.infer;\n\ndeclare const pendingInteractionResolutionSchema: z$1.ZodUnion;\n grantedPermissions: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n fileSystem: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n decision: z$1.ZodLiteral<\"allow_for_session\">;\n grantedPermissions: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n fileSystem: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n decision: z$1.ZodLiteral<\"deny\">;\n}, z$1.core.$strip>], \"decision\">, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"user_answer\">;\n answers: z$1.ZodRecord;\n freeText: z$1.ZodOptional;\n }, z$1.core.$strip>>;\n}, z$1.core.$strip>]>;\ntype PendingInteractionResolution = z$1.infer;\n\ndeclare const threadStatusSchema: z$1.ZodEnum<{\n error: \"error\";\n active: \"active\";\n starting: \"starting\";\n idle: \"idle\";\n stopping: \"stopping\";\n}>;\ntype ThreadStatus = z$1.infer;\n\ndeclare const threadTimelinePendingTodosSchema: z$1.ZodObject<{\n sourceSeq: z$1.ZodNumber;\n updatedAt: z$1.ZodNumber;\n items: z$1.ZodArray;\n }, z$1.core.$strip>>;\n}, z$1.core.$strip>;\ntype ThreadTimelinePendingTodos = z$1.infer;\n\ndeclare const createAutomationRequestSchema: z$1.ZodObject<{\n name: z$1.ZodString;\n enabled: z$1.ZodDefault;\n trigger: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n triggerType: z$1.ZodLiteral<\"schedule\">;\n cron: z$1.ZodString;\n timezone: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n triggerType: z$1.ZodLiteral<\"once\">;\n runAt: z$1.ZodNumber;\n }, z$1.core.$strip>], \"triggerType\">;\n execution: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"agent\">;\n prompt: z$1.ZodString;\n providerId: z$1.ZodString;\n model: z$1.ZodString;\n permissionMode: z$1.ZodEnum<{\n readonly: \"readonly\";\n full: \"full\";\n \"workspace-write\": \"workspace-write\";\n }>;\n targetThreadId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"script\">;\n script: z$1.ZodOptional;\n scriptFile: z$1.ZodOptional;\n interpreter: z$1.ZodOptional>;\n timeoutMs: z$1.ZodDefault;\n env: z$1.ZodOptional>;\n }, z$1.core.$strip>], \"mode\">;\n environment: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"reuse\">;\n environmentId: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"host\">;\n hostId: z$1.ZodOptional;\n workspace: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"unmanaged\">;\n path: z$1.ZodNullable;\n branch: z$1.ZodOptional;\n name: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"new\">;\n baseBranch: z$1.ZodString;\n }, z$1.core.$strict>], \"kind\">>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"managed-worktree\">;\n baseBranch: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"named\">;\n name: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"default\">;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"personal\">;\n }, z$1.core.$strip>], \"type\">;\n }, z$1.core.$strip>], \"type\">;\n autoArchive: z$1.ZodDefault;\n origin: z$1.ZodEnum<{\n agent: \"agent\";\n human: \"human\";\n app: \"app\";\n }>;\n createdByThreadId: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype CreateAutomationRequest = z$1.input;\ndeclare const updateAutomationRequestSchema: z$1.ZodObject<{\n name: z$1.ZodOptional;\n trigger: z$1.ZodOptional;\n cron: z$1.ZodString;\n timezone: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n triggerType: z$1.ZodLiteral<\"once\">;\n runAt: z$1.ZodNumber;\n }, z$1.core.$strip>], \"triggerType\">>;\n execution: z$1.ZodOptional;\n prompt: z$1.ZodString;\n providerId: z$1.ZodString;\n model: z$1.ZodString;\n permissionMode: z$1.ZodEnum<{\n readonly: \"readonly\";\n full: \"full\";\n \"workspace-write\": \"workspace-write\";\n }>;\n targetThreadId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"script\">;\n script: z$1.ZodOptional;\n scriptFile: z$1.ZodOptional;\n interpreter: z$1.ZodOptional>;\n timeoutMs: z$1.ZodDefault;\n env: z$1.ZodOptional>;\n }, z$1.core.$strip>], \"mode\">>;\n environment: z$1.ZodOptional;\n environmentId: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"host\">;\n hostId: z$1.ZodOptional;\n workspace: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"unmanaged\">;\n path: z$1.ZodNullable;\n branch: z$1.ZodOptional;\n name: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"new\">;\n baseBranch: z$1.ZodString;\n }, z$1.core.$strict>], \"kind\">>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"managed-worktree\">;\n baseBranch: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"named\">;\n name: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"default\">;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"personal\">;\n }, z$1.core.$strip>], \"type\">;\n }, z$1.core.$strip>], \"type\">>;\n autoArchive: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype UpdateAutomationRequest = z$1.infer;\ndeclare const runAutomationRequestSchema: z$1.ZodObject<{\n idempotencyKey: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype RunAutomationRequest = z$1.infer;\n\ndeclare const createProjectSourceRequestSchema: z$1.ZodObject<{\n hostId: z$1.ZodString;\n type: z$1.ZodLiteral<\"local_path\">;\n path: z$1.ZodPipe>;\n}, z$1.core.$strict>;\ntype CreateProjectSourceRequest = z$1.infer;\ndeclare const createProjectRequestSchema: z$1.ZodObject<{\n name: z$1.ZodString;\n source: z$1.ZodObject<{\n hostId: z$1.ZodString;\n type: z$1.ZodLiteral<\"local_path\">;\n path: z$1.ZodPipe>;\n }, z$1.core.$strict>;\n}, z$1.core.$strip>;\ntype CreateProjectRequest = z$1.infer;\ndeclare const projectListQuerySchema: z$1.ZodObject<{\n include: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype ProjectListQuery = z$1.infer;\ndeclare const updateProjectRequestSchema: z$1.ZodObject<{\n name: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype UpdateProjectRequest = z$1.infer;\ndeclare const updateProjectSourceRequestSchema: z$1.ZodObject<{\n type: z$1.ZodLiteral<\"local_path\">;\n path: z$1.ZodOptional>>;\n isDefault: z$1.ZodOptional>;\n}, z$1.core.$strict>;\ntype UpdateProjectSourceRequest = z$1.infer;\n\ndeclare const updateEnvironmentRequestSchema: z$1.ZodObject<{\n mergeBaseBranch: z$1.ZodOptional>;\n name: z$1.ZodOptional>;\n}, z$1.core.$strip>;\ntype UpdateEnvironmentRequest = z$1.infer;\ndeclare const environmentDiffBranchesQuerySchema: z$1.ZodObject<{\n query: z$1.ZodOptional;\n limit: z$1.ZodOptional;\n selectedBranch: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype EnvironmentDiffBranchesQuery = z$1.infer;\ndeclare const environmentStatusQuerySchema: z$1.ZodObject<{\n mergeBaseBranch: z$1.ZodOptional>;\n}, z$1.core.$strip>;\ntype EnvironmentStatusQuery = z$1.infer;\ndeclare const environmentDiffQuerySchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n target: z$1.ZodLiteral<\"uncommitted\">;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"branch_committed\">;\n mergeBaseBranch: z$1.ZodPipe;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"all\">;\n mergeBaseBranch: z$1.ZodPipe;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"commit\">;\n sha: z$1.ZodString;\n}, z$1.core.$strip>], \"target\">;\ntype EnvironmentDiffQuery = z$1.infer;\n/**\n * Query for fetching a single file's contents at one side of a diff target.\n * Used by the diff card to reparse the card's patch with full old/new contents\n * so `@pierre/diffs` can render expand-context buttons between hunks.\n *\n * For `branch_committed` / `all`, callers pass the resolved merge-base SHA\n * (`mergeBaseRef`, surfaced by `workspace.diff`) rather than the branch name\n * — the diff itself was computed against that SHA, so reading the old side\n * from the same SHA keeps the file content aligned with the hunk line\n * numbers. Reading from the branch tip is wrong whenever the branch has\n * moved past the merge-base since the file existed there.\n */\ndeclare const environmentDiffFileQuerySchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n target: z$1.ZodLiteral<\"uncommitted\">;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"branch_committed\">;\n mergeBaseRef: z$1.ZodString;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"all\">;\n mergeBaseRef: z$1.ZodString;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"commit\">;\n sha: z$1.ZodString;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>], \"target\">;\ntype EnvironmentDiffFileQuery = z$1.infer;\n\ndeclare const systemExecutionOptionsQuerySchema: z$1.ZodObject<{\n providerId: z$1.ZodOptional;\n hostId: z$1.ZodOptional;\n environmentId: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype SystemExecutionOptionsQuery = z$1.infer;\n/**\n * Theme catalog: the on-disk custom-theme directory plus the discovered custom\n * themes and the active palette. Drives `bb theme list` / `bb theme dir`.\n */\ndeclare const themeCatalogResponseSchema: z$1.ZodObject<{\n dir: z$1.ZodString;\n custom: z$1.ZodArray;\n active: z$1.ZodObject<{\n themeId: z$1.ZodString;\n customCss: z$1.ZodNullable;\n faviconColor: z$1.ZodEnum<{\n default: \"default\";\n red: \"red\";\n orange: \"orange\";\n yellow: \"yellow\";\n green: \"green\";\n teal: \"teal\";\n blue: \"blue\";\n purple: \"purple\";\n pink: \"pink\";\n }>;\n }, z$1.core.$strip>;\n}, z$1.core.$strip>;\ntype ThemeCatalogResponse = z$1.infer;\n\ndeclare const createTerminalRequestSchema: z$1.ZodObject<{\n cols: z$1.ZodNumber;\n rows: z$1.ZodNumber;\n start: z$1.ZodOptional;\n }, z$1.core.$strict>, z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"command\">;\n command: z$1.ZodString;\n }, z$1.core.$strict>], \"mode\">>;\n target: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"thread\">;\n threadId: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"environment\">;\n environmentId: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"host_path\">;\n hostId: z$1.ZodString;\n cwd: z$1.ZodNullable;\n }, z$1.core.$strict>], \"kind\">;\n title: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype CreateTerminalRequest = z$1.infer;\ndeclare const closeTerminalRequestSchema: z$1.ZodObject<{\n mode: z$1.ZodEnum<{\n force: \"force\";\n \"if-clean\": \"if-clean\";\n }>;\n reason: z$1.ZodLiteral<\"user\">;\n}, z$1.core.$strict>;\ntype CloseTerminalRequest = z$1.infer;\ndeclare const updateTerminalRequestSchema: z$1.ZodObject<{\n title: z$1.ZodString;\n}, z$1.core.$strict>;\ntype UpdateTerminalRequest = z$1.infer;\ndeclare const terminalInputRequestSchema: z$1.ZodObject<{\n dataBase64: z$1.ZodString;\n}, z$1.core.$strict>;\ntype TerminalInputRequest = z$1.infer;\ndeclare const terminalResizeRequestSchema: z$1.ZodObject<{\n cols: z$1.ZodNumber;\n rows: z$1.ZodNumber;\n}, z$1.core.$strict>;\ntype TerminalResizeRequest = z$1.infer;\ndeclare const terminalOutputQuerySchema: z$1.ZodObject<{\n sinceSeq: z$1.ZodOptional>;\n tailBytes: z$1.ZodOptional>;\n limitChunks: z$1.ZodOptional>;\n}, z$1.core.$strict>;\ntype TerminalOutputQuery = z$1.infer;\n\ndeclare const createThreadRequestSchema: z$1.ZodObject<{\n projectId: z$1.ZodString;\n providerId: z$1.ZodOptional;\n origin: z$1.ZodEnum<{\n plugin: \"plugin\";\n app: \"app\";\n automation: \"automation\";\n cli: \"cli\";\n sdk: \"sdk\";\n }>;\n originPluginId: z$1.ZodOptional;\n title: z$1.ZodOptional;\n input: z$1.ZodArray>;\n type: z$1.ZodLiteral<\"text\">;\n text: z$1.ZodString;\n mentions: z$1.ZodDefault;\n threadId: z$1.ZodString;\n projectId: z$1.ZodOptional;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"project\">;\n projectId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"path\">;\n source: z$1.ZodEnum<{\n workspace: \"workspace\";\n \"thread-storage\": \"thread-storage\";\n }>;\n entryKind: z$1.ZodEnum<{\n file: \"file\";\n directory: \"directory\";\n }>;\n path: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"command\">;\n trigger: z$1.ZodEnum<{\n \"/\": \"/\";\n }>;\n name: z$1.ZodString;\n source: z$1.ZodEnum<{\n command: \"command\";\n skill: \"skill\";\n }>;\n origin: z$1.ZodEnum<{\n user: \"user\";\n project: \"project\";\n builtin: \"builtin\";\n }>;\n label: z$1.ZodString;\n argumentHint: z$1.ZodNullable;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"plugin\">;\n pluginId: z$1.ZodString;\n itemId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>>>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"image\">;\n url: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localImage\">;\n path: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localFile\">;\n path: z$1.ZodString;\n name: z$1.ZodOptional;\n sizeBytes: z$1.ZodOptional;\n mimeType: z$1.ZodOptional;\n }, z$1.core.$strip>], \"type\">>;\n model: z$1.ZodOptional;\n serviceTier: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>;\n permissionMode: z$1.ZodOptional>;\n executionInputSources: z$1.ZodOptional>;\n model: z$1.ZodOptional>;\n serviceTier: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>;\n permissionMode: z$1.ZodOptional>;\n }, z$1.core.$strict>>;\n environment: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"reuse\">;\n environmentId: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"host\">;\n hostId: z$1.ZodOptional;\n workspace: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"unmanaged\">;\n path: z$1.ZodNullable;\n branch: z$1.ZodOptional;\n name: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"new\">;\n baseBranch: z$1.ZodString;\n }, z$1.core.$strict>], \"kind\">>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"managed-worktree\">;\n baseBranch: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"named\">;\n name: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"default\">;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"personal\">;\n }, z$1.core.$strip>], \"type\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"project-default\">;\n }, z$1.core.$strip>], \"type\">;\n parentThreadId: z$1.ZodOptional;\n folderId: z$1.ZodOptional>;\n sourceThreadId: z$1.ZodOptional;\n sourceSeqEnd: z$1.ZodOptional;\n startedOnBehalfOf: z$1.ZodDefault;\n senderThreadId: z$1.ZodString;\n }, z$1.core.$strip>>>;\n originKind: z$1.ZodDefault>>;\n childOrigin: z$1.ZodDefault>>;\n}, z$1.core.$strip>;\ntype CreateThreadRequest = z$1.infer;\ndeclare const sendMessageRequestSchema: z$1.ZodObject<{\n input: z$1.ZodArray>;\n type: z$1.ZodLiteral<\"text\">;\n text: z$1.ZodString;\n mentions: z$1.ZodDefault;\n threadId: z$1.ZodString;\n projectId: z$1.ZodOptional;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"project\">;\n projectId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"path\">;\n source: z$1.ZodEnum<{\n workspace: \"workspace\";\n \"thread-storage\": \"thread-storage\";\n }>;\n entryKind: z$1.ZodEnum<{\n file: \"file\";\n directory: \"directory\";\n }>;\n path: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"command\">;\n trigger: z$1.ZodEnum<{\n \"/\": \"/\";\n }>;\n name: z$1.ZodString;\n source: z$1.ZodEnum<{\n command: \"command\";\n skill: \"skill\";\n }>;\n origin: z$1.ZodEnum<{\n user: \"user\";\n project: \"project\";\n builtin: \"builtin\";\n }>;\n label: z$1.ZodString;\n argumentHint: z$1.ZodNullable;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"plugin\">;\n pluginId: z$1.ZodString;\n itemId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>>>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"image\">;\n url: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localImage\">;\n path: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localFile\">;\n path: z$1.ZodString;\n name: z$1.ZodOptional;\n sizeBytes: z$1.ZodOptional;\n mimeType: z$1.ZodOptional;\n }, z$1.core.$strip>], \"type\">>;\n model: z$1.ZodOptional;\n serviceTier: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>;\n permissionMode: z$1.ZodOptional>;\n executionInputSources: z$1.ZodOptional>;\n serviceTier: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>;\n permissionMode: z$1.ZodOptional>;\n }, z$1.core.$strict>>;\n mode: z$1.ZodEnum<{\n steer: \"steer\";\n start: \"start\";\n auto: \"auto\";\n \"queue-if-active\": \"queue-if-active\";\n \"steer-if-active\": \"steer-if-active\";\n }>;\n senderThreadId: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype SendMessageRequest = z$1.infer;\ndeclare const threadResponseSchema: z$1.ZodObject<{\n id: z$1.ZodString;\n projectId: z$1.ZodString;\n environmentId: z$1.ZodNullable;\n providerId: z$1.ZodString;\n title: z$1.ZodNullable;\n titleFallback: z$1.ZodNullable;\n folderId: z$1.ZodNullable;\n status: z$1.ZodEnum<{\n error: \"error\";\n stopping: \"stopping\";\n idle: \"idle\";\n starting: \"starting\";\n active: \"active\";\n }>;\n parentThreadId: z$1.ZodNullable;\n sourceThreadId: z$1.ZodNullable;\n originKind: z$1.ZodNullable>;\n childOrigin: z$1.ZodNullable>;\n originPluginId: z$1.ZodNullable;\n archivedAt: z$1.ZodNullable;\n pinnedAt: z$1.ZodNullable;\n deletedAt: z$1.ZodNullable;\n lastReadAt: z$1.ZodNullable;\n latestAttentionAt: z$1.ZodNumber;\n createdAt: z$1.ZodNumber;\n updatedAt: z$1.ZodNumber;\n runtime: z$1.ZodObject<{\n displayStatus: z$1.ZodEnum<{\n error: \"error\";\n provisioning: \"provisioning\";\n stopping: \"stopping\";\n idle: \"idle\";\n starting: \"starting\";\n active: \"active\";\n \"host-reconnecting\": \"host-reconnecting\";\n \"waiting-for-host\": \"waiting-for-host\";\n }>;\n hostReconnectGraceExpiresAt: z$1.ZodNullable;\n }, z$1.core.$strip>;\n canSpawnChild: z$1.ZodBoolean;\n}, z$1.core.$strip>;\ntype ThreadResponse = z$1.infer;\ndeclare const threadGetQuerySchema: z$1.ZodObject<{\n include: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype ThreadGetQuery = z$1.infer;\ndeclare const deleteThreadRequestSchema: z$1.ZodObject<{\n childThreadsConfirmed: z$1.ZodBoolean;\n}, z$1.core.$strip>;\ntype DeleteThreadRequest = z$1.infer;\ndeclare const updateThreadRequestSchema: z$1.ZodObject<{\n title: z$1.ZodOptional>;\n folderId: z$1.ZodOptional>;\n parentThreadId: z$1.ZodOptional>;\n model: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>>;\n}, z$1.core.$strip>;\ntype UpdateThreadRequest = z$1.infer;\n/** Which root a secondary-panel file path is relative to. */\ndeclare const panelFileSourceSchema: z$1.ZodEnum<{\n workspace: \"workspace\";\n \"thread-storage\": \"thread-storage\";\n}>;\ntype PanelFileSource = z$1.infer;\ndeclare const threadTimelineQuerySchema: z$1.ZodObject<{\n includeNestedRows: z$1.ZodOptional>;\n segmentLimit: z$1.ZodOptional;\n beforeAnchorSeq: z$1.ZodOptional;\n beforeAnchorId: z$1.ZodOptional;\n summaryOnly: z$1.ZodOptional>;\n afterSequence: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype ThreadTimelineQuery = z$1.infer;\n\ntype PublicApiSchema = unknown;\ntype ApiClient = unknown;\n\ntype FetchImplementation = typeof fetch;\ntype JsonBodyOf = TResponse extends {\n json(): Promise;\n} ? TBody : never;\n\ntype BbSdkRuntime = \"node\" | \"browser\";\ninterface BbSdkTransport {\n api: ApiClient[\"api\"];\n baseUrl: string;\n fetch: FetchImplementation;\n realtimeUrl?: string;\n runtime: BbSdkRuntime;\n readJson(response: Promise): Promise>;\n readVoid(response: Promise): Promise;\n resolve(response: Promise): Promise;\n websocket?: BbRealtimeSocketFactory;\n}\n/**\n * Raw socket payload. Treated as opaque until decoded — realtime ignores\n * non-string frames.\n */\ninterface BbRealtimeSocketMessageEvent {\n data: unknown;\n}\n/**\n * Minimal runtime-agnostic socket shape consumed by the realtime client.\n * Default factories adapt the environment's WebSocket (browser/Node global,\n * or the `ws` package on older Node) to this interface; custom `websocket`\n * factories can wrap any implementation the same way.\n */\ninterface BbRealtimeSocket {\n close(): void;\n onclose: (() => void) | null;\n onerror: (() => void) | null;\n onmessage: ((event: BbRealtimeSocketMessageEvent) => void) | null;\n onopen: (() => void) | null;\n readyState: number;\n send(data: string): void;\n}\ntype BbRealtimeSocketFactory = (url: string) => BbRealtimeSocket;\ninterface BbSdkContext {\n}\n\ninterface CreateSdkAreaArgs {\n context: BbSdkContext;\n transport: BbSdkTransport;\n}\ntype PublicApiEndpointOutput = TEndpoint extends {\n status: infer Status;\n output: infer Output;\n} ? Status extends SuccessfulHttpStatus ? Output : never : never;\ntype SuccessfulHttpStatus = 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226;\ntype PublicApiOutput = PublicApiEndpointOutput;\n\ninterface AutomationCreateArgs extends CreateAutomationRequest {\n projectId?: string;\n}\ninterface AutomationListArgs {\n projectId?: string;\n}\ninterface AutomationGetArgs {\n projectId?: string;\n automationId: string;\n}\ninterface AutomationUpdateArgs extends UpdateAutomationRequest {\n projectId?: string;\n automationId: string;\n}\ninterface AutomationActionArgs {\n projectId?: string;\n automationId: string;\n}\ninterface AutomationRunArgs extends RunAutomationRequest {\n projectId?: string;\n automationId: string;\n}\ninterface AutomationRunsArgs {\n projectId?: string;\n automationId: string;\n limit?: number;\n cursor?: string;\n}\ntype AutomationCreateResult = PublicApiOutput<\"/projects/:id/automations\", \"$post\">;\ntype AutomationListResult = PublicApiOutput<\"/projects/:id/automations\", \"$get\">;\ntype AutomationGetResult = PublicApiOutput<\"/projects/:id/automations/:automationId\", \"$get\">;\ntype AutomationUpdateResult = PublicApiOutput<\"/projects/:id/automations/:automationId\", \"$patch\">;\ntype AutomationPauseResult = PublicApiOutput<\"/projects/:id/automations/:automationId/pause\", \"$post\">;\ntype AutomationResumeResult = PublicApiOutput<\"/projects/:id/automations/:automationId/resume\", \"$post\">;\ntype AutomationRunResult = PublicApiOutput<\"/projects/:id/automations/:automationId/run\", \"$post\">;\ntype AutomationRunsResult = PublicApiOutput<\"/projects/:id/automations/:automationId/runs\", \"$get\">;\ntype AutomationsOverviewResult = PublicApiOutput<\"/automations\", \"$get\">;\ninterface AutomationsArea {\n create(args: AutomationCreateArgs): Promise;\n delete(args: AutomationActionArgs): Promise<{\n ok: true;\n }>;\n get(args: AutomationGetArgs): Promise;\n list(args?: AutomationListArgs): Promise;\n overview(): Promise;\n pause(args: AutomationActionArgs): Promise;\n resume(args: AutomationActionArgs): Promise;\n run(args: AutomationRunArgs): Promise;\n runs(args: AutomationRunsArgs): Promise;\n update(args: AutomationUpdateArgs): Promise;\n}\ndeclare function createAutomationsArea(args: CreateSdkAreaArgs): AutomationsArea;\n\ninterface EnvironmentGetArgs {\n environmentId: string;\n}\ntype EnvironmentMergeBaseBranchUpdateValue = Exclude;\ntype EnvironmentNameUpdateValue = Exclude;\ninterface EnvironmentMergeBaseBranchUpdate {\n mergeBaseBranch: EnvironmentMergeBaseBranchUpdateValue;\n name?: EnvironmentNameUpdateValue;\n}\ninterface EnvironmentNameUpdate {\n mergeBaseBranch?: EnvironmentMergeBaseBranchUpdateValue;\n name: EnvironmentNameUpdateValue;\n}\ntype EnvironmentUpdateFields = EnvironmentMergeBaseBranchUpdate | EnvironmentNameUpdate;\ntype EnvironmentUpdateArgs = EnvironmentUpdateFields & {\n environmentId: string;\n};\ninterface EnvironmentStatusArgs extends EnvironmentStatusQuery {\n environmentId: string;\n}\ntype EnvironmentDiffArgs = EnvironmentDiffQuery & {\n environmentId: string;\n};\ntype EnvironmentDiffFileArgs = EnvironmentDiffFileQuery & {\n environmentId: string;\n};\ninterface EnvironmentDiffBranchesArgs extends EnvironmentDiffBranchesQuery {\n environmentId: string;\n}\ninterface EnvironmentCommitArgs {\n environmentId: string;\n}\ninterface EnvironmentSquashMergeArgs {\n environmentId: string;\n mergeBaseBranch: string;\n}\ntype EnvironmentActionResult = PublicApiOutput<\"/environments/:id/actions\", \"$post\">;\ntype EnvironmentCommitResult = Extract;\ntype EnvironmentDiffResult = PublicApiOutput<\"/environments/:id/diff\", \"$get\">;\ntype EnvironmentDiffBranchesResult = PublicApiOutput<\"/environments/:id/diff/branches\", \"$get\">;\ntype EnvironmentDiffFileResult = PublicApiOutput<\"/environments/:id/diff/file\", \"$get\">;\ntype EnvironmentGetResult = PublicApiOutput<\"/environments/:id\", \"$get\">;\ntype EnvironmentPullRequestResult = PublicApiOutput<\"/environments/:id/pull-request\", \"$get\">;\ntype EnvironmentSquashMergeResult = Extract;\ntype EnvironmentStatusResult = PublicApiOutput<\"/environments/:id/status\", \"$get\">;\ntype EnvironmentUpdateResult = PublicApiOutput<\"/environments/:id\", \"$patch\">;\ninterface EnvironmentsArea {\n commit(args: EnvironmentCommitArgs): Promise;\n diff(args: EnvironmentDiffArgs): Promise;\n diffBranches(args: EnvironmentDiffBranchesArgs): Promise;\n diffFile(args: EnvironmentDiffFileArgs): Promise;\n get(args: EnvironmentGetArgs): Promise;\n pullRequest(args: EnvironmentGetArgs): Promise;\n squashMerge(args: EnvironmentSquashMergeArgs): Promise;\n status(args: EnvironmentStatusArgs): Promise;\n update(args: EnvironmentUpdateArgs): Promise;\n}\ndeclare function createEnvironmentsArea(args: CreateSdkAreaArgs): EnvironmentsArea;\n\n/**\n * Host file primitives. `hostId` may be omitted to target the server's\n * primary (local) host. `rootPath`, when set, confines the target beneath\n * that absolute root on the host (symlink-safe).\n */\ninterface FileReadArgs {\n hostId?: string;\n path: string;\n rootPath?: string;\n}\ninterface FileWriteArgs {\n hostId?: string;\n path: string;\n rootPath?: string;\n content: string;\n /** Defaults to \"utf8\". */\n contentEncoding?: \"utf8\" | \"base64\";\n /** Defaults to false. */\n createParents?: boolean;\n /**\n * Optimistic-concurrency guard: omitted → unconditional write; a hash →\n * write only when the current content hashes to it (use `read().sha256`);\n * null → create-only. A failed guard resolves to the `conflict` outcome.\n */\n expectedSha256?: string | null;\n}\ninterface FileListArgs {\n hostId?: string;\n path: string;\n query?: string;\n limit?: number;\n}\ntype FileReadResult = PublicApiOutput<\"/files/read\", \"$post\">;\ntype FileWriteResult = PublicApiOutput<\"/files/write\", \"$post\">;\ntype FileListResult = PublicApiOutput<\"/files/list\", \"$post\">;\ninterface FilesArea {\n read(args: FileReadArgs): Promise;\n write(args: FileWriteArgs): Promise;\n list(args: FileListArgs): Promise;\n}\ndeclare function createFilesArea(args: CreateSdkAreaArgs): FilesArea;\n\ninterface GuideRenderArgs {\n chapter?: string;\n}\ninterface GuideRenderResult {\n chapter?: string;\n content: string;\n}\ninterface GuideArea {\n render(args?: GuideRenderArgs): GuideRenderResult;\n}\ndeclare function createGuideArea(): GuideArea;\n\ninterface HostGetArgs {\n hostId: string;\n}\ntype HostGetResult = PublicApiOutput<\"/hosts/:id\", \"$get\">;\ntype HostListResult = PublicApiOutput<\"/hosts\", \"$get\">;\ninterface HostsArea {\n get(args: HostGetArgs): Promise;\n list(): Promise;\n}\ndeclare function createHostsArea(args: CreateSdkAreaArgs): HostsArea;\n\ninterface ProjectListArgs extends ProjectListQuery {\n}\ninterface ProjectCreateArgs extends CreateProjectRequest {\n}\ninterface ProjectGetArgs {\n projectId: string;\n}\ninterface ProjectUpdateArgs extends UpdateProjectRequest {\n projectId: string;\n}\ninterface ProjectDeleteArgs {\n projectId: string;\n}\ninterface ProjectSourceAddArgs extends CreateProjectSourceRequest {\n projectId: string;\n}\ninterface ProjectSourceUpdateArgs extends UpdateProjectSourceRequest {\n projectId: string;\n sourceId: string;\n}\ninterface ProjectSourceDeleteArgs {\n projectId: string;\n sourceId: string;\n}\ntype ProjectCreateResult = PublicApiOutput<\"/projects\", \"$post\">;\ntype ProjectDeleteResult = PublicApiOutput<\"/projects/:id\", \"$delete\">;\ntype ProjectGetResult = PublicApiOutput<\"/projects/:id\", \"$get\">;\ntype ProjectListResult = PublicApiOutput<\"/projects\", \"$get\">;\ntype ProjectUpdateResult = PublicApiOutput<\"/projects/:id\", \"$patch\">;\ntype ProjectSourceAddResult = PublicApiOutput<\"/projects/:id/sources\", \"$post\">;\ntype ProjectSourceUpdateResult = PublicApiOutput<\"/projects/:id/sources/:sourceId\", \"$patch\">;\ntype ProjectSourceDeleteResult = PublicApiOutput<\"/projects/:id/sources/:sourceId\", \"$delete\">;\ninterface ProjectSourcesArea {\n add(args: ProjectSourceAddArgs): Promise;\n delete(args: ProjectSourceDeleteArgs): Promise;\n update(args: ProjectSourceUpdateArgs): Promise;\n}\ninterface ProjectsArea {\n create(args: ProjectCreateArgs): Promise;\n delete(args: ProjectDeleteArgs): Promise;\n get(args: ProjectGetArgs): Promise;\n list(args?: ProjectListArgs): Promise;\n sources: ProjectSourcesArea;\n update(args: ProjectUpdateArgs): Promise;\n}\ndeclare function createProjectsArea(args: CreateSdkAreaArgs): ProjectsArea;\n\ninterface ProviderModelsArgs extends SystemExecutionOptionsQuery {\n}\ntype ProviderListResult = PublicApiOutput<\"/system/providers\", \"$get\">;\ntype ProviderModelsResult = PublicApiOutput<\"/system/execution-options\", \"$get\">;\ninterface ProvidersArea {\n list(): Promise;\n models(args?: ProviderModelsArgs): Promise;\n}\ndeclare function createProvidersArea(args: CreateSdkAreaArgs): ProvidersArea;\n\ntype BbRealtimeUnsubscribe = () => void;\ntype BbRealtimeEventName = \"thread:changed\" | \"project:changed\" | \"environment:changed\" | \"host:changed\" | \"system:changed\" | \"system:config-changed\" | \"realtime:connection\";\ntype ThreadRealtimeEvent = Extract;\ntype ProjectRealtimeEvent = Extract;\ntype EnvironmentRealtimeEvent = Extract;\ntype HostRealtimeEvent = Extract;\ntype SystemRealtimeEvent = Extract;\ntype BbRealtimeConnectionState = \"connecting\" | \"connected\" | \"disconnected\";\ninterface BbRealtimeConnectionEvent {\n reconnectDelayMs: number | null;\n reconnected: boolean;\n state: BbRealtimeConnectionState;\n}\n/**\n * Entity-changed events are delivered as one shared object to every matching\n * listener; their payload types are readonly so a listener cannot mutate what\n * the next listener receives.\n */\ninterface BbRealtimeEventMap {\n \"thread:changed\": ThreadRealtimeEvent;\n \"project:changed\": ProjectRealtimeEvent;\n \"environment:changed\": EnvironmentRealtimeEvent;\n \"host:changed\": HostRealtimeEvent;\n \"system:changed\": SystemRealtimeEvent;\n \"system:config-changed\": SystemRealtimeEvent;\n \"realtime:connection\": BbRealtimeConnectionEvent;\n}\ntype BbRealtimeCallback = (event: BbRealtimeEventMap[TEventName]) => void;\ninterface ThreadRealtimeOnArgs {\n callback: BbRealtimeCallback<\"thread:changed\">;\n event: \"thread:changed\";\n threadId?: string;\n}\ninterface ProjectRealtimeOnArgs {\n callback: BbRealtimeCallback<\"project:changed\">;\n event: \"project:changed\";\n projectId?: string;\n}\ninterface EnvironmentRealtimeOnArgs {\n callback: BbRealtimeCallback<\"environment:changed\">;\n environmentId?: string;\n event: \"environment:changed\";\n}\ninterface HostRealtimeOnArgs {\n callback: BbRealtimeCallback<\"host:changed\">;\n event: \"host:changed\";\n hostId?: string;\n}\ninterface SystemRealtimeOnArgs {\n callback: BbRealtimeCallback<\"system:changed\">;\n event: \"system:changed\";\n}\ninterface SystemConfigRealtimeOnArgs {\n callback: BbRealtimeCallback<\"system:config-changed\">;\n event: \"system:config-changed\";\n}\n/**\n * Connection listeners are pure observers — they never open or hold the\n * socket. A listener registered while a socket already exists receives the\n * latest connection event as a snapshot on the next microtask, so a status\n * UI mounted after connect still learns the current state.\n */\ninterface RealtimeConnectionOnArgs {\n callback: BbRealtimeCallback<\"realtime:connection\">;\n event: \"realtime:connection\";\n}\ntype BbRealtimeOnArgsUnion = ThreadRealtimeOnArgs | ProjectRealtimeOnArgs | EnvironmentRealtimeOnArgs | HostRealtimeOnArgs | SystemRealtimeOnArgs | SystemConfigRealtimeOnArgs | RealtimeConnectionOnArgs;\ntype BbRealtimeOnArgs = Extract;\ninterface BbRealtime {\n on(args: BbRealtimeOnArgs): BbRealtimeUnsubscribe;\n}\n\ninterface StatusGetArgs {\n projectId?: string;\n threadId?: string;\n}\ninterface StatusThreadSummary {\n environmentId: string | null;\n id: string;\n parentThreadId: string | null;\n pinnedAt: number | null;\n projectId: string;\n status: ThreadStatus;\n title: string | null;\n}\ntype StatusProject = PublicApiOutput<\"/projects/:id\", \"$get\">;\ntype StatusChildThreads = PublicApiOutput<\"/threads\", \"$get\">;\ninterface StatusResult {\n childThreads: StatusChildThreads | null;\n pendingTodos: ThreadTimelinePendingTodos | null;\n project: StatusProject | null;\n thread: StatusThreadSummary | null;\n}\ninterface StatusArea {\n get(args?: StatusGetArgs): Promise;\n}\ndeclare function createStatusArea(args: CreateSdkAreaArgs): StatusArea;\n\ninterface ThemeArea {\n /** The active app palette, resolved server-side (built-in id or custom CSS). */\n get(): Promise;\n /** The custom-theme directory plus discovered themes and the active palette. */\n catalog(): Promise;\n /**\n * Activate a palette by id — a built-in id or a custom theme name that exists\n * under `/theme//theme.css`. Broadcasts to all open windows.\n */\n set(themeId: string): Promise;\n}\ndeclare function createThemeArea(args: CreateSdkAreaArgs): ThemeArea;\n\ninterface ThreadListArgs {\n archived?: boolean;\n hasParent?: boolean;\n parentThreadId?: string;\n projectId?: string;\n}\ninterface ThreadGetArgs {\n include?: ThreadGetQuery[\"include\"];\n threadId: string;\n}\ntype ThreadGetResult = PublicApiOutput<\"/threads/:id\", \"$get\">;\ntype ThreadListResult = PublicApiOutput<\"/threads\", \"$get\">;\ntype ThreadOutputResponse = PublicApiOutput<\"/threads/:id/output\", \"$get\">;\ntype ThreadMutationResult = PublicApiOutput<\"/threads/:id\", \"$patch\">;\ntype ThreadSpawnResult = PublicApiOutput<\"/threads\", \"$post\">;\ntype ThreadInteractionGetResult = PublicApiOutput<\"/threads/:id/interactions/:interactionId\", \"$get\">;\ntype ThreadInteractionListResult = PublicApiOutput<\"/threads/:id/interactions\", \"$get\">;\ntype ThreadInteractionResolveResult = PublicApiOutput<\"/threads/:id/interactions/:interactionId/resolve\", \"$post\">;\ntype ThreadEventsListResult = PublicApiOutput<\"/threads/:id/events\", \"$get\">;\ntype ThreadEventWaitResult = PublicApiOutput<\"/threads/:id/events/wait\", \"$get\">;\ntype ThreadTimelineResult = PublicApiOutput<\"/threads/:id/timeline\", \"$get\">;\ntype ThreadArchiveResult = PublicApiOutput<\"/threads/:id/archive\", \"$post\">;\ntype ThreadOpenResult = PublicApiOutput<\"/threads/:id/open\", \"$post\">;\ntype ThreadDeleteResult = PublicApiOutput<\"/threads/:id\", \"$delete\">;\ntype ThreadSendResult = PublicApiOutput<\"/threads/:id/send\", \"$post\">;\ntype ThreadStopResult = PublicApiOutput<\"/threads/:id/stop\", \"$post\">;\ntype ThreadTerminalCloseResult = PublicApiOutput<\"/terminals/:terminalId/close\", \"$post\">;\ntype ThreadTerminalCreateResult = PublicApiOutput<\"/terminals\", \"$post\">;\ntype ThreadTerminalInputResult = PublicApiOutput<\"/terminals/:terminalId/input\", \"$post\">;\ntype ThreadTerminalListResult = PublicApiOutput<\"/terminals\", \"$get\">;\ntype ThreadTerminalOutputResult = PublicApiOutput<\"/terminals/:terminalId/output\", \"$get\">;\ntype ThreadTerminalResizeResult = PublicApiOutput<\"/terminals/:terminalId/resize\", \"$post\">;\ntype ThreadTerminalUpdateResult = PublicApiOutput<\"/terminals/:terminalId\", \"$patch\">;\ntype ThreadUnarchiveResult = PublicApiOutput<\"/threads/:id/unarchive\", \"$post\">;\ninterface ThreadSpawnBaseArgs extends Omit {\n childOrigin?: CreateThreadRequest[\"childOrigin\"];\n origin?: CreateThreadRequest[\"origin\"];\n originKind?: CreateThreadRequest[\"originKind\"];\n startedOnBehalfOf?: CreateThreadRequest[\"startedOnBehalfOf\"];\n}\ntype ThreadSpawnArgs = ThreadSpawnBaseArgs & ({\n input: CreateThreadRequest[\"input\"];\n prompt?: never;\n} | {\n input?: never;\n prompt: string;\n});\ninterface ThreadUpdateArgs extends UpdateThreadRequest {\n threadId: string;\n}\ninterface ThreadDeleteArgs extends DeleteThreadRequest {\n threadId: string;\n}\ninterface ThreadSendArgs extends SendMessageRequest {\n threadId: string;\n}\ninterface ThreadStatusArgs {\n threadId: string;\n}\ninterface ThreadOpenArgs {\n threadId: string;\n source: PanelFileSource;\n path: string;\n lineNumber: number | null;\n}\ninterface ThreadEventsListArgs {\n afterSeq?: string;\n limit?: string;\n threadId: string;\n}\ninterface ThreadEventWaitArgs {\n afterSeq?: string;\n threadId: string;\n type: string;\n waitMs: string;\n}\ninterface ThreadTimelineArgs extends ThreadTimelineQuery {\n threadId: string;\n}\ninterface ThreadOutputArgs {\n threadId: string;\n}\ninterface ThreadTerminalListArgs {\n threadId: string;\n}\ninterface ThreadTerminalCreateArgs extends Omit {\n threadId: string;\n}\ninterface ThreadTerminalTargetArgs {\n terminalId: string;\n threadId: string;\n}\ninterface ThreadTerminalUpdateArgs extends ThreadTerminalTargetArgs, UpdateTerminalRequest {\n}\ninterface ThreadTerminalCloseArgs extends ThreadTerminalTargetArgs, CloseTerminalRequest {\n}\ninterface ThreadTerminalInputArgs extends ThreadTerminalTargetArgs, TerminalInputRequest {\n}\ninterface ThreadTerminalResizeArgs extends ThreadTerminalTargetArgs, TerminalResizeRequest {\n}\ninterface ThreadTerminalOutputArgs extends ThreadTerminalTargetArgs, TerminalOutputQuery {\n}\ninterface ThreadInteractionListArgs {\n threadId: string;\n}\ninterface ThreadInteractionGetArgs extends ThreadInteractionListArgs {\n interactionId: string;\n}\ninterface ThreadInteractionResolveArgs extends ThreadInteractionGetArgs {\n resolution: PendingInteractionResolution;\n}\ntype ThreadWaitTarget = {\n kind: \"status\";\n status: ThreadStatus;\n} | {\n kind: \"event\";\n eventType: string;\n};\ninterface ThreadWaitArgs {\n event?: string;\n pollIntervalMs?: number;\n status?: ThreadStatus;\n threadId: string;\n timeoutMs?: number;\n}\ntype ThreadWaitResult = {\n event: NonNullable;\n matched: true;\n target: Extract;\n threadId: string;\n} | {\n matched: true;\n target: Extract;\n thread: ThreadGetResult;\n threadId: string;\n};\ninterface ThreadInteractionsArea {\n get(args: ThreadInteractionGetArgs): Promise;\n list(args: ThreadInteractionListArgs): Promise;\n resolve(args: ThreadInteractionResolveArgs): Promise;\n}\ninterface ThreadEventsArea {\n list(args: ThreadEventsListArgs): Promise;\n wait(args: ThreadEventWaitArgs): Promise;\n}\ninterface ThreadTerminalsArea {\n close(args: ThreadTerminalCloseArgs): Promise;\n create(args: ThreadTerminalCreateArgs): Promise;\n input(args: ThreadTerminalInputArgs): Promise;\n list(args: ThreadTerminalListArgs): Promise;\n output(args: ThreadTerminalOutputArgs): Promise;\n resize(args: ThreadTerminalResizeArgs): Promise;\n update(args: ThreadTerminalUpdateArgs): Promise;\n}\ninterface ThreadsArea {\n archive(args: ThreadStatusArgs): Promise;\n delete(args: ThreadDeleteArgs): Promise;\n events: ThreadEventsArea;\n get(args: ThreadGetArgs): Promise;\n interactions: ThreadInteractionsArea;\n list(args?: ThreadListArgs): Promise;\n open(args: ThreadOpenArgs): Promise;\n output(args: ThreadOutputArgs): Promise;\n pin(args: ThreadStatusArgs): Promise;\n send(args: ThreadSendArgs): Promise;\n spawn(args: ThreadSpawnArgs): Promise;\n stop(args: ThreadStatusArgs): Promise;\n terminals: ThreadTerminalsArea;\n timeline(args: ThreadTimelineArgs): Promise;\n unarchive(args: ThreadStatusArgs): Promise;\n unpin(args: ThreadStatusArgs): Promise;\n update(args: ThreadUpdateArgs): Promise;\n wait(args: ThreadWaitArgs): Promise;\n}\ndeclare function createThreadsArea(args: CreateSdkAreaArgs): ThreadsArea;\n\ninterface BbSdk extends BbRealtime {\n automations: ReturnType;\n environments: ReturnType;\n files: ReturnType;\n guide: ReturnType;\n hosts: ReturnType;\n projects: ReturnType;\n providers: ReturnType;\n status: ReturnType;\n theme: ReturnType;\n threads: ReturnType;\n}\n\n/**\n * The backend plugin API contract — the `bb` object handed to a plugin's\n * `server.ts` factory (`export default function plugin(bb: BbPluginApi)`).\n *\n * Types only: the implementation lives in the BB server\n * (apps/server/src/services/plugins/plugin-api.ts), which imports these\n * shapes so the contract and the implementation cannot drift. Plugin authors\n * import them type-only (`import type { BbPluginApi } from\n * \"@bb/plugin-sdk\"`); the import is erased when BB loads the file.\n *\n * Runtime classes stay host-side. NeedsConfigurationError in particular is\n * matched by NAME, so plugin code needs no runtime import:\n * `throw Object.assign(new Error(msg), { name: \"NeedsConfigurationError\" })`.\n */\ninterface PluginLogger {\n debug(message: string): void;\n info(message: string): void;\n warn(message: string): void;\n error(message: string): void;\n}\n/**\n * Declarative settings descriptors (`bb.settings.define`). Deliberately plain\n * data — not zod — so the host can render settings forms and the CLI can\n * parse values without executing plugin code.\n */\ntype PluginSettingDescriptor = {\n type: \"string\";\n label: string;\n description?: string;\n /** Stored in a 0600 file under /plugins//secrets/, never in the db or sent to the frontend. */\n secret?: true;\n default?: string;\n} | {\n type: \"boolean\";\n label: string;\n description?: string;\n default?: boolean;\n} | {\n type: \"select\";\n label: string;\n description?: string;\n options: string[];\n default?: string;\n} | {\n type: \"project\";\n label: string;\n description?: string;\n default?: string;\n};\ntype PluginSettingDescriptors = Record;\ntype PluginSettingValue = string | boolean;\n/** `default` present → non-optional value; absent → `T | undefined`. */\ntype PluginSettingsValues> = {\n [K in keyof Ds]: Ds[K] extends {\n default: string | boolean;\n } ? PluginSettingValueOf : PluginSettingValueOf | undefined;\n};\ntype PluginSettingValueOf = D extends {\n type: \"boolean\";\n} ? boolean : string;\ninterface PluginSettingsHandle> {\n /** Load-safe: callable inside the factory. */\n get(): Promise>;\n /** Fires after values change through the settings route/CLI. */\n onChange(listener: (next: PluginSettingsValues, prev: PluginSettingsValues) => void): void;\n}\ninterface PluginSettings {\n define>(descriptors: Ds): PluginSettingsHandle;\n}\ninterface PluginKvStorage {\n get(key: string): Promise;\n set(key: string, value: unknown): Promise;\n delete(key: string): Promise;\n list(prefix?: string): Promise;\n}\ninterface PluginStorage {\n /** Namespaced JSON key-value rows in bb.db; values ≤256KB each. */\n kv: PluginKvStorage;\n /**\n * Open (or reuse the path of) the plugin's own SQLite database at\n * /plugins//data.db — the server's better-sqlite3, WAL mode,\n * busy_timeout 5000. Handles are host-tracked and closed on\n * dispose/reload; a closed handle throws on use.\n */\n sqlite(): Database.Database;\n /**\n * Ordered-statement migration helper: statement index = migration id in a\n * `_bb_migrations` table; unapplied statements run in one transaction.\n * Append-only — never reorder or edit shipped statements.\n */\n migrate(db: Database.Database, statements: string[]): void;\n}\n/**\n * Thread lifecycle events a plugin can observe (design §4.5). Observe-only:\n * handlers run fire-and-forget after the transition is applied and can never\n * block or veto it. `thread` is the same public DTO GET /threads/:id serves.\n */\ninterface PluginThreadEventPayloads {\n \"thread.created\": {\n thread: ThreadResponse;\n };\n /** Fired when a thread transitions into `idle`. `lastAssistantText` is\n * assembled the same way GET /threads/:id/output is. */\n \"thread.idle\": {\n thread: ThreadResponse;\n lastAssistantText: string | null;\n };\n /** Fired when a thread transitions into `error`. `error` is the latest\n * system/error event message, when one exists. */\n \"thread.failed\": {\n thread: ThreadResponse;\n error: string | null;\n };\n}\ntype PluginThreadEventName = keyof PluginThreadEventPayloads;\ntype PluginThreadEventHandler = (payload: PluginThreadEventPayloads[E]) => void | Promise;\ntype PluginHttpAuthMode = \"local\" | \"token\" | \"none\";\ntype PluginHttpHandler = (context: Context) => Response | Promise;\ninterface PluginHttp {\n /**\n * Register an HTTP route, mounted at\n * `/api/v1/plugins//http/`. Auth modes (default \"local\"):\n * - \"local\": Origin/Host must be a local BB app origin; non-GET requires\n * content-type application/json (forces a CORS preflight).\n * - \"token\": requires the per-plugin token (`bb plugin token `) via\n * the x-bb-plugin-token header or ?token=.\n * - \"none\": no checks — only for signature-verified webhooks.\n */\n route(method: string, path: string, handler: PluginHttpHandler, opts?: {\n auth?: PluginHttpAuthMode;\n }): void;\n}\ninterface PluginRpc {\n /**\n * Register rpc methods, served at POST\n * `/api/v1/plugins//rpc/` with \"local\" auth semantics. The\n * JSON request body is the input; the response is\n * `{ ok: true, result }` or `{ ok: false, error }`. Inputs and outputs\n * must survive a JSON round-trip — results are serialized with\n * JSON.stringify and nothing else.\n */\n register(handlers: Record unknown>): void;\n}\ninterface PluginRealtime {\n /**\n * Broadcast an ephemeral `plugin-signal` WS message\n * `{ pluginId, channel, payload }` to every connected client (V1 has no\n * per-channel subscriptions). `payload` must be JSON-serializable;\n * `undefined` is normalized to `null`. Nothing is persisted.\n */\n publish(channel: string, payload: unknown): void;\n}\ninterface PluginBackground {\n /**\n * Register a long-lived background service. `start` runs after the\n * factory completes and should resolve when `signal` aborts\n * (dispose/reload/disable/shutdown). A crash restarts it with capped\n * exponential backoff; throwing NeedsConfigurationError marks the plugin\n * `needs-configuration` and stops restarting until the next load.\n */\n service(name: string, service: {\n start(signal: AbortSignal): void | Promise;\n }): void;\n /**\n * Register a cron schedule (5-field expression, server-local time). The\n * durable row keyed (pluginId, name) is upserted at load; the periodic\n * sweep claims due rows with a CAS on next_run_at, but only while this\n * plugin is loaded. Failures land in last_status/last_error, visible in\n * `bb plugin list`.\n */\n schedule(name: string, cron: string, fn: () => void | Promise): void;\n}\ninterface PluginCliCommandInfo {\n name: string;\n summary: string;\n usage: string;\n}\n/** Context forwarded from the invoking CLI when known; all fields optional. */\ninterface PluginCliContext {\n cwd?: string;\n threadId?: string;\n projectId?: string;\n}\ninterface PluginCliResult {\n exitCode: number;\n stdout?: string;\n stderr?: string;\n}\ninterface PluginCliRegistration {\n /** Top-level command name (`bb …`): lowercase [a-z0-9-]+, and not\n * a core bb command (see RESERVED_BB_CLI_COMMANDS in the server). */\n name: string;\n summary: string;\n /** Subcommand metadata rendered in help and the plugin-commands skill\n * without executing plugin code. Parsing argv is plugin-owned. */\n commands?: PluginCliCommandInfo[];\n run(argv: string[], ctx: PluginCliContext): PluginCliResult | Promise;\n}\ninterface PluginCli {\n /**\n * Register this plugin's `bb` subcommand. One registration per plugin —\n * a second call replaces the first. Core bb commands always win name\n * collisions; reserved names are rejected at registration.\n */\n register(registration: PluginCliRegistration): void;\n}\n/** Per-turn context handed to bb.agents context providers (design §4.4). */\n/** MCP-style content parts a native tool may return (design §4.4). */\ntype PluginAgentToolContentPart = {\n type: \"text\";\n text: string;\n} | {\n type: \"image\";\n data: string;\n mimeType: string;\n};\ntype PluginAgentToolResult = string | {\n content: PluginAgentToolContentPart[];\n isError?: boolean;\n};\n/** Per-call context handed to a native tool's execute (design §4.4). */\ninterface PluginAgentToolContext {\n threadId: string;\n projectId: string;\n /** The tool-call request's abort signal (aborts if the daemon round-trip\n * is torn down mid-call). */\n signal: AbortSignal;\n}\ninterface PluginAgentToolRegistrationBase {\n /** Tool name shown to the model: [a-zA-Z0-9_-]+, unique across plugins,\n * and not a built-in dynamic tool (see RESERVED_AGENT_TOOL_NAMES in the\n * server). */\n name: string;\n description: string;\n /**\n * Optional usage snippet appended to the thread instructions whenever\n * this tool is in the session's tool set (mirrors the built-in\n * update_environment_directory guidance). Keep it short.\n */\n instructions?: string;\n}\ninterface PluginAgents {\n /**\n * Register a native dynamic tool (design §4.4). `parameters` is either a\n * zod schema (validated per call; execute receives the parsed value) or a\n * plain JSON-schema object (no validation; execute receives the raw\n * arguments as `unknown`). Tool-set changes apply on the NEXT session\n * start — a tool registered mid-session is not hot-added to running\n * provider sessions. A second registration of the same name within this\n * plugin replaces the first; a name already registered by another plugin\n * is rejected and surfaced as this plugin's status detail.\n */\n registerTool(tool: PluginAgentToolRegistrationBase & {\n parameters: Schema;\n execute(params: z.output, ctx: PluginAgentToolContext): PluginAgentToolResult | Promise;\n }): void;\n registerTool(tool: PluginAgentToolRegistrationBase & {\n /** Raw JSON-schema escape hatch; params arrive unvalidated. */\n parameters: Record;\n execute(params: unknown, ctx: PluginAgentToolContext): PluginAgentToolResult | Promise;\n }): void;\n}\ninterface PluginThreadActionContext {\n threadId: string;\n projectId: string;\n}\ninterface PluginThreadActionToast {\n kind: \"success\" | \"error\" | \"info\";\n message: string;\n}\ntype PluginThreadActionResult = void | {\n toast?: PluginThreadActionToast;\n};\ninterface PluginThreadActionRegistration {\n /** Unique within this plugin: [a-zA-Z0-9_-]+ (becomes a URL segment). */\n id: string;\n /** Button label rendered in the thread header. */\n title: string;\n /** Optional icon name; the host falls back to a generic icon. */\n icon?: string;\n /** Optional confirmation prompt the host shows before running. */\n confirm?: string;\n /**\n * Runs server-side when the user clicks the action. The host shows a\n * pending state while in flight, the returned toast on completion, and an\n * automatic error toast when this throws.\n */\n run(ctx: PluginThreadActionContext): PluginThreadActionResult | Promise;\n}\n/** Search context handed to a mention provider (design §4.9). `projectId`/\n * `threadId` are null when the composer has not committed one yet. */\ninterface PluginMentionSearchContext {\n query: string;\n projectId: string | null;\n threadId: string | null;\n}\n/** One row a mention provider returns from `search`. `id` is the provider's\n * own item id — the host namespaces it before it reaches the wire. */\ninterface PluginMentionItem {\n id: string;\n title: string;\n subtitle?: string;\n icon?: string;\n}\ninterface PluginMentionProviderRegistration {\n /** Unique within this plugin: [a-zA-Z0-9_-]+ (no \":\" — the host composes\n * wire item ids as \":\"). */\n id: string;\n /** Section label shown above this provider's rows in the mention menu. */\n label: string;\n /**\n * Runs server-side as the user types after `@` in the composer. Each call\n * is time-boxed (2s) and failure-isolated: a slow or throwing provider\n * contributes an empty list — it can never break the mention menu.\n */\n search(ctx: PluginMentionSearchContext): PluginMentionItem[] | Promise;\n /**\n * Resolves one picked item into agent context, called once per unique\n * item at message send time. The returned `context` is attached to the\n * message as an agent-visible (user-hidden) prompt input. Throwing blocks\n * the send with a visible error.\n */\n resolve(itemId: string): {\n context: string;\n } | Promise<{\n context: string;\n }>;\n}\ninterface PluginUi {\n /**\n * Register a thread action rendered in the shipped app's thread header\n * (design §4.9). Multiple actions per plugin; ids must be unique within\n * the plugin. Invoked via POST /plugins/:id/actions/:actionId.\n */\n registerThreadAction(action: PluginThreadActionRegistration): void;\n /**\n * Register an `@`-mention provider for the shipped app's composer\n * (design §4.9). Items group under `label` in the mention menu; a picked\n * item becomes a `{ kind: \"plugin\" }` mention resource whose context is\n * resolved once at send time. Multiple providers per plugin; ids must be\n * unique within the plugin.\n */\n registerMentionProvider(provider: PluginMentionProviderRegistration): void;\n}\ninterface PluginStatusApi {\n /**\n * Mark this plugin `needs-configuration` (with a message shown in\n * `bb plugin list` and the UI) instead of failing — e.g. a factory or\n * service that finds no API key configured. Cleared on the next load;\n * saving settings does not auto-reload in V1, so ask the user to\n * `bb plugin reload ` after configuring.\n */\n needsConfiguration(message: string): void;\n}\n/**\n * The API object handed to a plugin's factory (design §4). Implemented by\n * the BB server; this contract is what plugin `server.ts` files compile\n * against.\n */\ninterface BbPluginApi {\n /** The plugin's own id (namespaces storage, routes, commands). */\n readonly pluginId: string;\n /** Leveled, plugin-scoped logger. */\n readonly log: PluginLogger;\n /** Declarative settings (design §4.2). */\n readonly settings: PluginSettings;\n /** Namespaced KV + per-plugin SQLite (design §4.3). */\n readonly storage: PluginStorage;\n /** HTTP routes under /api/v1/plugins//http/* (design §4.6). */\n readonly http: PluginHttp;\n /** RPC methods under /api/v1/plugins//rpc/ (design §4.6). */\n readonly rpc: PluginRpc;\n /** Ephemeral push to connected frontends (design §4.7). */\n readonly realtime: PluginRealtime;\n /** Long-lived services + cron schedules (design §4.8). */\n readonly background: PluginBackground;\n /** Agent-facing `bb` CLI subcommand (design §4.4). */\n readonly cli: PluginCli;\n /** Per-turn agent context contributions (design §4.4). */\n readonly agents: PluginAgents;\n /** Host-rendered UI contributions (design §4.9). */\n readonly ui: PluginUi;\n /** Plugin-reported status (needs-configuration). */\n readonly status: PluginStatusApi;\n /**\n * The full BB SDK, bound to this server over loopback (design §4.1).\n * Bind-gated: reading this before the host binds the SDK throws. The real\n * server binds it before loading plugins, so it is available from the\n * moment factories run there — but isolated harnesses may not, so prefer\n * using it from handlers, services, and timers for portability.\n * `threads.spawn` defaults `origin` to \"plugin\" and `originPluginId` to\n * this plugin's id so spawned threads are attributed automatically.\n */\n readonly sdk: BbSdk;\n /**\n * Observe thread lifecycle events (design §4.5). Load-safe registration;\n * handlers run async after the transition and never affect it. Errors are\n * caught, logged, and counted against this plugin's handler stats.\n */\n on(event: E, handler: PluginThreadEventHandler): void;\n /**\n * Register cleanup to run on reload/disable/shutdown. Hooks run LIFO.\n * The sanctioned place to clear timers and close connections.\n */\n onDispose(hook: () => void | Promise): void;\n}\n\nexport { PLUGIN_SDK_APP_EXPORT_NAMES, PLUGIN_SLOT_ID_PATTERN };\nexport type { BbContext, BbNavigate, BbPluginApi, PluginAgentToolContentPart, PluginAgentToolContext, PluginAgentToolRegistrationBase, PluginAgentToolResult, PluginAgents, PluginAppBuilder, PluginAppDefinition, PluginAppSetup, PluginAppSlots, PluginBackground, PluginCli, PluginCliCommandInfo, PluginCliContext, PluginCliRegistration, PluginCliResult, PluginComposerAccessoryProps, PluginComposerAccessoryRegistration, PluginComposerApi, PluginComposerMention, PluginComposerScope, PluginFileOpenerProps, PluginFileOpenerRegistration, PluginFileOpenerSource, PluginHomepageSectionProps, PluginHomepageSectionRegistration, PluginHttp, PluginHttpAuthMode, PluginHttpHandler, PluginKvStorage, PluginLogger, PluginMentionItem, PluginMentionProviderRegistration, PluginMentionSearchContext, PluginNavPanelProps, PluginNavPanelRegistration, PluginRealtime, PluginRpc, PluginRpcClient, PluginSdkApp, PluginSettingDescriptor, PluginSettingDescriptors, PluginSettingValue, PluginSettings, PluginSettingsHandle, PluginSettingsState, PluginSettingsValues, PluginStatusApi, PluginStorage, PluginThreadActionContext, PluginThreadActionRegistration, PluginThreadActionResult, PluginThreadActionToast, PluginThreadEventHandler, PluginThreadEventName, PluginThreadEventPayloads, PluginThreadPanelActionContext, PluginThreadPanelActionRegistration, PluginThreadPanelProps, PluginUi };\n"; -export const PLUGIN_SDK_APP_DTS = "// Bundled type declarations for `@bb/plugin-sdk`, shipped into scaffolded\n// plugins so they typecheck without the @bb/* workspace on disk.\n//\n// Confused by the API, or need a symbol that isn't here? Clone the BB repo\n// and read the real source: https://github.com/ymichael/bb\n\nimport { ComponentType } from 'react';\n\n/**\n * The `@bb/plugin-sdk/app` contract (plugin design §5.2) — pure types plus\n * the runtime export-name list, with no side effects. This module is what the\n * BB app imports to keep its real implementation in sync (`satisfies\n * PluginSdkApp`) and what `bb plugin build` imports to generate the shim's\n * named-export list. Plugin authors import the same shapes through\n * `@bb/plugin-sdk/app`.\n *\n * Per-slot props are versioned contracts: additive-only within an SDK major.\n */\n/** Props passed to a `homepageSection` component. */\ninterface PluginHomepageSectionProps {\n /** Project in view on the compose surface; null when none is selected. */\n projectId: string | null;\n}\n/** Props passed to a `navPanel` component (it owns its whole route). */\ninterface PluginNavPanelProps {\n /**\n * The route remainder after the panel root, \"\" at the root. The panel's\n * route is `/plugins///*`, so a deep link like\n * `/plugins/notes/notes/work/ideas.md` renders the panel with\n * `subPath: \"work/ideas.md\"`. Navigate within the panel via\n * `useBbNavigate().toPluginPanel(path, { subPath })` — browser\n * back/forward then walks panel-internal history.\n */\n subPath: string;\n}\n/** Props passed to a panel tab opened by a `threadPanelAction`. */\ninterface PluginThreadPanelProps {\n threadId: string;\n /**\n * The JSON value the action's `openPanel` call passed (round-tripped\n * through persistence, so the tab restores across reloads); null when the\n * action opened the panel without params.\n */\n params: unknown;\n}\n/** Props passed to a `composerAccessory` component. */\ninterface PluginComposerAccessoryProps {\n projectId: string | null;\n threadId: string | null;\n}\n/**\n * Slot/panel ids and nav-panel paths must match this pattern (letters,\n * digits, `-`, `_`): they ride URLs and persisted panel-tab keys.\n */\ndeclare const PLUGIN_SLOT_ID_PATTERN: RegExp;\ninterface PluginHomepageSectionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n component: ComponentType;\n}\ninterface PluginNavPanelRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n /** Icon hint (BB icon name); unknown names fall back to a generic icon. */\n icon: string;\n /** URL segment under `/plugins//`; letters, digits, `-`, `_`. */\n path: string;\n component: ComponentType;\n /**\n * Panel chrome (default \"page\"): \"page\" renders the host title bar (plugin\n * logo + `title` + your `headerContent`) above a full-width padded body;\n * \"none\" hands the ENTIRE panel area to `component` — no host padding, no\n * title bar (`headerContent` is ignored) — only the per-plugin error\n * boundary remains.\n */\n chrome?: \"page\" | \"none\";\n /**\n * Optional component rendered on the right side of the \"page\" title bar\n * (e.g. a sync button or a count). Contained separately from the body: a\n * throwing headerContent is hidden without breaking the title bar.\n */\n headerContent?: ComponentType;\n}\n/** Context handed to a `threadPanelAction`'s `run`. */\ninterface PluginThreadPanelActionContext {\n /** The thread whose panel launcher invoked the action. */\n threadId: string;\n /**\n * Open a tab in the thread's side panel rendering this action's\n * `component`. `title` labels the tab (default: the action's `title`);\n * `params` must be JSON-serializable — it is persisted with the tab and\n * reaches the component as its `params` prop. Opening with params\n * identical to an already-open tab of this action focuses that tab\n * (updating its title) instead of duplicating it. May be called more than\n * once (different params ⇒ multiple tabs) or not at all.\n */\n openPanel(options?: {\n title?: string;\n params?: unknown;\n }): void;\n}\ninterface PluginThreadPanelActionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n /** Label of the action row in the panel's new-tab launcher. */\n title: string;\n /**\n * Icon hint (BB icon name) used when the plugin ships no logo; the\n * launcher row and opened tabs prefer the plugin's logo.\n */\n icon?: string;\n /** Rendered inside every panel tab this action opens. */\n component: ComponentType;\n /**\n * Runs when the user activates the action: call your RPC methods, show a\n * toast, and/or open panel tabs via `context.openPanel`. Omitted =\n * immediately open a panel tab with defaults. Errors (sync or async) are\n * contained and logged; they never break the launcher.\n */\n run?(context: PluginThreadPanelActionContext): void | Promise;\n}\ninterface PluginComposerAccessoryRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n component: ComponentType;\n}\ninterface PluginAppSlots {\n homepageSection(registration: PluginHomepageSectionRegistration): void;\n navPanel(registration: PluginNavPanelRegistration): void;\n threadPanelAction(registration: PluginThreadPanelActionRegistration): void;\n composerAccessory(registration: PluginComposerAccessoryRegistration): void;\n}\ninterface PluginAppBuilder {\n slots: PluginAppSlots;\n}\ntype PluginAppSetup = (app: PluginAppBuilder) => void;\n/**\n * The opaque product of `definePluginApp` — a plugin's `app.tsx` default\n * export. The host re-runs `setup` against a fresh collector on every\n * (re)interpretation, replacing that plugin's registrations wholesale.\n */\ninterface PluginAppDefinition {\n /** Brand the host checks before interpreting a bundle's default export. */\n readonly __bbPluginApp: true;\n readonly setup: PluginAppSetup;\n}\ninterface PluginRpcClient {\n /**\n * Invoke one of the plugin's `bb.rpc` methods (POST\n * /api/v1/plugins/<id>/rpc/<method>). Resolves with the method's\n * result; rejects with an `Error` carrying the server's message when the\n * handler fails or the plugin is not running.\n */\n call(method: string, input?: unknown): Promise;\n}\ninterface PluginSettingsState {\n /**\n * Effective non-secret setting values (secret settings are excluded —\n * read them server-side). Undefined while loading or unavailable.\n */\n values: Record | undefined;\n isLoading: boolean;\n}\n/** Where `useComposer()` writes: the active thread's draft or the new-thread draft. */\ntype PluginComposerScope = {\n kind: \"thread\";\n threadId: string;\n} | {\n kind: \"new-thread\";\n projectId: string | null;\n};\n/** An @-mention pill bound to one of the calling plugin's mention providers. */\ninterface PluginComposerMention {\n /** Mention provider id registered by THIS plugin via `bb.ui.registerMentionProvider`. */\n provider: string;\n /** Item id your provider's `resolve` will receive at send time. */\n id: string;\n /** Pill text shown in the composer. */\n label: string;\n}\n/**\n * Programmatic access to the chat composer draft — the same shared draft the\n * built-in \"Add to chat\" affordances (file preview, diff, terminal selections)\n * write to. Inside a thread context writes land in that thread's draft;\n * anywhere else (nav panel, homepage section) they seed the new-thread\n * composer draft, which persists until the user sends or clears it.\n */\ninterface PluginComposerApi {\n scope: PluginComposerScope;\n /**\n * Append text to the draft as a `> ` blockquote block and focus the\n * composer. Blank text is a no-op. This is the \"reference this selection\n * in chat\" primitive.\n */\n addQuote(text: string): void;\n /**\n * Insert an @-mention pill that resolves through this plugin's mention\n * provider at send time — the durable way to reference an entity whose\n * content should be fetched fresh when the message is sent.\n */\n insertMention(mention: PluginComposerMention): void;\n /** Focus the composer caret at the end of the draft. */\n focus(): void;\n}\n/** Current app selection, derived from the route. */\ninterface BbContext {\n projectId: string | null;\n threadId: string | null;\n}\ninterface BbNavigate {\n toThread(threadId: string): void;\n toProject(projectId: string): void;\n /**\n * Navigate to one of this plugin's own nav panels by its `path`.\n * `subPath` targets a location inside the panel (the component's\n * `subPath` prop); `replace` swaps the current history entry instead of\n * pushing — use it for redirects so back does not bounce.\n */\n toPluginPanel(path: string, options?: {\n subPath?: string;\n replace?: boolean;\n }): void;\n}\n/**\n * Everything `@bb/plugin-sdk/app` resolves to at runtime. The BB app builds\n * the real implementation and `satisfies` this interface; `bb plugin build`\n * shims the specifier to that object on `globalThis.__bbPluginRuntime`.\n */\ninterface PluginSdkApp {\n definePluginApp(setup: PluginAppSetup): PluginAppDefinition;\n useRpc(): PluginRpcClient;\n useRealtime(channel: string, handler: (payload: unknown) => void): void;\n useSettings(): PluginSettingsState;\n useBbContext(): BbContext;\n useBbNavigate(): BbNavigate;\n useComposer(): PluginComposerApi;\n}\n/**\n * Named runtime exports of `@bb/plugin-sdk/app`, in sorted order. Single\n * source of truth for the build shim's export list and the app's\n * implementation-key test — adding a surface member without updating this\n * list fails the type assertion below.\n */\ndeclare const PLUGIN_SDK_APP_EXPORT_NAMES: readonly [\"definePluginApp\", \"useBbContext\", \"useBbNavigate\", \"useComposer\", \"useRealtime\", \"useRpc\", \"useSettings\"];\n\ndeclare const definePluginApp: (setup: PluginAppSetup) => PluginAppDefinition;\ndeclare const useRpc: () => PluginRpcClient;\ndeclare const useRealtime: (channel: string, handler: (payload: unknown) => void) => void;\ndeclare const useSettings: () => PluginSettingsState;\ndeclare const useBbContext: () => BbContext;\ndeclare const useBbNavigate: () => BbNavigate;\n\nexport { PLUGIN_SDK_APP_EXPORT_NAMES, PLUGIN_SLOT_ID_PATTERN, definePluginApp, useBbContext, useBbNavigate, useRealtime, useRpc, useSettings };\nexport type { BbContext, BbNavigate, PluginAppBuilder, PluginAppDefinition, PluginAppSetup, PluginAppSlots, PluginComposerAccessoryProps, PluginComposerAccessoryRegistration, PluginComposerApi, PluginComposerMention, PluginComposerScope, PluginHomepageSectionProps, PluginHomepageSectionRegistration, PluginNavPanelProps, PluginNavPanelRegistration, PluginRpcClient, PluginSdkApp, PluginSettingsState, PluginThreadPanelActionContext, PluginThreadPanelActionRegistration, PluginThreadPanelProps };\n"; +export const PLUGIN_SDK_APP_DTS = "// Bundled type declarations for `@bb/plugin-sdk`, shipped into scaffolded\n// plugins so they typecheck without the @bb/* workspace on disk.\n//\n// Confused by the API, or need a symbol that isn't here? Clone the BB repo\n// and read the real source: https://github.com/ymichael/bb\n\nimport { ComponentType } from 'react';\n\n/**\n * The `@bb/plugin-sdk/app` contract (plugin design §5.2) — pure types plus\n * the runtime export-name list, with no side effects. This module is what the\n * BB app imports to keep its real implementation in sync (`satisfies\n * PluginSdkApp`) and what `bb plugin build` imports to generate the shim's\n * named-export list. Plugin authors import the same shapes through\n * `@bb/plugin-sdk/app`.\n *\n * Per-slot props are versioned contracts: additive-only within an SDK major.\n */\n/** Props passed to a `homepageSection` component. */\ninterface PluginHomepageSectionProps {\n /** Project in view on the compose surface; null when none is selected. */\n projectId: string | null;\n}\n/** Props passed to a `navPanel` component (it owns its whole route). */\ninterface PluginNavPanelProps {\n /**\n * The route remainder after the panel root, \"\" at the root. The panel's\n * route is `/plugins///*`, so a deep link like\n * `/plugins/notes/notes/work/ideas.md` renders the panel with\n * `subPath: \"work/ideas.md\"`. Navigate within the panel via\n * `useBbNavigate().toPluginPanel(path, { subPath })` — browser\n * back/forward then walks panel-internal history.\n */\n subPath: string;\n}\n/** Props passed to a panel tab opened by a `threadPanelAction`. */\ninterface PluginThreadPanelProps {\n threadId: string;\n /**\n * The JSON value the action's `openPanel` call passed (round-tripped\n * through persistence, so the tab restores across reloads); null when the\n * action opened the panel without params.\n */\n params: unknown;\n}\n/** Props passed to a `composerAccessory` component. */\ninterface PluginComposerAccessoryProps {\n projectId: string | null;\n threadId: string | null;\n}\n/**\n * Where a file being opened by a `fileOpener` lives. `path` semantics follow\n * the source: workspace paths are relative to the environment's worktree,\n * thread-storage paths are relative to the thread's storage root, host paths\n * are absolute on the thread's host.\n */\ninterface PluginFileOpenerSource {\n kind: \"workspace\" | \"host\" | \"thread-storage\";\n threadId: string | null;\n environmentId: string | null;\n projectId: string | null;\n}\n/** Props passed to a `fileOpener` component (rendered as a panel file tab). */\ninterface PluginFileOpenerProps {\n path: string;\n source: PluginFileOpenerSource;\n}\n/**\n * Slot/panel ids and nav-panel paths must match this pattern (letters,\n * digits, `-`, `_`): they ride URLs and persisted panel-tab keys.\n */\ndeclare const PLUGIN_SLOT_ID_PATTERN: RegExp;\ninterface PluginHomepageSectionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n component: ComponentType;\n}\ninterface PluginNavPanelRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n /** Icon hint (BB icon name); unknown names fall back to a generic icon. */\n icon: string;\n /** URL segment under `/plugins//`; letters, digits, `-`, `_`. */\n path: string;\n component: ComponentType;\n /**\n * Panel chrome (default \"page\"): \"page\" renders the host title bar (plugin\n * logo + `title` + your `headerContent`) above a full-width padded body;\n * \"none\" hands the ENTIRE panel area to `component` — no host padding, no\n * title bar (`headerContent` is ignored) — only the per-plugin error\n * boundary remains.\n */\n chrome?: \"page\" | \"none\";\n /**\n * Optional component rendered on the right side of the \"page\" title bar\n * (e.g. a sync button or a count). Contained separately from the body: a\n * throwing headerContent is hidden without breaking the title bar.\n */\n headerContent?: ComponentType;\n}\n/** Context handed to a `threadPanelAction`'s `run`. */\ninterface PluginThreadPanelActionContext {\n /** The thread whose panel launcher invoked the action. */\n threadId: string;\n /**\n * Open a tab in the thread's side panel rendering this action's\n * `component`. `title` labels the tab (default: the action's `title`);\n * `params` must be JSON-serializable — it is persisted with the tab and\n * reaches the component as its `params` prop. Opening with params\n * identical to an already-open tab of this action focuses that tab\n * (updating its title) instead of duplicating it. May be called more than\n * once (different params ⇒ multiple tabs) or not at all.\n */\n openPanel(options?: {\n title?: string;\n params?: unknown;\n }): void;\n}\ninterface PluginThreadPanelActionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n /** Label of the action row in the panel's new-tab launcher. */\n title: string;\n /**\n * Icon hint (BB icon name) used when the plugin ships no logo; the\n * launcher row and opened tabs prefer the plugin's logo.\n */\n icon?: string;\n /** Rendered inside every panel tab this action opens. */\n component: ComponentType;\n /**\n * Runs when the user activates the action: call your RPC methods, show a\n * toast, and/or open panel tabs via `context.openPanel`. Omitted =\n * immediately open a panel tab with defaults. Errors (sync or async) are\n * contained and logged; they never break the launcher.\n */\n run?(context: PluginThreadPanelActionContext): void | Promise;\n}\ninterface PluginComposerAccessoryRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n component: ComponentType;\n}\n/**\n * Register this plugin as a viewer/editor for file extensions. The user\n * picks (and can set as default) an opener per extension via the file tab's\n * \"Open with\" menu; matching files opened in the panel then render\n * `component` in a plugin tab instead of the built-in preview. Applies to\n * working-tree, host, and thread-storage files — never to git-ref snapshots\n * (diff views always use the built-in preview). The built-in preview stays\n * one menu click away, and a missing/disabled opener falls back to it.\n */\ninterface PluginFileOpenerRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n /** Label in the \"Open with\" menu (e.g. \"Notes editor\"). */\n title: string;\n /** Lowercase extensions without the dot (e.g. [\"md\", \"mdx\"]). */\n extensions: readonly string[];\n component: ComponentType;\n}\ninterface PluginAppSlots {\n homepageSection(registration: PluginHomepageSectionRegistration): void;\n navPanel(registration: PluginNavPanelRegistration): void;\n threadPanelAction(registration: PluginThreadPanelActionRegistration): void;\n composerAccessory(registration: PluginComposerAccessoryRegistration): void;\n fileOpener(registration: PluginFileOpenerRegistration): void;\n}\ninterface PluginAppBuilder {\n slots: PluginAppSlots;\n}\ntype PluginAppSetup = (app: PluginAppBuilder) => void;\n/**\n * The opaque product of `definePluginApp` — a plugin's `app.tsx` default\n * export. The host re-runs `setup` against a fresh collector on every\n * (re)interpretation, replacing that plugin's registrations wholesale.\n */\ninterface PluginAppDefinition {\n /** Brand the host checks before interpreting a bundle's default export. */\n readonly __bbPluginApp: true;\n readonly setup: PluginAppSetup;\n}\ninterface PluginRpcClient {\n /**\n * Invoke one of the plugin's `bb.rpc` methods (POST\n * /api/v1/plugins/<id>/rpc/<method>). Resolves with the method's\n * result; rejects with an `Error` carrying the server's message when the\n * handler fails or the plugin is not running.\n */\n call(method: string, input?: unknown): Promise;\n}\ninterface PluginSettingsState {\n /**\n * Effective non-secret setting values (secret settings are excluded —\n * read them server-side). Undefined while loading or unavailable.\n */\n values: Record | undefined;\n isLoading: boolean;\n}\n/** Where `useComposer()` writes: the active thread's draft or the new-thread draft. */\ntype PluginComposerScope = {\n kind: \"thread\";\n threadId: string;\n} | {\n kind: \"new-thread\";\n projectId: string | null;\n};\n/** An @-mention pill bound to one of the calling plugin's mention providers. */\ninterface PluginComposerMention {\n /** Mention provider id registered by THIS plugin via `bb.ui.registerMentionProvider`. */\n provider: string;\n /** Item id your provider's `resolve` will receive at send time. */\n id: string;\n /** Pill text shown in the composer. */\n label: string;\n}\n/**\n * Programmatic access to the chat composer draft — the same shared draft the\n * built-in \"Add to chat\" affordances (file preview, diff, terminal selections)\n * write to. Inside a thread context writes land in that thread's draft;\n * anywhere else (nav panel, homepage section) they seed the new-thread\n * composer draft, which persists until the user sends or clears it.\n */\ninterface PluginComposerApi {\n scope: PluginComposerScope;\n /**\n * Append text to the draft as a `> ` blockquote block and focus the\n * composer. Blank text is a no-op. This is the \"reference this selection\n * in chat\" primitive.\n */\n addQuote(text: string): void;\n /**\n * Insert an @-mention pill that resolves through this plugin's mention\n * provider at send time — the durable way to reference an entity whose\n * content should be fetched fresh when the message is sent.\n */\n insertMention(mention: PluginComposerMention): void;\n /** Focus the composer caret at the end of the draft. */\n focus(): void;\n}\n/** Current app selection, derived from the route. */\ninterface BbContext {\n projectId: string | null;\n threadId: string | null;\n}\ninterface BbNavigate {\n toThread(threadId: string): void;\n toProject(projectId: string): void;\n /**\n * Navigate to one of this plugin's own nav panels by its `path`.\n * `subPath` targets a location inside the panel (the component's\n * `subPath` prop); `replace` swaps the current history entry instead of\n * pushing — use it for redirects so back does not bounce.\n */\n toPluginPanel(path: string, options?: {\n subPath?: string;\n replace?: boolean;\n }): void;\n}\n/**\n * Everything `@bb/plugin-sdk/app` resolves to at runtime. The BB app builds\n * the real implementation and `satisfies` this interface; `bb plugin build`\n * shims the specifier to that object on `globalThis.__bbPluginRuntime`.\n */\ninterface PluginSdkApp {\n definePluginApp(setup: PluginAppSetup): PluginAppDefinition;\n useRpc(): PluginRpcClient;\n useRealtime(channel: string, handler: (payload: unknown) => void): void;\n useSettings(): PluginSettingsState;\n useBbContext(): BbContext;\n useBbNavigate(): BbNavigate;\n useComposer(): PluginComposerApi;\n}\n/**\n * Named runtime exports of `@bb/plugin-sdk/app`, in sorted order. Single\n * source of truth for the build shim's export list and the app's\n * implementation-key test — adding a surface member without updating this\n * list fails the type assertion below.\n */\ndeclare const PLUGIN_SDK_APP_EXPORT_NAMES: readonly [\"definePluginApp\", \"useBbContext\", \"useBbNavigate\", \"useComposer\", \"useRealtime\", \"useRpc\", \"useSettings\"];\n\ndeclare const definePluginApp: (setup: PluginAppSetup) => PluginAppDefinition;\ndeclare const useRpc: () => PluginRpcClient;\ndeclare const useRealtime: (channel: string, handler: (payload: unknown) => void) => void;\ndeclare const useSettings: () => PluginSettingsState;\ndeclare const useBbContext: () => BbContext;\ndeclare const useBbNavigate: () => BbNavigate;\n\nexport { PLUGIN_SDK_APP_EXPORT_NAMES, PLUGIN_SLOT_ID_PATTERN, definePluginApp, useBbContext, useBbNavigate, useRealtime, useRpc, useSettings };\nexport type { BbContext, BbNavigate, PluginAppBuilder, PluginAppDefinition, PluginAppSetup, PluginAppSlots, PluginComposerAccessoryProps, PluginComposerAccessoryRegistration, PluginComposerApi, PluginComposerMention, PluginComposerScope, PluginFileOpenerProps, PluginFileOpenerRegistration, PluginFileOpenerSource, PluginHomepageSectionProps, PluginHomepageSectionRegistration, PluginNavPanelProps, PluginNavPanelRegistration, PluginRpcClient, PluginSdkApp, PluginSettingsState, PluginThreadPanelActionContext, PluginThreadPanelActionRegistration, PluginThreadPanelProps };\n"; diff --git a/packages/templates/src/generated/templates.generated.ts b/packages/templates/src/generated/templates.generated.ts index 2318734c8..2cb78f521 100644 --- a/packages/templates/src/generated/templates.generated.ts +++ b/packages/templates/src/generated/templates.generated.ts @@ -73,7 +73,7 @@ export const templateDefinitions = [ }, { "id": "bbGuidePlugins", - "body": "Plugin commands\n\nA bb plugin is a TypeScript package that extends the bb server in-process:\nbackground services, cron schedules, HTTP/RPC endpoints, thread lifecycle\nhandlers, settings, storage — and `bb` CLI subcommands that agents and humans\nrun like any other command. Plugins are full-trust code inside the server.\n\nPlugins are an experiment, off by default: enable \"Plugins\" under Settings →\nExperiments first. Until then `bb plugin` commands report that plugins are\ndisabled. Plugin state lives under `/plugins//` (per-plugin\nSQLite file, secrets, logs).\n\n bb plugin install Install from a local path, git:@,\n or npm:@ (npm: needs npm on\n PATH; installs prompt — pass --yes to skip)\n bb plugin list Status, services, schedules, handler timings\n bb plugin enable|disable Load or unload an installed plugin\n bb plugin reload [id] Re-run factories against current sources\n bb plugin config [set | unset ]\n Show or change a plugin's declared settings\n bb plugin logs [-n N] [-f] Print (or follow) a plugin's bb.log output\n bb plugin run [args...] Run the plugin's CLI command explicitly\n bb plugin token [--rotate] Print the token for auth:\"token\" HTTP\n routes; --rotate generates a new token,\n invalidating the old one\n bb plugin remove Uninstall (managed git:/npm: files deleted)\n bb plugin new [--app] Scaffold a new plugin (no server required;\n --app adds a frontend entry, app.tsx, plus a\n typecheck-only tsconfig.json)\n bb plugin build [path] Compile the plugin into dist/ — the backend\n bundle (server.js, server.meta.json) and,\n when bb.app is declared, the frontend bundle\n (app.js, app.css, app.meta.json); no server\n required\n bb plugin dev [path] Watch a plugin's sources (default: cwd) and\n on every change rebuild its frontend bundle\n (if it declares bb.app) and reload the\n plugin; Ctrl+C to stop\n\nFrontend builds are automatic once installed: path and git installs compile\ndist/ at install time (a build failure fails the install), and the server\nrebuilds them at load after a bb upgrade. npm packages must publish a\nprebuilt dist/ (app.js + app.meta.json) or the install is refused.\n\nThe backend half is prebuilt too: when a git/npm install ships a\ndist/server.js built for the running SDK major, the server loads it instead\nof the TypeScript source — consumers never need npm or node_modules. Path\ninstalls always load server.ts from source, so `bb plugin dev`/reload see\nedits immediately.\n\n`bb plugin dev` is the edit loop: it requires the directory to already be\ninstalled as a plugin (`bb plugin install .` first), ignores dist/,\nnode_modules/, and .git/, batches saves, and prints one line per cycle. A\nbuild or reload failure prints the error and keeps watching (a failed build\nskips that cycle's reload). Reloads reach open app pages live — changed\nfrontend bundles re-import and their UI slots remount without a page\nrefresh.\n\nFrontend entries (app.tsx) default-export `definePluginApp` from\n`@bb/plugin-sdk/app` and register UI slots: homepageSection (root compose),\nnavPanel (own sidebar entry + /plugins///* route; the remainder\narrives as the component's subPath prop for panel-internal deep links),\nthreadPanelAction\n(an entry in the thread right panel's new-tab Actions list whose run() can\nopen closable panel tabs with JSON params), composerAccessory (prompt box\nfooter). Hooks:\nuseRpc, useRealtime, useSettings (secrets excluded), useBbContext,\nuseBbNavigate, and useComposer (quote selections / insert mention pills\ninto the chat composer draft). Components are vendored shadcn source the plugin owns (the\nshadcn model): `bb plugin new --app` pre-vendors a starter set into\ncomponents/ui/ and `npx shadcn add @bb/` pulls more from the BB\ncomponent registry (the full stock shadcn set, version-matched to the\nrunning BB via the pinned ref in components.json). `import { toast } from\n\"sonner\"` reaches the host toaster; react, the portaling radix families,\nsonner, vaul, and @pierre/diffs (the app's syntax-highlighted diff\nrenderer) are runtime-shimmed (never bundled), everything else\nbundles from the plugin's node_modules (`npm install` for authors;\nconsumers install prebuilt dist). A crashing slot collapses to a\n\"plugin crashed\" chip without\ntouching the rest of the app. Installed plugins and their declared settings\n(same data as `bb plugin config`) also appear under Settings → Plugins.\n\nPlugin CLI commands: a plugin can register one top-level subcommand (for\nexample `bb github …`). Unknown `bb` commands are looked up against installed\nplugins and proxied to the server, so plugin commands work exactly like core\ncommands; core command names always win. Inside agent threads the generated\n`plugin-commands` skill lists the available plugin commands.\n\nSettings changes do not auto-reload a plugin — run `bb plugin reload `\nafter configuring. Add --json to plugin commands for machine-readable output.\n\nAuthoring a plugin\n\nThe loop: `bb plugin new ` scaffolds `./bb-plugin-` (add --app\nfor a frontend entry); `bb plugin install .` registers it; `bb plugin dev`\nwatches and reloads on every save. The manifest is package.json: `bb.server`\n(backend entry, loaded as TypeScript — no build step), optional `bb.app`\n(frontend entry), optional `bb.skills` (skills directories auto-imported\ninto agent threads; default `skills/`), and `engines.bb` (supported bb\nrange). The plugin id is the package name minus `bb-plugin-`.\n\nLogos: drop a logo.svg (or logo.png / logo.webp) in the plugin root and bb\nshows it wherever the plugin's contributions appear — the sidebar entry,\npanel title bar, composer command and @-mention menus, thread action\nbuttons, and Settings → Plugins. Optional `bb.logo` in the manifest\nrelocates the file (svg/png/webp only). An optional dark-theme variant —\nlogo-dark.svg/png/webp at the root, or `bb.logoDark` — is preferred while\nthe app is in dark mode. Without a logo bb falls back to the contribution's\nnamed icon. Reload the plugin to pick up logo changes.\n\nThe backend entry default-exports a factory receiving the full plugin API:\n\n import type { BbPluginApi } from \"@bb/plugin-sdk\";\n export default async function plugin(bb: BbPluginApi) { ... }\n\nThe import is type-only and erased at load; the scaffold ships the full API\nas bundled .d.ts in types/ (tsconfig maps @bb/plugin-sdk to them), so\n`npm install && npx tsc --noEmit` typechecks anywhere — no bb checkout\nneeded. Confused, or need a symbol the types don't explain? Clone the repo:\nhttps://github.com/ymichael/bb. The API in\none line each — bb.log (plugin-scoped logger behind `bb plugin logs`);\nbb.settings.define (declarative settings incl. secrets, editable via\n`bb plugin config`); bb.storage.kv (JSON rows ≤256KB) and\nbb.storage.sqlite()+migrate (the plugin's own database); bb.sdk (the full\nbb SDK — handlers/services only, not the factory; spawned threads are\nattributed to the plugin); bb.on (observe thread.created/idle/failed);\nbb.http.route (routes under /api/v1/plugins//http/* with\nlocal/token/none auth); bb.rpc.register (the frontend data plane);\nbb.realtime.publish (ephemeral signals to open app pages);\nbb.background.service (long-lived, AbortSignal, restart w/ backoff) and\nbb.background.schedule (durable cron rows); bb.cli.register (a top-level\n`bb ` command agents run through bash); bb.agents.registerTool\n(native tools with\nzod or JSON-schema parameters); bb.ui.registerThreadAction /\nregisterMentionProvider (host-rendered UI — no\nfrontend bundle needed); bb.status.needsConfiguration (report\n\"unconfigured\" instead of crashing); bb.onDispose (LIFO cleanup on\nreload/disable/shutdown).\n\nFrontend entries register React slots (homepageSection, navPanel,\nthreadPanelAction, composerAccessory) via definePluginApp, use the hooks\nlisted above, and render vendored components; styling is Tailwind against\nthe host theme's tokens only (semantic classes like bg-background and\ntw-animate-css utilities compile in plugin builds).\n\nFor the complete authoring reference — exact signatures, working snippets\nfor every surface, the reload lifecycle, testing tips, and gotchas — use\nthe built-in `bb-plugin-authoring` skill (agents: it loads on demand;\nhumans: apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/\nin a checkout). The `examples/plugins/` directory of a bb checkout has four\nreference plugins: github (full-stack: gh-CLI-backed issue/PR browser on\nvendored shadcn components), slack-bot (webhook bot), agent-enrichment\n(agent surfaces), small-ux-pack (host-rendered UI).", + "body": "Plugin commands\n\nA bb plugin is a TypeScript package that extends the bb server in-process:\nbackground services, cron schedules, HTTP/RPC endpoints, thread lifecycle\nhandlers, settings, storage — and `bb` CLI subcommands that agents and humans\nrun like any other command. Plugins are full-trust code inside the server.\n\nPlugins are an experiment, off by default: enable \"Plugins\" under Settings →\nExperiments first. Until then `bb plugin` commands report that plugins are\ndisabled. Plugin state lives under `/plugins//` (per-plugin\nSQLite file, secrets, logs).\n\n bb plugin install Install from a local path, git:@,\n or npm:@ (npm: needs npm on\n PATH; installs prompt — pass --yes to skip)\n bb plugin list Status, services, schedules, handler timings\n bb plugin enable|disable Load or unload an installed plugin\n bb plugin reload [id] Re-run factories against current sources\n bb plugin config [set | unset ]\n Show or change a plugin's declared settings\n bb plugin logs [-n N] [-f] Print (or follow) a plugin's bb.log output\n bb plugin run [args...] Run the plugin's CLI command explicitly\n bb plugin token [--rotate] Print the token for auth:\"token\" HTTP\n routes; --rotate generates a new token,\n invalidating the old one\n bb plugin remove Uninstall (managed git:/npm: files deleted)\n bb plugin new [--app] Scaffold a new plugin (no server required;\n --app adds a frontend entry, app.tsx, plus a\n typecheck-only tsconfig.json)\n bb plugin build [path] Compile the plugin into dist/ — the backend\n bundle (server.js, server.meta.json) and,\n when bb.app is declared, the frontend bundle\n (app.js, app.css, app.meta.json); no server\n required\n bb plugin dev [path] Watch a plugin's sources (default: cwd) and\n on every change rebuild its frontend bundle\n (if it declares bb.app) and reload the\n plugin; Ctrl+C to stop\n\nFrontend builds are automatic once installed: path and git installs compile\ndist/ at install time (a build failure fails the install), and the server\nrebuilds them at load after a bb upgrade. npm packages must publish a\nprebuilt dist/ (app.js + app.meta.json) or the install is refused.\n\nThe backend half is prebuilt too: when a git/npm install ships a\ndist/server.js built for the running SDK major, the server loads it instead\nof the TypeScript source — consumers never need npm or node_modules. Path\ninstalls always load server.ts from source, so `bb plugin dev`/reload see\nedits immediately.\n\n`bb plugin dev` is the edit loop: it requires the directory to already be\ninstalled as a plugin (`bb plugin install .` first), ignores dist/,\nnode_modules/, and .git/, batches saves, and prints one line per cycle. A\nbuild or reload failure prints the error and keeps watching (a failed build\nskips that cycle's reload). Reloads reach open app pages live — changed\nfrontend bundles re-import and their UI slots remount without a page\nrefresh.\n\nFrontend entries (app.tsx) default-export `definePluginApp` from\n`@bb/plugin-sdk/app` and register UI slots: homepageSection (root compose),\nnavPanel (own sidebar entry + /plugins///* route; the remainder\narrives as the component's subPath prop for panel-internal deep links),\nthreadPanelAction\n(an entry in the thread right panel's new-tab Actions list whose run() can\nopen closable panel tabs with JSON params), composerAccessory (prompt box\nfooter), and fileOpener (register as a per-extension file viewer/editor\nusers can set as the default via a file tab's \"Open with\" menu). Hooks:\nuseRpc, useRealtime, useSettings (secrets excluded), useBbContext,\nuseBbNavigate, and useComposer (quote selections / insert mention pills\ninto the chat composer draft). Components are vendored shadcn source the plugin owns (the\nshadcn model): `bb plugin new --app` pre-vendors a starter set into\ncomponents/ui/ and `npx shadcn add @bb/` pulls more from the BB\ncomponent registry (the full stock shadcn set, version-matched to the\nrunning BB via the pinned ref in components.json). `import { toast } from\n\"sonner\"` reaches the host toaster; react, the portaling radix families,\nsonner, vaul, and @pierre/diffs (the app's syntax-highlighted diff\nrenderer) are runtime-shimmed (never bundled), everything else\nbundles from the plugin's node_modules (`npm install` for authors;\nconsumers install prebuilt dist). A crashing slot collapses to a\n\"plugin crashed\" chip without\ntouching the rest of the app. Installed plugins and their declared settings\n(same data as `bb plugin config`) also appear under Settings → Plugins.\n\nPlugin CLI commands: a plugin can register one top-level subcommand (for\nexample `bb github …`). Unknown `bb` commands are looked up against installed\nplugins and proxied to the server, so plugin commands work exactly like core\ncommands; core command names always win. Inside agent threads the generated\n`plugin-commands` skill lists the available plugin commands.\n\nSettings changes do not auto-reload a plugin — run `bb plugin reload `\nafter configuring. Add --json to plugin commands for machine-readable output.\n\nAuthoring a plugin\n\nThe loop: `bb plugin new ` scaffolds `./bb-plugin-` (add --app\nfor a frontend entry); `bb plugin install .` registers it; `bb plugin dev`\nwatches and reloads on every save. The manifest is package.json: `bb.server`\n(backend entry, loaded as TypeScript — no build step), optional `bb.app`\n(frontend entry), optional `bb.skills` (skills directories auto-imported\ninto agent threads; default `skills/`), and `engines.bb` (supported bb\nrange). The plugin id is the package name minus `bb-plugin-`.\n\nLogos: drop a logo.svg (or logo.png / logo.webp) in the plugin root and bb\nshows it wherever the plugin's contributions appear — the sidebar entry,\npanel title bar, composer command and @-mention menus, thread action\nbuttons, and Settings → Plugins. Optional `bb.logo` in the manifest\nrelocates the file (svg/png/webp only). An optional dark-theme variant —\nlogo-dark.svg/png/webp at the root, or `bb.logoDark` — is preferred while\nthe app is in dark mode. Without a logo bb falls back to the contribution's\nnamed icon. Reload the plugin to pick up logo changes.\n\nThe backend entry default-exports a factory receiving the full plugin API:\n\n import type { BbPluginApi } from \"@bb/plugin-sdk\";\n export default async function plugin(bb: BbPluginApi) { ... }\n\nThe import is type-only and erased at load; the scaffold ships the full API\nas bundled .d.ts in types/ (tsconfig maps @bb/plugin-sdk to them), so\n`npm install && npx tsc --noEmit` typechecks anywhere — no bb checkout\nneeded. Confused, or need a symbol the types don't explain? Clone the repo:\nhttps://github.com/ymichael/bb. The API in\none line each — bb.log (plugin-scoped logger behind `bb plugin logs`);\nbb.settings.define (declarative settings incl. secrets, editable via\n`bb plugin config`); bb.storage.kv (JSON rows ≤256KB) and\nbb.storage.sqlite()+migrate (the plugin's own database); bb.sdk (the full\nbb SDK — handlers/services only, not the factory; spawned threads are\nattributed to the plugin); bb.on (observe thread.created/idle/failed);\nbb.http.route (routes under /api/v1/plugins//http/* with\nlocal/token/none auth); bb.rpc.register (the frontend data plane);\nbb.realtime.publish (ephemeral signals to open app pages);\nbb.background.service (long-lived, AbortSignal, restart w/ backoff) and\nbb.background.schedule (durable cron rows); bb.cli.register (a top-level\n`bb ` command agents run through bash); bb.agents.registerTool\n(native tools with\nzod or JSON-schema parameters); bb.ui.registerThreadAction /\nregisterMentionProvider (host-rendered UI — no\nfrontend bundle needed); bb.status.needsConfiguration (report\n\"unconfigured\" instead of crashing); bb.onDispose (LIFO cleanup on\nreload/disable/shutdown).\n\nFrontend entries register React slots (homepageSection, navPanel,\nthreadPanelAction, composerAccessory) via definePluginApp, use the hooks\nlisted above, and render vendored components; styling is Tailwind against\nthe host theme's tokens only (semantic classes like bg-background and\ntw-animate-css utilities compile in plugin builds).\n\nFor the complete authoring reference — exact signatures, working snippets\nfor every surface, the reload lifecycle, testing tips, and gotchas — use\nthe built-in `bb-plugin-authoring` skill (agents: it loads on demand;\nhumans: apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/\nin a checkout). The `examples/plugins/` directory of a bb checkout has four\nreference plugins: github (full-stack: gh-CLI-backed issue/PR browser on\nvendored shadcn components), slack-bot (webhook bot), agent-enrichment\n(agent surfaces), small-ux-pack (host-rendered UI).", "fileName": "bb-guide-plugins.md", "kind": "instruction", "title": "bb Guide — Plugins", diff --git a/packages/templates/src/templates/bb-guide-plugins.md b/packages/templates/src/templates/bb-guide-plugins.md index 29d18d902..0c3d4d773 100644 --- a/packages/templates/src/templates/bb-guide-plugins.md +++ b/packages/templates/src/templates/bb-guide-plugins.md @@ -70,7 +70,8 @@ arrives as the component's subPath prop for panel-internal deep links), threadPanelAction (an entry in the thread right panel's new-tab Actions list whose run() can open closable panel tabs with JSON params), composerAccessory (prompt box -footer). Hooks: +footer), and fileOpener (register as a per-extension file viewer/editor +users can set as the default via a file tab's "Open with" menu). Hooks: useRpc, useRealtime, useSettings (secrets excluded), useBbContext, useBbNavigate, and useComposer (quote selections / insert mention pills into the chat composer draft). Components are vendored shadcn source the plugin owns (the diff --git a/plans/plugin-system-design.md b/plans/plugin-system-design.md index 67f13b745..3a7c7493c 100644 --- a/plans/plugin-system-design.md +++ b/plans/plugin-system-design.md @@ -559,6 +559,7 @@ V1 slot set with **versioned per-slot props contracts** (additive-only within a | `navPanel` | `{}` (own route) | `AppRoutes` + `AppSidebar.tsx` (route + sidebar entry + nav state) | | `threadPanelAction` | action: `run({ threadId, openPanel })`; opened tab: `{ threadId, params }` | `NewTabFileSearch.tsx` Actions list; tabs are `plugin-panel` file-strip tabs (`fixed-panel-tabs-state.ts`) *(reworked 2026-07-03: replaced the fixed `threadPanelTab` toggle — actions run code and open closable, param-carrying tabs with the plugin logo as tab icon)* | | `composerAccessory` | `{ projectId, threadId: string \| null }` | `PromptBoxInternal.tsx` (`footerStart` slot prop already exists) | +| `fileOpener` *(added 2026-07-04)* | `{ path, source: { kind: workspace\|host\|thread-storage, threadId, environmentId, projectId } }` | `useThreadFileTabs.openTab` diversion → `plugin-panel` tabs (`file-opener:` action ids); "Open with" context menu on file tab pills; per-extension default in localStorage (`file-opener-preference.ts`). Live content only — never git-ref snapshots | | `settingsSection` | auto-generated from settings schema | `SettingsView.tsx` | Hooks from `@bb/plugin-sdk/app`: `useRpc()`, `useRealtime()`, From 903463816384b6680c00baa640956bfe1af448be Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Sat, 4 Jul 2026 09:25:26 -0700 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20notes=20hero=20plugin=20=E2=80=94?= =?UTF-8?q?=20Obsidian-style=20markdown=20notes=20(examples/plugins/notes)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mounted directories via a setting; reads/CAS-saves through bb.sdk.files (conflict banner on disk races); Milkdown Crepe WYSIWYG bundled per-plugin with its theme CSS served from a bb.http route and --crepe-* vars remapped to host tokens; navPanel (chrome none) with subPath deep links; thread panel action; md/mdx/markdown fileOpener; useComposer quote/@-mention buttons; fs-watcher realtime tree refresh; @Notes mention provider. Co-Authored-By: Claude Fable 5 --- .../__tests__/notes-example-bundle.test.ts | 144 ++ .../bb-plugin-authoring/SKILL.md | 7 + examples/plugins/notes/README.md | 37 + examples/plugins/notes/app.tsx | 718 ++++++++++ .../plugins/notes/components/ui/button.tsx | 61 + .../components/ui/coarse-pointer-sizing.ts | 62 + .../plugins/notes/components/ui/input.tsx | 31 + .../plugins/notes/components/ui/motion.ts | 21 + .../plugins/notes/components/ui/skeleton.tsx | 15 + examples/plugins/notes/lib/utils.ts | 6 + examples/plugins/notes/logo.svg | 4 + examples/plugins/notes/package.json | 29 + examples/plugins/notes/server.ts | 369 ++++++ examples/plugins/notes/tsconfig.json | 31 + .../bundled-types/bb-plugin-sdk-app.d.ts | 3 +- packages/plugin-sdk/src/app.ts | 1 + .../src/generated/plugin-sdk-dts.generated.ts | 2 +- plans/plugin-system-qa-catalog.md | 32 + pnpm-lock.yaml | 1167 ++++++++++++++++- 19 files changed, 2733 insertions(+), 7 deletions(-) create mode 100644 apps/cli/src/__tests__/notes-example-bundle.test.ts create mode 100644 examples/plugins/notes/README.md create mode 100644 examples/plugins/notes/app.tsx create mode 100644 examples/plugins/notes/components/ui/button.tsx create mode 100644 examples/plugins/notes/components/ui/coarse-pointer-sizing.ts create mode 100644 examples/plugins/notes/components/ui/input.tsx create mode 100644 examples/plugins/notes/components/ui/motion.ts create mode 100644 examples/plugins/notes/components/ui/skeleton.tsx create mode 100644 examples/plugins/notes/lib/utils.ts create mode 100644 examples/plugins/notes/logo.svg create mode 100644 examples/plugins/notes/package.json create mode 100644 examples/plugins/notes/server.ts create mode 100644 examples/plugins/notes/tsconfig.json diff --git a/apps/cli/src/__tests__/notes-example-bundle.test.ts b/apps/cli/src/__tests__/notes-example-bundle.test.ts new file mode 100644 index 000000000..4dfeafcb1 --- /dev/null +++ b/apps/cli/src/__tests__/notes-example-bundle.test.ts @@ -0,0 +1,144 @@ +import { cp, mkdtemp, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// The notes example bundles Milkdown Crepe (prosemirror + codemirror) — +// a large graph that blows the 5s default on cold CI runners. +vi.setConfig({ testTimeout: 120_000 }); +import { buildPluginApp } from "@bb/plugin-build"; + +/** + * Evaluates the notes hero example's built bundle against a stub runtime + * (the github-example-bundle.test.ts pattern) and asserts its default + * export registers the nav panel, the thread panel action, and the markdown + * fileOpener. + */ +const NOTES_DIR = fileURLToPath( + new URL("../../../../examples/plugins/notes", import.meta.url), +); + +interface SlotRegistration { + id: string; + title?: string; + icon?: string; + path?: string; + chrome?: string; + extensions?: string[]; + component: unknown; +} + +describe("notes example frontend bundle", () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "bb-notes-bundle-")); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + delete (globalThis as { __bbPluginRuntime?: unknown }).__bbPluginRuntime; + delete (globalThis as { document?: unknown }).document; + }); + + it("registers the notes nav panel, panel action, and markdown opener", async () => { + const pluginDir = join(root, "notes"); + await cp(NOTES_DIR, pluginDir, { + recursive: true, + filter: (source) => { + const name = basename(source); + return name !== "dist" && name !== "node_modules"; + }, + }); + // The example's own node_modules already holds every bundled dep + // (Milkdown among them) — link it wholesale instead of per-package. + await symlink(join(NOTES_DIR, "node_modules"), join(pluginDir, "node_modules"), "dir"); + const { jsPath } = await buildPluginApp(pluginDir); + + const registered: Record = { + homepageSection: [], + navPanel: [], + threadPanelAction: [], + composerAccessory: [], + fileOpener: [], + }; + const componentStub: unknown = new Proxy(function stub() {}, { + get: (target, prop) => + prop === "prototype" + ? Reflect.get(target, prop) + : (componentStub as object), + set: () => true, + }); + (globalThis as { __bbPluginRuntime?: unknown }).__bbPluginRuntime = { + react: { + forwardRef: (render: unknown) => render, + createContext: () => ({}), + memo: (component: unknown) => component, + }, + reactDom: componentStub, + reactDomClient: componentStub, + jsxRuntime: { jsx: () => ({}), jsxs: () => ({}), Fragment: {} }, + pluginSdkApp: { + definePluginApp: (setup: unknown) => ({ __bbPluginApp: true, setup }), + }, + sonner: componentStub, + vaul: componentStub, + pierreDiffs: componentStub, + pierreDiffsReact: componentStub, + }; + // decode-named-character-reference and CodeMirror's browser sniffing + // touch `document` at module scope (browser bundles legitimately assume + // one); registration evaluation never renders, so inert stubs suffice. + (globalThis as { document?: unknown }).document = { + createElement: () => ({ innerHTML: "", textContent: "", style: {} }), + documentElement: { style: {} }, + }; + const mod = (await import( + /* @vite-ignore */ pathToFileURL(jsPath).href + )) as { + default: { + __bbPluginApp: boolean; + setup: (app: { + slots: Record void>; + }) => void; + }; + }; + expect(mod.default.__bbPluginApp).toBe(true); + mod.default.setup({ + slots: { + homepageSection: (r) => registered.homepageSection.push(r), + navPanel: (r) => registered.navPanel.push(r), + threadPanelAction: (r) => registered.threadPanelAction.push(r), + composerAccessory: (r) => registered.composerAccessory.push(r), + fileOpener: (r) => registered.fileOpener.push(r), + }, + }); + + expect(registered.navPanel).toHaveLength(1); + expect(registered.navPanel[0]).toMatchObject({ + id: "notes", + title: "Notes", + path: "notes", + chrome: "none", + }); + expect(typeof registered.navPanel[0]?.component).toBe("function"); + + expect(registered.threadPanelAction).toHaveLength(1); + expect(registered.threadPanelAction[0]).toMatchObject({ + id: "note", + title: "Open note", + }); + + expect(registered.fileOpener).toHaveLength(1); + expect(registered.fileOpener[0]).toMatchObject({ + id: "editor", + title: "Notes editor", + extensions: ["md", "mdx", "markdown"], + }); + expect(typeof registered.fileOpener[0]?.component).toBe("function"); + + expect(registered.homepageSection).toHaveLength(0); + expect(registered.composerAccessory).toHaveLength(0); + }); +}); diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md index c68ab0f52..338e96d34 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md @@ -567,6 +567,13 @@ Reference examples in `examples/plugins/` (a bb checkout): vendored Tabs/Select/DropdownMenu/Badge/Skeleton + sonner toast throughout, background sync service, rpc + realtime, project setting, a `bb github` CLI command, and agent-spawn buttons. +- `notes` — full-surface markdown notes (Obsidian-style): mounted + directories via a setting, `bb.sdk.files` read/CAS-write, Milkdown Crepe + WYSIWYG bundled per-plugin (its theme CSS served from a `bb.http` route), + navPanel with `chrome: "none"` + subPath deep links, threadPanelAction, + a markdown `fileOpener`, `useComposer()` quote/mention buttons, an fs + watcher publishing realtime tree refreshes, and a `@Notes` mention + provider resolving note content at send. - `slack-bot` — headless webhook bot: `auth: "none"` route with signature verification, kv thread mapping, `thread.idle` handler, spawn/send, needsConfiguration. diff --git a/examples/plugins/notes/README.md b/examples/plugins/notes/README.md new file mode 100644 index 000000000..65bc4a8c9 --- /dev/null +++ b/examples/plugins/notes/README.md @@ -0,0 +1,37 @@ +# bb-plugin-notes + +An Obsidian-style markdown notes plugin — the hero example for the +`bb.sdk.files` host file API and the `fileOpener` / `useComposer()` / +navPanel-`subPath` frontend surfaces. + +- **Notes nav panel** (`chrome: "none"`): mounted-directory tree + a + [Milkdown Crepe](https://milkdown.dev/) WYSIWYG editor, deep-linked via + the panel's `subPath` (`/plugins/notes/notes//`). +- **Mounted directories** come from the `directories` setting + (comma-separated, `~` expands). Reads and saves go through + `bb.sdk.files` with `expectedSha256` compare-and-swap — a save that + races an agent editing the same file surfaces a reload/overwrite banner + instead of clobbering. +- **File opener**: registers as an opener for `md`/`mdx`/`markdown`, so + right-clicking a markdown file tab offers "Open with Notes editor" (and + "Always open .md with…"). Links clicked in rendered markdown then land + in the editor instead of the read-only preview. +- **Chat integration**: "Add to chat" quotes the current selection (or the + whole note) into the composer draft via `useComposer().addQuote`; + "@-mention" inserts a pill that resolves the note's content at send time + through the plugin's mention provider. Typing `@` in the composer also + searches notes directly. +- **Live refresh**: a background fs watcher publishes `notes-changed` over + `bb.realtime`, keeping the tree current while agents write notes. +- Crepe's stylesheet is served from the plugin's own `bb.http` route + (`/api/v1/plugins/notes/http/crepe.css`) because plugin bundles ship only + Tailwind-compiled CSS; `--crepe-*` variables are remapped to host theme + tokens so the editor follows light/dark and custom palettes. + +Install from a bb checkout: + +``` +bb plugin install examples/plugins/notes +bb plugin config notes set directories "~/Notes" +bb plugin reload notes +``` diff --git a/examples/plugins/notes/app.tsx b/examples/plugins/notes/app.tsx new file mode 100644 index 000000000..a539d62ad --- /dev/null +++ b/examples/plugins/notes/app.tsx @@ -0,0 +1,718 @@ +// bb-plugin-notes frontend: an Obsidian-style notes surface. +// - navPanel "Notes" (chrome none): mounted-directory tree + Milkdown Crepe +// WYSIWYG editor, deep-linked via the panel's subPath. +// - threadPanelAction "Open note": the same editor in a thread's side panel. +// - fileOpener "Notes editor" for md/mdx/markdown: workspace/host markdown +// opened in the panel renders here instead of the read-only preview (set +// as default via the tab's "Open with" menu). +// - useComposer(): quote a selection (or the whole note) into the chat +// draft, or insert an @-mention pill that resolves the note at send time. +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + definePluginApp, + useBbNavigate, + useComposer, + useRealtime, + useRpc, + type PluginFileOpenerProps, + type PluginNavPanelProps, + type PluginThreadPanelProps, +} from "@bb/plugin-sdk/app"; +import { Crepe } from "@milkdown/crepe"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; + +// --------------------------------------------------------------------------- +// rpc result shapes (rpc is untyped in V1 — narrow at the boundary). +// --------------------------------------------------------------------------- + +interface MountListing { + name: string; + root: string; + files: { path: string; name: string }[]; + error: string | null; +} + +interface NoteContent { + content: string; + sha256: string; +} + +type SaveResult = + | { outcome: "written"; sha256: string } + | { outcome: "conflict"; currentSha256: string | null }; + +interface ResolvedFile { + absPath: string; + rootPath: string | null; + hostId: string | null; +} + +function asMounts(value: unknown): MountListing[] { + const mounts = (value as { mounts?: unknown })?.mounts; + return Array.isArray(mounts) ? (mounts as MountListing[]) : []; +} + +// --------------------------------------------------------------------------- +// Crepe theme wiring. The plugin server serves Crepe's own stylesheet (the +// frontend bundle ships only Tailwind CSS); host-token variable overrides +// keep the editor on the app palette in both light and dark themes. +// --------------------------------------------------------------------------- + +const CREPE_CSS_URL = "/api/v1/plugins/notes/http/crepe.css"; +const STYLE_MARKER = "data-bb-notes-styles"; + +const EDITOR_THEME_CSS = ` +.bb-notes-editor .milkdown { + --crepe-color-background: transparent; + --crepe-color-on-background: var(--foreground); + --crepe-color-surface: var(--background); + --crepe-color-surface-low: var(--muted); + --crepe-color-on-surface: var(--foreground); + --crepe-color-on-surface-variant: var(--muted-foreground); + --crepe-color-outline: var(--border); + --crepe-color-primary: var(--primary); + --crepe-color-secondary: var(--secondary); + --crepe-color-on-secondary: var(--secondary-foreground); + --crepe-color-inverse: var(--foreground); + --crepe-color-on-inverse: var(--background); + --crepe-color-inline-code: var(--destructive); + --crepe-color-error: var(--destructive); + --crepe-color-hover: var(--accent); + --crepe-color-selected: var(--accent); + --crepe-color-inline-area: var(--muted); + --crepe-font-title: inherit; + --crepe-font-default: inherit; + --crepe-font-code: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + height: 100%; + font-size: 14px; +} +.bb-notes-editor .milkdown .ProseMirror { + padding: 20px 28px 96px; +} +`; + +function ensureEditorStyles(): void { + if (document.head.querySelector(`[${STYLE_MARKER}]`)) return; + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = CREPE_CSS_URL; + link.setAttribute(STYLE_MARKER, "link"); + document.head.appendChild(link); + const style = document.createElement("style"); + style.setAttribute(STYLE_MARKER, "style"); + style.textContent = EDITOR_THEME_CSS; + document.head.appendChild(style); +} + +// --------------------------------------------------------------------------- +// The Crepe editor. Remounted (via key) per note; markdown flows out through +// a ref so the effect never re-runs mid-edit. +// --------------------------------------------------------------------------- + +function CrepeEditor({ + initialValue, + onMarkdownChange, +}: { + initialValue: string; + onMarkdownChange: (markdown: string) => void; +}) { + const rootRef = useRef(null); + const onChangeRef = useRef(onMarkdownChange); + onChangeRef.current = onMarkdownChange; + + useEffect(() => { + ensureEditorStyles(); + const root = rootRef.current; + if (!root) return; + const crepe = new Crepe({ root, defaultValue: initialValue }); + crepe.on((listener) => { + listener.markdownUpdated((_ctx, markdown) => { + onChangeRef.current(markdown); + }); + }); + void crepe.create(); + return () => { + void crepe.destroy(); + }; + // initialValue is intentionally captured once — the editor owns the + // document after mount; callers remount with a new key to reload. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+ ); +} + +// --------------------------------------------------------------------------- +// One editor pane over an abstract load/save target. +// --------------------------------------------------------------------------- + +interface NoteTarget { + /** Stable identity — remounts the editor when it changes. */ + key: string; + /** Filename shown in the pane header. */ + name: string; + load(): Promise; + save(content: string, expectedSha256: string | null | undefined): Promise; + /** Present for mount-backed notes: enables the @-mention button. */ + mention?: { provider: string; id: string; label: string }; +} + +type PaneState = + | { phase: "loading" } + | { phase: "error"; message: string } + | { phase: "ready"; initialContent: string; sha256: string }; + +function NoteEditorPane({ target }: { target: NoteTarget }) { + const composer = useComposer(); + const [state, setState] = useState({ phase: "loading" }); + const [dirty, setDirty] = useState(false); + const [saving, setSaving] = useState(false); + const [conflict, setConflict] = useState(false); + const [notice, setNotice] = useState(null); + // Latest markdown + the sha we loaded/saved against, outside render state + // so keystrokes don't re-render the pane. + const markdownRef = useRef(""); + const sha256Ref = useRef(null); + const [reloadNonce, setReloadNonce] = useState(0); + + useEffect(() => { + let alive = true; + setState({ phase: "loading" }); + setDirty(false); + setConflict(false); + setNotice(null); + target + .load() + .then((note) => { + if (!alive) return; + markdownRef.current = note.content; + sha256Ref.current = note.sha256; + setState({ + phase: "ready", + initialContent: note.content, + sha256: note.sha256, + }); + }) + .catch((error: unknown) => { + if (!alive) return; + setState({ + phase: "error", + message: error instanceof Error ? error.message : String(error), + }); + }); + return () => { + alive = false; + }; + }, [target.key, reloadNonce]); // eslint-disable-line react-hooks/exhaustive-deps + + const save = useCallback( + async (options?: { force?: boolean }) => { + if (saving) return; + setSaving(true); + setNotice(null); + try { + const result = await target.save( + markdownRef.current, + options?.force ? undefined : sha256Ref.current, + ); + if (result.outcome === "conflict") { + setConflict(true); + } else { + sha256Ref.current = result.sha256; + setDirty(false); + setConflict(false); + } + } catch (error) { + setNotice(error instanceof Error ? error.message : String(error)); + } finally { + setSaving(false); + } + }, + [saving, target], + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key === "s") { + event.preventDefault(); + void save(); + } + }, + [save], + ); + + const quoteToChat = useCallback(() => { + const selection = window.getSelection()?.toString() ?? ""; + composer.addQuote( + selection.trim().length > 0 ? selection : markdownRef.current, + ); + }, [composer]); + + const mentionInChat = useCallback(() => { + if (target.mention) composer.insertMention(target.mention); + }, [composer, target.mention]); + + return ( +
+
+ + {target.name} + + {dirty ? ( + edited + ) : null} +
+ + {target.mention ? ( + + ) : null} + +
+ {conflict ? ( +
+ This file changed on disk since it was loaded. + + +
+ ) : null} + {notice ? ( +
+ {notice} +
+ ) : null} + {state.phase === "loading" ? ( +
Loading…
+ ) : state.phase === "error" ? ( +
{state.message}
+ ) : ( + { + markdownRef.current = markdown; + setDirty(true); + }} + /> + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Mount-backed note targets (nav panel + thread panel action). +// --------------------------------------------------------------------------- + +function useMounts(): { + mounts: MountListing[] | null; + error: string | null; + refresh: () => void; +} { + const rpc = useRpc(); + const [mounts, setMounts] = useState(null); + const [error, setError] = useState(null); + const refresh = useCallback(() => { + rpc + .call("listNotes") + .then((result) => { + setMounts(asMounts(result)); + setError(null); + }) + .catch((rpcError: unknown) => { + setError( + rpcError instanceof Error ? rpcError.message : String(rpcError), + ); + }); + }, [rpc]); + useEffect(() => { + refresh(); + }, [refresh]); + useRealtime("notes-changed", refresh); + return { mounts, error, refresh }; +} + +function useMountNoteTarget( + mount: MountListing | undefined, + notePath: string | null, +): NoteTarget | null { + const rpc = useRpc(); + return useMemo(() => { + if (!mount || notePath === null) return null; + const root = mount.root; + return { + key: `${root}\n${notePath}`, + name: notePath.split("/").at(-1) ?? notePath, + async load() { + return (await rpc.call("readNote", { + root, + path: notePath, + })) as NoteContent; + }, + async save(content, expectedSha256) { + return (await rpc.call("saveNote", { + root, + path: notePath, + content, + ...(expectedSha256 !== undefined ? { expectedSha256 } : {}), + })) as SaveResult; + }, + mention: { + provider: "notes", + id: `${root}\n${notePath}`, + label: notePath.split("/").at(-1) ?? notePath, + }, + }; + }, [mount, notePath, rpc]); +} + +// --------------------------------------------------------------------------- +// The notes tree (shared by the nav panel and the thread panel picker). +// --------------------------------------------------------------------------- + +function NotesTree({ + mounts, + error, + selectedKey, + onSelect, + onCreate, +}: { + mounts: MountListing[] | null; + error: string | null; + selectedKey: string | null; + onSelect: (mountIndex: number, path: string) => void; + onCreate?: (mountIndex: number, name: string) => void; +}) { + const [draftByMount, setDraftByMount] = useState>({}); + if (error) { + return
{error}
; + } + if (mounts === null) { + return
Loading…
; + } + if (mounts.length === 0) { + return ( +
+ No note directories yet. Add one under Settings → Plugins → notes + (e.g. ~/Notes), then reload the plugin. +
+ ); + } + return ( +
+ {mounts.map((mount, mountIndex) => ( +
+
+ {mount.name} +
+ {mount.error ? ( +
{mount.error}
+ ) : ( +
+ {mount.files.map((file) => { + const key = `${mountIndex}/${file.path}`; + return ( + + ); + })} + {mount.files.length === 0 ? ( +
+ No markdown files yet. +
+ ) : null} +
+ )} + {onCreate ? ( +
{ + event.preventDefault(); + const draft = (draftByMount[mountIndex] ?? "").trim(); + if (draft.length === 0) return; + onCreate( + mountIndex, + draft.endsWith(".md") ? draft : `${draft}.md`, + ); + setDraftByMount((previous) => ({ + ...previous, + [mountIndex]: "", + })); + }} + > + + setDraftByMount((previous) => ({ + ...previous, + [mountIndex]: event.target.value, + })) + } + placeholder="new-note.md" + className="h-7 text-xs" + /> + +
+ ) : null} +
+ ))} +
+ ); +} + +// --------------------------------------------------------------------------- +// navPanel: tree + editor, deep-linked via subPath ("/"). +// --------------------------------------------------------------------------- + +function parseSubPath(subPath: string): { mountIndex: number; path: string } | null { + const slash = subPath.indexOf("/"); + if (slash === -1) return null; + const mountIndex = Number(subPath.slice(0, slash)); + const path = subPath.slice(slash + 1); + if (!Number.isInteger(mountIndex) || mountIndex < 0 || path.length === 0) { + return null; + } + return { mountIndex, path }; +} + +function NotesPanel({ subPath }: PluginNavPanelProps) { + const rpc = useRpc(); + const navigate = useBbNavigate(); + const { mounts, error, refresh } = useMounts(); + const selected = useMemo(() => parseSubPath(subPath), [subPath]); + const selectedMount = + selected === null ? undefined : mounts?.[selected.mountIndex]; + const target = useMountNoteTarget( + selectedMount, + selected === null ? null : selected.path, + ); + + const openNote = useCallback( + (mountIndex: number, path: string) => { + navigate.toPluginPanel("notes", { subPath: `${mountIndex}/${path}` }); + }, + [navigate], + ); + + const createNote = useCallback( + (mountIndex: number, name: string) => { + const mount = mounts?.[mountIndex]; + if (!mount) return; + rpc + .call("saveNote", { + root: mount.root, + path: name, + content: `# ${name.replace(/\.mdx?$|\.markdown$/i, "")}\n\n`, + expectedSha256: null, + }) + .then(() => { + refresh(); + openNote(mountIndex, name); + }) + .catch((createError: unknown) => { + console.warn(`[plugin:notes] create failed:`, createError); + }); + }, + [mounts, openNote, refresh, rpc], + ); + + return ( +
+
+ +
+ {target ? ( + + ) : ( +
+ Select a note to start writing. +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// threadPanelAction: the same editor inside a thread's side panel. +// --------------------------------------------------------------------------- + +function NotePanelTab(_props: PluginThreadPanelProps) { + const { mounts, error } = useMounts(); + const [selected, setSelected] = useState<{ + mountIndex: number; + path: string; + } | null>(null); + const selectedMount = + selected === null ? undefined : mounts?.[selected.mountIndex]; + const target = useMountNoteTarget( + selectedMount, + selected === null ? null : selected.path, + ); + + if (target) { + return ( +
+
+ +
+ +
+ ); + } + return ( +
+ setSelected({ mountIndex, path })} + /> +
+ ); +} + +// --------------------------------------------------------------------------- +// fileOpener: workspace/host markdown in the notes editor. +// --------------------------------------------------------------------------- + +function NotesFileOpener({ path, source }: PluginFileOpenerProps) { + const rpc = useRpc(); + const [resolved, setResolved] = useState(null); + const [resolveError, setResolveError] = useState(null); + + useEffect(() => { + let alive = true; + setResolved(null); + setResolveError(null); + rpc + .call("resolveFile", { source, path }) + .then((result) => { + if (alive) setResolved(result as ResolvedFile); + }) + .catch((error: unknown) => { + if (alive) { + setResolveError( + error instanceof Error ? error.message : String(error), + ); + } + }); + return () => { + alive = false; + }; + }, [path, rpc, source]); + + const target = useMemo(() => { + if (resolved === null) return null; + return { + key: `file:${resolved.absPath}`, + name: path.split("/").at(-1) ?? path, + async load() { + return (await rpc.call("readFile", resolved)) as NoteContent; + }, + async save(content, expectedSha256) { + return (await rpc.call("writeFile", { + ...resolved, + content, + ...(expectedSha256 !== undefined ? { expectedSha256 } : {}), + })) as SaveResult; + }, + }; + }, [path, resolved, rpc]); + + if (resolveError !== null) { + return ( +
+ {resolveError} — use the tab's "Open with" menu to switch back to the + built-in preview. +
+ ); + } + if (target === null) { + return
Loading…
; + } + return ( +
+ +
+ ); +} + +// --------------------------------------------------------------------------- + +export default definePluginApp((app) => { + app.slots.navPanel({ + id: "notes", + title: "Notes", + icon: "FileText", + path: "notes", + chrome: "none", + component: NotesPanel, + }); + app.slots.threadPanelAction({ + id: "note", + title: "Open note", + icon: "FileText", + component: NotePanelTab, + }); + app.slots.fileOpener({ + id: "editor", + title: "Notes editor", + extensions: ["md", "mdx", "markdown"], + component: NotesFileOpener, + }); +}); diff --git a/examples/plugins/notes/components/ui/button.tsx b/examples/plugins/notes/components/ui/button.tsx new file mode 100644 index 000000000..acc7c180d --- /dev/null +++ b/examples/plugins/notes/components/ui/button.tsx @@ -0,0 +1,61 @@ +/* shadcn/ui-derived */ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; +import { CONTROL_HOVER_TRANSITION } from "./motion.js"; + +const buttonVariants = cva( + `inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ${CONTROL_HOVER_TRANSITION} focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0`, + { + variants: { + variant: { + default: + "bg-foreground text-background hover:bg-foreground/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-transparent hover:bg-state-hover hover:text-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-state-hover hover:text-foreground aria-pressed:bg-state-active aria-pressed:text-foreground aria-pressed:hover:bg-state-active data-[state=open]:bg-state-active data-[state=open]:text-foreground data-[state=open]:hover:bg-state-active", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends + Omit, "title">, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/examples/plugins/notes/components/ui/coarse-pointer-sizing.ts b/examples/plugins/notes/components/ui/coarse-pointer-sizing.ts new file mode 100644 index 000000000..b371ccb99 --- /dev/null +++ b/examples/plugins/notes/components/ui/coarse-pointer-sizing.ts @@ -0,0 +1,62 @@ +export const COARSE_POINTER_TEXT_BASE_CLASS = + "text-sm max-md:pointer-coarse:text-base"; + +export const COARSE_POINTER_TEXT_SM_CLASS = + "text-xs max-md:pointer-coarse:text-sm"; + +export const COARSE_POINTER_ICON_SIZE_CLASS = + "size-4 max-md:pointer-coarse:size-5"; + +export const COARSE_POINTER_ICON_SIZE_SHRINK_CLASS = + "size-4 shrink-0 max-md:pointer-coarse:size-5"; + +export const COARSE_POINTER_COMPACT_ICON_SIZE_CLASS = + "size-3.5 max-md:pointer-coarse:size-5"; + +export const COARSE_POINTER_COMPACT_ICON_SIZE_SHRINK_CLASS = + "size-3.5 shrink-0 max-md:pointer-coarse:size-5"; + +export const COARSE_POINTER_DOT_SIZE_CLASS = + "size-1.5 max-md:pointer-coarse:size-2"; + +export const COARSE_POINTER_GLYPH_BOX_CLASS = + "h-4 w-4 max-md:pointer-coarse:h-5 max-md:pointer-coarse:w-5"; + +export const COARSE_POINTER_CHECK_SLOT_CLASS = + "h-3.5 w-3.5 max-md:pointer-coarse:h-5 max-md:pointer-coarse:w-5"; + +export const COARSE_POINTER_HEADER_ICON_BUTTON_CLASS = + "h-[28px] w-[28px] rounded-md p-0 [&_svg]:size-[16px] max-md:pointer-coarse:h-[36px] max-md:pointer-coarse:w-[36px] max-md:pointer-coarse:[&_svg]:size-[20px]"; + +export const COARSE_POINTER_COMPACT_ICON_BUTTON_CLASS = + "h-7 w-7 rounded-md p-0 [&_svg]:size-3.5 max-md:pointer-coarse:h-9 max-md:pointer-coarse:w-9 max-md:pointer-coarse:[&_svg]:size-5"; + +export const COARSE_POINTER_CHILD_ICON_BUTTON_CLASS = + "h-8 w-8 justify-center p-0 [&>svg]:size-4 max-md:pointer-coarse:h-9 max-md:pointer-coarse:w-9 max-md:pointer-coarse:[&>svg]:size-5"; + +export const COARSE_POINTER_TOOLBAR_ACTION_BUTTON_CLASS = + "h-7 rounded-md border-border bg-transparent px-2 text-xs font-medium text-foreground shadow-none hover:bg-state-hover hover:text-foreground max-md:pointer-coarse:h-9"; + +export const COARSE_POINTER_PROMPT_ACTION_BUTTON_CLASS = + "h-8 px-2 transition-all max-md:pointer-coarse:h-10 max-md:pointer-coarse:px-2.5"; + +export const COARSE_POINTER_PROMPT_ICON_ACTION_BUTTON_CLASS = + "size-auto h-8 px-2 transition-all max-md:pointer-coarse:h-10 max-md:pointer-coarse:px-2.5"; + +export const COARSE_POINTER_PROMPT_COMBO_BUTTON_CLASS = + "h-8 w-8 rounded-l-none border-l border-l-primary-foreground/20 px-0 transition-all hover:border-l-primary-foreground/30 max-md:pointer-coarse:h-10 max-md:pointer-coarse:w-10"; + +export const COARSE_POINTER_INPUT_HEIGHT_CLASS = + "h-9 max-md:pointer-coarse:h-10"; + +export const COARSE_POINTER_COMPACT_ROW_HEIGHT_CLASS = + "h-7 max-md:pointer-coarse:h-9"; + +export const COARSE_POINTER_ROW_HEIGHT_CLASS = + "h-[var(--bb-sidebar-row-height)] max-md:pointer-coarse:h-[var(--bb-sidebar-row-height-coarse)]"; + +export const COARSE_POINTER_PROVIDER_TAB_SIZE_CLASS = + "h-7 w-6 max-md:pointer-coarse:h-9 max-md:pointer-coarse:w-9"; + +export const COARSE_POINTER_ROW_ACTION_SIZE_CLASS = + "h-7 w-7 max-md:pointer-coarse:h-9 max-md:pointer-coarse:w-9"; diff --git a/examples/plugins/notes/components/ui/input.tsx b/examples/plugins/notes/components/ui/input.tsx new file mode 100644 index 000000000..64087b6ed --- /dev/null +++ b/examples/plugins/notes/components/ui/input.tsx @@ -0,0 +1,31 @@ +/* shadcn/ui-derived */ +import * as React from "react"; + +import { + COARSE_POINTER_INPUT_HEIGHT_CLASS, + COARSE_POINTER_TEXT_BASE_CLASS, +} from "./coarse-pointer-sizing.js"; +import { cn } from "@/lib/utils"; +import { CONTROL_HOVER_TRANSITION } from "./motion.js"; + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ); + }, +); +Input.displayName = "Input"; + +export { Input }; diff --git a/examples/plugins/notes/components/ui/motion.ts b/examples/plugins/notes/components/ui/motion.ts new file mode 100644 index 000000000..e40d4c62a --- /dev/null +++ b/examples/plugins/notes/components/ui/motion.ts @@ -0,0 +1,21 @@ +/** + * Shared hover-motion heuristic for primitives, so the whole UI speaks one + * timing language instead of ad-hoc per-component transitions: + * + * - CONTROL_HOVER_TRANSITION — interactive controls (buttons, icon buttons): + * the hover/active fill snaps IN instantly (0ms) and eases OUT lazily (150ms). + * Immediate feedback on the way in, never twitchy on the way out. The trick: + * CSS applies the *end state's* transition-duration for each direction, so a + * base `duration-150` governs hover-out while `hover:duration-0` makes + * hover-in instant. + * - LIST_HOVER_TRANSITION — dense list/menu rows (menu items, list rows): no + * transition at all (instant both ways), so the highlight tracks the pointer + * and arrow keys exactly, with no lag during fast navigation. + * + * Reach for one of these rather than a bare `transition-colors` on anything with + * a hover/active state. + */ +export const CONTROL_HOVER_TRANSITION = + "transition-colors duration-150 hover:duration-0"; + +export const LIST_HOVER_TRANSITION = "transition-none"; diff --git a/examples/plugins/notes/components/ui/skeleton.tsx b/examples/plugins/notes/components/ui/skeleton.tsx new file mode 100644 index 000000000..971d1d851 --- /dev/null +++ b/examples/plugins/notes/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +/* shadcn/ui-derived */ +import type { HTMLAttributes } from "react"; +import { cn } from "@/lib/utils"; + +export function Skeleton({ + className, + ...props +}: HTMLAttributes) { + return ( +
+ ); +} diff --git a/examples/plugins/notes/lib/utils.ts b/examples/plugins/notes/lib/utils.ts new file mode 100644 index 000000000..365058ceb --- /dev/null +++ b/examples/plugins/notes/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/examples/plugins/notes/logo.svg b/examples/plugins/notes/logo.svg new file mode 100644 index 000000000..f62e895f5 --- /dev/null +++ b/examples/plugins/notes/logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/examples/plugins/notes/package.json b/examples/plugins/notes/package.json new file mode 100644 index 000000000..2e9c50711 --- /dev/null +++ b/examples/plugins/notes/package.json @@ -0,0 +1,29 @@ +{ + "name": "bb-plugin-notes", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "bb": ">=0.0" + }, + "bb": { + "server": "./server.ts", + "app": "./app.tsx" + }, + "keywords": [ + "bb-plugin" + ], + "devDependencies": { + "@bb/plugin-sdk": "workspace:*", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "typescript": "^5.7.0" + }, + "dependencies": { + "@milkdown/crepe": "^7.5.0", + "@radix-ui/react-slot": "^1.2.4", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "tailwind-merge": "^3.4.0" + } +} diff --git a/examples/plugins/notes/server.ts b/examples/plugins/notes/server.ts new file mode 100644 index 000000000..4863e2203 --- /dev/null +++ b/examples/plugins/notes/server.ts @@ -0,0 +1,369 @@ +// bb-plugin-notes — Obsidian-style markdown notes (plugin design hero for +// bb.sdk.files + the fileOpener/useComposer/subPath surfaces). +// +// The backend is a thin file service over `bb.sdk.files`: the user mounts +// directories via a setting, the rpc surface lists/reads/CAS-saves markdown +// under those mounts, an fs watcher pushes tree refreshes over realtime, and +// a mention provider lets `@`-mentions resolve a note's content at send +// time. Milkdown Crepe's theme CSS is served from the plugin's own http +// route (the frontend bundle pipeline ships only Tailwind-compiled CSS). +import { watch, type FSWatcher } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import type { BbPluginApi } from "@bb/plugin-sdk"; + +interface NotesMount { + name: string; + root: string; +} + +interface MountListing { + name: string; + root: string; + files: { path: string; name: string }[]; + error: string | null; +} + +interface OpenerSource { + kind: "workspace" | "host" | "thread-storage"; + threadId: string | null; + environmentId: string | null; + projectId: string | null; +} + +const NOTE_EXTENSIONS = new Set(["md", "mdx", "markdown"]); +const LIST_LIMIT = 2000; + +function isNotePath(filePath: string): boolean { + const dotIndex = filePath.lastIndexOf("."); + if (dotIndex === -1) return false; + return NOTE_EXTENSIONS.has(filePath.slice(dotIndex + 1).toLowerCase()); +} + +function expandHome(rawPath: string): string { + if (rawPath === "~") return os.homedir(); + if (rawPath.startsWith("~/")) return path.join(os.homedir(), rawPath.slice(2)); + return rawPath; +} + +function requireString(value: unknown, field: string): string { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`"${field}" must be a non-empty string`); + } + return value; +} + +/** Reject note paths that could step outside a mount. */ +function requireRelativeNotePath(value: unknown): string { + const notePath = requireString(value, "path"); + const segments = notePath.split("/"); + if ( + path.isAbsolute(notePath) || + segments.some((s) => s.length === 0 || s === "." || s === "..") + ) { + throw new Error(`Invalid note path: ${notePath}`); + } + return notePath; +} + +/** + * Inline the `@import './x.css'` chain of Crepe's common theme stylesheet so + * one http response carries the whole thing (a to this route has no + * base URL the relative imports could resolve against). + */ +async function loadCrepeCss(): Promise { + const require = createRequire(import.meta.url); + const entry = require.resolve("@milkdown/crepe/theme/common/style.css"); + const dir = path.dirname(entry); + const seen = new Set(); + const inline = async (file: string): Promise => { + if (seen.has(file)) return ""; + seen.add(file); + const source = await readFile(file, "utf8"); + const parts = await Promise.all( + source.split("\n").map(async (line) => { + const match = /^@import\s+['"]\.\/(.+\.css)['"];/.exec(line.trim()); + if (!match?.[1]) return line; + return inline(path.join(path.dirname(file), match[1])); + }), + ); + return parts.join("\n"); + }; + return inline(path.join(dir, "style.css")); +} + +export default async function plugin(bb: BbPluginApi) { + const settings = bb.settings.define({ + directories: { + type: "string", + label: "Note directories (comma-separated, ~ ok)", + default: "", + }, + }); + + async function getMounts(): Promise { + const { directories } = await settings.get(); + const roots = directories + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .map((entry) => path.resolve(expandHome(entry))); + const seenNames = new Map(); + return roots.map((root) => { + const base = path.basename(root) || root; + const count = seenNames.get(base) ?? 0; + seenNames.set(base, count + 1); + return { name: count === 0 ? base : `${base} (${count + 1})`, root }; + }); + } + + async function requireMount(rootInput: unknown): Promise { + const root = requireString(rootInput, "root"); + const mount = (await getMounts()).find((m) => m.root === root); + if (!mount) { + throw new Error( + `"${root}" is not a configured notes directory — add it in Settings → Plugins → notes`, + ); + } + return mount; + } + + if ((await getMounts()).length === 0) { + bb.status.needsConfiguration( + "Set the notes directories setting (e.g. ~/Notes), then `bb plugin reload notes`.", + ); + } + + // --- rpc ------------------------------------------------------------------ + + bb.rpc.register({ + async listNotes(): Promise<{ mounts: MountListing[] }> { + const mounts = await getMounts(); + const listings = await Promise.all( + mounts.map(async (mount): Promise => { + try { + const result = await bb.sdk.files.list({ + path: mount.root, + limit: LIST_LIMIT, + }); + const files = result.files + .filter((file) => isNotePath(file.path)) + .sort((a, b) => a.path.localeCompare(b.path)) + .map((file) => ({ path: file.path, name: file.name })); + return { ...mount, files, error: null }; + } catch (error) { + return { + ...mount, + files: [], + error: error instanceof Error ? error.message : String(error), + }; + } + }), + ); + return { mounts: listings }; + }, + + async readNote(input: { root?: unknown; path?: unknown }) { + const mount = await requireMount(input.root); + const notePath = requireRelativeNotePath(input.path); + const file = await bb.sdk.files.read({ + path: path.join(mount.root, notePath), + rootPath: mount.root, + }); + return { content: file.content, sha256: file.sha256 }; + }, + + async saveNote(input: { + root?: unknown; + path?: unknown; + content?: unknown; + expectedSha256?: unknown; + }) { + const mount = await requireMount(input.root); + const notePath = requireRelativeNotePath(input.path); + if (typeof input.content !== "string") { + throw new Error(`"content" must be a string`); + } + const result = await bb.sdk.files.write({ + path: path.join(mount.root, notePath), + rootPath: mount.root, + content: input.content, + createParents: true, + ...(input.expectedSha256 === null || + typeof input.expectedSha256 === "string" + ? { expectedSha256: input.expectedSha256 } + : {}), + }); + return result; + }, + + /** + * Resolve a fileOpener target into { absPath, rootPath, hostId } the + * read/write file rpcs below can use. Workspace paths are relative to + * the environment's worktree; host paths are already absolute. + */ + async resolveFile(input: { source?: OpenerSource; path?: unknown }) { + const filePath = requireString(input.path, "path"); + const source = input.source; + if (source?.kind === "host") { + return { absPath: filePath, rootPath: null, hostId: null }; + } + if (source?.kind === "workspace" && source.environmentId) { + const environment = await bb.sdk.environments.get({ + environmentId: source.environmentId, + }); + if (!environment.path) { + throw new Error("This environment has no workspace path"); + } + return { + absPath: path.join(environment.path, filePath), + rootPath: environment.path, + hostId: environment.hostId, + }; + } + throw new Error( + "Only workspace and host files can open in the notes editor", + ); + }, + + async readFile(input: { + absPath?: unknown; + rootPath?: unknown; + hostId?: unknown; + }) { + const file = await bb.sdk.files.read({ + path: requireString(input.absPath, "absPath"), + ...(typeof input.rootPath === "string" + ? { rootPath: input.rootPath } + : {}), + ...(typeof input.hostId === "string" ? { hostId: input.hostId } : {}), + }); + return { content: file.content, sha256: file.sha256 }; + }, + + async writeFile(input: { + absPath?: unknown; + rootPath?: unknown; + hostId?: unknown; + content?: unknown; + expectedSha256?: unknown; + }) { + if (typeof input.content !== "string") { + throw new Error(`"content" must be a string`); + } + return bb.sdk.files.write({ + path: requireString(input.absPath, "absPath"), + content: input.content, + ...(typeof input.rootPath === "string" + ? { rootPath: input.rootPath } + : {}), + ...(typeof input.hostId === "string" ? { hostId: input.hostId } : {}), + ...(input.expectedSha256 === null || + typeof input.expectedSha256 === "string" + ? { expectedSha256: input.expectedSha256 } + : {}), + }); + }, + }); + + // --- live tree refresh ------------------------------------------------------ + + bb.background.service("notes-watcher", { + start(signal) { + const watchers: FSWatcher[] = []; + let debounce: NodeJS.Timeout | null = null; + void getMounts().then((mounts) => { + if (signal.aborted) return; + for (const mount of mounts) { + try { + // Recursive fs.watch is unavailable on some platforms — the + // tree then refreshes only on demand, which is fine. + const watcher = watch(mount.root, { recursive: true }, () => { + if (debounce) clearTimeout(debounce); + debounce = setTimeout(() => { + bb.realtime.publish("notes-changed", {}); + }, 250); + }); + watcher.on("error", () => watcher.close()); + watchers.push(watcher); + } catch { + bb.log.warn(`cannot watch ${mount.root}; refresh is manual`); + } + } + }); + return new Promise((resolve) => { + signal.addEventListener("abort", () => { + if (debounce) clearTimeout(debounce); + for (const watcher of watchers) watcher.close(); + resolve(); + }); + }); + }, + }); + + // --- Crepe theme css --------------------------------------------------------- + + let crepeCss: string | null = null; + bb.http.route("GET", "/crepe.css", async () => { + crepeCss ??= await loadCrepeCss(); + return new Response(crepeCss, { + headers: { + "content-type": "text/css; charset=utf-8", + "cache-control": "no-store", + }, + }); + }); + + // --- @note mentions ------------------------------------------------------------ + + bb.ui.registerMentionProvider({ + id: "notes", + label: "Notes", + async search({ query }) { + const mounts = await getMounts(); + const needle = query.toLowerCase(); + const items: { id: string; title: string; subtitle: string }[] = []; + for (const mount of mounts) { + try { + const result = await bb.sdk.files.list({ + path: mount.root, + query, + limit: 25, + }); + for (const file of result.files) { + if (!isNotePath(file.path)) continue; + if ( + needle.length > 0 && + !file.path.toLowerCase().includes(needle) + ) { + continue; + } + items.push({ + id: `${mount.root}${file.path}`, + title: file.name, + subtitle: `${mount.name}/${file.path}`, + }); + } + } catch { + // Unreachable mount: contribute nothing from it. + } + } + return items.slice(0, 25); + }, + async resolve(itemId) { + const [root, notePath] = itemId.split(""); + if (!root || !notePath) throw new Error(`Unknown note: ${itemId}`); + const mount = (await getMounts()).find((m) => m.root === root); + if (!mount) throw new Error(`"${root}" is no longer a notes directory`); + const file = await bb.sdk.files.read({ + path: path.join(root, notePath), + rootPath: root, + }); + return { + context: `Note ${notePath} (from ${mount.name}):\n\n${file.content}`, + }; + }, + }); +} diff --git a/examples/plugins/notes/tsconfig.json b/examples/plugins/notes/tsconfig.json new file mode 100644 index 000000000..12f3586d8 --- /dev/null +++ b/examples/plugins/notes/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "lib": [ + "ES2022", + "DOM" + ], + "noEmit": true, + "skipLibCheck": true, + "types": [ + "node" + ], + "baseUrl": ".", + "paths": { + "@/*": [ + "./*" + ] + } + }, + "include": [ + "server.ts", + "app.tsx", + "components", + "lib", + "hooks" + ] +} diff --git a/packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts b/packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts index cc0113f4a..affec91dc 100644 --- a/packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts +++ b/packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts @@ -287,6 +287,7 @@ declare const useRealtime: (channel: string, handler: (payload: unknown) => void declare const useSettings: () => PluginSettingsState; declare const useBbContext: () => BbContext; declare const useBbNavigate: () => BbNavigate; +declare const useComposer: () => PluginComposerApi; -export { PLUGIN_SDK_APP_EXPORT_NAMES, PLUGIN_SLOT_ID_PATTERN, definePluginApp, useBbContext, useBbNavigate, useRealtime, useRpc, useSettings }; +export { PLUGIN_SDK_APP_EXPORT_NAMES, PLUGIN_SLOT_ID_PATTERN, definePluginApp, useBbContext, useBbNavigate, useComposer, useRealtime, useRpc, useSettings }; export type { BbContext, BbNavigate, PluginAppBuilder, PluginAppDefinition, PluginAppSetup, PluginAppSlots, PluginComposerAccessoryProps, PluginComposerAccessoryRegistration, PluginComposerApi, PluginComposerMention, PluginComposerScope, PluginFileOpenerProps, PluginFileOpenerRegistration, PluginFileOpenerSource, PluginHomepageSectionProps, PluginHomepageSectionRegistration, PluginNavPanelProps, PluginNavPanelRegistration, PluginRpcClient, PluginSdkApp, PluginSettingsState, PluginThreadPanelActionContext, PluginThreadPanelActionRegistration, PluginThreadPanelProps }; diff --git a/packages/plugin-sdk/src/app.ts b/packages/plugin-sdk/src/app.ts index 2973fbe04..13043d5d3 100644 --- a/packages/plugin-sdk/src/app.ts +++ b/packages/plugin-sdk/src/app.ts @@ -34,3 +34,4 @@ export const useRealtime = runtime.useRealtime; export const useSettings = runtime.useSettings; export const useBbContext = runtime.useBbContext; export const useBbNavigate = runtime.useBbNavigate; +export const useComposer = runtime.useComposer; diff --git a/packages/templates/src/generated/plugin-sdk-dts.generated.ts b/packages/templates/src/generated/plugin-sdk-dts.generated.ts index 60bd055fa..62eadb56e 100644 --- a/packages/templates/src/generated/plugin-sdk-dts.generated.ts +++ b/packages/templates/src/generated/plugin-sdk-dts.generated.ts @@ -4,4 +4,4 @@ export const PLUGIN_SDK_DTS = "// Bundled type declarations for `@bb/plugin-sdk`, shipped into scaffolded\n// plugins so they typecheck without the @bb/* workspace on disk.\n//\n// Confused by the API, or need a symbol that isn't here? Clone the BB repo\n// and read the real source: https://github.com/ymichael/bb\n\nimport { ComponentType } from 'react';\nimport Database from 'better-sqlite3';\nimport { Context } from 'hono';\nimport * as z from 'zod';\nimport { z as z$1 } from 'zod';\n\n/**\n * The `@bb/plugin-sdk/app` contract (plugin design §5.2) — pure types plus\n * the runtime export-name list, with no side effects. This module is what the\n * BB app imports to keep its real implementation in sync (`satisfies\n * PluginSdkApp`) and what `bb plugin build` imports to generate the shim's\n * named-export list. Plugin authors import the same shapes through\n * `@bb/plugin-sdk/app`.\n *\n * Per-slot props are versioned contracts: additive-only within an SDK major.\n */\n/** Props passed to a `homepageSection` component. */\ninterface PluginHomepageSectionProps {\n /** Project in view on the compose surface; null when none is selected. */\n projectId: string | null;\n}\n/** Props passed to a `navPanel` component (it owns its whole route). */\ninterface PluginNavPanelProps {\n /**\n * The route remainder after the panel root, \"\" at the root. The panel's\n * route is `/plugins///*`, so a deep link like\n * `/plugins/notes/notes/work/ideas.md` renders the panel with\n * `subPath: \"work/ideas.md\"`. Navigate within the panel via\n * `useBbNavigate().toPluginPanel(path, { subPath })` — browser\n * back/forward then walks panel-internal history.\n */\n subPath: string;\n}\n/** Props passed to a panel tab opened by a `threadPanelAction`. */\ninterface PluginThreadPanelProps {\n threadId: string;\n /**\n * The JSON value the action's `openPanel` call passed (round-tripped\n * through persistence, so the tab restores across reloads); null when the\n * action opened the panel without params.\n */\n params: unknown;\n}\n/** Props passed to a `composerAccessory` component. */\ninterface PluginComposerAccessoryProps {\n projectId: string | null;\n threadId: string | null;\n}\n/**\n * Where a file being opened by a `fileOpener` lives. `path` semantics follow\n * the source: workspace paths are relative to the environment's worktree,\n * thread-storage paths are relative to the thread's storage root, host paths\n * are absolute on the thread's host.\n */\ninterface PluginFileOpenerSource {\n kind: \"workspace\" | \"host\" | \"thread-storage\";\n threadId: string | null;\n environmentId: string | null;\n projectId: string | null;\n}\n/** Props passed to a `fileOpener` component (rendered as a panel file tab). */\ninterface PluginFileOpenerProps {\n path: string;\n source: PluginFileOpenerSource;\n}\n/**\n * Slot/panel ids and nav-panel paths must match this pattern (letters,\n * digits, `-`, `_`): they ride URLs and persisted panel-tab keys.\n */\ndeclare const PLUGIN_SLOT_ID_PATTERN: RegExp;\ninterface PluginHomepageSectionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n component: ComponentType;\n}\ninterface PluginNavPanelRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n /** Icon hint (BB icon name); unknown names fall back to a generic icon. */\n icon: string;\n /** URL segment under `/plugins//`; letters, digits, `-`, `_`. */\n path: string;\n component: ComponentType;\n /**\n * Panel chrome (default \"page\"): \"page\" renders the host title bar (plugin\n * logo + `title` + your `headerContent`) above a full-width padded body;\n * \"none\" hands the ENTIRE panel area to `component` — no host padding, no\n * title bar (`headerContent` is ignored) — only the per-plugin error\n * boundary remains.\n */\n chrome?: \"page\" | \"none\";\n /**\n * Optional component rendered on the right side of the \"page\" title bar\n * (e.g. a sync button or a count). Contained separately from the body: a\n * throwing headerContent is hidden without breaking the title bar.\n */\n headerContent?: ComponentType;\n}\n/** Context handed to a `threadPanelAction`'s `run`. */\ninterface PluginThreadPanelActionContext {\n /** The thread whose panel launcher invoked the action. */\n threadId: string;\n /**\n * Open a tab in the thread's side panel rendering this action's\n * `component`. `title` labels the tab (default: the action's `title`);\n * `params` must be JSON-serializable — it is persisted with the tab and\n * reaches the component as its `params` prop. Opening with params\n * identical to an already-open tab of this action focuses that tab\n * (updating its title) instead of duplicating it. May be called more than\n * once (different params ⇒ multiple tabs) or not at all.\n */\n openPanel(options?: {\n title?: string;\n params?: unknown;\n }): void;\n}\ninterface PluginThreadPanelActionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n /** Label of the action row in the panel's new-tab launcher. */\n title: string;\n /**\n * Icon hint (BB icon name) used when the plugin ships no logo; the\n * launcher row and opened tabs prefer the plugin's logo.\n */\n icon?: string;\n /** Rendered inside every panel tab this action opens. */\n component: ComponentType;\n /**\n * Runs when the user activates the action: call your RPC methods, show a\n * toast, and/or open panel tabs via `context.openPanel`. Omitted =\n * immediately open a panel tab with defaults. Errors (sync or async) are\n * contained and logged; they never break the launcher.\n */\n run?(context: PluginThreadPanelActionContext): void | Promise;\n}\ninterface PluginComposerAccessoryRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n component: ComponentType;\n}\n/**\n * Register this plugin as a viewer/editor for file extensions. The user\n * picks (and can set as default) an opener per extension via the file tab's\n * \"Open with\" menu; matching files opened in the panel then render\n * `component` in a plugin tab instead of the built-in preview. Applies to\n * working-tree, host, and thread-storage files — never to git-ref snapshots\n * (diff views always use the built-in preview). The built-in preview stays\n * one menu click away, and a missing/disabled opener falls back to it.\n */\ninterface PluginFileOpenerRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n /** Label in the \"Open with\" menu (e.g. \"Notes editor\"). */\n title: string;\n /** Lowercase extensions without the dot (e.g. [\"md\", \"mdx\"]). */\n extensions: readonly string[];\n component: ComponentType;\n}\ninterface PluginAppSlots {\n homepageSection(registration: PluginHomepageSectionRegistration): void;\n navPanel(registration: PluginNavPanelRegistration): void;\n threadPanelAction(registration: PluginThreadPanelActionRegistration): void;\n composerAccessory(registration: PluginComposerAccessoryRegistration): void;\n fileOpener(registration: PluginFileOpenerRegistration): void;\n}\ninterface PluginAppBuilder {\n slots: PluginAppSlots;\n}\ntype PluginAppSetup = (app: PluginAppBuilder) => void;\n/**\n * The opaque product of `definePluginApp` — a plugin's `app.tsx` default\n * export. The host re-runs `setup` against a fresh collector on every\n * (re)interpretation, replacing that plugin's registrations wholesale.\n */\ninterface PluginAppDefinition {\n /** Brand the host checks before interpreting a bundle's default export. */\n readonly __bbPluginApp: true;\n readonly setup: PluginAppSetup;\n}\ninterface PluginRpcClient {\n /**\n * Invoke one of the plugin's `bb.rpc` methods (POST\n * /api/v1/plugins/<id>/rpc/<method>). Resolves with the method's\n * result; rejects with an `Error` carrying the server's message when the\n * handler fails or the plugin is not running.\n */\n call(method: string, input?: unknown): Promise;\n}\ninterface PluginSettingsState {\n /**\n * Effective non-secret setting values (secret settings are excluded —\n * read them server-side). Undefined while loading or unavailable.\n */\n values: Record | undefined;\n isLoading: boolean;\n}\n/** Where `useComposer()` writes: the active thread's draft or the new-thread draft. */\ntype PluginComposerScope = {\n kind: \"thread\";\n threadId: string;\n} | {\n kind: \"new-thread\";\n projectId: string | null;\n};\n/** An @-mention pill bound to one of the calling plugin's mention providers. */\ninterface PluginComposerMention {\n /** Mention provider id registered by THIS plugin via `bb.ui.registerMentionProvider`. */\n provider: string;\n /** Item id your provider's `resolve` will receive at send time. */\n id: string;\n /** Pill text shown in the composer. */\n label: string;\n}\n/**\n * Programmatic access to the chat composer draft — the same shared draft the\n * built-in \"Add to chat\" affordances (file preview, diff, terminal selections)\n * write to. Inside a thread context writes land in that thread's draft;\n * anywhere else (nav panel, homepage section) they seed the new-thread\n * composer draft, which persists until the user sends or clears it.\n */\ninterface PluginComposerApi {\n scope: PluginComposerScope;\n /**\n * Append text to the draft as a `> ` blockquote block and focus the\n * composer. Blank text is a no-op. This is the \"reference this selection\n * in chat\" primitive.\n */\n addQuote(text: string): void;\n /**\n * Insert an @-mention pill that resolves through this plugin's mention\n * provider at send time — the durable way to reference an entity whose\n * content should be fetched fresh when the message is sent.\n */\n insertMention(mention: PluginComposerMention): void;\n /** Focus the composer caret at the end of the draft. */\n focus(): void;\n}\n/** Current app selection, derived from the route. */\ninterface BbContext {\n projectId: string | null;\n threadId: string | null;\n}\ninterface BbNavigate {\n toThread(threadId: string): void;\n toProject(projectId: string): void;\n /**\n * Navigate to one of this plugin's own nav panels by its `path`.\n * `subPath` targets a location inside the panel (the component's\n * `subPath` prop); `replace` swaps the current history entry instead of\n * pushing — use it for redirects so back does not bounce.\n */\n toPluginPanel(path: string, options?: {\n subPath?: string;\n replace?: boolean;\n }): void;\n}\n/**\n * Everything `@bb/plugin-sdk/app` resolves to at runtime. The BB app builds\n * the real implementation and `satisfies` this interface; `bb plugin build`\n * shims the specifier to that object on `globalThis.__bbPluginRuntime`.\n */\ninterface PluginSdkApp {\n definePluginApp(setup: PluginAppSetup): PluginAppDefinition;\n useRpc(): PluginRpcClient;\n useRealtime(channel: string, handler: (payload: unknown) => void): void;\n useSettings(): PluginSettingsState;\n useBbContext(): BbContext;\n useBbNavigate(): BbNavigate;\n useComposer(): PluginComposerApi;\n}\n/**\n * Named runtime exports of `@bb/plugin-sdk/app`, in sorted order. Single\n * source of truth for the build shim's export list and the app's\n * implementation-key test — adding a surface member without updating this\n * list fails the type assertion below.\n */\ndeclare const PLUGIN_SDK_APP_EXPORT_NAMES: readonly [\"definePluginApp\", \"useBbContext\", \"useBbNavigate\", \"useComposer\", \"useRealtime\", \"useRpc\", \"useSettings\"];\n\ndeclare const appThemeSchema: z$1.ZodObject<{\n themeId: z$1.ZodString;\n customCss: z$1.ZodNullable;\n faviconColor: z$1.ZodEnum<{\n default: \"default\";\n red: \"red\";\n orange: \"orange\";\n yellow: \"yellow\";\n green: \"green\";\n teal: \"teal\";\n blue: \"blue\";\n purple: \"purple\";\n pink: \"pink\";\n }>;\n}, z$1.core.$strip>;\ntype AppTheme = z$1.infer;\n\ndeclare const changedMessageSchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"thread\">;\n id: z$1.ZodOptional;\n metadata: z$1.ZodOptional;\n eventTypes: z$1.ZodOptional>>>>;\n hasPendingInteraction: z$1.ZodOptional;\n projectId: z$1.ZodOptional;\n }, z$1.core.$strict>>;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"project\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"environment\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"host\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"system\">;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>], \"entity\">;\ntype ChangedMessage = z$1.infer;\n\ndeclare const pendingInteractionResolutionSchema: z$1.ZodUnion;\n grantedPermissions: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n fileSystem: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n decision: z$1.ZodLiteral<\"allow_for_session\">;\n grantedPermissions: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n fileSystem: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n decision: z$1.ZodLiteral<\"deny\">;\n}, z$1.core.$strip>], \"decision\">, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"user_answer\">;\n answers: z$1.ZodRecord;\n freeText: z$1.ZodOptional;\n }, z$1.core.$strip>>;\n}, z$1.core.$strip>]>;\ntype PendingInteractionResolution = z$1.infer;\n\ndeclare const threadStatusSchema: z$1.ZodEnum<{\n error: \"error\";\n active: \"active\";\n starting: \"starting\";\n idle: \"idle\";\n stopping: \"stopping\";\n}>;\ntype ThreadStatus = z$1.infer;\n\ndeclare const threadTimelinePendingTodosSchema: z$1.ZodObject<{\n sourceSeq: z$1.ZodNumber;\n updatedAt: z$1.ZodNumber;\n items: z$1.ZodArray;\n }, z$1.core.$strip>>;\n}, z$1.core.$strip>;\ntype ThreadTimelinePendingTodos = z$1.infer;\n\ndeclare const createAutomationRequestSchema: z$1.ZodObject<{\n name: z$1.ZodString;\n enabled: z$1.ZodDefault;\n trigger: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n triggerType: z$1.ZodLiteral<\"schedule\">;\n cron: z$1.ZodString;\n timezone: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n triggerType: z$1.ZodLiteral<\"once\">;\n runAt: z$1.ZodNumber;\n }, z$1.core.$strip>], \"triggerType\">;\n execution: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"agent\">;\n prompt: z$1.ZodString;\n providerId: z$1.ZodString;\n model: z$1.ZodString;\n permissionMode: z$1.ZodEnum<{\n readonly: \"readonly\";\n full: \"full\";\n \"workspace-write\": \"workspace-write\";\n }>;\n targetThreadId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"script\">;\n script: z$1.ZodOptional;\n scriptFile: z$1.ZodOptional;\n interpreter: z$1.ZodOptional>;\n timeoutMs: z$1.ZodDefault;\n env: z$1.ZodOptional>;\n }, z$1.core.$strip>], \"mode\">;\n environment: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"reuse\">;\n environmentId: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"host\">;\n hostId: z$1.ZodOptional;\n workspace: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"unmanaged\">;\n path: z$1.ZodNullable;\n branch: z$1.ZodOptional;\n name: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"new\">;\n baseBranch: z$1.ZodString;\n }, z$1.core.$strict>], \"kind\">>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"managed-worktree\">;\n baseBranch: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"named\">;\n name: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"default\">;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"personal\">;\n }, z$1.core.$strip>], \"type\">;\n }, z$1.core.$strip>], \"type\">;\n autoArchive: z$1.ZodDefault;\n origin: z$1.ZodEnum<{\n agent: \"agent\";\n human: \"human\";\n app: \"app\";\n }>;\n createdByThreadId: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype CreateAutomationRequest = z$1.input;\ndeclare const updateAutomationRequestSchema: z$1.ZodObject<{\n name: z$1.ZodOptional;\n trigger: z$1.ZodOptional;\n cron: z$1.ZodString;\n timezone: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n triggerType: z$1.ZodLiteral<\"once\">;\n runAt: z$1.ZodNumber;\n }, z$1.core.$strip>], \"triggerType\">>;\n execution: z$1.ZodOptional;\n prompt: z$1.ZodString;\n providerId: z$1.ZodString;\n model: z$1.ZodString;\n permissionMode: z$1.ZodEnum<{\n readonly: \"readonly\";\n full: \"full\";\n \"workspace-write\": \"workspace-write\";\n }>;\n targetThreadId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"script\">;\n script: z$1.ZodOptional;\n scriptFile: z$1.ZodOptional;\n interpreter: z$1.ZodOptional>;\n timeoutMs: z$1.ZodDefault;\n env: z$1.ZodOptional>;\n }, z$1.core.$strip>], \"mode\">>;\n environment: z$1.ZodOptional;\n environmentId: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"host\">;\n hostId: z$1.ZodOptional;\n workspace: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"unmanaged\">;\n path: z$1.ZodNullable;\n branch: z$1.ZodOptional;\n name: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"new\">;\n baseBranch: z$1.ZodString;\n }, z$1.core.$strict>], \"kind\">>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"managed-worktree\">;\n baseBranch: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"named\">;\n name: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"default\">;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"personal\">;\n }, z$1.core.$strip>], \"type\">;\n }, z$1.core.$strip>], \"type\">>;\n autoArchive: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype UpdateAutomationRequest = z$1.infer;\ndeclare const runAutomationRequestSchema: z$1.ZodObject<{\n idempotencyKey: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype RunAutomationRequest = z$1.infer;\n\ndeclare const createProjectSourceRequestSchema: z$1.ZodObject<{\n hostId: z$1.ZodString;\n type: z$1.ZodLiteral<\"local_path\">;\n path: z$1.ZodPipe>;\n}, z$1.core.$strict>;\ntype CreateProjectSourceRequest = z$1.infer;\ndeclare const createProjectRequestSchema: z$1.ZodObject<{\n name: z$1.ZodString;\n source: z$1.ZodObject<{\n hostId: z$1.ZodString;\n type: z$1.ZodLiteral<\"local_path\">;\n path: z$1.ZodPipe>;\n }, z$1.core.$strict>;\n}, z$1.core.$strip>;\ntype CreateProjectRequest = z$1.infer;\ndeclare const projectListQuerySchema: z$1.ZodObject<{\n include: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype ProjectListQuery = z$1.infer;\ndeclare const updateProjectRequestSchema: z$1.ZodObject<{\n name: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype UpdateProjectRequest = z$1.infer;\ndeclare const updateProjectSourceRequestSchema: z$1.ZodObject<{\n type: z$1.ZodLiteral<\"local_path\">;\n path: z$1.ZodOptional>>;\n isDefault: z$1.ZodOptional>;\n}, z$1.core.$strict>;\ntype UpdateProjectSourceRequest = z$1.infer;\n\ndeclare const updateEnvironmentRequestSchema: z$1.ZodObject<{\n mergeBaseBranch: z$1.ZodOptional>;\n name: z$1.ZodOptional>;\n}, z$1.core.$strip>;\ntype UpdateEnvironmentRequest = z$1.infer;\ndeclare const environmentDiffBranchesQuerySchema: z$1.ZodObject<{\n query: z$1.ZodOptional;\n limit: z$1.ZodOptional;\n selectedBranch: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype EnvironmentDiffBranchesQuery = z$1.infer;\ndeclare const environmentStatusQuerySchema: z$1.ZodObject<{\n mergeBaseBranch: z$1.ZodOptional>;\n}, z$1.core.$strip>;\ntype EnvironmentStatusQuery = z$1.infer;\ndeclare const environmentDiffQuerySchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n target: z$1.ZodLiteral<\"uncommitted\">;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"branch_committed\">;\n mergeBaseBranch: z$1.ZodPipe;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"all\">;\n mergeBaseBranch: z$1.ZodPipe;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"commit\">;\n sha: z$1.ZodString;\n}, z$1.core.$strip>], \"target\">;\ntype EnvironmentDiffQuery = z$1.infer;\n/**\n * Query for fetching a single file's contents at one side of a diff target.\n * Used by the diff card to reparse the card's patch with full old/new contents\n * so `@pierre/diffs` can render expand-context buttons between hunks.\n *\n * For `branch_committed` / `all`, callers pass the resolved merge-base SHA\n * (`mergeBaseRef`, surfaced by `workspace.diff`) rather than the branch name\n * — the diff itself was computed against that SHA, so reading the old side\n * from the same SHA keeps the file content aligned with the hunk line\n * numbers. Reading from the branch tip is wrong whenever the branch has\n * moved past the merge-base since the file existed there.\n */\ndeclare const environmentDiffFileQuerySchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n target: z$1.ZodLiteral<\"uncommitted\">;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"branch_committed\">;\n mergeBaseRef: z$1.ZodString;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"all\">;\n mergeBaseRef: z$1.ZodString;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"commit\">;\n sha: z$1.ZodString;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>], \"target\">;\ntype EnvironmentDiffFileQuery = z$1.infer;\n\ndeclare const systemExecutionOptionsQuerySchema: z$1.ZodObject<{\n providerId: z$1.ZodOptional;\n hostId: z$1.ZodOptional;\n environmentId: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype SystemExecutionOptionsQuery = z$1.infer;\n/**\n * Theme catalog: the on-disk custom-theme directory plus the discovered custom\n * themes and the active palette. Drives `bb theme list` / `bb theme dir`.\n */\ndeclare const themeCatalogResponseSchema: z$1.ZodObject<{\n dir: z$1.ZodString;\n custom: z$1.ZodArray;\n active: z$1.ZodObject<{\n themeId: z$1.ZodString;\n customCss: z$1.ZodNullable;\n faviconColor: z$1.ZodEnum<{\n default: \"default\";\n red: \"red\";\n orange: \"orange\";\n yellow: \"yellow\";\n green: \"green\";\n teal: \"teal\";\n blue: \"blue\";\n purple: \"purple\";\n pink: \"pink\";\n }>;\n }, z$1.core.$strip>;\n}, z$1.core.$strip>;\ntype ThemeCatalogResponse = z$1.infer;\n\ndeclare const createTerminalRequestSchema: z$1.ZodObject<{\n cols: z$1.ZodNumber;\n rows: z$1.ZodNumber;\n start: z$1.ZodOptional;\n }, z$1.core.$strict>, z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"command\">;\n command: z$1.ZodString;\n }, z$1.core.$strict>], \"mode\">>;\n target: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"thread\">;\n threadId: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"environment\">;\n environmentId: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"host_path\">;\n hostId: z$1.ZodString;\n cwd: z$1.ZodNullable;\n }, z$1.core.$strict>], \"kind\">;\n title: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype CreateTerminalRequest = z$1.infer;\ndeclare const closeTerminalRequestSchema: z$1.ZodObject<{\n mode: z$1.ZodEnum<{\n force: \"force\";\n \"if-clean\": \"if-clean\";\n }>;\n reason: z$1.ZodLiteral<\"user\">;\n}, z$1.core.$strict>;\ntype CloseTerminalRequest = z$1.infer;\ndeclare const updateTerminalRequestSchema: z$1.ZodObject<{\n title: z$1.ZodString;\n}, z$1.core.$strict>;\ntype UpdateTerminalRequest = z$1.infer;\ndeclare const terminalInputRequestSchema: z$1.ZodObject<{\n dataBase64: z$1.ZodString;\n}, z$1.core.$strict>;\ntype TerminalInputRequest = z$1.infer;\ndeclare const terminalResizeRequestSchema: z$1.ZodObject<{\n cols: z$1.ZodNumber;\n rows: z$1.ZodNumber;\n}, z$1.core.$strict>;\ntype TerminalResizeRequest = z$1.infer;\ndeclare const terminalOutputQuerySchema: z$1.ZodObject<{\n sinceSeq: z$1.ZodOptional>;\n tailBytes: z$1.ZodOptional>;\n limitChunks: z$1.ZodOptional>;\n}, z$1.core.$strict>;\ntype TerminalOutputQuery = z$1.infer;\n\ndeclare const createThreadRequestSchema: z$1.ZodObject<{\n projectId: z$1.ZodString;\n providerId: z$1.ZodOptional;\n origin: z$1.ZodEnum<{\n plugin: \"plugin\";\n app: \"app\";\n automation: \"automation\";\n cli: \"cli\";\n sdk: \"sdk\";\n }>;\n originPluginId: z$1.ZodOptional;\n title: z$1.ZodOptional;\n input: z$1.ZodArray>;\n type: z$1.ZodLiteral<\"text\">;\n text: z$1.ZodString;\n mentions: z$1.ZodDefault;\n threadId: z$1.ZodString;\n projectId: z$1.ZodOptional;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"project\">;\n projectId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"path\">;\n source: z$1.ZodEnum<{\n workspace: \"workspace\";\n \"thread-storage\": \"thread-storage\";\n }>;\n entryKind: z$1.ZodEnum<{\n file: \"file\";\n directory: \"directory\";\n }>;\n path: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"command\">;\n trigger: z$1.ZodEnum<{\n \"/\": \"/\";\n }>;\n name: z$1.ZodString;\n source: z$1.ZodEnum<{\n command: \"command\";\n skill: \"skill\";\n }>;\n origin: z$1.ZodEnum<{\n user: \"user\";\n project: \"project\";\n builtin: \"builtin\";\n }>;\n label: z$1.ZodString;\n argumentHint: z$1.ZodNullable;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"plugin\">;\n pluginId: z$1.ZodString;\n itemId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>>>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"image\">;\n url: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localImage\">;\n path: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localFile\">;\n path: z$1.ZodString;\n name: z$1.ZodOptional;\n sizeBytes: z$1.ZodOptional;\n mimeType: z$1.ZodOptional;\n }, z$1.core.$strip>], \"type\">>;\n model: z$1.ZodOptional;\n serviceTier: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>;\n permissionMode: z$1.ZodOptional>;\n executionInputSources: z$1.ZodOptional>;\n model: z$1.ZodOptional>;\n serviceTier: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>;\n permissionMode: z$1.ZodOptional>;\n }, z$1.core.$strict>>;\n environment: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"reuse\">;\n environmentId: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"host\">;\n hostId: z$1.ZodOptional;\n workspace: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"unmanaged\">;\n path: z$1.ZodNullable;\n branch: z$1.ZodOptional;\n name: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"new\">;\n baseBranch: z$1.ZodString;\n }, z$1.core.$strict>], \"kind\">>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"managed-worktree\">;\n baseBranch: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"named\">;\n name: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"default\">;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"personal\">;\n }, z$1.core.$strip>], \"type\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"project-default\">;\n }, z$1.core.$strip>], \"type\">;\n parentThreadId: z$1.ZodOptional;\n folderId: z$1.ZodOptional>;\n sourceThreadId: z$1.ZodOptional;\n sourceSeqEnd: z$1.ZodOptional;\n startedOnBehalfOf: z$1.ZodDefault;\n senderThreadId: z$1.ZodString;\n }, z$1.core.$strip>>>;\n originKind: z$1.ZodDefault>>;\n childOrigin: z$1.ZodDefault>>;\n}, z$1.core.$strip>;\ntype CreateThreadRequest = z$1.infer;\ndeclare const sendMessageRequestSchema: z$1.ZodObject<{\n input: z$1.ZodArray>;\n type: z$1.ZodLiteral<\"text\">;\n text: z$1.ZodString;\n mentions: z$1.ZodDefault;\n threadId: z$1.ZodString;\n projectId: z$1.ZodOptional;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"project\">;\n projectId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"path\">;\n source: z$1.ZodEnum<{\n workspace: \"workspace\";\n \"thread-storage\": \"thread-storage\";\n }>;\n entryKind: z$1.ZodEnum<{\n file: \"file\";\n directory: \"directory\";\n }>;\n path: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"command\">;\n trigger: z$1.ZodEnum<{\n \"/\": \"/\";\n }>;\n name: z$1.ZodString;\n source: z$1.ZodEnum<{\n command: \"command\";\n skill: \"skill\";\n }>;\n origin: z$1.ZodEnum<{\n user: \"user\";\n project: \"project\";\n builtin: \"builtin\";\n }>;\n label: z$1.ZodString;\n argumentHint: z$1.ZodNullable;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"plugin\">;\n pluginId: z$1.ZodString;\n itemId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>>>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"image\">;\n url: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localImage\">;\n path: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localFile\">;\n path: z$1.ZodString;\n name: z$1.ZodOptional;\n sizeBytes: z$1.ZodOptional;\n mimeType: z$1.ZodOptional;\n }, z$1.core.$strip>], \"type\">>;\n model: z$1.ZodOptional;\n serviceTier: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>;\n permissionMode: z$1.ZodOptional>;\n executionInputSources: z$1.ZodOptional>;\n serviceTier: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>;\n permissionMode: z$1.ZodOptional>;\n }, z$1.core.$strict>>;\n mode: z$1.ZodEnum<{\n steer: \"steer\";\n start: \"start\";\n auto: \"auto\";\n \"queue-if-active\": \"queue-if-active\";\n \"steer-if-active\": \"steer-if-active\";\n }>;\n senderThreadId: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype SendMessageRequest = z$1.infer;\ndeclare const threadResponseSchema: z$1.ZodObject<{\n id: z$1.ZodString;\n projectId: z$1.ZodString;\n environmentId: z$1.ZodNullable;\n providerId: z$1.ZodString;\n title: z$1.ZodNullable;\n titleFallback: z$1.ZodNullable;\n folderId: z$1.ZodNullable;\n status: z$1.ZodEnum<{\n error: \"error\";\n stopping: \"stopping\";\n idle: \"idle\";\n starting: \"starting\";\n active: \"active\";\n }>;\n parentThreadId: z$1.ZodNullable;\n sourceThreadId: z$1.ZodNullable;\n originKind: z$1.ZodNullable>;\n childOrigin: z$1.ZodNullable>;\n originPluginId: z$1.ZodNullable;\n archivedAt: z$1.ZodNullable;\n pinnedAt: z$1.ZodNullable;\n deletedAt: z$1.ZodNullable;\n lastReadAt: z$1.ZodNullable;\n latestAttentionAt: z$1.ZodNumber;\n createdAt: z$1.ZodNumber;\n updatedAt: z$1.ZodNumber;\n runtime: z$1.ZodObject<{\n displayStatus: z$1.ZodEnum<{\n error: \"error\";\n provisioning: \"provisioning\";\n stopping: \"stopping\";\n idle: \"idle\";\n starting: \"starting\";\n active: \"active\";\n \"host-reconnecting\": \"host-reconnecting\";\n \"waiting-for-host\": \"waiting-for-host\";\n }>;\n hostReconnectGraceExpiresAt: z$1.ZodNullable;\n }, z$1.core.$strip>;\n canSpawnChild: z$1.ZodBoolean;\n}, z$1.core.$strip>;\ntype ThreadResponse = z$1.infer;\ndeclare const threadGetQuerySchema: z$1.ZodObject<{\n include: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype ThreadGetQuery = z$1.infer;\ndeclare const deleteThreadRequestSchema: z$1.ZodObject<{\n childThreadsConfirmed: z$1.ZodBoolean;\n}, z$1.core.$strip>;\ntype DeleteThreadRequest = z$1.infer;\ndeclare const updateThreadRequestSchema: z$1.ZodObject<{\n title: z$1.ZodOptional>;\n folderId: z$1.ZodOptional>;\n parentThreadId: z$1.ZodOptional>;\n model: z$1.ZodOptional>;\n reasoningLevel: z$1.ZodOptional>>;\n}, z$1.core.$strip>;\ntype UpdateThreadRequest = z$1.infer;\n/** Which root a secondary-panel file path is relative to. */\ndeclare const panelFileSourceSchema: z$1.ZodEnum<{\n workspace: \"workspace\";\n \"thread-storage\": \"thread-storage\";\n}>;\ntype PanelFileSource = z$1.infer;\ndeclare const threadTimelineQuerySchema: z$1.ZodObject<{\n includeNestedRows: z$1.ZodOptional>;\n segmentLimit: z$1.ZodOptional;\n beforeAnchorSeq: z$1.ZodOptional;\n beforeAnchorId: z$1.ZodOptional;\n summaryOnly: z$1.ZodOptional>;\n afterSequence: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype ThreadTimelineQuery = z$1.infer;\n\ntype PublicApiSchema = unknown;\ntype ApiClient = unknown;\n\ntype FetchImplementation = typeof fetch;\ntype JsonBodyOf = TResponse extends {\n json(): Promise;\n} ? TBody : never;\n\ntype BbSdkRuntime = \"node\" | \"browser\";\ninterface BbSdkTransport {\n api: ApiClient[\"api\"];\n baseUrl: string;\n fetch: FetchImplementation;\n realtimeUrl?: string;\n runtime: BbSdkRuntime;\n readJson(response: Promise): Promise>;\n readVoid(response: Promise): Promise;\n resolve(response: Promise): Promise;\n websocket?: BbRealtimeSocketFactory;\n}\n/**\n * Raw socket payload. Treated as opaque until decoded — realtime ignores\n * non-string frames.\n */\ninterface BbRealtimeSocketMessageEvent {\n data: unknown;\n}\n/**\n * Minimal runtime-agnostic socket shape consumed by the realtime client.\n * Default factories adapt the environment's WebSocket (browser/Node global,\n * or the `ws` package on older Node) to this interface; custom `websocket`\n * factories can wrap any implementation the same way.\n */\ninterface BbRealtimeSocket {\n close(): void;\n onclose: (() => void) | null;\n onerror: (() => void) | null;\n onmessage: ((event: BbRealtimeSocketMessageEvent) => void) | null;\n onopen: (() => void) | null;\n readyState: number;\n send(data: string): void;\n}\ntype BbRealtimeSocketFactory = (url: string) => BbRealtimeSocket;\ninterface BbSdkContext {\n}\n\ninterface CreateSdkAreaArgs {\n context: BbSdkContext;\n transport: BbSdkTransport;\n}\ntype PublicApiEndpointOutput = TEndpoint extends {\n status: infer Status;\n output: infer Output;\n} ? Status extends SuccessfulHttpStatus ? Output : never : never;\ntype SuccessfulHttpStatus = 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226;\ntype PublicApiOutput = PublicApiEndpointOutput;\n\ninterface AutomationCreateArgs extends CreateAutomationRequest {\n projectId?: string;\n}\ninterface AutomationListArgs {\n projectId?: string;\n}\ninterface AutomationGetArgs {\n projectId?: string;\n automationId: string;\n}\ninterface AutomationUpdateArgs extends UpdateAutomationRequest {\n projectId?: string;\n automationId: string;\n}\ninterface AutomationActionArgs {\n projectId?: string;\n automationId: string;\n}\ninterface AutomationRunArgs extends RunAutomationRequest {\n projectId?: string;\n automationId: string;\n}\ninterface AutomationRunsArgs {\n projectId?: string;\n automationId: string;\n limit?: number;\n cursor?: string;\n}\ntype AutomationCreateResult = PublicApiOutput<\"/projects/:id/automations\", \"$post\">;\ntype AutomationListResult = PublicApiOutput<\"/projects/:id/automations\", \"$get\">;\ntype AutomationGetResult = PublicApiOutput<\"/projects/:id/automations/:automationId\", \"$get\">;\ntype AutomationUpdateResult = PublicApiOutput<\"/projects/:id/automations/:automationId\", \"$patch\">;\ntype AutomationPauseResult = PublicApiOutput<\"/projects/:id/automations/:automationId/pause\", \"$post\">;\ntype AutomationResumeResult = PublicApiOutput<\"/projects/:id/automations/:automationId/resume\", \"$post\">;\ntype AutomationRunResult = PublicApiOutput<\"/projects/:id/automations/:automationId/run\", \"$post\">;\ntype AutomationRunsResult = PublicApiOutput<\"/projects/:id/automations/:automationId/runs\", \"$get\">;\ntype AutomationsOverviewResult = PublicApiOutput<\"/automations\", \"$get\">;\ninterface AutomationsArea {\n create(args: AutomationCreateArgs): Promise;\n delete(args: AutomationActionArgs): Promise<{\n ok: true;\n }>;\n get(args: AutomationGetArgs): Promise;\n list(args?: AutomationListArgs): Promise;\n overview(): Promise;\n pause(args: AutomationActionArgs): Promise;\n resume(args: AutomationActionArgs): Promise;\n run(args: AutomationRunArgs): Promise;\n runs(args: AutomationRunsArgs): Promise;\n update(args: AutomationUpdateArgs): Promise;\n}\ndeclare function createAutomationsArea(args: CreateSdkAreaArgs): AutomationsArea;\n\ninterface EnvironmentGetArgs {\n environmentId: string;\n}\ntype EnvironmentMergeBaseBranchUpdateValue = Exclude;\ntype EnvironmentNameUpdateValue = Exclude;\ninterface EnvironmentMergeBaseBranchUpdate {\n mergeBaseBranch: EnvironmentMergeBaseBranchUpdateValue;\n name?: EnvironmentNameUpdateValue;\n}\ninterface EnvironmentNameUpdate {\n mergeBaseBranch?: EnvironmentMergeBaseBranchUpdateValue;\n name: EnvironmentNameUpdateValue;\n}\ntype EnvironmentUpdateFields = EnvironmentMergeBaseBranchUpdate | EnvironmentNameUpdate;\ntype EnvironmentUpdateArgs = EnvironmentUpdateFields & {\n environmentId: string;\n};\ninterface EnvironmentStatusArgs extends EnvironmentStatusQuery {\n environmentId: string;\n}\ntype EnvironmentDiffArgs = EnvironmentDiffQuery & {\n environmentId: string;\n};\ntype EnvironmentDiffFileArgs = EnvironmentDiffFileQuery & {\n environmentId: string;\n};\ninterface EnvironmentDiffBranchesArgs extends EnvironmentDiffBranchesQuery {\n environmentId: string;\n}\ninterface EnvironmentCommitArgs {\n environmentId: string;\n}\ninterface EnvironmentSquashMergeArgs {\n environmentId: string;\n mergeBaseBranch: string;\n}\ntype EnvironmentActionResult = PublicApiOutput<\"/environments/:id/actions\", \"$post\">;\ntype EnvironmentCommitResult = Extract;\ntype EnvironmentDiffResult = PublicApiOutput<\"/environments/:id/diff\", \"$get\">;\ntype EnvironmentDiffBranchesResult = PublicApiOutput<\"/environments/:id/diff/branches\", \"$get\">;\ntype EnvironmentDiffFileResult = PublicApiOutput<\"/environments/:id/diff/file\", \"$get\">;\ntype EnvironmentGetResult = PublicApiOutput<\"/environments/:id\", \"$get\">;\ntype EnvironmentPullRequestResult = PublicApiOutput<\"/environments/:id/pull-request\", \"$get\">;\ntype EnvironmentSquashMergeResult = Extract;\ntype EnvironmentStatusResult = PublicApiOutput<\"/environments/:id/status\", \"$get\">;\ntype EnvironmentUpdateResult = PublicApiOutput<\"/environments/:id\", \"$patch\">;\ninterface EnvironmentsArea {\n commit(args: EnvironmentCommitArgs): Promise;\n diff(args: EnvironmentDiffArgs): Promise;\n diffBranches(args: EnvironmentDiffBranchesArgs): Promise;\n diffFile(args: EnvironmentDiffFileArgs): Promise;\n get(args: EnvironmentGetArgs): Promise;\n pullRequest(args: EnvironmentGetArgs): Promise;\n squashMerge(args: EnvironmentSquashMergeArgs): Promise;\n status(args: EnvironmentStatusArgs): Promise;\n update(args: EnvironmentUpdateArgs): Promise;\n}\ndeclare function createEnvironmentsArea(args: CreateSdkAreaArgs): EnvironmentsArea;\n\n/**\n * Host file primitives. `hostId` may be omitted to target the server's\n * primary (local) host. `rootPath`, when set, confines the target beneath\n * that absolute root on the host (symlink-safe).\n */\ninterface FileReadArgs {\n hostId?: string;\n path: string;\n rootPath?: string;\n}\ninterface FileWriteArgs {\n hostId?: string;\n path: string;\n rootPath?: string;\n content: string;\n /** Defaults to \"utf8\". */\n contentEncoding?: \"utf8\" | \"base64\";\n /** Defaults to false. */\n createParents?: boolean;\n /**\n * Optimistic-concurrency guard: omitted → unconditional write; a hash →\n * write only when the current content hashes to it (use `read().sha256`);\n * null → create-only. A failed guard resolves to the `conflict` outcome.\n */\n expectedSha256?: string | null;\n}\ninterface FileListArgs {\n hostId?: string;\n path: string;\n query?: string;\n limit?: number;\n}\ntype FileReadResult = PublicApiOutput<\"/files/read\", \"$post\">;\ntype FileWriteResult = PublicApiOutput<\"/files/write\", \"$post\">;\ntype FileListResult = PublicApiOutput<\"/files/list\", \"$post\">;\ninterface FilesArea {\n read(args: FileReadArgs): Promise;\n write(args: FileWriteArgs): Promise;\n list(args: FileListArgs): Promise;\n}\ndeclare function createFilesArea(args: CreateSdkAreaArgs): FilesArea;\n\ninterface GuideRenderArgs {\n chapter?: string;\n}\ninterface GuideRenderResult {\n chapter?: string;\n content: string;\n}\ninterface GuideArea {\n render(args?: GuideRenderArgs): GuideRenderResult;\n}\ndeclare function createGuideArea(): GuideArea;\n\ninterface HostGetArgs {\n hostId: string;\n}\ntype HostGetResult = PublicApiOutput<\"/hosts/:id\", \"$get\">;\ntype HostListResult = PublicApiOutput<\"/hosts\", \"$get\">;\ninterface HostsArea {\n get(args: HostGetArgs): Promise;\n list(): Promise;\n}\ndeclare function createHostsArea(args: CreateSdkAreaArgs): HostsArea;\n\ninterface ProjectListArgs extends ProjectListQuery {\n}\ninterface ProjectCreateArgs extends CreateProjectRequest {\n}\ninterface ProjectGetArgs {\n projectId: string;\n}\ninterface ProjectUpdateArgs extends UpdateProjectRequest {\n projectId: string;\n}\ninterface ProjectDeleteArgs {\n projectId: string;\n}\ninterface ProjectSourceAddArgs extends CreateProjectSourceRequest {\n projectId: string;\n}\ninterface ProjectSourceUpdateArgs extends UpdateProjectSourceRequest {\n projectId: string;\n sourceId: string;\n}\ninterface ProjectSourceDeleteArgs {\n projectId: string;\n sourceId: string;\n}\ntype ProjectCreateResult = PublicApiOutput<\"/projects\", \"$post\">;\ntype ProjectDeleteResult = PublicApiOutput<\"/projects/:id\", \"$delete\">;\ntype ProjectGetResult = PublicApiOutput<\"/projects/:id\", \"$get\">;\ntype ProjectListResult = PublicApiOutput<\"/projects\", \"$get\">;\ntype ProjectUpdateResult = PublicApiOutput<\"/projects/:id\", \"$patch\">;\ntype ProjectSourceAddResult = PublicApiOutput<\"/projects/:id/sources\", \"$post\">;\ntype ProjectSourceUpdateResult = PublicApiOutput<\"/projects/:id/sources/:sourceId\", \"$patch\">;\ntype ProjectSourceDeleteResult = PublicApiOutput<\"/projects/:id/sources/:sourceId\", \"$delete\">;\ninterface ProjectSourcesArea {\n add(args: ProjectSourceAddArgs): Promise;\n delete(args: ProjectSourceDeleteArgs): Promise;\n update(args: ProjectSourceUpdateArgs): Promise;\n}\ninterface ProjectsArea {\n create(args: ProjectCreateArgs): Promise;\n delete(args: ProjectDeleteArgs): Promise;\n get(args: ProjectGetArgs): Promise;\n list(args?: ProjectListArgs): Promise;\n sources: ProjectSourcesArea;\n update(args: ProjectUpdateArgs): Promise;\n}\ndeclare function createProjectsArea(args: CreateSdkAreaArgs): ProjectsArea;\n\ninterface ProviderModelsArgs extends SystemExecutionOptionsQuery {\n}\ntype ProviderListResult = PublicApiOutput<\"/system/providers\", \"$get\">;\ntype ProviderModelsResult = PublicApiOutput<\"/system/execution-options\", \"$get\">;\ninterface ProvidersArea {\n list(): Promise;\n models(args?: ProviderModelsArgs): Promise;\n}\ndeclare function createProvidersArea(args: CreateSdkAreaArgs): ProvidersArea;\n\ntype BbRealtimeUnsubscribe = () => void;\ntype BbRealtimeEventName = \"thread:changed\" | \"project:changed\" | \"environment:changed\" | \"host:changed\" | \"system:changed\" | \"system:config-changed\" | \"realtime:connection\";\ntype ThreadRealtimeEvent = Extract;\ntype ProjectRealtimeEvent = Extract;\ntype EnvironmentRealtimeEvent = Extract;\ntype HostRealtimeEvent = Extract;\ntype SystemRealtimeEvent = Extract;\ntype BbRealtimeConnectionState = \"connecting\" | \"connected\" | \"disconnected\";\ninterface BbRealtimeConnectionEvent {\n reconnectDelayMs: number | null;\n reconnected: boolean;\n state: BbRealtimeConnectionState;\n}\n/**\n * Entity-changed events are delivered as one shared object to every matching\n * listener; their payload types are readonly so a listener cannot mutate what\n * the next listener receives.\n */\ninterface BbRealtimeEventMap {\n \"thread:changed\": ThreadRealtimeEvent;\n \"project:changed\": ProjectRealtimeEvent;\n \"environment:changed\": EnvironmentRealtimeEvent;\n \"host:changed\": HostRealtimeEvent;\n \"system:changed\": SystemRealtimeEvent;\n \"system:config-changed\": SystemRealtimeEvent;\n \"realtime:connection\": BbRealtimeConnectionEvent;\n}\ntype BbRealtimeCallback = (event: BbRealtimeEventMap[TEventName]) => void;\ninterface ThreadRealtimeOnArgs {\n callback: BbRealtimeCallback<\"thread:changed\">;\n event: \"thread:changed\";\n threadId?: string;\n}\ninterface ProjectRealtimeOnArgs {\n callback: BbRealtimeCallback<\"project:changed\">;\n event: \"project:changed\";\n projectId?: string;\n}\ninterface EnvironmentRealtimeOnArgs {\n callback: BbRealtimeCallback<\"environment:changed\">;\n environmentId?: string;\n event: \"environment:changed\";\n}\ninterface HostRealtimeOnArgs {\n callback: BbRealtimeCallback<\"host:changed\">;\n event: \"host:changed\";\n hostId?: string;\n}\ninterface SystemRealtimeOnArgs {\n callback: BbRealtimeCallback<\"system:changed\">;\n event: \"system:changed\";\n}\ninterface SystemConfigRealtimeOnArgs {\n callback: BbRealtimeCallback<\"system:config-changed\">;\n event: \"system:config-changed\";\n}\n/**\n * Connection listeners are pure observers — they never open or hold the\n * socket. A listener registered while a socket already exists receives the\n * latest connection event as a snapshot on the next microtask, so a status\n * UI mounted after connect still learns the current state.\n */\ninterface RealtimeConnectionOnArgs {\n callback: BbRealtimeCallback<\"realtime:connection\">;\n event: \"realtime:connection\";\n}\ntype BbRealtimeOnArgsUnion = ThreadRealtimeOnArgs | ProjectRealtimeOnArgs | EnvironmentRealtimeOnArgs | HostRealtimeOnArgs | SystemRealtimeOnArgs | SystemConfigRealtimeOnArgs | RealtimeConnectionOnArgs;\ntype BbRealtimeOnArgs = Extract;\ninterface BbRealtime {\n on(args: BbRealtimeOnArgs): BbRealtimeUnsubscribe;\n}\n\ninterface StatusGetArgs {\n projectId?: string;\n threadId?: string;\n}\ninterface StatusThreadSummary {\n environmentId: string | null;\n id: string;\n parentThreadId: string | null;\n pinnedAt: number | null;\n projectId: string;\n status: ThreadStatus;\n title: string | null;\n}\ntype StatusProject = PublicApiOutput<\"/projects/:id\", \"$get\">;\ntype StatusChildThreads = PublicApiOutput<\"/threads\", \"$get\">;\ninterface StatusResult {\n childThreads: StatusChildThreads | null;\n pendingTodos: ThreadTimelinePendingTodos | null;\n project: StatusProject | null;\n thread: StatusThreadSummary | null;\n}\ninterface StatusArea {\n get(args?: StatusGetArgs): Promise;\n}\ndeclare function createStatusArea(args: CreateSdkAreaArgs): StatusArea;\n\ninterface ThemeArea {\n /** The active app palette, resolved server-side (built-in id or custom CSS). */\n get(): Promise;\n /** The custom-theme directory plus discovered themes and the active palette. */\n catalog(): Promise;\n /**\n * Activate a palette by id — a built-in id or a custom theme name that exists\n * under `/theme//theme.css`. Broadcasts to all open windows.\n */\n set(themeId: string): Promise;\n}\ndeclare function createThemeArea(args: CreateSdkAreaArgs): ThemeArea;\n\ninterface ThreadListArgs {\n archived?: boolean;\n hasParent?: boolean;\n parentThreadId?: string;\n projectId?: string;\n}\ninterface ThreadGetArgs {\n include?: ThreadGetQuery[\"include\"];\n threadId: string;\n}\ntype ThreadGetResult = PublicApiOutput<\"/threads/:id\", \"$get\">;\ntype ThreadListResult = PublicApiOutput<\"/threads\", \"$get\">;\ntype ThreadOutputResponse = PublicApiOutput<\"/threads/:id/output\", \"$get\">;\ntype ThreadMutationResult = PublicApiOutput<\"/threads/:id\", \"$patch\">;\ntype ThreadSpawnResult = PublicApiOutput<\"/threads\", \"$post\">;\ntype ThreadInteractionGetResult = PublicApiOutput<\"/threads/:id/interactions/:interactionId\", \"$get\">;\ntype ThreadInteractionListResult = PublicApiOutput<\"/threads/:id/interactions\", \"$get\">;\ntype ThreadInteractionResolveResult = PublicApiOutput<\"/threads/:id/interactions/:interactionId/resolve\", \"$post\">;\ntype ThreadEventsListResult = PublicApiOutput<\"/threads/:id/events\", \"$get\">;\ntype ThreadEventWaitResult = PublicApiOutput<\"/threads/:id/events/wait\", \"$get\">;\ntype ThreadTimelineResult = PublicApiOutput<\"/threads/:id/timeline\", \"$get\">;\ntype ThreadArchiveResult = PublicApiOutput<\"/threads/:id/archive\", \"$post\">;\ntype ThreadOpenResult = PublicApiOutput<\"/threads/:id/open\", \"$post\">;\ntype ThreadDeleteResult = PublicApiOutput<\"/threads/:id\", \"$delete\">;\ntype ThreadSendResult = PublicApiOutput<\"/threads/:id/send\", \"$post\">;\ntype ThreadStopResult = PublicApiOutput<\"/threads/:id/stop\", \"$post\">;\ntype ThreadTerminalCloseResult = PublicApiOutput<\"/terminals/:terminalId/close\", \"$post\">;\ntype ThreadTerminalCreateResult = PublicApiOutput<\"/terminals\", \"$post\">;\ntype ThreadTerminalInputResult = PublicApiOutput<\"/terminals/:terminalId/input\", \"$post\">;\ntype ThreadTerminalListResult = PublicApiOutput<\"/terminals\", \"$get\">;\ntype ThreadTerminalOutputResult = PublicApiOutput<\"/terminals/:terminalId/output\", \"$get\">;\ntype ThreadTerminalResizeResult = PublicApiOutput<\"/terminals/:terminalId/resize\", \"$post\">;\ntype ThreadTerminalUpdateResult = PublicApiOutput<\"/terminals/:terminalId\", \"$patch\">;\ntype ThreadUnarchiveResult = PublicApiOutput<\"/threads/:id/unarchive\", \"$post\">;\ninterface ThreadSpawnBaseArgs extends Omit {\n childOrigin?: CreateThreadRequest[\"childOrigin\"];\n origin?: CreateThreadRequest[\"origin\"];\n originKind?: CreateThreadRequest[\"originKind\"];\n startedOnBehalfOf?: CreateThreadRequest[\"startedOnBehalfOf\"];\n}\ntype ThreadSpawnArgs = ThreadSpawnBaseArgs & ({\n input: CreateThreadRequest[\"input\"];\n prompt?: never;\n} | {\n input?: never;\n prompt: string;\n});\ninterface ThreadUpdateArgs extends UpdateThreadRequest {\n threadId: string;\n}\ninterface ThreadDeleteArgs extends DeleteThreadRequest {\n threadId: string;\n}\ninterface ThreadSendArgs extends SendMessageRequest {\n threadId: string;\n}\ninterface ThreadStatusArgs {\n threadId: string;\n}\ninterface ThreadOpenArgs {\n threadId: string;\n source: PanelFileSource;\n path: string;\n lineNumber: number | null;\n}\ninterface ThreadEventsListArgs {\n afterSeq?: string;\n limit?: string;\n threadId: string;\n}\ninterface ThreadEventWaitArgs {\n afterSeq?: string;\n threadId: string;\n type: string;\n waitMs: string;\n}\ninterface ThreadTimelineArgs extends ThreadTimelineQuery {\n threadId: string;\n}\ninterface ThreadOutputArgs {\n threadId: string;\n}\ninterface ThreadTerminalListArgs {\n threadId: string;\n}\ninterface ThreadTerminalCreateArgs extends Omit {\n threadId: string;\n}\ninterface ThreadTerminalTargetArgs {\n terminalId: string;\n threadId: string;\n}\ninterface ThreadTerminalUpdateArgs extends ThreadTerminalTargetArgs, UpdateTerminalRequest {\n}\ninterface ThreadTerminalCloseArgs extends ThreadTerminalTargetArgs, CloseTerminalRequest {\n}\ninterface ThreadTerminalInputArgs extends ThreadTerminalTargetArgs, TerminalInputRequest {\n}\ninterface ThreadTerminalResizeArgs extends ThreadTerminalTargetArgs, TerminalResizeRequest {\n}\ninterface ThreadTerminalOutputArgs extends ThreadTerminalTargetArgs, TerminalOutputQuery {\n}\ninterface ThreadInteractionListArgs {\n threadId: string;\n}\ninterface ThreadInteractionGetArgs extends ThreadInteractionListArgs {\n interactionId: string;\n}\ninterface ThreadInteractionResolveArgs extends ThreadInteractionGetArgs {\n resolution: PendingInteractionResolution;\n}\ntype ThreadWaitTarget = {\n kind: \"status\";\n status: ThreadStatus;\n} | {\n kind: \"event\";\n eventType: string;\n};\ninterface ThreadWaitArgs {\n event?: string;\n pollIntervalMs?: number;\n status?: ThreadStatus;\n threadId: string;\n timeoutMs?: number;\n}\ntype ThreadWaitResult = {\n event: NonNullable;\n matched: true;\n target: Extract;\n threadId: string;\n} | {\n matched: true;\n target: Extract;\n thread: ThreadGetResult;\n threadId: string;\n};\ninterface ThreadInteractionsArea {\n get(args: ThreadInteractionGetArgs): Promise;\n list(args: ThreadInteractionListArgs): Promise;\n resolve(args: ThreadInteractionResolveArgs): Promise;\n}\ninterface ThreadEventsArea {\n list(args: ThreadEventsListArgs): Promise;\n wait(args: ThreadEventWaitArgs): Promise;\n}\ninterface ThreadTerminalsArea {\n close(args: ThreadTerminalCloseArgs): Promise;\n create(args: ThreadTerminalCreateArgs): Promise;\n input(args: ThreadTerminalInputArgs): Promise;\n list(args: ThreadTerminalListArgs): Promise;\n output(args: ThreadTerminalOutputArgs): Promise;\n resize(args: ThreadTerminalResizeArgs): Promise;\n update(args: ThreadTerminalUpdateArgs): Promise;\n}\ninterface ThreadsArea {\n archive(args: ThreadStatusArgs): Promise;\n delete(args: ThreadDeleteArgs): Promise;\n events: ThreadEventsArea;\n get(args: ThreadGetArgs): Promise;\n interactions: ThreadInteractionsArea;\n list(args?: ThreadListArgs): Promise;\n open(args: ThreadOpenArgs): Promise;\n output(args: ThreadOutputArgs): Promise;\n pin(args: ThreadStatusArgs): Promise;\n send(args: ThreadSendArgs): Promise;\n spawn(args: ThreadSpawnArgs): Promise;\n stop(args: ThreadStatusArgs): Promise;\n terminals: ThreadTerminalsArea;\n timeline(args: ThreadTimelineArgs): Promise;\n unarchive(args: ThreadStatusArgs): Promise;\n unpin(args: ThreadStatusArgs): Promise;\n update(args: ThreadUpdateArgs): Promise;\n wait(args: ThreadWaitArgs): Promise;\n}\ndeclare function createThreadsArea(args: CreateSdkAreaArgs): ThreadsArea;\n\ninterface BbSdk extends BbRealtime {\n automations: ReturnType;\n environments: ReturnType;\n files: ReturnType;\n guide: ReturnType;\n hosts: ReturnType;\n projects: ReturnType;\n providers: ReturnType;\n status: ReturnType;\n theme: ReturnType;\n threads: ReturnType;\n}\n\n/**\n * The backend plugin API contract — the `bb` object handed to a plugin's\n * `server.ts` factory (`export default function plugin(bb: BbPluginApi)`).\n *\n * Types only: the implementation lives in the BB server\n * (apps/server/src/services/plugins/plugin-api.ts), which imports these\n * shapes so the contract and the implementation cannot drift. Plugin authors\n * import them type-only (`import type { BbPluginApi } from\n * \"@bb/plugin-sdk\"`); the import is erased when BB loads the file.\n *\n * Runtime classes stay host-side. NeedsConfigurationError in particular is\n * matched by NAME, so plugin code needs no runtime import:\n * `throw Object.assign(new Error(msg), { name: \"NeedsConfigurationError\" })`.\n */\ninterface PluginLogger {\n debug(message: string): void;\n info(message: string): void;\n warn(message: string): void;\n error(message: string): void;\n}\n/**\n * Declarative settings descriptors (`bb.settings.define`). Deliberately plain\n * data — not zod — so the host can render settings forms and the CLI can\n * parse values without executing plugin code.\n */\ntype PluginSettingDescriptor = {\n type: \"string\";\n label: string;\n description?: string;\n /** Stored in a 0600 file under /plugins//secrets/, never in the db or sent to the frontend. */\n secret?: true;\n default?: string;\n} | {\n type: \"boolean\";\n label: string;\n description?: string;\n default?: boolean;\n} | {\n type: \"select\";\n label: string;\n description?: string;\n options: string[];\n default?: string;\n} | {\n type: \"project\";\n label: string;\n description?: string;\n default?: string;\n};\ntype PluginSettingDescriptors = Record;\ntype PluginSettingValue = string | boolean;\n/** `default` present → non-optional value; absent → `T | undefined`. */\ntype PluginSettingsValues> = {\n [K in keyof Ds]: Ds[K] extends {\n default: string | boolean;\n } ? PluginSettingValueOf : PluginSettingValueOf | undefined;\n};\ntype PluginSettingValueOf = D extends {\n type: \"boolean\";\n} ? boolean : string;\ninterface PluginSettingsHandle> {\n /** Load-safe: callable inside the factory. */\n get(): Promise>;\n /** Fires after values change through the settings route/CLI. */\n onChange(listener: (next: PluginSettingsValues, prev: PluginSettingsValues) => void): void;\n}\ninterface PluginSettings {\n define>(descriptors: Ds): PluginSettingsHandle;\n}\ninterface PluginKvStorage {\n get(key: string): Promise;\n set(key: string, value: unknown): Promise;\n delete(key: string): Promise;\n list(prefix?: string): Promise;\n}\ninterface PluginStorage {\n /** Namespaced JSON key-value rows in bb.db; values ≤256KB each. */\n kv: PluginKvStorage;\n /**\n * Open (or reuse the path of) the plugin's own SQLite database at\n * /plugins//data.db — the server's better-sqlite3, WAL mode,\n * busy_timeout 5000. Handles are host-tracked and closed on\n * dispose/reload; a closed handle throws on use.\n */\n sqlite(): Database.Database;\n /**\n * Ordered-statement migration helper: statement index = migration id in a\n * `_bb_migrations` table; unapplied statements run in one transaction.\n * Append-only — never reorder or edit shipped statements.\n */\n migrate(db: Database.Database, statements: string[]): void;\n}\n/**\n * Thread lifecycle events a plugin can observe (design §4.5). Observe-only:\n * handlers run fire-and-forget after the transition is applied and can never\n * block or veto it. `thread` is the same public DTO GET /threads/:id serves.\n */\ninterface PluginThreadEventPayloads {\n \"thread.created\": {\n thread: ThreadResponse;\n };\n /** Fired when a thread transitions into `idle`. `lastAssistantText` is\n * assembled the same way GET /threads/:id/output is. */\n \"thread.idle\": {\n thread: ThreadResponse;\n lastAssistantText: string | null;\n };\n /** Fired when a thread transitions into `error`. `error` is the latest\n * system/error event message, when one exists. */\n \"thread.failed\": {\n thread: ThreadResponse;\n error: string | null;\n };\n}\ntype PluginThreadEventName = keyof PluginThreadEventPayloads;\ntype PluginThreadEventHandler = (payload: PluginThreadEventPayloads[E]) => void | Promise;\ntype PluginHttpAuthMode = \"local\" | \"token\" | \"none\";\ntype PluginHttpHandler = (context: Context) => Response | Promise;\ninterface PluginHttp {\n /**\n * Register an HTTP route, mounted at\n * `/api/v1/plugins//http/`. Auth modes (default \"local\"):\n * - \"local\": Origin/Host must be a local BB app origin; non-GET requires\n * content-type application/json (forces a CORS preflight).\n * - \"token\": requires the per-plugin token (`bb plugin token `) via\n * the x-bb-plugin-token header or ?token=.\n * - \"none\": no checks — only for signature-verified webhooks.\n */\n route(method: string, path: string, handler: PluginHttpHandler, opts?: {\n auth?: PluginHttpAuthMode;\n }): void;\n}\ninterface PluginRpc {\n /**\n * Register rpc methods, served at POST\n * `/api/v1/plugins//rpc/` with \"local\" auth semantics. The\n * JSON request body is the input; the response is\n * `{ ok: true, result }` or `{ ok: false, error }`. Inputs and outputs\n * must survive a JSON round-trip — results are serialized with\n * JSON.stringify and nothing else.\n */\n register(handlers: Record unknown>): void;\n}\ninterface PluginRealtime {\n /**\n * Broadcast an ephemeral `plugin-signal` WS message\n * `{ pluginId, channel, payload }` to every connected client (V1 has no\n * per-channel subscriptions). `payload` must be JSON-serializable;\n * `undefined` is normalized to `null`. Nothing is persisted.\n */\n publish(channel: string, payload: unknown): void;\n}\ninterface PluginBackground {\n /**\n * Register a long-lived background service. `start` runs after the\n * factory completes and should resolve when `signal` aborts\n * (dispose/reload/disable/shutdown). A crash restarts it with capped\n * exponential backoff; throwing NeedsConfigurationError marks the plugin\n * `needs-configuration` and stops restarting until the next load.\n */\n service(name: string, service: {\n start(signal: AbortSignal): void | Promise;\n }): void;\n /**\n * Register a cron schedule (5-field expression, server-local time). The\n * durable row keyed (pluginId, name) is upserted at load; the periodic\n * sweep claims due rows with a CAS on next_run_at, but only while this\n * plugin is loaded. Failures land in last_status/last_error, visible in\n * `bb plugin list`.\n */\n schedule(name: string, cron: string, fn: () => void | Promise): void;\n}\ninterface PluginCliCommandInfo {\n name: string;\n summary: string;\n usage: string;\n}\n/** Context forwarded from the invoking CLI when known; all fields optional. */\ninterface PluginCliContext {\n cwd?: string;\n threadId?: string;\n projectId?: string;\n}\ninterface PluginCliResult {\n exitCode: number;\n stdout?: string;\n stderr?: string;\n}\ninterface PluginCliRegistration {\n /** Top-level command name (`bb …`): lowercase [a-z0-9-]+, and not\n * a core bb command (see RESERVED_BB_CLI_COMMANDS in the server). */\n name: string;\n summary: string;\n /** Subcommand metadata rendered in help and the plugin-commands skill\n * without executing plugin code. Parsing argv is plugin-owned. */\n commands?: PluginCliCommandInfo[];\n run(argv: string[], ctx: PluginCliContext): PluginCliResult | Promise;\n}\ninterface PluginCli {\n /**\n * Register this plugin's `bb` subcommand. One registration per plugin —\n * a second call replaces the first. Core bb commands always win name\n * collisions; reserved names are rejected at registration.\n */\n register(registration: PluginCliRegistration): void;\n}\n/** Per-turn context handed to bb.agents context providers (design §4.4). */\n/** MCP-style content parts a native tool may return (design §4.4). */\ntype PluginAgentToolContentPart = {\n type: \"text\";\n text: string;\n} | {\n type: \"image\";\n data: string;\n mimeType: string;\n};\ntype PluginAgentToolResult = string | {\n content: PluginAgentToolContentPart[];\n isError?: boolean;\n};\n/** Per-call context handed to a native tool's execute (design §4.4). */\ninterface PluginAgentToolContext {\n threadId: string;\n projectId: string;\n /** The tool-call request's abort signal (aborts if the daemon round-trip\n * is torn down mid-call). */\n signal: AbortSignal;\n}\ninterface PluginAgentToolRegistrationBase {\n /** Tool name shown to the model: [a-zA-Z0-9_-]+, unique across plugins,\n * and not a built-in dynamic tool (see RESERVED_AGENT_TOOL_NAMES in the\n * server). */\n name: string;\n description: string;\n /**\n * Optional usage snippet appended to the thread instructions whenever\n * this tool is in the session's tool set (mirrors the built-in\n * update_environment_directory guidance). Keep it short.\n */\n instructions?: string;\n}\ninterface PluginAgents {\n /**\n * Register a native dynamic tool (design §4.4). `parameters` is either a\n * zod schema (validated per call; execute receives the parsed value) or a\n * plain JSON-schema object (no validation; execute receives the raw\n * arguments as `unknown`). Tool-set changes apply on the NEXT session\n * start — a tool registered mid-session is not hot-added to running\n * provider sessions. A second registration of the same name within this\n * plugin replaces the first; a name already registered by another plugin\n * is rejected and surfaced as this plugin's status detail.\n */\n registerTool(tool: PluginAgentToolRegistrationBase & {\n parameters: Schema;\n execute(params: z.output, ctx: PluginAgentToolContext): PluginAgentToolResult | Promise;\n }): void;\n registerTool(tool: PluginAgentToolRegistrationBase & {\n /** Raw JSON-schema escape hatch; params arrive unvalidated. */\n parameters: Record;\n execute(params: unknown, ctx: PluginAgentToolContext): PluginAgentToolResult | Promise;\n }): void;\n}\ninterface PluginThreadActionContext {\n threadId: string;\n projectId: string;\n}\ninterface PluginThreadActionToast {\n kind: \"success\" | \"error\" | \"info\";\n message: string;\n}\ntype PluginThreadActionResult = void | {\n toast?: PluginThreadActionToast;\n};\ninterface PluginThreadActionRegistration {\n /** Unique within this plugin: [a-zA-Z0-9_-]+ (becomes a URL segment). */\n id: string;\n /** Button label rendered in the thread header. */\n title: string;\n /** Optional icon name; the host falls back to a generic icon. */\n icon?: string;\n /** Optional confirmation prompt the host shows before running. */\n confirm?: string;\n /**\n * Runs server-side when the user clicks the action. The host shows a\n * pending state while in flight, the returned toast on completion, and an\n * automatic error toast when this throws.\n */\n run(ctx: PluginThreadActionContext): PluginThreadActionResult | Promise;\n}\n/** Search context handed to a mention provider (design §4.9). `projectId`/\n * `threadId` are null when the composer has not committed one yet. */\ninterface PluginMentionSearchContext {\n query: string;\n projectId: string | null;\n threadId: string | null;\n}\n/** One row a mention provider returns from `search`. `id` is the provider's\n * own item id — the host namespaces it before it reaches the wire. */\ninterface PluginMentionItem {\n id: string;\n title: string;\n subtitle?: string;\n icon?: string;\n}\ninterface PluginMentionProviderRegistration {\n /** Unique within this plugin: [a-zA-Z0-9_-]+ (no \":\" — the host composes\n * wire item ids as \":\"). */\n id: string;\n /** Section label shown above this provider's rows in the mention menu. */\n label: string;\n /**\n * Runs server-side as the user types after `@` in the composer. Each call\n * is time-boxed (2s) and failure-isolated: a slow or throwing provider\n * contributes an empty list — it can never break the mention menu.\n */\n search(ctx: PluginMentionSearchContext): PluginMentionItem[] | Promise;\n /**\n * Resolves one picked item into agent context, called once per unique\n * item at message send time. The returned `context` is attached to the\n * message as an agent-visible (user-hidden) prompt input. Throwing blocks\n * the send with a visible error.\n */\n resolve(itemId: string): {\n context: string;\n } | Promise<{\n context: string;\n }>;\n}\ninterface PluginUi {\n /**\n * Register a thread action rendered in the shipped app's thread header\n * (design §4.9). Multiple actions per plugin; ids must be unique within\n * the plugin. Invoked via POST /plugins/:id/actions/:actionId.\n */\n registerThreadAction(action: PluginThreadActionRegistration): void;\n /**\n * Register an `@`-mention provider for the shipped app's composer\n * (design §4.9). Items group under `label` in the mention menu; a picked\n * item becomes a `{ kind: \"plugin\" }` mention resource whose context is\n * resolved once at send time. Multiple providers per plugin; ids must be\n * unique within the plugin.\n */\n registerMentionProvider(provider: PluginMentionProviderRegistration): void;\n}\ninterface PluginStatusApi {\n /**\n * Mark this plugin `needs-configuration` (with a message shown in\n * `bb plugin list` and the UI) instead of failing — e.g. a factory or\n * service that finds no API key configured. Cleared on the next load;\n * saving settings does not auto-reload in V1, so ask the user to\n * `bb plugin reload ` after configuring.\n */\n needsConfiguration(message: string): void;\n}\n/**\n * The API object handed to a plugin's factory (design §4). Implemented by\n * the BB server; this contract is what plugin `server.ts` files compile\n * against.\n */\ninterface BbPluginApi {\n /** The plugin's own id (namespaces storage, routes, commands). */\n readonly pluginId: string;\n /** Leveled, plugin-scoped logger. */\n readonly log: PluginLogger;\n /** Declarative settings (design §4.2). */\n readonly settings: PluginSettings;\n /** Namespaced KV + per-plugin SQLite (design §4.3). */\n readonly storage: PluginStorage;\n /** HTTP routes under /api/v1/plugins//http/* (design §4.6). */\n readonly http: PluginHttp;\n /** RPC methods under /api/v1/plugins//rpc/ (design §4.6). */\n readonly rpc: PluginRpc;\n /** Ephemeral push to connected frontends (design §4.7). */\n readonly realtime: PluginRealtime;\n /** Long-lived services + cron schedules (design §4.8). */\n readonly background: PluginBackground;\n /** Agent-facing `bb` CLI subcommand (design §4.4). */\n readonly cli: PluginCli;\n /** Per-turn agent context contributions (design §4.4). */\n readonly agents: PluginAgents;\n /** Host-rendered UI contributions (design §4.9). */\n readonly ui: PluginUi;\n /** Plugin-reported status (needs-configuration). */\n readonly status: PluginStatusApi;\n /**\n * The full BB SDK, bound to this server over loopback (design §4.1).\n * Bind-gated: reading this before the host binds the SDK throws. The real\n * server binds it before loading plugins, so it is available from the\n * moment factories run there — but isolated harnesses may not, so prefer\n * using it from handlers, services, and timers for portability.\n * `threads.spawn` defaults `origin` to \"plugin\" and `originPluginId` to\n * this plugin's id so spawned threads are attributed automatically.\n */\n readonly sdk: BbSdk;\n /**\n * Observe thread lifecycle events (design §4.5). Load-safe registration;\n * handlers run async after the transition and never affect it. Errors are\n * caught, logged, and counted against this plugin's handler stats.\n */\n on(event: E, handler: PluginThreadEventHandler): void;\n /**\n * Register cleanup to run on reload/disable/shutdown. Hooks run LIFO.\n * The sanctioned place to clear timers and close connections.\n */\n onDispose(hook: () => void | Promise): void;\n}\n\nexport { PLUGIN_SDK_APP_EXPORT_NAMES, PLUGIN_SLOT_ID_PATTERN };\nexport type { BbContext, BbNavigate, BbPluginApi, PluginAgentToolContentPart, PluginAgentToolContext, PluginAgentToolRegistrationBase, PluginAgentToolResult, PluginAgents, PluginAppBuilder, PluginAppDefinition, PluginAppSetup, PluginAppSlots, PluginBackground, PluginCli, PluginCliCommandInfo, PluginCliContext, PluginCliRegistration, PluginCliResult, PluginComposerAccessoryProps, PluginComposerAccessoryRegistration, PluginComposerApi, PluginComposerMention, PluginComposerScope, PluginFileOpenerProps, PluginFileOpenerRegistration, PluginFileOpenerSource, PluginHomepageSectionProps, PluginHomepageSectionRegistration, PluginHttp, PluginHttpAuthMode, PluginHttpHandler, PluginKvStorage, PluginLogger, PluginMentionItem, PluginMentionProviderRegistration, PluginMentionSearchContext, PluginNavPanelProps, PluginNavPanelRegistration, PluginRealtime, PluginRpc, PluginRpcClient, PluginSdkApp, PluginSettingDescriptor, PluginSettingDescriptors, PluginSettingValue, PluginSettings, PluginSettingsHandle, PluginSettingsState, PluginSettingsValues, PluginStatusApi, PluginStorage, PluginThreadActionContext, PluginThreadActionRegistration, PluginThreadActionResult, PluginThreadActionToast, PluginThreadEventHandler, PluginThreadEventName, PluginThreadEventPayloads, PluginThreadPanelActionContext, PluginThreadPanelActionRegistration, PluginThreadPanelProps, PluginUi };\n"; -export const PLUGIN_SDK_APP_DTS = "// Bundled type declarations for `@bb/plugin-sdk`, shipped into scaffolded\n// plugins so they typecheck without the @bb/* workspace on disk.\n//\n// Confused by the API, or need a symbol that isn't here? Clone the BB repo\n// and read the real source: https://github.com/ymichael/bb\n\nimport { ComponentType } from 'react';\n\n/**\n * The `@bb/plugin-sdk/app` contract (plugin design §5.2) — pure types plus\n * the runtime export-name list, with no side effects. This module is what the\n * BB app imports to keep its real implementation in sync (`satisfies\n * PluginSdkApp`) and what `bb plugin build` imports to generate the shim's\n * named-export list. Plugin authors import the same shapes through\n * `@bb/plugin-sdk/app`.\n *\n * Per-slot props are versioned contracts: additive-only within an SDK major.\n */\n/** Props passed to a `homepageSection` component. */\ninterface PluginHomepageSectionProps {\n /** Project in view on the compose surface; null when none is selected. */\n projectId: string | null;\n}\n/** Props passed to a `navPanel` component (it owns its whole route). */\ninterface PluginNavPanelProps {\n /**\n * The route remainder after the panel root, \"\" at the root. The panel's\n * route is `/plugins///*`, so a deep link like\n * `/plugins/notes/notes/work/ideas.md` renders the panel with\n * `subPath: \"work/ideas.md\"`. Navigate within the panel via\n * `useBbNavigate().toPluginPanel(path, { subPath })` — browser\n * back/forward then walks panel-internal history.\n */\n subPath: string;\n}\n/** Props passed to a panel tab opened by a `threadPanelAction`. */\ninterface PluginThreadPanelProps {\n threadId: string;\n /**\n * The JSON value the action's `openPanel` call passed (round-tripped\n * through persistence, so the tab restores across reloads); null when the\n * action opened the panel without params.\n */\n params: unknown;\n}\n/** Props passed to a `composerAccessory` component. */\ninterface PluginComposerAccessoryProps {\n projectId: string | null;\n threadId: string | null;\n}\n/**\n * Where a file being opened by a `fileOpener` lives. `path` semantics follow\n * the source: workspace paths are relative to the environment's worktree,\n * thread-storage paths are relative to the thread's storage root, host paths\n * are absolute on the thread's host.\n */\ninterface PluginFileOpenerSource {\n kind: \"workspace\" | \"host\" | \"thread-storage\";\n threadId: string | null;\n environmentId: string | null;\n projectId: string | null;\n}\n/** Props passed to a `fileOpener` component (rendered as a panel file tab). */\ninterface PluginFileOpenerProps {\n path: string;\n source: PluginFileOpenerSource;\n}\n/**\n * Slot/panel ids and nav-panel paths must match this pattern (letters,\n * digits, `-`, `_`): they ride URLs and persisted panel-tab keys.\n */\ndeclare const PLUGIN_SLOT_ID_PATTERN: RegExp;\ninterface PluginHomepageSectionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n component: ComponentType;\n}\ninterface PluginNavPanelRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n /** Icon hint (BB icon name); unknown names fall back to a generic icon. */\n icon: string;\n /** URL segment under `/plugins//`; letters, digits, `-`, `_`. */\n path: string;\n component: ComponentType;\n /**\n * Panel chrome (default \"page\"): \"page\" renders the host title bar (plugin\n * logo + `title` + your `headerContent`) above a full-width padded body;\n * \"none\" hands the ENTIRE panel area to `component` — no host padding, no\n * title bar (`headerContent` is ignored) — only the per-plugin error\n * boundary remains.\n */\n chrome?: \"page\" | \"none\";\n /**\n * Optional component rendered on the right side of the \"page\" title bar\n * (e.g. a sync button or a count). Contained separately from the body: a\n * throwing headerContent is hidden without breaking the title bar.\n */\n headerContent?: ComponentType;\n}\n/** Context handed to a `threadPanelAction`'s `run`. */\ninterface PluginThreadPanelActionContext {\n /** The thread whose panel launcher invoked the action. */\n threadId: string;\n /**\n * Open a tab in the thread's side panel rendering this action's\n * `component`. `title` labels the tab (default: the action's `title`);\n * `params` must be JSON-serializable — it is persisted with the tab and\n * reaches the component as its `params` prop. Opening with params\n * identical to an already-open tab of this action focuses that tab\n * (updating its title) instead of duplicating it. May be called more than\n * once (different params ⇒ multiple tabs) or not at all.\n */\n openPanel(options?: {\n title?: string;\n params?: unknown;\n }): void;\n}\ninterface PluginThreadPanelActionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n /** Label of the action row in the panel's new-tab launcher. */\n title: string;\n /**\n * Icon hint (BB icon name) used when the plugin ships no logo; the\n * launcher row and opened tabs prefer the plugin's logo.\n */\n icon?: string;\n /** Rendered inside every panel tab this action opens. */\n component: ComponentType;\n /**\n * Runs when the user activates the action: call your RPC methods, show a\n * toast, and/or open panel tabs via `context.openPanel`. Omitted =\n * immediately open a panel tab with defaults. Errors (sync or async) are\n * contained and logged; they never break the launcher.\n */\n run?(context: PluginThreadPanelActionContext): void | Promise;\n}\ninterface PluginComposerAccessoryRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n component: ComponentType;\n}\n/**\n * Register this plugin as a viewer/editor for file extensions. The user\n * picks (and can set as default) an opener per extension via the file tab's\n * \"Open with\" menu; matching files opened in the panel then render\n * `component` in a plugin tab instead of the built-in preview. Applies to\n * working-tree, host, and thread-storage files — never to git-ref snapshots\n * (diff views always use the built-in preview). The built-in preview stays\n * one menu click away, and a missing/disabled opener falls back to it.\n */\ninterface PluginFileOpenerRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n /** Label in the \"Open with\" menu (e.g. \"Notes editor\"). */\n title: string;\n /** Lowercase extensions without the dot (e.g. [\"md\", \"mdx\"]). */\n extensions: readonly string[];\n component: ComponentType;\n}\ninterface PluginAppSlots {\n homepageSection(registration: PluginHomepageSectionRegistration): void;\n navPanel(registration: PluginNavPanelRegistration): void;\n threadPanelAction(registration: PluginThreadPanelActionRegistration): void;\n composerAccessory(registration: PluginComposerAccessoryRegistration): void;\n fileOpener(registration: PluginFileOpenerRegistration): void;\n}\ninterface PluginAppBuilder {\n slots: PluginAppSlots;\n}\ntype PluginAppSetup = (app: PluginAppBuilder) => void;\n/**\n * The opaque product of `definePluginApp` — a plugin's `app.tsx` default\n * export. The host re-runs `setup` against a fresh collector on every\n * (re)interpretation, replacing that plugin's registrations wholesale.\n */\ninterface PluginAppDefinition {\n /** Brand the host checks before interpreting a bundle's default export. */\n readonly __bbPluginApp: true;\n readonly setup: PluginAppSetup;\n}\ninterface PluginRpcClient {\n /**\n * Invoke one of the plugin's `bb.rpc` methods (POST\n * /api/v1/plugins/<id>/rpc/<method>). Resolves with the method's\n * result; rejects with an `Error` carrying the server's message when the\n * handler fails or the plugin is not running.\n */\n call(method: string, input?: unknown): Promise;\n}\ninterface PluginSettingsState {\n /**\n * Effective non-secret setting values (secret settings are excluded —\n * read them server-side). Undefined while loading or unavailable.\n */\n values: Record | undefined;\n isLoading: boolean;\n}\n/** Where `useComposer()` writes: the active thread's draft or the new-thread draft. */\ntype PluginComposerScope = {\n kind: \"thread\";\n threadId: string;\n} | {\n kind: \"new-thread\";\n projectId: string | null;\n};\n/** An @-mention pill bound to one of the calling plugin's mention providers. */\ninterface PluginComposerMention {\n /** Mention provider id registered by THIS plugin via `bb.ui.registerMentionProvider`. */\n provider: string;\n /** Item id your provider's `resolve` will receive at send time. */\n id: string;\n /** Pill text shown in the composer. */\n label: string;\n}\n/**\n * Programmatic access to the chat composer draft — the same shared draft the\n * built-in \"Add to chat\" affordances (file preview, diff, terminal selections)\n * write to. Inside a thread context writes land in that thread's draft;\n * anywhere else (nav panel, homepage section) they seed the new-thread\n * composer draft, which persists until the user sends or clears it.\n */\ninterface PluginComposerApi {\n scope: PluginComposerScope;\n /**\n * Append text to the draft as a `> ` blockquote block and focus the\n * composer. Blank text is a no-op. This is the \"reference this selection\n * in chat\" primitive.\n */\n addQuote(text: string): void;\n /**\n * Insert an @-mention pill that resolves through this plugin's mention\n * provider at send time — the durable way to reference an entity whose\n * content should be fetched fresh when the message is sent.\n */\n insertMention(mention: PluginComposerMention): void;\n /** Focus the composer caret at the end of the draft. */\n focus(): void;\n}\n/** Current app selection, derived from the route. */\ninterface BbContext {\n projectId: string | null;\n threadId: string | null;\n}\ninterface BbNavigate {\n toThread(threadId: string): void;\n toProject(projectId: string): void;\n /**\n * Navigate to one of this plugin's own nav panels by its `path`.\n * `subPath` targets a location inside the panel (the component's\n * `subPath` prop); `replace` swaps the current history entry instead of\n * pushing — use it for redirects so back does not bounce.\n */\n toPluginPanel(path: string, options?: {\n subPath?: string;\n replace?: boolean;\n }): void;\n}\n/**\n * Everything `@bb/plugin-sdk/app` resolves to at runtime. The BB app builds\n * the real implementation and `satisfies` this interface; `bb plugin build`\n * shims the specifier to that object on `globalThis.__bbPluginRuntime`.\n */\ninterface PluginSdkApp {\n definePluginApp(setup: PluginAppSetup): PluginAppDefinition;\n useRpc(): PluginRpcClient;\n useRealtime(channel: string, handler: (payload: unknown) => void): void;\n useSettings(): PluginSettingsState;\n useBbContext(): BbContext;\n useBbNavigate(): BbNavigate;\n useComposer(): PluginComposerApi;\n}\n/**\n * Named runtime exports of `@bb/plugin-sdk/app`, in sorted order. Single\n * source of truth for the build shim's export list and the app's\n * implementation-key test — adding a surface member without updating this\n * list fails the type assertion below.\n */\ndeclare const PLUGIN_SDK_APP_EXPORT_NAMES: readonly [\"definePluginApp\", \"useBbContext\", \"useBbNavigate\", \"useComposer\", \"useRealtime\", \"useRpc\", \"useSettings\"];\n\ndeclare const definePluginApp: (setup: PluginAppSetup) => PluginAppDefinition;\ndeclare const useRpc: () => PluginRpcClient;\ndeclare const useRealtime: (channel: string, handler: (payload: unknown) => void) => void;\ndeclare const useSettings: () => PluginSettingsState;\ndeclare const useBbContext: () => BbContext;\ndeclare const useBbNavigate: () => BbNavigate;\n\nexport { PLUGIN_SDK_APP_EXPORT_NAMES, PLUGIN_SLOT_ID_PATTERN, definePluginApp, useBbContext, useBbNavigate, useRealtime, useRpc, useSettings };\nexport type { BbContext, BbNavigate, PluginAppBuilder, PluginAppDefinition, PluginAppSetup, PluginAppSlots, PluginComposerAccessoryProps, PluginComposerAccessoryRegistration, PluginComposerApi, PluginComposerMention, PluginComposerScope, PluginFileOpenerProps, PluginFileOpenerRegistration, PluginFileOpenerSource, PluginHomepageSectionProps, PluginHomepageSectionRegistration, PluginNavPanelProps, PluginNavPanelRegistration, PluginRpcClient, PluginSdkApp, PluginSettingsState, PluginThreadPanelActionContext, PluginThreadPanelActionRegistration, PluginThreadPanelProps };\n"; +export const PLUGIN_SDK_APP_DTS = "// Bundled type declarations for `@bb/plugin-sdk`, shipped into scaffolded\n// plugins so they typecheck without the @bb/* workspace on disk.\n//\n// Confused by the API, or need a symbol that isn't here? Clone the BB repo\n// and read the real source: https://github.com/ymichael/bb\n\nimport { ComponentType } from 'react';\n\n/**\n * The `@bb/plugin-sdk/app` contract (plugin design §5.2) — pure types plus\n * the runtime export-name list, with no side effects. This module is what the\n * BB app imports to keep its real implementation in sync (`satisfies\n * PluginSdkApp`) and what `bb plugin build` imports to generate the shim's\n * named-export list. Plugin authors import the same shapes through\n * `@bb/plugin-sdk/app`.\n *\n * Per-slot props are versioned contracts: additive-only within an SDK major.\n */\n/** Props passed to a `homepageSection` component. */\ninterface PluginHomepageSectionProps {\n /** Project in view on the compose surface; null when none is selected. */\n projectId: string | null;\n}\n/** Props passed to a `navPanel` component (it owns its whole route). */\ninterface PluginNavPanelProps {\n /**\n * The route remainder after the panel root, \"\" at the root. The panel's\n * route is `/plugins///*`, so a deep link like\n * `/plugins/notes/notes/work/ideas.md` renders the panel with\n * `subPath: \"work/ideas.md\"`. Navigate within the panel via\n * `useBbNavigate().toPluginPanel(path, { subPath })` — browser\n * back/forward then walks panel-internal history.\n */\n subPath: string;\n}\n/** Props passed to a panel tab opened by a `threadPanelAction`. */\ninterface PluginThreadPanelProps {\n threadId: string;\n /**\n * The JSON value the action's `openPanel` call passed (round-tripped\n * through persistence, so the tab restores across reloads); null when the\n * action opened the panel without params.\n */\n params: unknown;\n}\n/** Props passed to a `composerAccessory` component. */\ninterface PluginComposerAccessoryProps {\n projectId: string | null;\n threadId: string | null;\n}\n/**\n * Where a file being opened by a `fileOpener` lives. `path` semantics follow\n * the source: workspace paths are relative to the environment's worktree,\n * thread-storage paths are relative to the thread's storage root, host paths\n * are absolute on the thread's host.\n */\ninterface PluginFileOpenerSource {\n kind: \"workspace\" | \"host\" | \"thread-storage\";\n threadId: string | null;\n environmentId: string | null;\n projectId: string | null;\n}\n/** Props passed to a `fileOpener` component (rendered as a panel file tab). */\ninterface PluginFileOpenerProps {\n path: string;\n source: PluginFileOpenerSource;\n}\n/**\n * Slot/panel ids and nav-panel paths must match this pattern (letters,\n * digits, `-`, `_`): they ride URLs and persisted panel-tab keys.\n */\ndeclare const PLUGIN_SLOT_ID_PATTERN: RegExp;\ninterface PluginHomepageSectionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n component: ComponentType;\n}\ninterface PluginNavPanelRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n /** Icon hint (BB icon name); unknown names fall back to a generic icon. */\n icon: string;\n /** URL segment under `/plugins//`; letters, digits, `-`, `_`. */\n path: string;\n component: ComponentType;\n /**\n * Panel chrome (default \"page\"): \"page\" renders the host title bar (plugin\n * logo + `title` + your `headerContent`) above a full-width padded body;\n * \"none\" hands the ENTIRE panel area to `component` — no host padding, no\n * title bar (`headerContent` is ignored) — only the per-plugin error\n * boundary remains.\n */\n chrome?: \"page\" | \"none\";\n /**\n * Optional component rendered on the right side of the \"page\" title bar\n * (e.g. a sync button or a count). Contained separately from the body: a\n * throwing headerContent is hidden without breaking the title bar.\n */\n headerContent?: ComponentType;\n}\n/** Context handed to a `threadPanelAction`'s `run`. */\ninterface PluginThreadPanelActionContext {\n /** The thread whose panel launcher invoked the action. */\n threadId: string;\n /**\n * Open a tab in the thread's side panel rendering this action's\n * `component`. `title` labels the tab (default: the action's `title`);\n * `params` must be JSON-serializable — it is persisted with the tab and\n * reaches the component as its `params` prop. Opening with params\n * identical to an already-open tab of this action focuses that tab\n * (updating its title) instead of duplicating it. May be called more than\n * once (different params ⇒ multiple tabs) or not at all.\n */\n openPanel(options?: {\n title?: string;\n params?: unknown;\n }): void;\n}\ninterface PluginThreadPanelActionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n /** Label of the action row in the panel's new-tab launcher. */\n title: string;\n /**\n * Icon hint (BB icon name) used when the plugin ships no logo; the\n * launcher row and opened tabs prefer the plugin's logo.\n */\n icon?: string;\n /** Rendered inside every panel tab this action opens. */\n component: ComponentType;\n /**\n * Runs when the user activates the action: call your RPC methods, show a\n * toast, and/or open panel tabs via `context.openPanel`. Omitted =\n * immediately open a panel tab with defaults. Errors (sync or async) are\n * contained and logged; they never break the launcher.\n */\n run?(context: PluginThreadPanelActionContext): void | Promise;\n}\ninterface PluginComposerAccessoryRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n component: ComponentType;\n}\n/**\n * Register this plugin as a viewer/editor for file extensions. The user\n * picks (and can set as default) an opener per extension via the file tab's\n * \"Open with\" menu; matching files opened in the panel then render\n * `component` in a plugin tab instead of the built-in preview. Applies to\n * working-tree, host, and thread-storage files — never to git-ref snapshots\n * (diff views always use the built-in preview). The built-in preview stays\n * one menu click away, and a missing/disabled opener falls back to it.\n */\ninterface PluginFileOpenerRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n /** Label in the \"Open with\" menu (e.g. \"Notes editor\"). */\n title: string;\n /** Lowercase extensions without the dot (e.g. [\"md\", \"mdx\"]). */\n extensions: readonly string[];\n component: ComponentType;\n}\ninterface PluginAppSlots {\n homepageSection(registration: PluginHomepageSectionRegistration): void;\n navPanel(registration: PluginNavPanelRegistration): void;\n threadPanelAction(registration: PluginThreadPanelActionRegistration): void;\n composerAccessory(registration: PluginComposerAccessoryRegistration): void;\n fileOpener(registration: PluginFileOpenerRegistration): void;\n}\ninterface PluginAppBuilder {\n slots: PluginAppSlots;\n}\ntype PluginAppSetup = (app: PluginAppBuilder) => void;\n/**\n * The opaque product of `definePluginApp` — a plugin's `app.tsx` default\n * export. The host re-runs `setup` against a fresh collector on every\n * (re)interpretation, replacing that plugin's registrations wholesale.\n */\ninterface PluginAppDefinition {\n /** Brand the host checks before interpreting a bundle's default export. */\n readonly __bbPluginApp: true;\n readonly setup: PluginAppSetup;\n}\ninterface PluginRpcClient {\n /**\n * Invoke one of the plugin's `bb.rpc` methods (POST\n * /api/v1/plugins/<id>/rpc/<method>). Resolves with the method's\n * result; rejects with an `Error` carrying the server's message when the\n * handler fails or the plugin is not running.\n */\n call(method: string, input?: unknown): Promise;\n}\ninterface PluginSettingsState {\n /**\n * Effective non-secret setting values (secret settings are excluded —\n * read them server-side). Undefined while loading or unavailable.\n */\n values: Record | undefined;\n isLoading: boolean;\n}\n/** Where `useComposer()` writes: the active thread's draft or the new-thread draft. */\ntype PluginComposerScope = {\n kind: \"thread\";\n threadId: string;\n} | {\n kind: \"new-thread\";\n projectId: string | null;\n};\n/** An @-mention pill bound to one of the calling plugin's mention providers. */\ninterface PluginComposerMention {\n /** Mention provider id registered by THIS plugin via `bb.ui.registerMentionProvider`. */\n provider: string;\n /** Item id your provider's `resolve` will receive at send time. */\n id: string;\n /** Pill text shown in the composer. */\n label: string;\n}\n/**\n * Programmatic access to the chat composer draft — the same shared draft the\n * built-in \"Add to chat\" affordances (file preview, diff, terminal selections)\n * write to. Inside a thread context writes land in that thread's draft;\n * anywhere else (nav panel, homepage section) they seed the new-thread\n * composer draft, which persists until the user sends or clears it.\n */\ninterface PluginComposerApi {\n scope: PluginComposerScope;\n /**\n * Append text to the draft as a `> ` blockquote block and focus the\n * composer. Blank text is a no-op. This is the \"reference this selection\n * in chat\" primitive.\n */\n addQuote(text: string): void;\n /**\n * Insert an @-mention pill that resolves through this plugin's mention\n * provider at send time — the durable way to reference an entity whose\n * content should be fetched fresh when the message is sent.\n */\n insertMention(mention: PluginComposerMention): void;\n /** Focus the composer caret at the end of the draft. */\n focus(): void;\n}\n/** Current app selection, derived from the route. */\ninterface BbContext {\n projectId: string | null;\n threadId: string | null;\n}\ninterface BbNavigate {\n toThread(threadId: string): void;\n toProject(projectId: string): void;\n /**\n * Navigate to one of this plugin's own nav panels by its `path`.\n * `subPath` targets a location inside the panel (the component's\n * `subPath` prop); `replace` swaps the current history entry instead of\n * pushing — use it for redirects so back does not bounce.\n */\n toPluginPanel(path: string, options?: {\n subPath?: string;\n replace?: boolean;\n }): void;\n}\n/**\n * Everything `@bb/plugin-sdk/app` resolves to at runtime. The BB app builds\n * the real implementation and `satisfies` this interface; `bb plugin build`\n * shims the specifier to that object on `globalThis.__bbPluginRuntime`.\n */\ninterface PluginSdkApp {\n definePluginApp(setup: PluginAppSetup): PluginAppDefinition;\n useRpc(): PluginRpcClient;\n useRealtime(channel: string, handler: (payload: unknown) => void): void;\n useSettings(): PluginSettingsState;\n useBbContext(): BbContext;\n useBbNavigate(): BbNavigate;\n useComposer(): PluginComposerApi;\n}\n/**\n * Named runtime exports of `@bb/plugin-sdk/app`, in sorted order. Single\n * source of truth for the build shim's export list and the app's\n * implementation-key test — adding a surface member without updating this\n * list fails the type assertion below.\n */\ndeclare const PLUGIN_SDK_APP_EXPORT_NAMES: readonly [\"definePluginApp\", \"useBbContext\", \"useBbNavigate\", \"useComposer\", \"useRealtime\", \"useRpc\", \"useSettings\"];\n\ndeclare const definePluginApp: (setup: PluginAppSetup) => PluginAppDefinition;\ndeclare const useRpc: () => PluginRpcClient;\ndeclare const useRealtime: (channel: string, handler: (payload: unknown) => void) => void;\ndeclare const useSettings: () => PluginSettingsState;\ndeclare const useBbContext: () => BbContext;\ndeclare const useBbNavigate: () => BbNavigate;\ndeclare const useComposer: () => PluginComposerApi;\n\nexport { PLUGIN_SDK_APP_EXPORT_NAMES, PLUGIN_SLOT_ID_PATTERN, definePluginApp, useBbContext, useBbNavigate, useComposer, useRealtime, useRpc, useSettings };\nexport type { BbContext, BbNavigate, PluginAppBuilder, PluginAppDefinition, PluginAppSetup, PluginAppSlots, PluginComposerAccessoryProps, PluginComposerAccessoryRegistration, PluginComposerApi, PluginComposerMention, PluginComposerScope, PluginFileOpenerProps, PluginFileOpenerRegistration, PluginFileOpenerSource, PluginHomepageSectionProps, PluginHomepageSectionRegistration, PluginNavPanelProps, PluginNavPanelRegistration, PluginRpcClient, PluginSdkApp, PluginSettingsState, PluginThreadPanelActionContext, PluginThreadPanelActionRegistration, PluginThreadPanelProps };\n"; diff --git a/plans/plugin-system-qa-catalog.md b/plans/plugin-system-qa-catalog.md index efd689664..957fcd0ca 100644 --- a/plans/plugin-system-qa-catalog.md +++ b/plans/plugin-system-qa-catalog.md @@ -908,3 +908,35 @@ authoring-docs pins (`threadPanelAction: ["threadId", "params"]`). - [ ] **Old persisted state**: pre-rework localStorage with a plugin-panel fixed tab fails schema parse → that thread's panel state resets cleanly (fresh default panel, no error). + +## Files API + composer bridge + sub-routing + fileOpener + notes hero (2026-07-04) + +Four platform surfaces (PR: bb.sdk.files / useComposer / navPanel subPath / +fileOpener) plus the notes hero plugin. Automated: daemon `file-write.test.ts` +(CAS matrix, symlink containment), server `host-file-routes.test.ts` +(defaults, ENOENT remap), app `plugin-slot-mounts.test.tsx` (useComposer +quote/mention/focus bus, subPath splat, opener content + degrade), +`useThreadFileTabs.test.ts` (opener diversion, ref-snapshot skip, +missing-opener fallback, in-place switch), collector validation, authoring +docs pins, cli `notes-example-bundle.test.ts`. + +- [ ] **CAS save race live**: open a note in the notes editor, edit the file + on disk (or via an agent), save in the editor → conflict banner with + Reload/Overwrite; Reload shows the disk content. +- [ ] **Open with menu live**: open a `.md` workspace file → right-click the + tab → "Open with Notes editor" swaps the tab in place; "Always open + .md with Notes editor" then makes markdown links open in the editor; + "Open with built-in preview" switches back. +- [ ] **Opener fallback live**: set the .md default to the notes opener, + disable the plugin → opening a markdown file lands on the built-in + preview (no dead tab); the persisted opener tab shows the placeholder. +- [ ] **subPath deep link**: /plugins/notes/notes/0/.md loads with that + note selected; browser back walks note-to-note history. +- [ ] **Composer bridge live**: "Add to chat" from the notes nav panel seeds + the home composer draft (blockquote + focus); in a thread panel tab it + lands in that thread's draft; "@-mention" inserts a pill that resolves + the note at send (visible in the agent's context). +- [ ] **Crepe theming**: notes editor follows light/dark and a custom + palette (Nord) — no stranded white editor chrome. +- [ ] **Watcher refresh**: agent writes a new .md into a mounted directory → + the tree updates without a manual refresh. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0fa81ebb..021780974 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -741,7 +741,7 @@ importers: version: link:../../packages/thread-view '@better-auth/api-key': specifier: ^1.5.6 - version: 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(better-auth@1.5.6(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.2)))(better-sqlite3@12.10.0)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/react@19.2.13)(better-sqlite3@12.10.0)(kysely@0.28.15)(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(typescript@5.9.3))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.2)))) + version: 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(better-auth@1.5.6(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.2)))(better-sqlite3@12.10.0)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/react@19.2.13)(better-sqlite3@12.10.0)(kysely@0.28.15)(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(typescript@5.9.3))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.2)))(vue@3.5.39(typescript@5.9.3))) '@better-auth/drizzle-adapter': specifier: ^1.5.6 version: 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/react@19.2.13)(better-sqlite3@12.10.0)(kysely@0.28.15)(react@19.2.4)) @@ -762,7 +762,7 @@ importers: version: 4.3.0 better-auth: specifier: ^1.5.6 - version: 1.5.6(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.2)))(better-sqlite3@12.10.0)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/react@19.2.13)(better-sqlite3@12.10.0)(kysely@0.28.15)(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(typescript@5.9.3))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.2))) + version: 1.5.6(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.2)))(better-sqlite3@12.10.0)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/react@19.2.13)(better-sqlite3@12.10.0)(kysely@0.28.15)(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(typescript@5.9.3))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.2)))(vue@3.5.39(typescript@5.9.3)) better-sqlite3: specifier: 12.10.0 version: 12.10.0 @@ -901,6 +901,37 @@ importers: specifier: ^1.1.2 version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + examples/plugins/notes: + dependencies: + '@milkdown/crepe': + specifier: ^7.5.0 + version: 7.21.2(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(typescript@5.9.3) + '@radix-ui/react-slot': + specifier: ^1.2.4 + version: 1.3.0(@types/react@19.2.13)(react@19.2.4) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + tailwind-merge: + specifier: ^3.4.0 + version: 3.4.0 + devDependencies: + '@bb/plugin-sdk': + specifier: workspace:* + version: link:../../../packages/plugin-sdk + '@types/node': + specifier: ^22.0.0 + version: 22.19.10 + '@types/react': + specifier: ^19.0.0 + version: 19.2.13 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + examples/plugins/slack-bot: {} examples/plugins/small-ux-pack: {} @@ -2092,6 +2123,10 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} @@ -2113,6 +2148,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-transform-react-jsx-self@7.27.1': resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} @@ -2145,6 +2185,10 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + '@better-auth/api-key@1.5.6': resolution: {integrity: sha512-jr3m4/caFxn9BuY9pGDJ4B1HP1Qoqmyd7heBHm4KUFel+a9Whe/euROgZ/L+o7mbmUdZtreneaU15dpn0tJZ5g==} peerDependencies: @@ -2283,6 +2327,99 @@ packages: cpu: [x64] os: [win32] + '@codemirror/autocomplete@6.20.3': + resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} + + '@codemirror/commands@6.10.4': + resolution: {integrity: sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==} + + '@codemirror/lang-angular@0.1.4': + resolution: {integrity: sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==} + + '@codemirror/lang-cpp@6.0.3': + resolution: {integrity: sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==} + + '@codemirror/lang-css@6.3.1': + resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} + + '@codemirror/lang-go@6.0.1': + resolution: {integrity: sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==} + + '@codemirror/lang-html@6.4.11': + resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==} + + '@codemirror/lang-java@6.0.2': + resolution: {integrity: sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==} + + '@codemirror/lang-javascript@6.2.5': + resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==} + + '@codemirror/lang-jinja@6.0.1': + resolution: {integrity: sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw==} + + '@codemirror/lang-json@6.0.2': + resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} + + '@codemirror/lang-less@6.0.2': + resolution: {integrity: sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==} + + '@codemirror/lang-liquid@6.3.2': + resolution: {integrity: sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw==} + + '@codemirror/lang-markdown@6.5.0': + resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==} + + '@codemirror/lang-php@6.0.2': + resolution: {integrity: sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==} + + '@codemirror/lang-python@6.2.1': + resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==} + + '@codemirror/lang-rust@6.0.2': + resolution: {integrity: sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==} + + '@codemirror/lang-sass@6.0.2': + resolution: {integrity: sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==} + + '@codemirror/lang-sql@6.10.0': + resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==} + + '@codemirror/lang-vue@0.1.3': + resolution: {integrity: sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==} + + '@codemirror/lang-wast@6.0.2': + resolution: {integrity: sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==} + + '@codemirror/lang-xml@6.1.0': + resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==} + + '@codemirror/lang-yaml@6.1.3': + resolution: {integrity: sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==} + + '@codemirror/language-data@6.5.2': + resolution: {integrity: sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==} + + '@codemirror/language@6.12.4': + resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==} + + '@codemirror/legacy-modes@6.5.3': + resolution: {integrity: sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==} + + '@codemirror/lint@6.9.7': + resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==} + + '@codemirror/search@6.7.1': + resolution: {integrity: sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==} + + '@codemirror/state@6.7.0': + resolution: {integrity: sha512-Zbl9NyscLMZkfXPQnNAIIAFftidrA1UbcJEIMp24C0Bukc2I5T8wJS0wsXYsnDOqCFJUeJ1BITGNs5CqPDSmSg==} + + '@codemirror/theme-one-dark@6.1.3': + resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==} + + '@codemirror/view@6.43.5': + resolution: {integrity: sha512-7uT/vUgH6dfXWn3WqOe23KneILMvGy5wQjNMEcRXLKzziJ9NOktpW6tGoyQpwVkBgE5Gj6hKkCcsddbnkaWrOQ==} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -3499,6 +3636,57 @@ packages: react: '>=18.0.0' react-dom: '>=18.0.0' + '@lezer/common@1.5.2': + resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} + + '@lezer/cpp@1.1.6': + resolution: {integrity: sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==} + + '@lezer/css@1.3.4': + resolution: {integrity: sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q==} + + '@lezer/go@1.0.1': + resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/html@1.3.13': + resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==} + + '@lezer/java@1.1.3': + resolution: {integrity: sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==} + + '@lezer/javascript@1.5.4': + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + + '@lezer/json@1.0.3': + resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} + + '@lezer/lr@1.4.10': + resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==} + + '@lezer/markdown@1.6.4': + resolution: {integrity: sha512-N0SxazMj4k65DBfaf1azqtMZd6u7MqluP84/NZnB/io8Td9aleFmAhz9hcbvSfsxT5tdYlJ5qgv5aMJGY4zEtA==} + + '@lezer/php@1.0.5': + resolution: {integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==} + + '@lezer/python@1.1.19': + resolution: {integrity: sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==} + + '@lezer/rust@1.0.2': + resolution: {integrity: sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==} + + '@lezer/sass@1.1.0': + resolution: {integrity: sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==} + + '@lezer/xml@1.0.6': + resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==} + + '@lezer/yaml@1.0.4': + resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==} + '@malept/cross-spawn-promise@2.0.0': resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} engines: {node: '>= 12.13.0'} @@ -3507,6 +3695,9 @@ packages: resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} engines: {node: '>= 10.0.0'} + '@marijn/find-cluster-break@1.0.3': + resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==} + '@mariozechner/clipboard-darwin-arm64@0.3.3': resolution: {integrity: sha512-+zhuZGXqVrdkbIRdnwiZNbTJ7V3elq/A+C5d5laJoyhJgWs41eO5NUMkBkj6f23F2L4PRXEhdn5/ktlPx+bG3Q==} engines: {node: '>= 10'} @@ -3608,6 +3799,79 @@ packages: '@mermaid-js/parser@1.1.1': resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==} + '@milkdown/components@7.21.2': + resolution: {integrity: sha512-u3eCgRAnJgglZtJ6m5g09OF3SFPOagCe3xFvqtXRaUdX0/7E9z3hOvXkuisZNQUEF9623qwT/zNhK2rctvuFrg==} + peerDependencies: + '@codemirror/language': ^6 + '@codemirror/state': ^6 + '@codemirror/view': ^6 + + '@milkdown/core@7.21.2': + resolution: {integrity: sha512-b9xrcN9g+o05skEc4O78vGPvwJm1sbfqIPROncZH2vSE/lOIcp9AR1SdqveWmhymd/QILgTzW9WsnJfgQkGMXQ==} + + '@milkdown/crepe@7.21.2': + resolution: {integrity: sha512-3BaEADeWxVYTn6TwromOKnu/m5GLJvIKGl4VlHJ9dVpdh7VLEwLbggfw5nweQmpnGWEaqyDplk2uPH0xK5MMhg==} + + '@milkdown/ctx@7.21.2': + resolution: {integrity: sha512-dqiA0GeERv86QEde+ZvxqBy4cDmfAsfLTIpi10doFJLKLerei23TVb5lHD8/CGUVqpS995MTu3dAFRBs9C0q5Q==} + + '@milkdown/exception@7.21.2': + resolution: {integrity: sha512-/6QkN1XmEtVHe5XhPm87lCUtV9qQNRHuAhxki/wtF+10vAeq8xOBW9/S5Kv/zpS9DSgdM/Ce+BPO3+yT1FIvWg==} + + '@milkdown/kit@7.21.2': + resolution: {integrity: sha512-93E5PgJgQXTwn0N8xSmk3Y6+oVvMMQYDV3HF22ZNE8tFVRLOz3vMLJFfxncxY0CONadCpreoOT3Ut+p9JYjySA==} + + '@milkdown/plugin-block@7.21.2': + resolution: {integrity: sha512-ZAv3QSr9J8+U4u8SVPcKeVeMmSA9OrrwS6jeY4YP/k6OxPfGK7H5+i6qW73gDxXvRzTGMSyTuFOdX4tgcXnV9A==} + + '@milkdown/plugin-clipboard@7.21.2': + resolution: {integrity: sha512-Noc4rFpVzHvzjNaIKLhvzX2DEeyjWCTzDZR9k6B/0wgD0ZiZSGDnnq8op1Is1s5NjFq6UpCeZtXfs8HuLxbgFA==} + + '@milkdown/plugin-cursor@7.21.2': + resolution: {integrity: sha512-PDENEK8mZ44YTfQyR5fuqVk5ZF8CJLdYpcavcorfhtvVx+3ZpQodDz0q7o42kE17jEMXCfsL/t2gUHxOp3pPIw==} + + '@milkdown/plugin-diff@7.21.2': + resolution: {integrity: sha512-I1aLHTAcKk04MFDA/RmhRYPY4kHoSjZf9yoCxl5eNYi3PtOzJ2aoKcBi/uNbU0TG+MoJc/DKizVcaI1t4jyAWg==} + + '@milkdown/plugin-history@7.21.2': + resolution: {integrity: sha512-iymGm2fm/desC0tAukz3ubHZ7KSj0UFvHZTY8H55IzPnRLsVErnGFOZ5l/VFeHQI4xoBq+NWUBuPFFF4ApxBlw==} + + '@milkdown/plugin-indent@7.21.2': + resolution: {integrity: sha512-CY7lOWZPckjgsLPhMKa/96lNSbr1Bf5xT8sUQ+rC6Vv1PgRy63R8dTO+99xuHTmZp7XZm33cuswEW7VJuPFA4g==} + + '@milkdown/plugin-listener@7.21.2': + resolution: {integrity: sha512-CyVNlB5uyQlH7aAX1kBKF3O+2zATGwXmjWZ7qFLNyII+31Wv4jshAzxHSIBi4qrIWbqBEbT72j2OQTOCdRNLOA==} + + '@milkdown/plugin-slash@7.21.2': + resolution: {integrity: sha512-da8UxNr73UC/32BQNZnWaalj0HjZIH2zIA6k7fHEgwwSXq3/aAgPc8x80/toHyVBMeeNA0hEcEobJLvEGFYV7g==} + + '@milkdown/plugin-streaming@7.21.2': + resolution: {integrity: sha512-gWuMAz4HStGDdtQ2Al9pr35RXk2MzZonJy1ODFuzmFImLpBDAWJaEZkigFWQXijGff00IvS3nkXJSWZQBOesJg==} + + '@milkdown/plugin-tooltip@7.21.2': + resolution: {integrity: sha512-L8123LL4sv2nsSUN86dwmF9NXwH+TZFaS6bt7Dd43DjZgxb+HMfpDJCLEA9f4zrJgDUr1Tis501WZvQGzPMrTQ==} + + '@milkdown/plugin-trailing@7.21.2': + resolution: {integrity: sha512-G0hZs94ZwXsZ+skpuitLjBeAFt/3JJz4B4A5pE18xGruEZ2LAObhJ7+Y8sGgcAQvMfl3LcTbuggv3uU5Pr5xxA==} + + '@milkdown/plugin-upload@7.21.2': + resolution: {integrity: sha512-g3c0IXlZSJ9Bcg+iFzAHJ5lLHc6YHf6r1Wl8IQAIXWMh8G4p9N7yXoYknLuZGyT//7s1/mzcc/UI1Yq7kCGNWg==} + + '@milkdown/preset-commonmark@7.21.2': + resolution: {integrity: sha512-LpPltwT+2ywK0EufhEAm+YnMaTqohaIdiarDDVfvMjVz7cF3ciXOa2/SOsYK76+/vbyQaUizuIG0sooxYDpHqg==} + + '@milkdown/preset-gfm@7.21.2': + resolution: {integrity: sha512-t39W/o2KzCCNdFu9D1GSsSsuEQSCCwM5YQjj16HaHjtUryScjy4epEyXoMffXfT2FOF5oiTdBf+HukvzKFaoqA==} + + '@milkdown/prose@7.21.2': + resolution: {integrity: sha512-ufnHhRaevLbqISBpOA5Rl4+S66n+h0JRfoZw2j/WyJ1eBNHg42xoQNXUEI9YiP/GoQPKeOitKux50GpafyNtQw==} + + '@milkdown/transformer@7.21.2': + resolution: {integrity: sha512-j2VQ6baKy6b6rAo/yo00YwSsWHoW0sqgwRTDqSCwXW8dgdFtl4M2i/73exRLcRQ4MvDOHrSqGS5RcRz+fzPOeQ==} + + '@milkdown/utils@7.21.2': + resolution: {integrity: sha512-m1ZJDnh+/tnIBxLhh5euNGr1WXvsGnJkaGctsd/an5+73HqHOdtWD6L7oJF8+DQG4Kksr4WPNNZutqYGb6OAxw==} + '@mistralai/mistralai@2.2.1': resolution: {integrity: sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==} @@ -3651,6 +3915,9 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@ocavue/utils@1.7.0': + resolution: {integrity: sha512-yEk9ATNBjTZTtuVFMB/MAIF6zJBvJ2+lVNQvK2+O+ggEBGTgx2tp27d4FPgmD5bRsNHHP3D0SleQia/bvIeV8w==} + '@oozcitak/dom@2.0.2': resolution: {integrity: sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w==} engines: {node: '>=20.0'} @@ -6038,6 +6305,12 @@ packages: '@types/keyv@3.1.4': resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.24': + resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -6206,6 +6479,35 @@ packages: '@vitest/utils@4.1.1': resolution: {integrity: sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==} + '@vue/compiler-core@3.5.39': + resolution: {integrity: sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==} + + '@vue/compiler-dom@3.5.39': + resolution: {integrity: sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==} + + '@vue/compiler-sfc@3.5.39': + resolution: {integrity: sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==} + + '@vue/compiler-ssr@3.5.39': + resolution: {integrity: sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==} + + '@vue/reactivity@3.5.39': + resolution: {integrity: sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==} + + '@vue/runtime-core@3.5.39': + resolution: {integrity: sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==} + + '@vue/runtime-dom@3.5.39': + resolution: {integrity: sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==} + + '@vue/server-renderer@3.5.39': + resolution: {integrity: sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==} + peerDependencies: + vue: 3.5.39 + + '@vue/shared@3.5.39': + resolution: {integrity: sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==} + '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} @@ -6687,6 +6989,9 @@ packages: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + codemirror@6.0.2: + resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} @@ -6795,6 +7100,9 @@ packages: crc@3.8.0: resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==} + crelt@1.0.7: + resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} + cron-parser@5.5.0: resolution: {integrity: sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==} engines: {node: '>=18'} @@ -7344,6 +7652,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} @@ -7514,6 +7826,9 @@ packages: estree-util-visit@2.0.0: resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -8314,6 +8629,10 @@ packages: resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} hasBin: true + katex@0.17.0: + resolution: {integrity: sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==} + hasBin: true + keygrip@1.1.0: resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} engines: {node: '>= 0.6'} @@ -8539,6 +8858,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdast-util-definitions@6.0.0: + resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==} + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -8819,6 +9141,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + nanoid@5.1.6: resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} engines: {node: ^18 || >=20} @@ -9134,6 +9461,10 @@ packages: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + posthog-js@1.386.6: resolution: {integrity: sha512-xRcbToRtU07Ojk+VaCCos6I/zD60sNKfWxdeZih0xbncgegIqLZJmBzi4HdkSkXrf14b6HhwMI/s2GxWha5ADQ==} @@ -9207,6 +9538,9 @@ packages: prosemirror-commands@1.7.1: resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==} + prosemirror-drop-indicator@0.1.4: + resolution: {integrity: sha512-YaRB1pZmU5GCorPVWbc9dbhbwqr4iMBO/AjPu4BTKHCUzxEDUXje2dUyoxOHib/z4uyPUZTJz64h7mHDJZeSzA==} + prosemirror-dropcursor@1.8.2: resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==} @@ -9225,6 +9559,9 @@ packages: prosemirror-model@1.25.7: resolution: {integrity: sha512-A79aN8QEFUwI6cax8Yq4Rpcx1TJZ3Kagn+ii7qLo4/V8H3mMiHrhFyhTyHHvpSnOgMPpWiDGSwM3etwrxE50ug==} + prosemirror-safari-ime-span@1.0.2: + resolution: {integrity: sha512-QJqD8s1zE/CuK56kDsUhndh5hiHh/gFnAuPOA9ytva2s85/ZEt2tNWeALTJN48DtWghSKOmiBsvVn2OlnJ5H2w==} + prosemirror-schema-list@1.5.1: resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} @@ -9240,6 +9577,20 @@ packages: prosemirror-view@1.41.8: resolution: {integrity: sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==} + prosemirror-virtual-cursor@0.4.2: + resolution: {integrity: sha512-pUMKnIuOhhnMcgIJUjhIQTVJruBEGxfMBVQSrK0g2qhGPDm1i12KdsVaFw15dYk+29tZcxjMeR7P5VDKwmbwJg==} + peerDependencies: + prosemirror-model: ^1.0.0 + prosemirror-state: ^1.0.0 + prosemirror-view: ^1.0.0 + peerDependenciesMeta: + prosemirror-model: + optional: true + prosemirror-state: + optional: true + prosemirror-view: + optional: true + protobufjs@7.5.4: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} engines: {node: '>=12.0.0'} @@ -9490,6 +9841,9 @@ packages: remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + remark-inline-links@7.0.0: + resolution: {integrity: sha512-4uj1pPM+F495ySZhTIB6ay2oSkTsKgmYaKk/q5HIdhX2fuyLEegpjWa0VdJRJ01sgOqAFo7MBKdDUejIYBMVMQ==} + remark-math@6.0.0: resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==} @@ -9505,6 +9859,9 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + remark@15.0.1: + resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -9895,6 +10252,9 @@ packages: resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} engines: {node: '>=18'} + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -10440,6 +10800,14 @@ packages: jsdom: optional: true + vue@3.5.39: + resolution: {integrity: sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -11217,6 +11585,8 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} '@babel/helper-validator-identifier@7.29.7': {} @@ -11232,6 +11602,10 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -11269,11 +11643,16 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@better-auth/api-key@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(better-auth@1.5.6(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.2)))(better-sqlite3@12.10.0)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/react@19.2.13)(better-sqlite3@12.10.0)(kysely@0.28.15)(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(typescript@5.9.3))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.2))))': + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@better-auth/api-key@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(better-auth@1.5.6(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.2)))(better-sqlite3@12.10.0)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/react@19.2.13)(better-sqlite3@12.10.0)(kysely@0.28.15)(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(typescript@5.9.3))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.2)))(vue@3.5.39(typescript@5.9.3)))': dependencies: '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0) '@better-auth/utils': 0.3.1 - better-auth: 1.5.6(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.2)))(better-sqlite3@12.10.0)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/react@19.2.13)(better-sqlite3@12.10.0)(kysely@0.28.15)(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(typescript@5.9.3))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.2))) + better-auth: 1.5.6(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.2)))(better-sqlite3@12.10.0)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/react@19.2.13)(better-sqlite3@12.10.0)(kysely@0.28.15)(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(typescript@5.9.3))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.2)))(vue@3.5.39(typescript@5.9.3)) zod: 4.3.6 '@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0)': @@ -11362,6 +11741,264 @@ snapshots: '@cloudflare/workerd-windows-64@1.20260611.1': optional: true + '@codemirror/autocomplete@6.20.3': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.0 + '@codemirror/view': 6.43.5 + '@lezer/common': 1.5.2 + + '@codemirror/commands@6.10.4': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.0 + '@codemirror/view': 6.43.5 + '@lezer/common': 1.5.2 + + '@codemirror/lang-angular@0.1.4': + dependencies: + '@codemirror/lang-html': 6.4.11 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.4 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-cpp@6.0.3': + dependencies: + '@codemirror/language': 6.12.4 + '@lezer/cpp': 1.1.6 + + '@codemirror/lang-css@6.3.1': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.0 + '@lezer/common': 1.5.2 + '@lezer/css': 1.3.4 + + '@codemirror/lang-go@6.0.1': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.0 + '@lezer/common': 1.5.2 + '@lezer/go': 1.0.1 + + '@codemirror/lang-html@6.4.11': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.0 + '@codemirror/view': 6.43.5 + '@lezer/common': 1.5.2 + '@lezer/css': 1.3.4 + '@lezer/html': 1.3.13 + + '@codemirror/lang-java@6.0.2': + dependencies: + '@codemirror/language': 6.12.4 + '@lezer/java': 1.1.3 + + '@codemirror/lang-javascript@6.2.5': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/lint': 6.9.7 + '@codemirror/state': 6.7.0 + '@codemirror/view': 6.43.5 + '@lezer/common': 1.5.2 + '@lezer/javascript': 1.5.4 + + '@codemirror/lang-jinja@6.0.1': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.0 + '@codemirror/view': 6.43.5 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-json@6.0.2': + dependencies: + '@codemirror/language': 6.12.4 + '@lezer/json': 1.0.3 + + '@codemirror/lang-less@6.0.2': + dependencies: + '@codemirror/lang-css': 6.3.1 + '@codemirror/language': 6.12.4 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-liquid@6.3.2': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.0 + '@codemirror/view': 6.43.5 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-markdown@6.5.0': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.0 + '@codemirror/view': 6.43.5 + '@lezer/common': 1.5.2 + '@lezer/markdown': 1.6.4 + + '@codemirror/lang-php@6.0.2': + dependencies: + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.0 + '@lezer/common': 1.5.2 + '@lezer/php': 1.0.5 + + '@codemirror/lang-python@6.2.1': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.0 + '@lezer/common': 1.5.2 + '@lezer/python': 1.1.19 + + '@codemirror/lang-rust@6.0.2': + dependencies: + '@codemirror/language': 6.12.4 + '@lezer/rust': 1.0.2 + + '@codemirror/lang-sass@6.0.2': + dependencies: + '@codemirror/lang-css': 6.3.1 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.0 + '@lezer/common': 1.5.2 + '@lezer/sass': 1.1.0 + + '@codemirror/lang-sql@6.10.0': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.0 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-vue@0.1.3': + dependencies: + '@codemirror/lang-html': 6.4.11 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.4 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-wast@6.0.2': + dependencies: + '@codemirror/language': 6.12.4 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-xml@6.1.0': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.0 + '@codemirror/view': 6.43.5 + '@lezer/common': 1.5.2 + '@lezer/xml': 1.0.6 + + '@codemirror/lang-yaml@6.1.3': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.0 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + '@lezer/yaml': 1.0.4 + + '@codemirror/language-data@6.5.2': + dependencies: + '@codemirror/lang-angular': 0.1.4 + '@codemirror/lang-cpp': 6.0.3 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-go': 6.0.1 + '@codemirror/lang-html': 6.4.11 + '@codemirror/lang-java': 6.0.2 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/lang-jinja': 6.0.1 + '@codemirror/lang-json': 6.0.2 + '@codemirror/lang-less': 6.0.2 + '@codemirror/lang-liquid': 6.3.2 + '@codemirror/lang-markdown': 6.5.0 + '@codemirror/lang-php': 6.0.2 + '@codemirror/lang-python': 6.2.1 + '@codemirror/lang-rust': 6.0.2 + '@codemirror/lang-sass': 6.0.2 + '@codemirror/lang-sql': 6.10.0 + '@codemirror/lang-vue': 0.1.3 + '@codemirror/lang-wast': 6.0.2 + '@codemirror/lang-xml': 6.1.0 + '@codemirror/lang-yaml': 6.1.3 + '@codemirror/language': 6.12.4 + '@codemirror/legacy-modes': 6.5.3 + + '@codemirror/language@6.12.4': + dependencies: + '@codemirror/state': 6.7.0 + '@codemirror/view': 6.43.5 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + style-mod: 4.1.3 + + '@codemirror/legacy-modes@6.5.3': + dependencies: + '@codemirror/language': 6.12.4 + + '@codemirror/lint@6.9.7': + dependencies: + '@codemirror/state': 6.7.0 + '@codemirror/view': 6.43.5 + crelt: 1.0.7 + + '@codemirror/search@6.7.1': + dependencies: + '@codemirror/state': 6.7.0 + '@codemirror/view': 6.43.5 + crelt: 1.0.7 + + '@codemirror/state@6.7.0': + dependencies: + '@marijn/find-cluster-break': 1.0.3 + + '@codemirror/theme-one-dark@6.1.3': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.0 + '@codemirror/view': 6.43.5 + '@lezer/highlight': 1.2.3 + + '@codemirror/view@6.43.5': + dependencies: + '@codemirror/state': 6.7.0 + crelt: 1.0.7 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + '@colors/colors@1.5.0': optional: true @@ -12309,6 +12946,99 @@ snapshots: - typescript - yaml + '@lezer/common@1.5.2': {} + + '@lezer/cpp@1.1.6': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/css@1.3.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/go@1.0.1': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/html@1.3.13': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/java@1.1.3': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/javascript@1.5.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/json@1.0.3': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/lr@1.4.10': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/markdown@1.6.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + + '@lezer/php@1.0.5': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/python@1.1.19': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/rust@1.0.2': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/sass@1.1.0': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/xml@1.0.6': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/yaml@1.0.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + '@malept/cross-spawn-promise@2.0.0': dependencies: cross-spawn: 7.0.6 @@ -12322,6 +13052,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@marijn/find-cluster-break@1.0.3': {} + '@mariozechner/clipboard-darwin-arm64@0.3.3': optional: true @@ -12512,6 +13244,289 @@ snapshots: dependencies: '@chevrotain/types': 11.1.2 + '@milkdown/components@7.21.2(@codemirror/language@6.12.4)(@codemirror/state@6.7.0)(@codemirror/view@6.43.5)(typescript@5.9.3)': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.0 + '@codemirror/view': 6.43.5 + '@floating-ui/dom': 1.7.5 + '@milkdown/core': 7.21.2 + '@milkdown/ctx': 7.21.2 + '@milkdown/exception': 7.21.2 + '@milkdown/plugin-diff': 7.21.2 + '@milkdown/plugin-tooltip': 7.21.2 + '@milkdown/preset-commonmark': 7.21.2 + '@milkdown/preset-gfm': 7.21.2 + '@milkdown/prose': 7.21.2 + '@milkdown/transformer': 7.21.2 + '@milkdown/utils': 7.21.2 + '@types/lodash-es': 4.17.12 + clsx: 2.1.1 + dompurify: 3.4.9 + lodash-es: 4.18.1 + nanoid: 5.1.6 + unist-util-visit: 5.1.0 + vue: 3.5.39(typescript@5.9.3) + transitivePeerDependencies: + - supports-color + - typescript + + '@milkdown/core@7.21.2': + dependencies: + '@milkdown/ctx': 7.21.2 + '@milkdown/exception': 7.21.2 + '@milkdown/prose': 7.21.2 + '@milkdown/transformer': 7.21.2 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + '@milkdown/crepe@7.21.2(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(typescript@5.9.3)': + dependencies: + '@codemirror/commands': 6.10.4 + '@codemirror/language': 6.12.4 + '@codemirror/language-data': 6.5.2 + '@codemirror/state': 6.7.0 + '@codemirror/theme-one-dark': 6.1.3 + '@codemirror/view': 6.43.5 + '@milkdown/kit': 7.21.2(@codemirror/language@6.12.4)(@codemirror/state@6.7.0)(@codemirror/view@6.43.5)(typescript@5.9.3) + '@types/lodash-es': 4.17.12 + clsx: 2.1.1 + codemirror: 6.0.2 + dompurify: 3.4.9 + katex: 0.17.0 + lodash-es: 4.18.1 + prosemirror-virtual-cursor: 0.4.2(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8) + remark-math: 6.0.0 + unist-util-visit: 5.1.0 + vue: 3.5.39(typescript@5.9.3) + transitivePeerDependencies: + - prosemirror-model + - prosemirror-state + - prosemirror-view + - supports-color + - typescript + + '@milkdown/ctx@7.21.2': + dependencies: + '@milkdown/exception': 7.21.2 + + '@milkdown/exception@7.21.2': {} + + '@milkdown/kit@7.21.2(@codemirror/language@6.12.4)(@codemirror/state@6.7.0)(@codemirror/view@6.43.5)(typescript@5.9.3)': + dependencies: + '@milkdown/components': 7.21.2(@codemirror/language@6.12.4)(@codemirror/state@6.7.0)(@codemirror/view@6.43.5)(typescript@5.9.3) + '@milkdown/core': 7.21.2 + '@milkdown/ctx': 7.21.2 + '@milkdown/exception': 7.21.2 + '@milkdown/plugin-block': 7.21.2 + '@milkdown/plugin-clipboard': 7.21.2 + '@milkdown/plugin-cursor': 7.21.2 + '@milkdown/plugin-diff': 7.21.2 + '@milkdown/plugin-history': 7.21.2 + '@milkdown/plugin-indent': 7.21.2 + '@milkdown/plugin-listener': 7.21.2 + '@milkdown/plugin-slash': 7.21.2 + '@milkdown/plugin-streaming': 7.21.2 + '@milkdown/plugin-tooltip': 7.21.2 + '@milkdown/plugin-trailing': 7.21.2 + '@milkdown/plugin-upload': 7.21.2 + '@milkdown/preset-commonmark': 7.21.2 + '@milkdown/preset-gfm': 7.21.2 + '@milkdown/prose': 7.21.2 + '@milkdown/transformer': 7.21.2 + '@milkdown/utils': 7.21.2 + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + - supports-color + - typescript + + '@milkdown/plugin-block@7.21.2': + dependencies: + '@floating-ui/dom': 1.7.5 + '@milkdown/core': 7.21.2 + '@milkdown/ctx': 7.21.2 + '@milkdown/prose': 7.21.2 + '@milkdown/utils': 7.21.2 + '@types/lodash-es': 4.17.12 + lodash-es: 4.18.1 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-clipboard@7.21.2': + dependencies: + '@milkdown/core': 7.21.2 + '@milkdown/ctx': 7.21.2 + '@milkdown/prose': 7.21.2 + '@milkdown/utils': 7.21.2 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-cursor@7.21.2': + dependencies: + '@milkdown/ctx': 7.21.2 + '@milkdown/prose': 7.21.2 + '@milkdown/utils': 7.21.2 + prosemirror-drop-indicator: 0.1.4 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-diff@7.21.2': + dependencies: + '@milkdown/core': 7.21.2 + '@milkdown/ctx': 7.21.2 + '@milkdown/prose': 7.21.2 + '@milkdown/transformer': 7.21.2 + '@milkdown/utils': 7.21.2 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-history@7.21.2': + dependencies: + '@milkdown/core': 7.21.2 + '@milkdown/ctx': 7.21.2 + '@milkdown/prose': 7.21.2 + '@milkdown/utils': 7.21.2 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-indent@7.21.2': + dependencies: + '@milkdown/ctx': 7.21.2 + '@milkdown/prose': 7.21.2 + '@milkdown/utils': 7.21.2 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-listener@7.21.2': + dependencies: + '@milkdown/core': 7.21.2 + '@milkdown/ctx': 7.21.2 + '@milkdown/prose': 7.21.2 + '@types/lodash-es': 4.17.12 + lodash-es: 4.18.1 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-slash@7.21.2': + dependencies: + '@floating-ui/dom': 1.7.5 + '@milkdown/ctx': 7.21.2 + '@milkdown/prose': 7.21.2 + '@milkdown/utils': 7.21.2 + '@types/lodash-es': 4.17.12 + lodash-es: 4.18.1 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-streaming@7.21.2': + dependencies: + '@milkdown/core': 7.21.2 + '@milkdown/ctx': 7.21.2 + '@milkdown/plugin-diff': 7.21.2 + '@milkdown/prose': 7.21.2 + '@milkdown/utils': 7.21.2 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-tooltip@7.21.2': + dependencies: + '@floating-ui/dom': 1.7.5 + '@milkdown/ctx': 7.21.2 + '@milkdown/prose': 7.21.2 + '@milkdown/utils': 7.21.2 + '@types/lodash-es': 4.17.12 + lodash-es: 4.18.1 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-trailing@7.21.2': + dependencies: + '@milkdown/ctx': 7.21.2 + '@milkdown/prose': 7.21.2 + '@milkdown/utils': 7.21.2 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-upload@7.21.2': + dependencies: + '@milkdown/core': 7.21.2 + '@milkdown/ctx': 7.21.2 + '@milkdown/exception': 7.21.2 + '@milkdown/prose': 7.21.2 + '@milkdown/utils': 7.21.2 + transitivePeerDependencies: + - supports-color + + '@milkdown/preset-commonmark@7.21.2': + dependencies: + '@milkdown/core': 7.21.2 + '@milkdown/ctx': 7.21.2 + '@milkdown/exception': 7.21.2 + '@milkdown/prose': 7.21.2 + '@milkdown/transformer': 7.21.2 + '@milkdown/utils': 7.21.2 + remark-inline-links: 7.0.0 + unist-util-visit: 5.1.0 + unist-util-visit-parents: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@milkdown/preset-gfm@7.21.2': + dependencies: + '@milkdown/core': 7.21.2 + '@milkdown/ctx': 7.21.2 + '@milkdown/exception': 7.21.2 + '@milkdown/preset-commonmark': 7.21.2 + '@milkdown/prose': 7.21.2 + '@milkdown/transformer': 7.21.2 + '@milkdown/utils': 7.21.2 + prosemirror-safari-ime-span: 1.0.2 + remark-gfm: 4.0.1 + transitivePeerDependencies: + - supports-color + + '@milkdown/prose@7.21.2': + dependencies: + '@milkdown/exception': 7.21.2 + prosemirror-changeset: 2.4.1 + prosemirror-commands: 1.7.1 + prosemirror-dropcursor: 1.8.2 + prosemirror-gapcursor: 1.4.1 + prosemirror-history: 1.5.0 + prosemirror-inputrules: 1.5.1 + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.7 + prosemirror-schema-list: 1.5.1 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + + '@milkdown/transformer@7.21.2': + dependencies: + '@milkdown/exception': 7.21.2 + '@milkdown/prose': 7.21.2 + remark: 15.0.1 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + '@milkdown/utils@7.21.2': + dependencies: + '@milkdown/core': 7.21.2 + '@milkdown/ctx': 7.21.2 + '@milkdown/exception': 7.21.2 + '@milkdown/prose': 7.21.2 + '@milkdown/transformer': 7.21.2 + nanoid: 5.1.6 + transitivePeerDependencies: + - supports-color + '@mistralai/mistralai@2.2.1': dependencies: ws: 8.19.0 @@ -12575,6 +13590,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@ocavue/utils@1.7.0': {} + '@oozcitak/dom@2.0.2': dependencies: '@oozcitak/infra': 2.0.2 @@ -14968,6 +15985,12 @@ snapshots: dependencies: '@types/node': 24.12.4 + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.24 + + '@types/lodash@4.17.24': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -15184,6 +16207,60 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.0.3 + '@vue/compiler-core@3.5.39': + dependencies: + '@babel/parser': 7.29.7 + '@vue/shared': 3.5.39 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.39': + dependencies: + '@vue/compiler-core': 3.5.39 + '@vue/shared': 3.5.39 + + '@vue/compiler-sfc@3.5.39': + dependencies: + '@babel/parser': 7.29.7 + '@vue/compiler-core': 3.5.39 + '@vue/compiler-dom': 3.5.39 + '@vue/compiler-ssr': 3.5.39 + '@vue/shared': 3.5.39 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.15 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.39': + dependencies: + '@vue/compiler-dom': 3.5.39 + '@vue/shared': 3.5.39 + + '@vue/reactivity@3.5.39': + dependencies: + '@vue/shared': 3.5.39 + + '@vue/runtime-core@3.5.39': + dependencies: + '@vue/reactivity': 3.5.39 + '@vue/shared': 3.5.39 + + '@vue/runtime-dom@3.5.39': + dependencies: + '@vue/reactivity': 3.5.39 + '@vue/runtime-core': 3.5.39 + '@vue/shared': 3.5.39 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.39(vue@3.5.39(typescript@5.9.3))': + dependencies: + '@vue/compiler-ssr': 3.5.39 + '@vue/shared': 3.5.39 + vue: 3.5.39(typescript@5.9.3) + + '@vue/shared@3.5.39': {} + '@xmldom/xmldom@0.8.13': {} '@xmldom/xmldom@0.9.10': @@ -15373,7 +16450,7 @@ snapshots: bcp-47-match@2.0.3: {} - better-auth@1.5.6(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.2)))(better-sqlite3@12.10.0)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/react@19.2.13)(better-sqlite3@12.10.0)(kysely@0.28.15)(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(typescript@5.9.3))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.2))): + better-auth@1.5.6(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.2)))(better-sqlite3@12.10.0)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/react@19.2.13)(better-sqlite3@12.10.0)(kysely@0.28.15)(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(typescript@5.9.3))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.2)))(vue@3.5.39(typescript@5.9.3)): dependencies: '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0) '@better-auth/drizzle-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/react@19.2.13)(better-sqlite3@12.10.0)(kysely@0.28.15)(react@19.2.4)) @@ -15399,6 +16476,7 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) vitest: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(typescript@5.9.3))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.2)) + vue: 3.5.39(typescript@5.9.3) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -15671,6 +16749,16 @@ snapshots: co@4.6.0: {} + codemirror@6.0.2: + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/commands': 6.10.4 + '@codemirror/language': 6.12.4 + '@codemirror/lint': 6.9.7 + '@codemirror/search': 6.7.1 + '@codemirror/state': 6.7.0 + '@codemirror/view': 6.43.5 + collapse-white-space@2.1.0: {} color-convert@2.0.1: @@ -15752,6 +16840,8 @@ snapshots: buffer: 5.7.1 optional: true + crelt@1.0.7: {} + cron-parser@5.5.0: dependencies: luxon: 3.7.2 @@ -16280,6 +17370,8 @@ snapshots: entities@6.0.1: {} + entities@7.0.1: {} + entities@8.0.0: optional: true @@ -16598,6 +17690,8 @@ snapshots: '@types/estree-jsx': 1.0.5 '@types/unist': 3.0.3 + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -17573,6 +18667,10 @@ snapshots: dependencies: commander: 8.3.0 + katex@0.17.0: + dependencies: + commander: 8.3.0 + keygrip@1.1.0: dependencies: tsscmp: 1.0.6 @@ -17761,6 +18859,12 @@ snapshots: math-intrinsics@1.1.0: {} + mdast-util-definitions@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -18378,6 +19482,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@3.3.15: {} + nanoid@5.1.6: {} nanostores@1.2.0: {} @@ -18704,6 +19810,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.15: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + posthog-js@1.386.6: dependencies: '@posthog/core': 1.32.3 @@ -18794,6 +19906,13 @@ snapshots: prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 + prosemirror-drop-indicator@0.1.4: + dependencies: + '@ocavue/utils': 1.7.0 + prosemirror-model: 1.25.7 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.8 + prosemirror-dropcursor@1.8.2: dependencies: prosemirror-state: 1.4.4 @@ -18828,6 +19947,11 @@ snapshots: dependencies: orderedmap: 2.1.1 + prosemirror-safari-ime-span@1.0.2: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.8 + prosemirror-schema-list@1.5.1: dependencies: prosemirror-model: 1.25.7 @@ -18858,6 +19982,12 @@ snapshots: prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 + prosemirror-virtual-cursor@0.4.2(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8): + optionalDependencies: + prosemirror-model: 1.25.7 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.8 + protobufjs@7.5.4: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -19179,6 +20309,12 @@ snapshots: transitivePeerDependencies: - supports-color + remark-inline-links@7.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-definitions: 6.0.0 + unist-util-visit: 5.1.0 + remark-math@6.0.0: dependencies: '@types/mdast': 4.0.4 @@ -19218,6 +20354,15 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + remark@15.0.1: + dependencies: + '@types/mdast': 4.0.4 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -19695,6 +20840,8 @@ snapshots: dependencies: '@tokenizer/token': 0.3.0 + style-mod@4.1.3: {} + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -20264,6 +21411,16 @@ snapshots: transitivePeerDependencies: - msw + vue@3.5.39(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.39 + '@vue/compiler-sfc': 3.5.39 + '@vue/runtime-dom': 3.5.39 + '@vue/server-renderer': 3.5.39(vue@3.5.39(typescript@5.9.3)) + '@vue/shared': 3.5.39 + optionalDependencies: + typescript: 5.9.3 + w3c-keyname@2.2.8: {} w3c-xmlserializer@5.0.0: From fa1ab9eaf03cc5f3c416d168cbf95430892f960b Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Sat, 4 Jul 2026 09:29:41 -0700 Subject: [PATCH 6/8] fix: notes plugin inlines Crepe's bare-specifier CSS imports The served crepe.css left @milkdown/kit/... @imports un-inlined (they 404 against the http route in a browser); resolve them through Crepe's own package context and inline recursively. Verified live: 94KB fully-inlined stylesheet, editor renders with ProseMirror base styles. Co-Authored-By: Claude Fable 5 --- examples/plugins/notes/server.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/examples/plugins/notes/server.ts b/examples/plugins/notes/server.ts index 4863e2203..7476da9d4 100644 --- a/examples/plugins/notes/server.ts +++ b/examples/plugins/notes/server.ts @@ -76,7 +76,9 @@ function requireRelativeNotePath(value: unknown): string { async function loadCrepeCss(): Promise { const require = createRequire(import.meta.url); const entry = require.resolve("@milkdown/crepe/theme/common/style.css"); - const dir = path.dirname(entry); + // Bare-specifier imports (e.g. @milkdown/kit/...) are Crepe's own deps — + // resolve them from Crepe's package context, not the plugin's. + const crepeRequire = createRequire(entry); const seen = new Set(); const inline = async (file: string): Promise => { if (seen.has(file)) return ""; @@ -84,14 +86,21 @@ async function loadCrepeCss(): Promise { const source = await readFile(file, "utf8"); const parts = await Promise.all( source.split("\n").map(async (line) => { - const match = /^@import\s+['"]\.\/(.+\.css)['"];/.exec(line.trim()); + const match = /^@import\s+['"](.+\.css)['"];/.exec(line.trim()); if (!match?.[1]) return line; - return inline(path.join(path.dirname(file), match[1])); + const specifier = match[1]; + // Relative imports resolve against the importing file; bare + // specifiers (e.g. @milkdown/kit/prose/view/style/prosemirror.css) + // resolve through node — both end up inlined. + const resolved = specifier.startsWith(".") + ? path.join(path.dirname(file), specifier) + : crepeRequire.resolve(specifier); + return inline(resolved); }), ); return parts.join("\n"); }; - return inline(path.join(dir, "style.css")); + return inline(entry); } export default async function plugin(bb: BbPluginApi) { From c30eaf6ca744c06455ffd1564a3aa85bf5b8a53a Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Sat, 4 Jul 2026 10:13:02 -0700 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20rework=20Open-with=20UX=20=E2=80=94?= =?UTF-8?q?=20right-click=20file=20links=20+=20Settings=20defaults?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-open viewer choice moves from the tab pill to a right-click context menu on rendered-markdown file links (built-in preview / matching plugin openers, threaded through the link-resolution pipeline as an openTab viewer override). Per-extension defaults move to a Settings → File openers section (one dropdown per extension any installed opener claims). The tab-pill context menu and its in-place tab replacement plumbing are removed. Co-Authored-By: Claude Fable 5 --- .../src/components/plugin/file-opener-tabs.ts | 29 ++- .../components/plugin/useOpenWithTabMenu.ts | 233 ------------------ .../SecondaryPanelTabStrip.tsx | 36 +-- .../secondary-panel/secondaryPanelFileTab.ts | 11 - .../secondary-panel/useThreadFileTabs.test.ts | 62 +++-- .../secondary-panel/useThreadFileTabs.ts | 36 +-- .../FileOpenersSettingsSection.test.tsx | 75 ++++++ .../settings/FileOpenersSettingsSection.tsx | 153 ++++++++++++ .../components/ui/markdown-link-routing.ts | 16 ++ .../components/ui/markdown-preview.test.tsx | 55 +++++ .../src/components/ui/markdown-preview.tsx | 29 ++- apps/app/src/views/RootComposeView.tsx | 10 +- apps/app/src/views/SettingsView.tsx | 3 + .../views/thread-detail/ThreadDetailView.tsx | 115 ++++++--- .../useThreadSecondaryPanelVisibility.ts | 23 +- .../bb-plugin-authoring/SKILL.md | 11 +- examples/plugins/notes/README.md | 8 +- .../src/generated/templates.generated.ts | 2 +- .../src/templates/bb-guide-plugins.md | 5 +- plans/plugin-system-design.md | 2 +- plans/plugin-system-qa-catalog.md | 9 +- 21 files changed, 523 insertions(+), 400 deletions(-) delete mode 100644 apps/app/src/components/plugin/useOpenWithTabMenu.ts create mode 100644 apps/app/src/components/settings/FileOpenersSettingsSection.test.tsx create mode 100644 apps/app/src/components/settings/FileOpenersSettingsSection.tsx diff --git a/apps/app/src/components/plugin/file-opener-tabs.ts b/apps/app/src/components/plugin/file-opener-tabs.ts index 94d500dc9..e8027528f 100644 --- a/apps/app/src/components/plugin/file-opener-tabs.ts +++ b/apps/app/src/components/plugin/file-opener-tabs.ts @@ -75,6 +75,15 @@ export function parseFileOpenerParams( }; } +/** + * A per-open viewer choice (the link context menu): "builtin" pins the + * built-in preview; an opener ref forces that plugin opener. Absent = + * follow the per-extension default. + */ +export type FileTabViewerOverride = + | "builtin" + | { pluginId: string; openerId: string }; + export interface CreateFileOpenerTabForRequestArgs { fileOpeners: readonly PluginFileOpenerSlot[]; preference: FileOpenerPreferenceMap; @@ -82,6 +91,7 @@ export interface CreateFileOpenerTabForRequestArgs { request: OpenSecondaryPanelTabRequest; resolvedEnvironmentId: string | null | undefined; threadId: string | null | undefined; + viewer?: FileTabViewerOverride; } /** @@ -97,7 +107,9 @@ export function createFileOpenerTabForRequest({ request, resolvedEnvironmentId, threadId, + viewer, }: CreateFileOpenerTabForRequestArgs): PluginPanelFixedPanelTab | null { + if (viewer === "builtin") return null; const file = fileForOpenRequest({ projectId, request, @@ -105,11 +117,18 @@ export function createFileOpenerTabForRequest({ threadId, }); if (file === null) return null; - const opener = resolvePreferredFileOpener({ - openers: fileOpeners, - preference, - path: file.path, - }); + const opener = + viewer !== undefined + ? (fileOpeners.find( + (candidate) => + candidate.pluginId === viewer.pluginId && + candidate.id === viewer.openerId, + ) ?? null) + : resolvePreferredFileOpener({ + openers: fileOpeners, + preference, + path: file.path, + }); if (opener === null) return null; return buildFileOpenerPanelTab(opener, file); } diff --git a/apps/app/src/components/plugin/useOpenWithTabMenu.ts b/apps/app/src/components/plugin/useOpenWithTabMenu.ts deleted file mode 100644 index 5a2e5fb45..000000000 --- a/apps/app/src/components/plugin/useOpenWithTabMenu.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { useCallback } from "react"; -import type { PluginFileOpenerProps } from "@bb/plugin-sdk"; -import { - createHostFilePreviewFixedPanelTab, - createThreadStorageFilePreviewFixedPanelTab, - createWorkspaceFilePreviewFixedPanelTab, - type FixedPanelTab, -} from "@/lib/fixed-panel-tabs-state"; -import { - buildFileOpenerRef, - findFileOpenersForPath, - getFileExtension, - resolvePreferredFileOpener, - useFileOpenerPreference, -} from "@/lib/file-opener-preference"; -import { usePluginSlots } from "@/lib/plugin-slots"; -import type { SecondaryPanelTabMenuItem } from "@/components/secondary-panel/secondaryPanelFileTab"; -import { - buildFileOpenerPanelTab, - fileOpenerIdFromActionId, - parseFileOpenerParams, -} from "./file-opener-tabs"; - -/** - * The "Open with" context menu for file tabs: switch the tab between the - * built-in preview and registered plugin `fileOpener`s, and pin the current - * viewer as the extension's default. Returns undefined (no menu) for tabs - * that aren't live file content or have no alternative viewer. - */ - -interface OpenWithFile { - file: PluginFileOpenerProps; - /** null = the built-in preview. */ - currentOpenerRef: string | null; -} - -function fileFromTab(tab: FixedPanelTab): OpenWithFile | null { - switch (tab.kind) { - case "workspace-file-preview": - // Live working-tree content only — ref snapshots and deleted files - // stay on the built-in preview (nothing on disk to edit). - if (tab.source.kind !== "working-tree" || tab.statusLabel === "deleted") { - return null; - } - return { - currentOpenerRef: null, - file: { - path: tab.path, - source: { - kind: "workspace", - threadId: null, - environmentId: tab.environmentId, - projectId: tab.projectId, - }, - }, - }; - case "host-file-preview": - return { - currentOpenerRef: null, - file: { - path: tab.path, - source: { - kind: "host", - threadId: tab.threadId, - environmentId: tab.environmentId, - projectId: null, - }, - }, - }; - case "thread-storage-file-preview": - if (tab.isPinned) return null; - return { - currentOpenerRef: null, - file: { - path: tab.path, - source: { - kind: "thread-storage", - threadId: tab.threadId, - environmentId: tab.environmentId, - projectId: null, - }, - }, - }; - case "plugin-panel": { - const openerId = fileOpenerIdFromActionId(tab.actionId); - if (openerId === null) return null; - const file = parseFileOpenerParams(tab.paramsJson); - if (file === null) return null; - return { - currentOpenerRef: buildFileOpenerRef({ - pluginId: tab.pluginId, - id: openerId, - }), - file, - }; - } - default: - return null; - } -} - -function buildBuiltinTab(file: PluginFileOpenerProps): FixedPanelTab | null { - const { path, source } = file; - switch (source.kind) { - case "workspace": - return createWorkspaceFilePreviewFixedPanelTab({ - environmentId: source.environmentId, - projectId: source.projectId, - tab: { - lineRange: null, - path, - source: { kind: "working-tree" }, - statusLabel: null, - }, - }); - case "host": - if (source.threadId === null || source.environmentId === null) { - return null; - } - return createHostFilePreviewFixedPanelTab({ - environmentId: source.environmentId, - tab: { lineRange: null, path }, - threadId: source.threadId, - }); - case "thread-storage": - if (source.threadId === null) return null; - return createThreadStorageFilePreviewFixedPanelTab({ - environmentId: source.environmentId, - isPinned: false, - tab: { lineRange: null, path }, - threadId: source.threadId, - }); - } -} - -export type BuildTabMenuItems = ( - tab: FixedPanelTab, -) => readonly SecondaryPanelTabMenuItem[] | undefined; - -export function useOpenWithTabMenu({ - replaceTab, -}: { - replaceTab: (args: { fromTabId: string; toTab: FixedPanelTab }) => void; -}): BuildTabMenuItems { - const { fileOpeners } = usePluginSlots(); - const [preference, setPreference] = useFileOpenerPreference(); - - return useCallback( - (tab) => { - const resolved = fileFromTab(tab); - if (resolved === null) return undefined; - const { file, currentOpenerRef } = resolved; - const matchingOpeners = findFileOpenersForPath(fileOpeners, file.path); - // No menu when the built-in preview is the only possible viewer. - if (matchingOpeners.length === 0 && currentOpenerRef === null) { - return undefined; - } - - const items: SecondaryPanelTabMenuItem[] = []; - if (currentOpenerRef !== null) { - const builtinTab = buildBuiltinTab(file); - if (builtinTab !== null) { - items.push({ - id: "open-with:builtin", - label: "Open with built-in preview", - onSelect: () => - replaceTab({ fromTabId: tab.id, toTab: builtinTab }), - }); - } - } - for (const opener of matchingOpeners) { - const openerRef = buildFileOpenerRef(opener); - if (openerRef === currentOpenerRef) continue; - items.push({ - id: `open-with:${openerRef}`, - label: `Open with ${opener.title}`, - onSelect: () => - replaceTab({ - fromTabId: tab.id, - toTab: buildFileOpenerPanelTab(opener, file), - }), - }); - } - - const extension = getFileExtension(file.path); - if (extension !== null) { - const defaultOpener = resolvePreferredFileOpener({ - openers: fileOpeners, - preference, - path: file.path, - }); - const defaultRef = - defaultOpener === null ? null : buildFileOpenerRef(defaultOpener); - const currentOpener = - currentOpenerRef === null - ? null - : (matchingOpeners.find( - (opener) => buildFileOpenerRef(opener) === currentOpenerRef, - ) ?? null); - const currentTitle = - currentOpenerRef === null - ? "built-in preview" - : (currentOpener?.title ?? "this opener"); - // Only offer pinning viewers that are actually registered. - if (currentOpenerRef === null || currentOpener !== null) { - items.push({ - id: "open-with:set-default", - label: `Always open .${extension} with ${currentTitle}`, - checked: defaultRef === currentOpenerRef, - onSelect: () => - setPreference((previous) => { - const next = { ...previous }; - if ( - currentOpenerRef === null || - defaultRef === currentOpenerRef - ) { - // Built-in is the implicit default; unchecking an opener - // default also reverts to it. - delete next[extension]; - } else { - next[extension] = currentOpenerRef; - } - return next; - }), - }); - } - } - - return items.length > 0 ? items : undefined; - }, - [fileOpeners, preference, replaceTab, setPreference], - ); -} diff --git a/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.tsx b/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.tsx index 3a3bf76c9..ebdd53d1a 100644 --- a/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.tsx +++ b/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.tsx @@ -10,13 +10,6 @@ import { useState, } from "react"; import { createPortal } from "react-dom"; -import { - ContextMenu, - ContextMenuCheckboxItem, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, -} from "@/components/ui/context-menu"; import { closestCenter, DndContext, @@ -545,7 +538,7 @@ function FileTab({ tab }: { tab: SecondaryPanelFileTab }) { tab.statusLabel === null ? tab.filename : `${tab.filename} (${tab.statusLabel})`; - const pill = ( + return ( ); - if (tab.menuItems === undefined || tab.menuItems.length === 0) { - return pill; - } - return ( - - -
{pill}
-
- - {tab.menuItems.map((item) => - item.checked === undefined ? ( - - {item.label} - - ) : ( - - {item.label} - - ), - )} - -
- ); } diff --git a/apps/app/src/components/secondary-panel/secondaryPanelFileTab.ts b/apps/app/src/components/secondary-panel/secondaryPanelFileTab.ts index bf6276ddb..d6bf7f1d3 100644 --- a/apps/app/src/components/secondary-panel/secondaryPanelFileTab.ts +++ b/apps/app/src/components/secondary-panel/secondaryPanelFileTab.ts @@ -9,15 +9,6 @@ export type SecondaryPanelTabReorderHandler = ( request: SecondaryPanelTabReorderRequest, ) => void; -/** One entry in a tab's right-click context menu (e.g. "Open with…"). */ -export interface SecondaryPanelTabMenuItem { - id: string; - label: string; - /** Renders a check-style item reflecting this state when set. */ - checked?: boolean; - onSelect: () => void; -} - /** * A single closable tab rendered in the right panel's scrolling tab strip. */ @@ -29,8 +20,6 @@ export interface SecondaryPanelFileTab { isPinned?: boolean; leadingVisual: ReactNode; statusLabel: string | null; - /** Right-click context menu; omitted = no menu. */ - menuItems?: readonly SecondaryPanelTabMenuItem[]; onSelect: () => void; onClose: () => void; } diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts index fc68f45c2..558cc0e10 100644 --- a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts +++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts @@ -483,48 +483,56 @@ describe("useThreadFileTabs file opener diversion", () => { expect(result.current.activeWorkspaceFilePath).toBe("notes/todo.md"); }); - it("replaces a tab in place for Open with switches", () => { + it("honors per-open viewer overrides in both directions", () => { registerNotesOpener(); + setDefaultOpener(); const { result } = renderHook(() => useThreadFileTabs({ - threadId: "opener-switch", + threadId: "opener-override", environmentId: "env_1", storageFiles: undefined, terminalSessions: undefined, }), ); - // Open built-in (no default set), then switch to the opener tab. + // "builtin" override skips the opener default entirely. act(() => - result.current.openTab({ - kind: "workspace-file-preview", - tab: { - lineRange: null, - path: "notes/todo.md", - source: { kind: "working-tree" }, - statusLabel: null, + result.current.openTab( + { + kind: "workspace-file-preview", + tab: { + lineRange: null, + path: "notes/todo.md", + source: { kind: "working-tree" }, + statusLabel: null, + }, }, - }), + { viewer: "builtin" }, + ), ); - const builtinTabId = result.current.orderedSecondaryFileTabs[0]?.id; - expect(builtinTabId).toBeDefined(); + expect(result.current.activePluginPanelTab).toBeNull(); + expect(result.current.activeWorkspaceFilePath).toBe("notes/todo.md"); + // A forced opener applies even with no default preference set. + window.localStorage.removeItem("bb.fileOpenerByExtension"); act(() => - result.current.replaceSecondaryPanelTab({ - fromTabId: builtinTabId ?? "", - toTab: { - kind: "plugin-panel", - id: "fixed:plugin-panel:x:none", - pluginId: "notes", - actionId: "file-opener:editor", - title: "todo.md", - paramsJson: "{}", + result.current.openTab( + { + kind: "workspace-file-preview", + tab: { + lineRange: null, + path: "notes/other.md", + source: { kind: "working-tree" }, + statusLabel: null, + }, }, - }), - ); - expect(result.current.orderedSecondaryFileTabs).toHaveLength(1); - expect(result.current.activePluginPanelTab?.actionId).toBe( - "file-opener:editor", + { viewer: { pluginId: "notes", openerId: "editor" } }, + ), ); + expect(result.current.activePluginPanelTab).toMatchObject({ + pluginId: "notes", + actionId: "file-opener:editor", + title: "other.md", + }); }); }); diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts index 52b6def33..3c9e6f55b 100644 --- a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts +++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts @@ -22,7 +22,10 @@ import { } from "@/lib/fixed-panel-tabs-state"; import { usePluginSlots } from "@/lib/plugin-slots"; import { useFileOpenerPreferenceValue } from "@/lib/file-opener-preference"; -import { createFileOpenerTabForRequest } from "@/components/plugin/file-opener-tabs"; +import { + createFileOpenerTabForRequest, + type FileTabViewerOverride, +} from "@/components/plugin/file-opener-tabs"; import type { OpenPluginPanelArgs } from "@/components/plugin/PluginPanelActions"; import type { HostFileTabState, @@ -412,11 +415,15 @@ export function useThreadFileTabs({ const fileOpenerPreference = useFileOpenerPreferenceValue(); const openTab = useCallback( - (request: OpenSecondaryPanelTabRequest) => { + ( + request: OpenSecondaryPanelTabRequest, + options?: { viewer?: FileTabViewerOverride }, + ) => { // Default-opener diversion (plugin design §5.2): every file-open flow // funnels through here (links, file search, `bb thread open`), so a // preferred plugin opener applies uniformly. Falls through to the - // built-in tab when no opener matches. + // built-in tab when no opener matches; a link menu's per-open viewer + // choice overrides the default in either direction. const openerTab = createFileOpenerTabForRequest({ fileOpeners, preference: fileOpenerPreference, @@ -424,6 +431,7 @@ export function useThreadFileTabs({ request, resolvedEnvironmentId, threadId: resolvedFileOwnerThreadId, + ...(options?.viewer !== undefined ? { viewer: options.viewer } : {}), }); const tab = openerTab ?? @@ -463,27 +471,6 @@ export function useThreadFileTabs({ ], ); - // "Open with…" switches a file tab's viewer in place: activate/insert the - // replacement, then drop the original (identity-keyed tabs mean the - // replacement may already exist — it is focused instead of duplicated). - const replaceSecondaryPanelTab = useCallback( - ({ fromTabId, toTab }: { fromTabId: string; toTab: FixedPanelTab }) => { - updateFixedPanelTabsState((state) => { - const opened = openSecondaryPanelTabInState({ state, tab: toTab }); - const tabs = opened.secondary.tabs.filter( - (candidate) => candidate.id !== fromTabId || candidate.id === toTab.id, - ); - return setSecondaryPanelTabsInState({ - activeTabId: toTab.id, - isOpen: opened.secondary.isOpen, - state: opened, - tabs, - }); - }); - }, - [updateFixedPanelTabsState], - ); - const activateTab = useCallback( (tabId: string) => { updateFixedPanelTabsState((state) => @@ -765,7 +752,6 @@ export function useThreadFileTabs({ openExistingSideChatTab, openTab, orderedSecondaryFileTabs, - replaceSecondaryPanelTab, reorderFileTab, selectFileSearchResult, setSideChatThreadId, diff --git a/apps/app/src/components/settings/FileOpenersSettingsSection.test.tsx b/apps/app/src/components/settings/FileOpenersSettingsSection.test.tsx new file mode 100644 index 000000000..b4f3fb22f --- /dev/null +++ b/apps/app/src/components/settings/FileOpenersSettingsSection.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { + resetPluginSlotStoreForTest, + setPluginSlotRegistrations, +} from "@/lib/plugin-slots"; +import { FileOpenersSettingsSection } from "./FileOpenersSettingsSection"; + +function NotesEditor() { + return null; +} + +function registerNotesOpener() { + setPluginSlotRegistrations("notes", { + homepageSections: [], + navPanels: [], + threadPanelActions: [], + composerAccessories: [], + fileOpeners: [ + { + id: "editor", + title: "Notes editor", + extensions: ["md", "mdx"], + component: NotesEditor, + }, + ], + }); +} + +afterEach(() => { + cleanup(); + window.localStorage.clear(); + resetPluginSlotStoreForTest(); +}); + +describe("FileOpenersSettingsSection", () => { + it("renders nothing while no plugin openers are registered", () => { + const { container } = render(); + expect(container.innerHTML).toBe(""); + }); + + it("lists one row per extension and persists a picked default", async () => { + registerNotesOpener(); + render(); + + expect(screen.getByText(".md files")).toBeDefined(); + expect(screen.getByText(".mdx files")).toBeDefined(); + + fireEvent.pointerDown( + screen.getByRole("button", { name: "Default opener for .md files" }), + { button: 0 }, + ); + fireEvent.click( + await screen.findByRole("menuitem", { name: /Notes editor/ }), + ); + + expect( + JSON.parse(window.localStorage.getItem("bb.fileOpenerByExtension") ?? "{}"), + ).toEqual({ md: "notes:editor" }); + + // Switching back to the built-in preview clears the entry. + fireEvent.pointerDown( + screen.getByRole("button", { name: "Default opener for .md files" }), + { button: 0 }, + ); + fireEvent.click( + await screen.findByRole("menuitem", { name: /Built-in preview/ }), + ); + expect( + JSON.parse(window.localStorage.getItem("bb.fileOpenerByExtension") ?? "{}"), + ).toEqual({}); + }); +}); diff --git a/apps/app/src/components/settings/FileOpenersSettingsSection.tsx b/apps/app/src/components/settings/FileOpenersSettingsSection.tsx new file mode 100644 index 000000000..9fa7c1469 --- /dev/null +++ b/apps/app/src/components/settings/FileOpenersSettingsSection.tsx @@ -0,0 +1,153 @@ +import { useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Icon } from "@/components/ui/icon"; +import { + SettingsSection, + SettingsWithControl, +} from "@/components/ui/settings-section"; +import { COARSE_POINTER_ICON_SIZE_CLASS } from "@/components/ui/coarse-pointer-sizing"; +import { + buildFileOpenerRef, + resolvePreferredFileOpener, + useFileOpenerPreference, +} from "@/lib/file-opener-preference"; +import { usePluginSlots, type PluginFileOpenerSlot } from "@/lib/plugin-slots"; +import { cn } from "@/lib/utils"; + +const DROPDOWN_TRIGGER_CLASS = + "h-7 w-full justify-between border-border/60 bg-card px-2 text-xs sm:w-44"; +const DROPDOWN_CONTENT_CLASS = + "min-w-[var(--radix-dropdown-menu-trigger-width)]"; + +/** + * Default viewer per file extension (Settings → File openers). One row per + * extension any installed plugin registers a `fileOpener` for: built-in + * preview (the default) or a plugin opener. Hidden entirely while no plugin + * openers are installed — the built-in preview needs no configuration. + */ +export function FileOpenersSettingsSection() { + const { fileOpeners } = usePluginSlots(); + const [preference, setPreference] = useFileOpenerPreference(); + + const extensions = useMemo( + () => + [...new Set(fileOpeners.flatMap((opener) => opener.extensions))].sort(), + [fileOpeners], + ); + + if (extensions.length === 0) { + return null; + } + + return ( + +
+ {extensions.map((extension) => ( + + opener.extensions.includes(extension), + )} + selected={resolvePreferredFileOpener({ + openers: fileOpeners, + preference, + path: `file.${extension}`, + })} + onSelect={(opener) => + setPreference((previous) => { + const next = { ...previous }; + if (opener === null) { + delete next[extension]; + } else { + next[extension] = buildFileOpenerRef(opener); + } + return next; + }) + } + /> + ))} +
+
+ ); +} + +const BUILTIN_LABEL = "Built-in preview"; + +function ExtensionOpenerControl({ + extension, + onSelect, + openers, + selected, +}: { + extension: string; + onSelect: (opener: PluginFileOpenerSlot | null) => void; + openers: PluginFileOpenerSlot[]; + selected: PluginFileOpenerSlot | null; +}) { + const selectedLabel = + selected === null ? BUILTIN_LABEL : `${selected.title} (${selected.pluginId})`; + return ( + + + + + + + onSelect(null)}> + {BUILTIN_LABEL} + + + {openers.map((opener) => ( + onSelect(opener)} + > + + {opener.title} ({opener.pluginId}) + + + + ))} + + + + ); +} diff --git a/apps/app/src/components/ui/markdown-link-routing.ts b/apps/app/src/components/ui/markdown-link-routing.ts index e5d783071..5679905f2 100644 --- a/apps/app/src/components/ui/markdown-link-routing.ts +++ b/apps/app/src/components/ui/markdown-link-routing.ts @@ -1,14 +1,30 @@ import type { MarkdownPreviewLinkHandler } from "./markdown-link.js"; import type { MarkdownAbsoluteLocalFileLinkRouting, + MarkdownPreviewLocalFileLink, MarkdownPreviewLocalFileLinkHandler, MarkdownRelativeLocalFileLinkRouting, } from "./markdown-local-file-link.js"; +/** One entry in a local file link's right-click "Open with" menu. */ +export interface MarkdownLocalFileOpenWithItem { + id: string; + label: string; + onSelect: () => void; +} + export interface MarkdownLocalFileLinkRouting { absoluteLinks: MarkdownAbsoluteLocalFileLinkRouting; onOpenLink: MarkdownPreviewLocalFileLinkHandler; relativeLinks?: MarkdownRelativeLocalFileLinkRouting; + /** + * Viewer choices for the right-click menu on a local file link (e.g. + * "Open with built-in preview" / plugin file openers). Null/empty = no + * menu; left-click behavior is unchanged either way. + */ + getOpenWithItems?: ( + link: MarkdownPreviewLocalFileLink, + ) => MarkdownLocalFileOpenWithItem[] | null; } export interface MarkdownLinkRouting { diff --git a/apps/app/src/components/ui/markdown-preview.test.tsx b/apps/app/src/components/ui/markdown-preview.test.tsx index dd4afc4c5..d607f46ef 100644 --- a/apps/app/src/components/ui/markdown-preview.test.tsx +++ b/apps/app/src/components/ui/markdown-preview.test.tsx @@ -74,6 +74,61 @@ describe("MarkdownPreview", () => { expect(screen.getByText("src/app.ts").tagName).toBe("CODE"); }); + it("shows an Open with menu on local file links when routing provides items", () => { + const openBuiltin = vi.fn(); + const openWithPlugin = vi.fn(); + render( + true), + getOpenWithItems: (link) => + link.path.endsWith(".md") + ? [ + { + id: "builtin", + label: "Open with built-in preview", + onSelect: openBuiltin, + }, + { + id: "notes:editor", + label: "Open with Notes editor", + onSelect: openWithPlugin, + }, + ] + : null, + }, + }} + />, + ); + + const link = screen.getByRole("link", { name: /notes/ }); + fireEvent.contextMenu(link); + fireEvent.click(screen.getByText("Open with Notes editor")); + expect(openWithPlugin).toHaveBeenCalledTimes(1); + expect(openBuiltin).not.toHaveBeenCalled(); + }); + + it("renders plain anchors when the routing offers no viewer items", () => { + render( + true), + getOpenWithItems: () => null, + }, + }} + />, + ); + const link = screen.getByRole("link", { name: /app/ }); + fireEvent.contextMenu(link); + expect(screen.queryByText(/Open with/)).toBeNull(); + }); + it("leaves inline-code Markdown paths as code without local file routing", () => { render(); diff --git a/apps/app/src/components/ui/markdown-preview.tsx b/apps/app/src/components/ui/markdown-preview.tsx index 059ced74c..6f31b6a32 100644 --- a/apps/app/src/components/ui/markdown-preview.tsx +++ b/apps/app/src/components/ui/markdown-preview.tsx @@ -10,6 +10,12 @@ import { type SetStateAction, } from "react"; import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; import type { Components, ExtraProps, @@ -473,6 +479,10 @@ function MarkdownAnchor({ }) : null; const anchorHref = buildLocalFileAnchorHref(localFileLink, rewrittenHref); + const openWithItems = + localFileLink !== null && localFileRouting?.getOpenWithItems + ? localFileRouting.getOpenWithItems(localFileLink) + : null; const handleAnchorClick = (event: MarkdownAnchorEvent) => { if (localFileLink && onOpenLocalFileLink) { if (onOpenLocalFileLink(localFileLink)) { @@ -498,7 +508,7 @@ function MarkdownAnchor({ } }; - return ( + const anchor = ( ); + if (openWithItems === null || openWithItems.length === 0) { + return anchor; + } + // Local file links with viewer choices get a right-click "Open with" menu + // (per-open override of the extension's default opener). + return ( + + {anchor} + + {openWithItems.map((item) => ( + + {item.label} + + ))} + + + ); } function MarkdownCode({ diff --git a/apps/app/src/views/RootComposeView.tsx b/apps/app/src/views/RootComposeView.tsx index 6340442a3..cf80827b5 100644 --- a/apps/app/src/views/RootComposeView.tsx +++ b/apps/app/src/views/RootComposeView.tsx @@ -68,7 +68,6 @@ import { usePointerCoarse } from "@/components/ui/hooks/use-pointer-coarse.js"; import { COARSE_POINTER_COMPACT_ICON_SIZE_CLASS } from "@/components/ui/coarse-pointer-sizing.js"; import { PluginIcon } from "@/components/plugin/PluginIcon"; import { PluginPanelTabContent } from "@/components/plugin/PluginPanelActions"; -import { useOpenWithTabMenu } from "@/components/plugin/useOpenWithTabMenu"; import { useUploadPromptAttachment } from "@/hooks/mutations/project-mutations"; import { useCreateThread } from "@/hooks/mutations/thread-runtime-mutations"; import { @@ -2035,7 +2034,6 @@ export function RootComposeView(props: RootComposeViewProps) { isNewTabActive, openTab, orderedSecondaryFileTabs, - replaceSecondaryPanelTab, reorderFileTab, selectFileSearchResult, updateBrowserTab, @@ -2050,9 +2048,7 @@ export function RootComposeView(props: RootComposeViewProps) { storageFiles: rootThreadStorageFiles?.files, terminalSessions: loadedTerminalSessions, }); - const buildTabMenuItems = useOpenWithTabMenu({ - replaceTab: replaceSecondaryPanelTab, - }); + const activeRootHostFileThreadId = activeHostFileThreadId ?? (activeHostFilePath !== null ? rootPanelThreadId : null); @@ -2553,7 +2549,6 @@ export function RootComposeView(props: RootComposeViewProps) { isActive: tab.id === activeFixedSecondaryTabId, leadingVisual: , statusLabel: tab.statusLabel, - menuItems: buildTabMenuItems(tab), onSelect: () => handleActivateFileTab(tab.id), onClose: () => closeTab(tab.id), }; @@ -2564,7 +2559,6 @@ export function RootComposeView(props: RootComposeViewProps) { isActive: tab.id === activeFixedSecondaryTabId, leadingVisual: , statusLabel: null, - menuItems: buildTabMenuItems(tab), onSelect: () => handleActivateFileTab(tab.id), onClose: () => closeTab(tab.id), }; @@ -2576,7 +2570,6 @@ export function RootComposeView(props: RootComposeViewProps) { isPinned: tab.isPinned, leadingVisual: , statusLabel: null, - menuItems: buildTabMenuItems(tab), onSelect: () => handleActivateFileTab(tab.id), onClose: () => closeTab(tab.id), }; @@ -2629,7 +2622,6 @@ export function RootComposeView(props: RootComposeViewProps) { /> ), statusLabel: null, - menuItems: buildTabMenuItems(tab), onSelect: () => handleActivateFileTab(tab.id), onClose: () => closeTab(tab.id), }; diff --git a/apps/app/src/views/SettingsView.tsx b/apps/app/src/views/SettingsView.tsx index 799db8aeb..d8161f81e 100644 --- a/apps/app/src/views/SettingsView.tsx +++ b/apps/app/src/views/SettingsView.tsx @@ -37,6 +37,7 @@ import { import { useHostDaemon } from "@/hooks/useHostDaemon"; import { UsageLimitsSettingsSection } from "@/components/settings/UsageLimitsSettingsSection"; import { PluginsSettingsSection } from "@/components/settings/PluginsSettingsSection"; +import { FileOpenersSettingsSection } from "@/components/settings/FileOpenersSettingsSection"; import { useUpdateAppearance, useUpdateExperiments, @@ -1021,6 +1022,8 @@ export function SettingsView() { targets={workspaceOpenTargets} /> + + ( - (file) => openTab({ kind: "workspace-file-preview", tab: file }), + (file, options) => + openTab({ kind: "workspace-file-preview", tab: file }, options), [openTab], ); const openPersistedStorageFile = useCallback( - (file) => openTab({ kind: "thread-storage-file-preview", tab: file }), + (file, options) => + openTab({ kind: "thread-storage-file-preview", tab: file }, options), [openTab], ); const openPersistedHostFile = useCallback( - (file) => openTab({ kind: "host-file-preview", tab: file }), + (file, options) => + openTab({ kind: "host-file-preview", tab: file }, options), [openTab], ); const openBrowserTab = useCallback( @@ -1300,7 +1305,6 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { isActive: tab.id === activeFixedSecondaryTabId, leadingVisual: , statusLabel: tab.statusLabel, - menuItems: buildTabMenuItems(tab), onSelect: () => handleActivateFileTab(tab.id), onClose: () => closeTab(tab.id), }; @@ -1311,7 +1315,6 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { isActive: tab.id === activeFixedSecondaryTabId, leadingVisual: , statusLabel: null, - menuItems: buildTabMenuItems(tab), onSelect: () => handleActivateFileTab(tab.id), onClose: () => closeTab(tab.id), }; @@ -1323,7 +1326,6 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { isPinned: tab.isPinned, leadingVisual: , statusLabel: null, - menuItems: buildTabMenuItems(tab), onSelect: () => handleActivateFileTab(tab.id), onClose: () => closeTab(tab.id), }; @@ -1368,7 +1370,6 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { /> ), statusLabel: null, - menuItems: buildTabMenuItems(tab), onSelect: () => handleActivateFileTab(tab.id), onClose: () => closeTab(tab.id), }; @@ -1380,7 +1381,6 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { activateSideChatTab, activeFixedSecondaryTabId, activeSideChatTabId, - buildTabMenuItems, closeTab, closeSideChatTab, handleActivateFileTab, @@ -1675,7 +1675,10 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { [thread, updateThread], ); const handleTimelineLocalFileLinkResolution = useCallback( - (resolution: ThreadLocalFileLinkResolution) => { + ( + resolution: ThreadLocalFileLinkResolution, + options?: ThreadSecondaryPanelFileOpenOptions, + ) => { if (resolution.kind === "app-route") { return false; } @@ -1687,33 +1690,45 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { } if (resolution.kind === "open-workspace-path") { - openWorkspaceFile({ - lineRange: resolution.request.lineRange, - path: resolution.request.relativePath, - source: { kind: "working-tree" }, - statusLabel: null, - }); + openWorkspaceFile( + { + lineRange: resolution.request.lineRange, + path: resolution.request.relativePath, + source: { kind: "working-tree" }, + statusLabel: null, + }, + options, + ); return true; } if (resolution.kind === "open-thread-storage-path") { - openStorageFile({ - lineRange: resolution.request.lineRange, - path: resolution.request.relativePath, - }); + openStorageFile( + { + lineRange: resolution.request.lineRange, + path: resolution.request.relativePath, + }, + options, + ); return true; } - openHostFile({ - lineRange: resolution.request.lineRange, - path: resolution.request.path, - }); + openHostFile( + { + lineRange: resolution.request.lineRange, + path: resolution.request.path, + }, + options, + ); return true; }, [openHostFile, openStorageFile, openWorkspaceFile], ); const handleOpenTimelineLocalFileLink = useCallback( - (link: ThreadTimelineLocalFileLink) => { + ( + link: ThreadTimelineLocalFileLink, + options?: ThreadSecondaryPanelFileOpenOptions, + ) => { const resolution = resolveThreadLocalFileLink({ hostFileLinksAvailable: thread?.environmentId !== null && thread?.environmentId !== undefined, @@ -1726,7 +1741,7 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { resolution.kind !== "open-host-path" || threadStorageRootPath !== null ) { - return handleTimelineLocalFileLinkResolution(resolution); + return handleTimelineLocalFileLinkResolution(resolution, options); } void refetchThreadStorageFiles() @@ -1746,7 +1761,7 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { threadStorageRootPath: resolvedThreadStorageRootPath, workspaceRootPath: workspacePreviewRootPath, }); - handleTimelineLocalFileLinkResolution(resolvedResolution); + handleTimelineLocalFileLinkResolution(resolvedResolution, options); }) .catch((error: Error) => { appToast.error("Failed to open file locally", { @@ -1873,15 +1888,49 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { threadStorageRootPath, workspaceRootPath: workspacePreviewRootPath, }); + // Right-click "Open with" on local file links: per-open viewer choice + // between the built-in preview and any plugin opener matching the file's + // extension. Only rendered when at least one opener matches. + const getLocalFileOpenWithItems = useCallback( + (link: ThreadTimelineLocalFileLink) => { + const extension = getFileExtension(link.path); + if (extension === null) return null; + const matching = pluginFileOpeners.filter((opener) => + opener.extensions.includes(extension), + ); + if (matching.length === 0) return null; + return [ + { + id: "builtin", + label: "Open with built-in preview", + onSelect: () => { + handleOpenTimelineLocalFileLink(link, { viewer: "builtin" }); + }, + }, + ...matching.map((opener) => ({ + id: `${opener.pluginId}:${opener.id}`, + label: `Open with ${opener.title}`, + onSelect: () => { + handleOpenTimelineLocalFileLink(link, { + viewer: { pluginId: opener.pluginId, openerId: opener.id }, + }); + }, + })), + ]; + }, + [handleOpenTimelineLocalFileLink, pluginFileOpeners], + ); const workspaceMarkdownLinkRouting = useMemo( () => buildMarkdownPreviewLinkRouting({ baseDir: workspaceFileLinkBaseDir, + getOpenWithItems: getLocalFileOpenWithItems, onOpenLink: handleOpenTimelineLink, onOpenLocalFileLink: handleOpenTimelineLocalFileLink, rootPath: workspacePreviewRootPath, }), [ + getLocalFileOpenWithItems, handleOpenTimelineLink, handleOpenTimelineLocalFileLink, workspaceFileLinkBaseDir, @@ -1892,11 +1941,13 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { () => buildMarkdownPreviewLinkRouting({ baseDir: hostFileLinkBaseDir, + getOpenWithItems: getLocalFileOpenWithItems, onOpenLink: handleOpenTimelineLink, onOpenLocalFileLink: handleOpenTimelineLocalFileLink, rootPath: hostFileLinkRootPath, }), [ + getLocalFileOpenWithItems, handleOpenTimelineLink, handleOpenTimelineLocalFileLink, hostFileLinkBaseDir, @@ -1907,11 +1958,13 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { () => buildMarkdownPreviewLinkRouting({ baseDir: storageFileLinkBaseDir, + getOpenWithItems: getLocalFileOpenWithItems, onOpenLink: handleOpenTimelineLink, onOpenLocalFileLink: handleOpenTimelineLocalFileLink, rootPath: threadStorageRootPath, }), [ + getLocalFileOpenWithItems, handleOpenTimelineLink, handleOpenTimelineLocalFileLink, storageFileLinkBaseDir, diff --git a/apps/app/src/views/thread-detail/useThreadSecondaryPanelVisibility.ts b/apps/app/src/views/thread-detail/useThreadSecondaryPanelVisibility.ts index de4825e4f..c20e40526 100644 --- a/apps/app/src/views/thread-detail/useThreadSecondaryPanelVisibility.ts +++ b/apps/app/src/views/thread-detail/useThreadSecondaryPanelVisibility.ts @@ -5,6 +5,7 @@ import type { WorkspaceFileTabState, } from "@/lib/file-preview"; import type { ThreadSecondaryPanel } from "@/lib/thread-secondary-panel"; +import type { FileTabViewerOverride } from "@/components/plugin/file-opener-tabs"; type ThreadSecondaryPanelThreadId = string | undefined; type ThreadDetailSurface = "page" | "popout"; @@ -14,14 +15,21 @@ export type ThreadSecondaryPanelOpenHandler = ( ) => void; export type ThreadSecondaryPanelDiffFileOpenHandler = (path: string) => void; export type ThreadSecondaryPanelCommitDiffOpenHandler = (sha: string) => void; +export interface ThreadSecondaryPanelFileOpenOptions { + /** Per-open viewer choice (link context menu); absent = extension default. */ + viewer?: FileTabViewerOverride; +} export type ThreadSecondaryPanelWorkspaceFileOpenHandler = ( file: WorkspaceFileTabState, + options?: ThreadSecondaryPanelFileOpenOptions, ) => void; export type ThreadSecondaryPanelStorageFileOpenHandler = ( file: ThreadStorageFileTabState, + options?: ThreadSecondaryPanelFileOpenOptions, ) => void; export type ThreadSecondaryPanelHostFileOpenHandler = ( file: HostFileTabState, + options?: ThreadSecondaryPanelFileOpenOptions, ) => void; export interface UseThreadSecondaryPanelVisibilityArgs { @@ -159,11 +167,12 @@ export function useThreadSecondaryPanelVisibility({ const openWorkspaceFile = useCallback( - (file) => { + (file, options) => { if (surface === "popout") { return; } - openPersistedWorkspaceFile(file); + if (options !== undefined) openPersistedWorkspaceFile(file, options); + else openPersistedWorkspaceFile(file); openCompactDrawer(); }, [openCompactDrawer, openPersistedWorkspaceFile, surface], @@ -171,22 +180,24 @@ export function useThreadSecondaryPanelVisibility({ const openStorageFile = useCallback( - (file) => { + (file, options) => { if (surface === "popout") { return; } - openPersistedStorageFile(file); + if (options !== undefined) openPersistedStorageFile(file, options); + else openPersistedStorageFile(file); openCompactDrawer(); }, [openCompactDrawer, openPersistedStorageFile, surface], ); const openHostFile = useCallback( - (file) => { + (file, options) => { if (surface === "popout") { return; } - openPersistedHostFile(file); + if (options !== undefined) openPersistedHostFile(file, options); + else openPersistedHostFile(file); openCompactDrawer(); }, [openCompactDrawer, openPersistedHostFile, surface], diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md index 338e96d34..88ec0c515 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md @@ -443,11 +443,12 @@ Slot props contracts (versioned, additive-only): — rendered in the composer footer. Registration: `{ id, component }`. - `fileOpener` → `{ path: string, source }` — register as a viewer/editor for file extensions: `{ id, title, extensions: ["md"], component }`. - Users pick it (and can set it as the default per extension) from a file - tab's right-click "Open with" menu; matching files opened in the right - panel then render your component in a plugin tab instead of the built-in - preview — this includes links clicked in rendered markdown, the file - picker, and `bb thread open`. `source` is + Users set the per-extension default under Settings → "File openers", and + right-clicking a file link in rendered markdown offers a one-off + "Open with …" choice; matching files opened in the right panel then + render your component in a plugin tab instead of the built-in preview — + this includes links clicked in rendered markdown, the file picker, and + `bb thread open`. `source` is `{ kind: "workspace" | "host" | "thread-storage", threadId, environmentId, projectId }` (nullable fields) and `path` follows the source (workspace: worktree-relative; host: absolute; thread-storage: storage-relative). diff --git a/examples/plugins/notes/README.md b/examples/plugins/notes/README.md index 65bc4a8c9..315a8f616 100644 --- a/examples/plugins/notes/README.md +++ b/examples/plugins/notes/README.md @@ -12,10 +12,10 @@ navPanel-`subPath` frontend surfaces. `bb.sdk.files` with `expectedSha256` compare-and-swap — a save that races an agent editing the same file surfaces a reload/overwrite banner instead of clobbering. -- **File opener**: registers as an opener for `md`/`mdx`/`markdown`, so - right-clicking a markdown file tab offers "Open with Notes editor" (and - "Always open .md with…"). Links clicked in rendered markdown then land - in the editor instead of the read-only preview. +- **File opener**: registers as an opener for `md`/`mdx`/`markdown`. Set + it as the default under Settings → "File openers" and markdown links + land in the editor instead of the read-only preview; right-click any + file link for a one-off "Open with…" choice in either direction. - **Chat integration**: "Add to chat" quotes the current selection (or the whole note) into the composer draft via `useComposer().addQuote`; "@-mention" inserts a pill that resolves the note's content at send time diff --git a/packages/templates/src/generated/templates.generated.ts b/packages/templates/src/generated/templates.generated.ts index 2cb78f521..7b2167b7c 100644 --- a/packages/templates/src/generated/templates.generated.ts +++ b/packages/templates/src/generated/templates.generated.ts @@ -73,7 +73,7 @@ export const templateDefinitions = [ }, { "id": "bbGuidePlugins", - "body": "Plugin commands\n\nA bb plugin is a TypeScript package that extends the bb server in-process:\nbackground services, cron schedules, HTTP/RPC endpoints, thread lifecycle\nhandlers, settings, storage — and `bb` CLI subcommands that agents and humans\nrun like any other command. Plugins are full-trust code inside the server.\n\nPlugins are an experiment, off by default: enable \"Plugins\" under Settings →\nExperiments first. Until then `bb plugin` commands report that plugins are\ndisabled. Plugin state lives under `/plugins//` (per-plugin\nSQLite file, secrets, logs).\n\n bb plugin install Install from a local path, git:@,\n or npm:@ (npm: needs npm on\n PATH; installs prompt — pass --yes to skip)\n bb plugin list Status, services, schedules, handler timings\n bb plugin enable|disable Load or unload an installed plugin\n bb plugin reload [id] Re-run factories against current sources\n bb plugin config [set | unset ]\n Show or change a plugin's declared settings\n bb plugin logs [-n N] [-f] Print (or follow) a plugin's bb.log output\n bb plugin run [args...] Run the plugin's CLI command explicitly\n bb plugin token [--rotate] Print the token for auth:\"token\" HTTP\n routes; --rotate generates a new token,\n invalidating the old one\n bb plugin remove Uninstall (managed git:/npm: files deleted)\n bb plugin new [--app] Scaffold a new plugin (no server required;\n --app adds a frontend entry, app.tsx, plus a\n typecheck-only tsconfig.json)\n bb plugin build [path] Compile the plugin into dist/ — the backend\n bundle (server.js, server.meta.json) and,\n when bb.app is declared, the frontend bundle\n (app.js, app.css, app.meta.json); no server\n required\n bb plugin dev [path] Watch a plugin's sources (default: cwd) and\n on every change rebuild its frontend bundle\n (if it declares bb.app) and reload the\n plugin; Ctrl+C to stop\n\nFrontend builds are automatic once installed: path and git installs compile\ndist/ at install time (a build failure fails the install), and the server\nrebuilds them at load after a bb upgrade. npm packages must publish a\nprebuilt dist/ (app.js + app.meta.json) or the install is refused.\n\nThe backend half is prebuilt too: when a git/npm install ships a\ndist/server.js built for the running SDK major, the server loads it instead\nof the TypeScript source — consumers never need npm or node_modules. Path\ninstalls always load server.ts from source, so `bb plugin dev`/reload see\nedits immediately.\n\n`bb plugin dev` is the edit loop: it requires the directory to already be\ninstalled as a plugin (`bb plugin install .` first), ignores dist/,\nnode_modules/, and .git/, batches saves, and prints one line per cycle. A\nbuild or reload failure prints the error and keeps watching (a failed build\nskips that cycle's reload). Reloads reach open app pages live — changed\nfrontend bundles re-import and their UI slots remount without a page\nrefresh.\n\nFrontend entries (app.tsx) default-export `definePluginApp` from\n`@bb/plugin-sdk/app` and register UI slots: homepageSection (root compose),\nnavPanel (own sidebar entry + /plugins///* route; the remainder\narrives as the component's subPath prop for panel-internal deep links),\nthreadPanelAction\n(an entry in the thread right panel's new-tab Actions list whose run() can\nopen closable panel tabs with JSON params), composerAccessory (prompt box\nfooter), and fileOpener (register as a per-extension file viewer/editor\nusers can set as the default via a file tab's \"Open with\" menu). Hooks:\nuseRpc, useRealtime, useSettings (secrets excluded), useBbContext,\nuseBbNavigate, and useComposer (quote selections / insert mention pills\ninto the chat composer draft). Components are vendored shadcn source the plugin owns (the\nshadcn model): `bb plugin new --app` pre-vendors a starter set into\ncomponents/ui/ and `npx shadcn add @bb/` pulls more from the BB\ncomponent registry (the full stock shadcn set, version-matched to the\nrunning BB via the pinned ref in components.json). `import { toast } from\n\"sonner\"` reaches the host toaster; react, the portaling radix families,\nsonner, vaul, and @pierre/diffs (the app's syntax-highlighted diff\nrenderer) are runtime-shimmed (never bundled), everything else\nbundles from the plugin's node_modules (`npm install` for authors;\nconsumers install prebuilt dist). A crashing slot collapses to a\n\"plugin crashed\" chip without\ntouching the rest of the app. Installed plugins and their declared settings\n(same data as `bb plugin config`) also appear under Settings → Plugins.\n\nPlugin CLI commands: a plugin can register one top-level subcommand (for\nexample `bb github …`). Unknown `bb` commands are looked up against installed\nplugins and proxied to the server, so plugin commands work exactly like core\ncommands; core command names always win. Inside agent threads the generated\n`plugin-commands` skill lists the available plugin commands.\n\nSettings changes do not auto-reload a plugin — run `bb plugin reload `\nafter configuring. Add --json to plugin commands for machine-readable output.\n\nAuthoring a plugin\n\nThe loop: `bb plugin new ` scaffolds `./bb-plugin-` (add --app\nfor a frontend entry); `bb plugin install .` registers it; `bb plugin dev`\nwatches and reloads on every save. The manifest is package.json: `bb.server`\n(backend entry, loaded as TypeScript — no build step), optional `bb.app`\n(frontend entry), optional `bb.skills` (skills directories auto-imported\ninto agent threads; default `skills/`), and `engines.bb` (supported bb\nrange). The plugin id is the package name minus `bb-plugin-`.\n\nLogos: drop a logo.svg (or logo.png / logo.webp) in the plugin root and bb\nshows it wherever the plugin's contributions appear — the sidebar entry,\npanel title bar, composer command and @-mention menus, thread action\nbuttons, and Settings → Plugins. Optional `bb.logo` in the manifest\nrelocates the file (svg/png/webp only). An optional dark-theme variant —\nlogo-dark.svg/png/webp at the root, or `bb.logoDark` — is preferred while\nthe app is in dark mode. Without a logo bb falls back to the contribution's\nnamed icon. Reload the plugin to pick up logo changes.\n\nThe backend entry default-exports a factory receiving the full plugin API:\n\n import type { BbPluginApi } from \"@bb/plugin-sdk\";\n export default async function plugin(bb: BbPluginApi) { ... }\n\nThe import is type-only and erased at load; the scaffold ships the full API\nas bundled .d.ts in types/ (tsconfig maps @bb/plugin-sdk to them), so\n`npm install && npx tsc --noEmit` typechecks anywhere — no bb checkout\nneeded. Confused, or need a symbol the types don't explain? Clone the repo:\nhttps://github.com/ymichael/bb. The API in\none line each — bb.log (plugin-scoped logger behind `bb plugin logs`);\nbb.settings.define (declarative settings incl. secrets, editable via\n`bb plugin config`); bb.storage.kv (JSON rows ≤256KB) and\nbb.storage.sqlite()+migrate (the plugin's own database); bb.sdk (the full\nbb SDK — handlers/services only, not the factory; spawned threads are\nattributed to the plugin); bb.on (observe thread.created/idle/failed);\nbb.http.route (routes under /api/v1/plugins//http/* with\nlocal/token/none auth); bb.rpc.register (the frontend data plane);\nbb.realtime.publish (ephemeral signals to open app pages);\nbb.background.service (long-lived, AbortSignal, restart w/ backoff) and\nbb.background.schedule (durable cron rows); bb.cli.register (a top-level\n`bb ` command agents run through bash); bb.agents.registerTool\n(native tools with\nzod or JSON-schema parameters); bb.ui.registerThreadAction /\nregisterMentionProvider (host-rendered UI — no\nfrontend bundle needed); bb.status.needsConfiguration (report\n\"unconfigured\" instead of crashing); bb.onDispose (LIFO cleanup on\nreload/disable/shutdown).\n\nFrontend entries register React slots (homepageSection, navPanel,\nthreadPanelAction, composerAccessory) via definePluginApp, use the hooks\nlisted above, and render vendored components; styling is Tailwind against\nthe host theme's tokens only (semantic classes like bg-background and\ntw-animate-css utilities compile in plugin builds).\n\nFor the complete authoring reference — exact signatures, working snippets\nfor every surface, the reload lifecycle, testing tips, and gotchas — use\nthe built-in `bb-plugin-authoring` skill (agents: it loads on demand;\nhumans: apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/\nin a checkout). The `examples/plugins/` directory of a bb checkout has four\nreference plugins: github (full-stack: gh-CLI-backed issue/PR browser on\nvendored shadcn components), slack-bot (webhook bot), agent-enrichment\n(agent surfaces), small-ux-pack (host-rendered UI).", + "body": "Plugin commands\n\nA bb plugin is a TypeScript package that extends the bb server in-process:\nbackground services, cron schedules, HTTP/RPC endpoints, thread lifecycle\nhandlers, settings, storage — and `bb` CLI subcommands that agents and humans\nrun like any other command. Plugins are full-trust code inside the server.\n\nPlugins are an experiment, off by default: enable \"Plugins\" under Settings →\nExperiments first. Until then `bb plugin` commands report that plugins are\ndisabled. Plugin state lives under `/plugins//` (per-plugin\nSQLite file, secrets, logs).\n\n bb plugin install Install from a local path, git:@,\n or npm:@ (npm: needs npm on\n PATH; installs prompt — pass --yes to skip)\n bb plugin list Status, services, schedules, handler timings\n bb plugin enable|disable Load or unload an installed plugin\n bb plugin reload [id] Re-run factories against current sources\n bb plugin config [set | unset ]\n Show or change a plugin's declared settings\n bb plugin logs [-n N] [-f] Print (or follow) a plugin's bb.log output\n bb plugin run [args...] Run the plugin's CLI command explicitly\n bb plugin token [--rotate] Print the token for auth:\"token\" HTTP\n routes; --rotate generates a new token,\n invalidating the old one\n bb plugin remove Uninstall (managed git:/npm: files deleted)\n bb plugin new [--app] Scaffold a new plugin (no server required;\n --app adds a frontend entry, app.tsx, plus a\n typecheck-only tsconfig.json)\n bb plugin build [path] Compile the plugin into dist/ — the backend\n bundle (server.js, server.meta.json) and,\n when bb.app is declared, the frontend bundle\n (app.js, app.css, app.meta.json); no server\n required\n bb plugin dev [path] Watch a plugin's sources (default: cwd) and\n on every change rebuild its frontend bundle\n (if it declares bb.app) and reload the\n plugin; Ctrl+C to stop\n\nFrontend builds are automatic once installed: path and git installs compile\ndist/ at install time (a build failure fails the install), and the server\nrebuilds them at load after a bb upgrade. npm packages must publish a\nprebuilt dist/ (app.js + app.meta.json) or the install is refused.\n\nThe backend half is prebuilt too: when a git/npm install ships a\ndist/server.js built for the running SDK major, the server loads it instead\nof the TypeScript source — consumers never need npm or node_modules. Path\ninstalls always load server.ts from source, so `bb plugin dev`/reload see\nedits immediately.\n\n`bb plugin dev` is the edit loop: it requires the directory to already be\ninstalled as a plugin (`bb plugin install .` first), ignores dist/,\nnode_modules/, and .git/, batches saves, and prints one line per cycle. A\nbuild or reload failure prints the error and keeps watching (a failed build\nskips that cycle's reload). Reloads reach open app pages live — changed\nfrontend bundles re-import and their UI slots remount without a page\nrefresh.\n\nFrontend entries (app.tsx) default-export `definePluginApp` from\n`@bb/plugin-sdk/app` and register UI slots: homepageSection (root compose),\nnavPanel (own sidebar entry + /plugins///* route; the remainder\narrives as the component's subPath prop for panel-internal deep links),\nthreadPanelAction\n(an entry in the thread right panel's new-tab Actions list whose run() can\nopen closable panel tabs with JSON params), composerAccessory (prompt box\nfooter), and fileOpener (register as a per-extension file viewer/editor;\nusers pick defaults under Settings → File openers and can right-click a\nfile link for a one-off choice). Hooks:\nuseRpc, useRealtime, useSettings (secrets excluded), useBbContext,\nuseBbNavigate, and useComposer (quote selections / insert mention pills\ninto the chat composer draft). Components are vendored shadcn source the plugin owns (the\nshadcn model): `bb plugin new --app` pre-vendors a starter set into\ncomponents/ui/ and `npx shadcn add @bb/` pulls more from the BB\ncomponent registry (the full stock shadcn set, version-matched to the\nrunning BB via the pinned ref in components.json). `import { toast } from\n\"sonner\"` reaches the host toaster; react, the portaling radix families,\nsonner, vaul, and @pierre/diffs (the app's syntax-highlighted diff\nrenderer) are runtime-shimmed (never bundled), everything else\nbundles from the plugin's node_modules (`npm install` for authors;\nconsumers install prebuilt dist). A crashing slot collapses to a\n\"plugin crashed\" chip without\ntouching the rest of the app. Installed plugins and their declared settings\n(same data as `bb plugin config`) also appear under Settings → Plugins.\n\nPlugin CLI commands: a plugin can register one top-level subcommand (for\nexample `bb github …`). Unknown `bb` commands are looked up against installed\nplugins and proxied to the server, so plugin commands work exactly like core\ncommands; core command names always win. Inside agent threads the generated\n`plugin-commands` skill lists the available plugin commands.\n\nSettings changes do not auto-reload a plugin — run `bb plugin reload `\nafter configuring. Add --json to plugin commands for machine-readable output.\n\nAuthoring a plugin\n\nThe loop: `bb plugin new ` scaffolds `./bb-plugin-` (add --app\nfor a frontend entry); `bb plugin install .` registers it; `bb plugin dev`\nwatches and reloads on every save. The manifest is package.json: `bb.server`\n(backend entry, loaded as TypeScript — no build step), optional `bb.app`\n(frontend entry), optional `bb.skills` (skills directories auto-imported\ninto agent threads; default `skills/`), and `engines.bb` (supported bb\nrange). The plugin id is the package name minus `bb-plugin-`.\n\nLogos: drop a logo.svg (or logo.png / logo.webp) in the plugin root and bb\nshows it wherever the plugin's contributions appear — the sidebar entry,\npanel title bar, composer command and @-mention menus, thread action\nbuttons, and Settings → Plugins. Optional `bb.logo` in the manifest\nrelocates the file (svg/png/webp only). An optional dark-theme variant —\nlogo-dark.svg/png/webp at the root, or `bb.logoDark` — is preferred while\nthe app is in dark mode. Without a logo bb falls back to the contribution's\nnamed icon. Reload the plugin to pick up logo changes.\n\nThe backend entry default-exports a factory receiving the full plugin API:\n\n import type { BbPluginApi } from \"@bb/plugin-sdk\";\n export default async function plugin(bb: BbPluginApi) { ... }\n\nThe import is type-only and erased at load; the scaffold ships the full API\nas bundled .d.ts in types/ (tsconfig maps @bb/plugin-sdk to them), so\n`npm install && npx tsc --noEmit` typechecks anywhere — no bb checkout\nneeded. Confused, or need a symbol the types don't explain? Clone the repo:\nhttps://github.com/ymichael/bb. The API in\none line each — bb.log (plugin-scoped logger behind `bb plugin logs`);\nbb.settings.define (declarative settings incl. secrets, editable via\n`bb plugin config`); bb.storage.kv (JSON rows ≤256KB) and\nbb.storage.sqlite()+migrate (the plugin's own database); bb.sdk (the full\nbb SDK — handlers/services only, not the factory; spawned threads are\nattributed to the plugin); bb.on (observe thread.created/idle/failed);\nbb.http.route (routes under /api/v1/plugins//http/* with\nlocal/token/none auth); bb.rpc.register (the frontend data plane);\nbb.realtime.publish (ephemeral signals to open app pages);\nbb.background.service (long-lived, AbortSignal, restart w/ backoff) and\nbb.background.schedule (durable cron rows); bb.cli.register (a top-level\n`bb ` command agents run through bash); bb.agents.registerTool\n(native tools with\nzod or JSON-schema parameters); bb.ui.registerThreadAction /\nregisterMentionProvider (host-rendered UI — no\nfrontend bundle needed); bb.status.needsConfiguration (report\n\"unconfigured\" instead of crashing); bb.onDispose (LIFO cleanup on\nreload/disable/shutdown).\n\nFrontend entries register React slots (homepageSection, navPanel,\nthreadPanelAction, composerAccessory) via definePluginApp, use the hooks\nlisted above, and render vendored components; styling is Tailwind against\nthe host theme's tokens only (semantic classes like bg-background and\ntw-animate-css utilities compile in plugin builds).\n\nFor the complete authoring reference — exact signatures, working snippets\nfor every surface, the reload lifecycle, testing tips, and gotchas — use\nthe built-in `bb-plugin-authoring` skill (agents: it loads on demand;\nhumans: apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/\nin a checkout). The `examples/plugins/` directory of a bb checkout has four\nreference plugins: github (full-stack: gh-CLI-backed issue/PR browser on\nvendored shadcn components), slack-bot (webhook bot), agent-enrichment\n(agent surfaces), small-ux-pack (host-rendered UI).", "fileName": "bb-guide-plugins.md", "kind": "instruction", "title": "bb Guide — Plugins", diff --git a/packages/templates/src/templates/bb-guide-plugins.md b/packages/templates/src/templates/bb-guide-plugins.md index 0c3d4d773..0efdd270b 100644 --- a/packages/templates/src/templates/bb-guide-plugins.md +++ b/packages/templates/src/templates/bb-guide-plugins.md @@ -70,8 +70,9 @@ arrives as the component's subPath prop for panel-internal deep links), threadPanelAction (an entry in the thread right panel's new-tab Actions list whose run() can open closable panel tabs with JSON params), composerAccessory (prompt box -footer), and fileOpener (register as a per-extension file viewer/editor -users can set as the default via a file tab's "Open with" menu). Hooks: +footer), and 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). Hooks: useRpc, useRealtime, useSettings (secrets excluded), useBbContext, useBbNavigate, and useComposer (quote selections / insert mention pills into the chat composer draft). Components are vendored shadcn source the plugin owns (the diff --git a/plans/plugin-system-design.md b/plans/plugin-system-design.md index 3a7c7493c..7901639e4 100644 --- a/plans/plugin-system-design.md +++ b/plans/plugin-system-design.md @@ -559,7 +559,7 @@ V1 slot set with **versioned per-slot props contracts** (additive-only within a | `navPanel` | `{}` (own route) | `AppRoutes` + `AppSidebar.tsx` (route + sidebar entry + nav state) | | `threadPanelAction` | action: `run({ threadId, openPanel })`; opened tab: `{ threadId, params }` | `NewTabFileSearch.tsx` Actions list; tabs are `plugin-panel` file-strip tabs (`fixed-panel-tabs-state.ts`) *(reworked 2026-07-03: replaced the fixed `threadPanelTab` toggle — actions run code and open closable, param-carrying tabs with the plugin logo as tab icon)* | | `composerAccessory` | `{ projectId, threadId: string \| null }` | `PromptBoxInternal.tsx` (`footerStart` slot prop already exists) | -| `fileOpener` *(added 2026-07-04)* | `{ path, source: { kind: workspace\|host\|thread-storage, threadId, environmentId, projectId } }` | `useThreadFileTabs.openTab` diversion → `plugin-panel` tabs (`file-opener:` action ids); "Open with" context menu on file tab pills; per-extension default in localStorage (`file-opener-preference.ts`). Live content only — never git-ref snapshots | +| `fileOpener` *(added 2026-07-04)* | `{ path, source: { kind: workspace\|host\|thread-storage, threadId, environmentId, projectId } }` | `useThreadFileTabs.openTab` diversion → `plugin-panel` tabs (`file-opener:` action ids); "Open with" context menu on rendered-markdown file links (per-open); Settings → File openers section for per-extension defaults in localStorage (`file-opener-preference.ts`). Live content only — never git-ref snapshots | | `settingsSection` | auto-generated from settings schema | `SettingsView.tsx` | Hooks from `@bb/plugin-sdk/app`: `useRpc()`, `useRealtime()`, diff --git a/plans/plugin-system-qa-catalog.md b/plans/plugin-system-qa-catalog.md index 957fcd0ca..b6da0a7f9 100644 --- a/plans/plugin-system-qa-catalog.md +++ b/plans/plugin-system-qa-catalog.md @@ -923,10 +923,11 @@ docs pins, cli `notes-example-bundle.test.ts`. - [ ] **CAS save race live**: open a note in the notes editor, edit the file on disk (or via an agent), save in the editor → conflict banner with Reload/Overwrite; Reload shows the disk content. -- [ ] **Open with menu live**: open a `.md` workspace file → right-click the - tab → "Open with Notes editor" swaps the tab in place; "Always open - .md with Notes editor" then makes markdown links open in the editor; - "Open with built-in preview" switches back. +- [ ] **Open with live**: Settings → File openers shows a `.md` row once the + notes plugin is installed; picking "Notes editor" makes markdown links + open in the editor. Right-clicking a markdown link offers one-off + "Open with built-in preview" / "Open with Notes editor" that override + the default for that open only. - [ ] **Opener fallback live**: set the .md default to the notes opener, disable the plugin → opening a markdown file lands on the built-in preview (no dead tab); the persisted opener tab shows the placeholder. From ad2c6552f2eb876d71235c42bf46193dec7f10d6 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Sat, 4 Jul 2026 10:27:12 -0700 Subject: [PATCH 8/8] fix: Open-with link menu reaches timeline links via context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The timeline builds its own MarkdownLinkRouting deep in ConversationMessageContent, so the routing-field approach never reached chat-message links (the main place users click file links). The menu items provider now rides MarkdownLocalFileOpenWithContext, provided once at the thread view root — timeline and file-preview links get the menu uniformly, and the routing field is gone (one path). Co-Authored-By: Claude Fable 5 --- .../components/ui/markdown-link-routing.ts | 26 +++++-- .../components/ui/markdown-preview.test.tsx | 77 +++++++++---------- .../src/components/ui/markdown-preview.tsx | 13 ++-- .../views/thread-detail/ThreadDetailView.tsx | 20 ++--- 4 files changed, 69 insertions(+), 67 deletions(-) diff --git a/apps/app/src/components/ui/markdown-link-routing.ts b/apps/app/src/components/ui/markdown-link-routing.ts index 5679905f2..03eb8ba54 100644 --- a/apps/app/src/components/ui/markdown-link-routing.ts +++ b/apps/app/src/components/ui/markdown-link-routing.ts @@ -1,3 +1,4 @@ +import { createContext } from "react"; import type { MarkdownPreviewLinkHandler } from "./markdown-link.js"; import type { MarkdownAbsoluteLocalFileLinkRouting, @@ -13,18 +14,27 @@ export interface MarkdownLocalFileOpenWithItem { onSelect: () => void; } +/** + * Viewer choices for the right-click menu on local file links (e.g. "Open + * with built-in preview" / plugin file openers). Null/empty = no menu; + * left-click behavior is unchanged either way. + */ +export type MarkdownLocalFileOpenWithItemsProvider = ( + link: MarkdownPreviewLocalFileLink, +) => MarkdownLocalFileOpenWithItem[] | null; + +/** + * Context, not a routing field: local file links render across the thread + * timeline and every file-preview surface, whose link routings are built in + * many places — one provider at the view root covers them all uniformly. + */ +export const MarkdownLocalFileOpenWithContext = + createContext(null); + export interface MarkdownLocalFileLinkRouting { absoluteLinks: MarkdownAbsoluteLocalFileLinkRouting; onOpenLink: MarkdownPreviewLocalFileLinkHandler; relativeLinks?: MarkdownRelativeLocalFileLinkRouting; - /** - * Viewer choices for the right-click menu on a local file link (e.g. - * "Open with built-in preview" / plugin file openers). Null/empty = no - * menu; left-click behavior is unchanged either way. - */ - getOpenWithItems?: ( - link: MarkdownPreviewLocalFileLink, - ) => MarkdownLocalFileOpenWithItem[] | null; } export interface MarkdownLinkRouting { diff --git a/apps/app/src/components/ui/markdown-preview.test.tsx b/apps/app/src/components/ui/markdown-preview.test.tsx index d607f46ef..ff2b8a557 100644 --- a/apps/app/src/components/ui/markdown-preview.test.tsx +++ b/apps/app/src/components/ui/markdown-preview.test.tsx @@ -3,7 +3,10 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { MarkdownPreview } from "./markdown-preview"; -import type { MarkdownLinkRouting } from "./markdown-link-routing"; +import { + MarkdownLocalFileOpenWithContext, + type MarkdownLinkRouting, +} from "./markdown-link-routing"; const workspaceLinkRouting = { localFile: { @@ -74,34 +77,38 @@ describe("MarkdownPreview", () => { expect(screen.getByText("src/app.ts").tagName).toBe("CODE"); }); - it("shows an Open with menu on local file links when routing provides items", () => { + it("shows an Open with menu on local file links when the context provides items", () => { const openBuiltin = vi.fn(); const openWithPlugin = vi.fn(); render( - true), - getOpenWithItems: (link) => - link.path.endsWith(".md") - ? [ - { - id: "builtin", - label: "Open with built-in preview", - onSelect: openBuiltin, - }, - { - id: "notes:editor", - label: "Open with Notes editor", - onSelect: openWithPlugin, - }, - ] - : null, - }, - }} - />, + + link.path.endsWith(".md") + ? [ + { + id: "builtin", + label: "Open with built-in preview", + onSelect: openBuiltin, + }, + { + id: "notes:editor", + label: "Open with Notes editor", + onSelect: openWithPlugin, + }, + ] + : null + } + > + true), + }, + }} + /> + , ); const link = screen.getByRole("link", { name: /notes/ }); @@ -109,23 +116,9 @@ describe("MarkdownPreview", () => { fireEvent.click(screen.getByText("Open with Notes editor")); expect(openWithPlugin).toHaveBeenCalledTimes(1); expect(openBuiltin).not.toHaveBeenCalled(); - }); - it("renders plain anchors when the routing offers no viewer items", () => { - render( - true), - getOpenWithItems: () => null, - }, - }} - />, - ); - const link = screen.getByRole("link", { name: /app/ }); - fireEvent.contextMenu(link); + // The provider returned null for the .ts link — plain anchor, no menu. + fireEvent.contextMenu(screen.getByRole("link", { name: /app/ })); expect(screen.queryByText(/Open with/)).toBeNull(); }); diff --git a/apps/app/src/components/ui/markdown-preview.tsx b/apps/app/src/components/ui/markdown-preview.tsx index 6f31b6a32..f988c4c72 100644 --- a/apps/app/src/components/ui/markdown-preview.tsx +++ b/apps/app/src/components/ui/markdown-preview.tsx @@ -1,6 +1,7 @@ import { memo, useLayoutEffect, + useContext, useMemo, useRef, useState, @@ -47,9 +48,10 @@ import { type MarkdownAbsoluteLocalFileLinkRouting, type MarkdownRelativeLocalFileLinkRouting, } from "./markdown-local-file-link.js"; -import type { - MarkdownLinkRouting, - MarkdownLocalFileLinkRouting, +import { + MarkdownLocalFileOpenWithContext, + type MarkdownLinkRouting, + type MarkdownLocalFileLinkRouting, } from "./markdown-link-routing.js"; import { buildThreadMentionComponent, @@ -479,9 +481,10 @@ function MarkdownAnchor({ }) : null; const anchorHref = buildLocalFileAnchorHref(localFileLink, rewrittenHref); + const getOpenWithItems = useContext(MarkdownLocalFileOpenWithContext); const openWithItems = - localFileLink !== null && localFileRouting?.getOpenWithItems - ? localFileRouting.getOpenWithItems(localFileLink) + localFileLink !== null && getOpenWithItems !== null + ? getOpenWithItems(localFileLink) : null; const handleAnchorClick = (event: MarkdownAnchorEvent) => { if (localFileLink && onOpenLocalFileLink) { diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index 246dac2b1..4c701aec8 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -181,9 +181,10 @@ import { resolveThreadLocalFileLink, type ThreadLocalFileLinkResolution, } from "@/lib/thread-local-file-links"; -import type { - MarkdownLinkRouting, - MarkdownLocalFileLinkRouting, +import { + MarkdownLocalFileOpenWithContext, + type MarkdownLinkRouting, + type MarkdownLocalFileLinkRouting, } from "@/components/ui/markdown-link-routing"; import { useFixedPanelTabsState, @@ -356,7 +357,6 @@ function PopoutThreadHeader({ interface BuildMarkdownPreviewLinkRoutingArgs { baseDir: string | undefined; - getOpenWithItems?: MarkdownLocalFileLinkRouting["getOpenWithItems"]; onOpenLink: ThreadTimelineLinkHandler; onOpenLocalFileLink: ThreadTimelineLocalFileLinkHandler; rootPath: string | null | undefined; @@ -390,7 +390,6 @@ function buildHostConnectionNotice( function buildMarkdownPreviewLinkRouting({ baseDir, - getOpenWithItems, onOpenLink, onOpenLocalFileLink, rootPath, @@ -407,7 +406,6 @@ function buildMarkdownPreviewLinkRouting({ rootPath, }, onOpenLink: onOpenLocalFileLink, - ...(getOpenWithItems !== undefined ? { getOpenWithItems } : {}), }; if (baseDir !== undefined) { localFileRouting.relativeLinks = { @@ -1924,13 +1922,11 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { () => buildMarkdownPreviewLinkRouting({ baseDir: workspaceFileLinkBaseDir, - getOpenWithItems: getLocalFileOpenWithItems, onOpenLink: handleOpenTimelineLink, onOpenLocalFileLink: handleOpenTimelineLocalFileLink, rootPath: workspacePreviewRootPath, }), [ - getLocalFileOpenWithItems, handleOpenTimelineLink, handleOpenTimelineLocalFileLink, workspaceFileLinkBaseDir, @@ -1941,13 +1937,11 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { () => buildMarkdownPreviewLinkRouting({ baseDir: hostFileLinkBaseDir, - getOpenWithItems: getLocalFileOpenWithItems, onOpenLink: handleOpenTimelineLink, onOpenLocalFileLink: handleOpenTimelineLocalFileLink, rootPath: hostFileLinkRootPath, }), [ - getLocalFileOpenWithItems, handleOpenTimelineLink, handleOpenTimelineLocalFileLink, hostFileLinkBaseDir, @@ -1958,13 +1952,11 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { () => buildMarkdownPreviewLinkRouting({ baseDir: storageFileLinkBaseDir, - getOpenWithItems: getLocalFileOpenWithItems, onOpenLink: handleOpenTimelineLink, onOpenLocalFileLink: handleOpenTimelineLocalFileLink, rootPath: threadStorageRootPath, }), [ - getLocalFileOpenWithItems, handleOpenTimelineLink, handleOpenTimelineLocalFileLink, storageFileLinkBaseDir, @@ -2259,6 +2251,9 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { ); return ( + ) : null} + ); }