Рантайм и жизненный цикл
Здесь — что bb делает с плагином во время работы: где и как загружается каждая точка входа, восемь статусов, порядок загрузки и dispose, горячая перезагрузка, фоновые сервисы и расписания. Сначала прочитайте Как работает плагин, чтобы понять модель, стоящую за этими деталями.
Где работает каждая точка входа
Заголовок раздела «Где работает каждая точка входа»Четыре процесса разделены двумя контрактами: @bb/server-contract между клиентами и сервером и @bb/host-daemon-contract между сервером и демонами. Каждая точка входа плагина работает в своём процессе и с разным уровнем доверия.
Browser · Electron renderer — one JS realm, one origin · the user and the agent
plugin dist/app.js inside the bb React app
native import(), the same realm — not a sandbox
globalThis.__bbPluginRuntime ← installPluginRuntime()
shims: react, radix portals, sonner, vaul, clsx…
1↓ useRpc → POST /rpc → Hono · /api/v1/* (origin guard) · /internal/* (daemon bearer)
bb CLI and the agent
$BB_CLI · bb <command>
agent tools and skills
the tool set is frozen at session start
3↓ POST /plugins/<id>/cli → Hono · /api/v1/* (origin guard) · /internal/* (daemon bearer)
Server process (Node) — SQLite is the source of truth, 127.0.0.1 by default
Hono · /api/v1/* (origin guard) · /internal/* (daemon bearer)
/plugins/<id>/{assets, rpc, http, cli, token} · WebSocket /ws · POST /plugins/reload
the only exemption from the origin guard is /plugins/<id>/http/*
2↓ /ws: plugin-signal → plugin dist/app.js inside the bb React app (signal back)
4↓ plugin server.ts — jiti.import, same process
plugin server.ts — jiti.import, same process
full trust: fs, net, child_process, fetch
bb.rpc · bb.http · bb.realtime · bb.storage
bb.agents · bb.cli · bb.providers · bb.sdk
factory time-boxed at 30 s, dispose LIFO
6↓ plugin.host.call → plugin host worker — dist/host.js
PluginService
loadAll / loadOne / reload
status: 8 values
wireLookup(loaded)
generation = randomUUID() per artifact load
5↓ thread commands → provider subprocess — the agent
Host daemon — one per enrolled machine
plugin host worker — dist/host.js
fork(bb-plugin-host-worker.mjs)
stdio: ignore, ignore, pipe, ipc
sha256 verified before launch, ≤ 256 MiB
7↓ experimental_emitSignal → plugin server.ts — jiti.import, same process (signal back)
8↓ bootstrap({ key, executor }) → MachineExecutor.exec(argv)
provider subprocess — the agent
bridge: JSON-RPC 2.0 over stdio
the delta assembler stays in the daemon
claude-code · codex · pi · acp-*
Remote machine — provisioned by a machine provider
MachineExecutor.exec(argv)
the plugin owns the transport, stdin stays private
output flows through onOutput into the progress log
installed host daemon
enroll → server, its own WebSocket session
its own copies of the plugins’ host workers
9↓ enrollment + WebSocket back → Hono · /api/v1/* (origin guard) · /internal/* (daemon bearer) (signal back)
Браузер (или рендерер Electron) — это один JS-контекст (realm) и один origin. Пользователь и агент делят их между собой. Бандл плагина dist/app.js нативно импортируется в этот же контекст без песочницы и берёт шимы из globalThis.__bbPluginRuntime, который устанавливает installPluginRuntime(). CLI bb и агент обращаются к серверу через $BB_CLI, а также через инструменты и скиллы агента; набор инструментов замораживается в начале сессии.
Процесс сервера — это Node с SQLite в качестве источника правды, по умолчанию привязанный к 127.0.0.1. Hono отдаёт /api/v1/* за проверкой origin, а /internal/* — за bearer-кредами демона: /plugins/<id>/{assets, rpc, http, cli, token}, WebSocket /ws и POST /plugins/reload. Файл server.ts плагина загружается в этот же процесс через jiti.import с полным доверием (fs, net, child_process, fetch). PluginService содержит функции loadAll / loadOne / reload, восемь значений статуса, мапу wireLookup(loaded) и generation = randomUUID() для каждой загрузки артефакта.
Демон хоста запускается один раз на каждую зарегистрированную машину. Он форкает хост-воркер плагина из dist/host.js (fork(bb-plugin-host-worker.mjs), stdio ignore, ignore, pipe, ipc, проверяет sha256 перед запуском, не более 256 MiB) и подпроцесс провайдера, выступающий агентом. Провайдер общается через мост по JSON-RPC 2.0 через stdio, тогда как ассемблер дельт остаётся в демоне (claude-code, codex, pi, acp-*). Доступ к удалённой машине, которую создаёт провайдер машины, идёт через MachineExecutor.exec(argv): плагин управляет транспортом, stdin остаётся приватным, а вывод течёт через onOutput в лог прогресса. На этой машине работает собственный демон хоста. Он регистрируется на сервере, держит отдельную сессию WebSocket и свои копии хост-воркеров плагинов.
Механизмы загрузки
Заголовок раздела «Механизмы загрузки»| Точка входа | Обязательна | Процесс | Механизм загрузки |
|---|---|---|---|
bb.server | да | процесс сервера, в том же процессе | jiti.import, moduleCache:false, SDK указывает на копию хоста |
bb.app | нет | тот же JS-контекст, что и React-приложение bb | нативный import(url) по HTTP; шимы через один глобальный объект |
bb.host | нет, ровно одна | форкнутый дочерний процесс на демоне хоста | fork() + import(pathToFileURL(...)), дайджест sha256 проверяется перед запуском |
Файлы сборки, откуда загружается каждая точка входа, перечислены в Анатомии пакета.
У серверной точки входа нет песочницы и изоляции. В apps/server/src/services/plugins/ нет ни worker_threads, ни node:vm, ни spawn. Настройки jiti содержат только moduleCache и alias: нет списка разрешённых модулей и перехвата импортов. Нативные аддоны не поддерживаются, ошибка ERR_DLOPEN_FAILED выводит соответствующее пояснение.
Клиентская точка входа делит контекст с приложением, но получает собственный предохранитель (error boundary). eval, new Function и blob-URL не используются. Бандл загружается из /api/v1/plugins/<id>/assets/app.js?h=<hash16>, а его экспорт по умолчанию должен содержать метку __bbPluginApp === true. Каждая регистрация оборачивается в PluginSlotBoundary. Упавший слот остаётся мёртвым до конца сессии или до перезагрузки плагина.
Хостовая точка входа доставляется по контентной адресации. Демон скачивает артефакт из внутреннего API (GET /internal/plugins/<id>/host/<sha256>). Он проверяет длину и хеш при скачивании и при каждом повторном использовании кеша. Затем артефакт сохраняется в <daemonDataDir>/plugin-host-artifacts/<id>/<sha256>/host.mjs.
Три точки входа в сравнении
Заголовок раздела «Три точки входа в сравнении»server.ts | app.tsx | host.ts | |
|---|---|---|---|
| Процесс | процесс сервера bb | JS-контекст приложения (браузер или рендерер) | форкнутый дочерний процесс на демоне хоста |
| Форма модуля | export default (bb: BbPluginApi) => …, sync или async; возвращаемое значение игнорируется | export default definePluginApp((app) => …), с меткой __bbPluginApp | export default experimental_defineHostEntry({ contract, handlers, dispose? }) |
| Доверие | полное: тот же процесс, что и сервер; без изоляции | полное доверие к коду страницы с тем же origin, не песочница; изоляция только от падений | полное на своей машине: Node 22, тот же пользователь ОС, что и агент |
| Что может импортировать | любые встроенные модули Node и npm-зависимости; SDK и better-sqlite3 остаются внешними | всё, что работает в браузере; пакеты-шимы берутся из хоста; zod собирается в бандл | чистый JS собирается целиком; приватные @bb/* запрещены при разрешении и загрузке |
| Цель / формат | esm · node · node22 · sourcemap | esm · browser · es2022 · minify (кроме dev) | esm · node · node22 · sourcemap · без внешних зависимостей (no externals) |
| Как общается с остальными | обслуживает bb.rpc/bb.http, публикует bb.realtime, вызывает хост через bb.hosts.experimental_client | useRpc() → POST; useRealtime() ← WebSocket; не может вызывать хост | отвечает на plugin.host.call, отправляет experimental_emitSignal обратно на сервер |
| Типы | import type { BbPluginApi } from "@get-bb/plugin-sdk", стираются при загрузке | @get-bb/plugin-sdk/app | @get-bb/plugin-sdk/host |
Состояния объектов
Заголовок раздела «Состояния объектов»Плагин читает эти состояния через bb.sdk и реагирует на них через события треда.
| Объект | Состояния |
|---|---|
| Тред | pending, idle, starting, active, stopping, error |
| Ход | accepted → dispatched → started → completed|failed|interrupted |
| Окружение | creating, provisioning, ready, error, destroyed |
| Машина | available | setup-required | unavailable |
Запрашивайте повторную попытку по ссылке (bb.sdk.threads.retry), а не повторной отправкой сообщения. Переходы состояний окружения нарисованы в разделе Провайдеры окружения.
Детали обмена
Заголовок раздела «Детали обмена»RPC-контракт
Заголовок раздела «RPC-контракт»import { defineRpcContract } from "@get-bb/plugin-sdk";import { z } from "zod";
export const rpcContract = defineRpcContract({ listIssues: { input: z.object({ filter: z.string() }), output: z.object({ issues: z.array(z.string()) }) }, ping: { input: z.null(), output: z.object({ ok: z.literal(true) }) },});
export default function plugin(bb: BbPluginApi) { bb.rpc.register(rpcContract, { listIssues: ({ filter }) => ({ issues: search(filter) }), ping: () => ({ ok: true as const }), });}Валидатор использует Standard Schema v1 (zod 4 реализует её напрямую). Значение z.null() на входе позволяет фронтенду опускать аргумент. Имена методов состоят из разделённых точками сегментов с буквами, цифрами, - и _. Хук useRpc<typeof rpcContract>() вызывает POST /plugins/<id>/rpc/<method> с auth: local (защита origin) и no-store: сначала отрабатывает схема ввода, затем обработчик, затем схема вывода. Неизвестные методы возвращают 404 unknown_method, невалидный ввод — 400 invalid_input.
HTTP-маршруты и токен
Заголовок раздела «HTTP-маршруты и токен»// mount: /api/v1/plugins/<id>/http<path> — an EXACT match;// ":" and "*" are literal characters, not parameters and not wildcardsbb.http.route("POST", "/upload", async (context) => { const body = await readRequestBody(context.req.raw); return context.json({ ok: true }, 201);}, { auth: "token" });
bb.http.experimental_websocket("/live", ({ request, url }) => ({ onOpen(socket) { socket.send("hello"); }, onMessage(socket, data) { /* … */ }, onClose({ code, reason }) { /* reload/disable → 1012 */ },}), { auth: "local" });Команда bb plugin token <id> [--rotate] выдаёт 32 случайных байта в hex из <dataDir>/plugins/<id>/secrets/.http-token. У файла права 0o600, сервер сравнивает токен через timingSafeEqual. Токен работает ровно для одного плагина и только для его маршрутов с auth: "token"; это не сессия пользователя и не идентичность.
Realtime
Заголовок раздела «Realtime»Вызов bb.realtime.publish(channel, payload) идёт по цепочке notifyPluginSignal → broadcastToAllClients и доходит до каждого клиента по одному общему WebSocket /ws в виде { type, pluginId, channel, payload }. В V1 нет серверных подписок на каналы: хук useRealtime(channel, handler) фильтрует сообщения по pluginId и channel на клиенте. Полезная нагрузка доходит до сокета каждого подключённого клиента, так что это не граница конфиденциальности. Сигналы не воспроизводятся повторно. Проверяйте долгоживущее состояние заново, когда useRealtimeConnectionState() переходит в connected.
CLI, инструменты агента и хостовый RPC
Заголовок раздела «CLI, инструменты агента и хостовый RPC»Команда bb <command> … отправляет { argv, cwd?, threadId? } в POST /plugins/<id>/cli (массив argv не содержит имя команды). Сервер вызывает инструмент агента внутри своего процесса с ctx: { threadId, projectId, signal }. Инструмент возвращает ошибку текстом, а не бросает исключение.
Сервер обращается к хостовой точке входа через bb.hosts.experimental_client({ contract }).call(method, input, { hostId, signal, timeoutMs }). Обмен идёт событиями plugin.host.call / .cancel / .dispose по WebSocket демона. В обратную сторону вызов context.experimental_emitSignal(name, payload) приходит в experimental_onSignal. Демон вытесняет простаивающий воркер через 5 минут. Лимиты на объём вывода, размер полезной нагрузки и таймауты для CLI и хостового RPC собраны в таблице числовых лимитов в разделе Бэкенд-неймспейсы.
Статусы
Заголовок раздела «Статусы»export const pluginRuntimeStatusSchema = z.enum([ "starting", "running", "error", "incompatible", "missing", "disabled", "degraded", "needs-configuration",]);// there is NO "healthy" value — the healthy state is called "running"| Статус | Причина |
|---|---|
starting | строка включена, но ещё не загружена |
disabled | !row.enabled, или для этого источника активна блокировка загрузки |
missing | stat(row.rootDir) упал → «plugin directory not found: … (reinstall)» |
error | манифест не парсится; проблема с артефактом хоста; фабрика бросила исключение или превысила тайм-бокс; сервис упал во время активации |
incompatible | engines.bb или engines.bbPluginSdk не совпали; проблема с артефактом упакованного builtin-плагина |
needs-configuration | вызов bb.status.needsConfiguration(msg) или сервис упал с NeedsConfigurationError |
degraded | фоновый сервис не остановился в пределах serviceStopTimeoutMs (5 с) |
running | успешная загрузка, а также статус, который сохраняется при неудачной перезагрузке с деталью «reload failed: …» |
Сервер сопоставляет NeedsConfigurationError по имени, поэтому импорт в рантайме не нужен: пишите throw Object.assign(new Error(msg), { name: "NeedsConfigurationError" }). Статус сбрасывается при следующей загрузке. Соседние перечисления: состояние сервиса — "running" | "backoff" | "stopped", поле lastStatus расписания — "running" | "ok" | "error", результат обновления — "current" | "update-available" | "pinned" | "incompatible" | "unavailable".
Порядок загрузки и dispose
Заголовок раздела «Порядок загрузки и dispose»При перезагрузке сервер сначала загружает новый экземпляр, а затем делает dispose старого. Если новая фабрика бросает исключение, предыдущий экземпляр продолжает работать, так как шаг dispose в loadOne стоит после фабрики.
loadOne
1hold → identity → enabled
a held source or !row.enabled → disabled
↓ stat(rootDir) → manifest
2stat(rootDir) → manifest
no directory → missing
↓ engines and SDK range
3engines and SDK range
no match → incompatible
↓ app bundle and host artifact
4app bundle and host artifact
the digest is checked against host.meta.json
↓ branding assets
5branding assets
the SVG validator can fail the load
↓ createPluginApi
6createPluginApi
the bb object for this load
↓ jiti.import + factory
7jiti.import + factory
time-boxed at 30 s
↓ dispose the previous instance
↓ throw → the factory throws (signal back)
8dispose the previous instance
here, after the factory — not before it
↓ loaded.set(id, …)
9loaded.set(id, …)
wireLookup starts seeing it
↓ handle.activate()
10handle.activate()
providers, AI services, ports
↓ cron strings and services
11cron strings and services
→ setStatus("running")
the factory throws
the previous instance stays alive; status running with the detail “reload failed: …”
disposePluginInstance — strict order
1closeWebSockets
code 1012
↓ disposePluginHost
2disposePluginHost
{ pluginId, generation }
↓ abortPluginToolCalls
3abortPluginToolCalls
"plugin-disposed"
↓ interruptInteractions
4interruptInteractions
reason: plugin-disposed
↓ stopServices
5stopServices
5 s → degraded
↓ onDispose
6onDispose
LIFO; one failing hook does not stop the rest
↓ drainInvocations
7drainInvocations
wait for in-flight work, log after 5 s
↓ close better-sqlite3
8close better-sqlite3
every tracked handle
↓ finally handle.invalidate()
9finally handle.invalidate()
any late bb.* call → PluginContextStaleError
Порядок работы loadOne:
| # | Шаг | Примечание |
|---|---|---|
| 1 | hold → identity → enabled | удерживаемый источник или !row.enabled → disabled |
| 2 | stat(rootDir) → манифест | нет директории → missing |
| 3 | engines и диапазон SDK | нет совпадения → incompatible |
| 4 | бандл приложения и артефакт хоста | дайджест проверяется по host.meta.json |
| 5 | ассеты брендинга | валидатор SVG может завалить загрузку |
| 6 | createPluginApi | объект bb для этой загрузки |
| 7 | jiti.import + фабрика | тайм-бокс в 30 с |
| 8 | dispose предыдущего экземпляра | здесь и только здесь: после фабрики |
| 9 | loaded.set(id, …) | wireLookup начинает его видеть |
| 10 | handle.activate() | провайдеры, ИИ-сервисы, порты |
| 11 | cron-строки и сервисы | → setStatus("running") |
Функция disposePluginInstance выполняется в строгом порядке:
| # | Шаг | Примечание |
|---|---|---|
| 1 | closeWebSockets | код 1012 |
| 2 | disposePluginHost | { pluginId, generation } |
| 3 | abortPluginToolCalls | "plugin-disposed" |
| 4 | interruptInteractions | reason: plugin-disposed |
| 5 | stopServices | 5 с → degraded |
| 6 | onDispose | обратный порядок; одна ошибка не останавливает остальные |
| 7 | drainInvocations | ожидание выполняющейся работы, логирование через 5 с |
| 8 | закрытие better-sqlite3 | каждый отслеживаемый дескриптор |
| 9 | finally handle.invalidate() | любые запоздалые bb.* → PluginContextStaleError |
Один упавший хук dispose не останавливает остальную очистку. Функция disposeOne дополнительно удаляет артефакт хоста и отзывает объявления общих портов.
Время работы фабрики ограничено 30 секундами: DEFAULT_LOAD_TIMEOUT_MS = 30_000 применяется через runFactoryTimeBoxed. Ключевые регистрации должны оставаться уникальными в рамках одного запуска фабрики. К ним относятся настройки, маршруты, RPC-методы, сервисы, расписания, регистрация CLI, инструменты, провайдеры инструкций и упоминаний. Слушатели (bb.events.on, settings.onChange, bb.onDispose) аддитивны.
Хуки onDispose выполняются в порядке, обратном регистрации. Очищайте таймеры и закрывайте соединения именно в них. После handle.invalidate() любой метод сохранённого объекта bb бросает PluginContextStaleError. Не храните bb в состоянии на уровне модуля.
HTTP- и WS-маршруты, RPC-методы, инструменты агента, хуки, провайдеры упоминаний, окружения и машин, а также расписания нельзя отменить по одному. Они привязаны к дескриптору и становятся недоступными при изменении loaded: любой поиск идёт через wireLookup, который читает только эту мапу. Cron-строки остаются в plugin_schedules, но сборщик пропускает записи, если их плагин отсутствует в loaded.
Неудачная активация откатывает три вещи. Вызов rollbackGeneration?.() восстанавливает предыдущую эпоху mutable-root и возвращает вытесненные CJS-модули. Вызов discardCandidateHandle закрывает соединения упавшей фабрики с базой. Предыдущий экземпляр при этом не уничтожается. Вызывающий код получает ошибку «<message> (the previous instance is still running)». У управляемых обновлений есть второй уровень отката: неудачная активация восстанавливает снимок состояния, а pluginApplyUpdateResultSchema.outcome включает "rolled-back".
Три значения поколения (generation)
Заголовок раздела «Три значения поколения (generation)»| Значение | Что это такое |
|---|---|
| 1. эпоха кеша модулей (сервер) | registerHooks добавляет ?bbPluginLoad=<rootId>.<epoch> к каждому file:-URL внутри наблюдаемого корня плагина и очищает require.cache; работает только для источников path и builtin. Именно это, а не кеш jiti, заставляет переоценивать весь граф модулей |
| 2. идентичность артефакта хоста | Вызов randomUUID() для каждой загрузки артефакта хоста. Шаг dispose воркера адресуется по этому идентификатору. Выход воркера и доставка сигналов ограничены им же |
| 3. поколение монтирования фронтенда | Монотонный счетчик для каждого клиента. Увеличивается при каждой переинтерпретации и приходит в content script как context.generation |
Горячая перезагрузка: bb plugin dev
Заголовок раздела «Горячая перезагрузка: bb plugin dev»Оба пути используют createPluginDevLoop: debounce на 300 мс, который игнорирует директории dist, node_modules и .git. Сам цикл выглядит так: targets() → сборка app → сборка host → reload.
- Команда
bb plugin dev [path]следит за файлами черезfs.watch(rootDir, { recursive: true })и перезагружает плагин вызовомPOST /plugins/reload?id=…. Директория уже должна быть установлена. Бандл приложения собирается без минификации. - Сервер следит за исходниками плагинов из поставки bb (builtin-плагинов), только если задан флаг
deps.watchBuiltinPluginSources. Происходит внутрипроцессная пересборка, а затемdisposeOne+loadOne. Если вотчер падает, в лог пишется «source watcher failed; hot reload is off until the server restarts». - Открытые страницы подхватывают обновлённый UI через WebSocket-уведомление
plugins-changed. Еслиhashбандла плагина не изменился, фронтенд его пропускает. Ошибки dev-сборки попадают в детали статуса с пометкойfrontend bundle build failed/host bundle build failed.
Фоновые сервисы и расписания
Заголовок раздела «Фоновые сервисы и расписания»Сервис запускается после завершения работы фабрики. Он должен завершиться, когда сервер отменяет его signal. При падении сервис перезапускается с ограниченной экспоненциальной задержкой (bounded exponential backoff): базовое время 1 с, максимум 60 с, сброс счётчика ошибок через 5 минут. Ошибка вне промиса start (необработанное событие 'error' у EventEmitter, throw в таймере, отсоединённый reject) также считается падением. Сервер атрибутирует ошибки через AsyncLocalStorage, поэтому непойманное исключение одного плагина не убивает весь процесс сервера.
Расписание задаётся строкой из пяти полей cron в локальном времени сервера. Данные сохраняются (durable) по ключу (pluginId, name) в таблице plugin_schedules. Периодический сборщик забирает расписание через compare-and-swap по полю next_run_at, но делает это, только пока плагин загружен. Исключения в расписании попадают в last_status/last_error и отображаются в выводе bb plugin list. Они не меняют статус самого плагина.