Перейти к содержимому

Рантайм и жизненный цикл

Здесь — что 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)

The three plugin entries are the filled boxes: bb.server in the server process, bb.app in the application’s realm, bb.host on the daemon. Each runs at a different level of trust.

Браузер (или рендерер 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.tsapp.tsxhost.ts
Процесспроцесс сервера bbJS-контекст приложения (браузер или рендерер)форкнутый дочерний процесс на демоне хоста
Форма модуляexport default (bb: BbPluginApi) => …, sync или async; возвращаемое значение игнорируетсяexport default definePluginApp((app) => …), с меткой __bbPluginAppexport default experimental_defineHostEntry({ contract, handlers, dispose? })
Довериеполное: тот же процесс, что и сервер; без изоляцииполное доверие к коду страницы с тем же origin, не песочница; изоляция только от паденийполное на своей машине: Node 22, тот же пользователь ОС, что и агент
Что может импортироватьлюбые встроенные модули Node и npm-зависимости; SDK и better-sqlite3 остаются внешнимивсё, что работает в браузере; пакеты-шимы берутся из хоста; zod собирается в бандлчистый JS собирается целиком; приватные @bb/* запрещены при разрешении и загрузке
Цель / форматesm · node · node22 · sourcemapesm · browser · es2022 · minify (кроме dev)esm · node · node22 · sourcemap · без внешних зависимостей (no externals)
Как общается с остальнымиобслуживает bb.rpc/bb.http, публикует bb.realtime, вызывает хост через bb.hosts.experimental_clientuseRpc() → 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), а не повторной отправкой сообщения. Переходы состояний окружения нарисованы в разделе Провайдеры окружения.

server.ts
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.

// mount: /api/v1/plugins/<id>/http<path> — an EXACT match;
// ":" and "*" are literal characters, not parameters and not wildcards
bb.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"; это не сессия пользователя и не идентичность.

Вызов bb.realtime.publish(channel, payload) идёт по цепочке notifyPluginSignal → broadcastToAllClients и доходит до каждого клиента по одному общему WebSocket /ws в виде { type, pluginId, channel, payload }. В V1 нет серверных подписок на каналы: хук useRealtime(channel, handler) фильтрует сообщения по pluginId и channel на клиенте. Полезная нагрузка доходит до сокета каждого подключённого клиента, так что это не граница конфиденциальности. Сигналы не воспроизводятся повторно. Проверяйте долгоживущее состояние заново, когда useRealtimeConnectionState() переходит в connected.

Команда 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 собраны в таблице числовых лимитов в разделе Бэкенд-неймспейсы.

packages/server-contract/src/api/plugins.ts
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, или для этого источника активна блокировка загрузки
missingstat(row.rootDir) упал → «plugin directory not found: … (reinstall)»
errorманифест не парсится; проблема с артефактом хоста; фабрика бросила исключение или превысила тайм-бокс; сервис упал во время активации
incompatibleengines.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 в 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

The previous instance is disposed after the new factory has run, not before it, so a failing reload leaves the running plugin alone. One failing dispose hook does not stop the rest of the cleanup.

Порядок работы loadOne:

#ШагПримечание
1hold → identity → enabledудерживаемый источник или !row.enabled → disabled
2stat(rootDir) → манифестнет директории → missing
3engines и диапазон SDKнет совпадения → incompatible
4бандл приложения и артефакт хостадайджест проверяется по host.meta.json
5ассеты брендингавалидатор SVG может завалить загрузку
6createPluginApiобъект bb для этой загрузки
7jiti.import + фабрикатайм-бокс в 30 с
8dispose предыдущего экземпляраздесь и только здесь: после фабрики
9loaded.set(id, …)wireLookup начинает его видеть
10handle.activate()провайдеры, ИИ-сервисы, порты
11cron-строки и сервисы→ setStatus("running")

Функция disposePluginInstance выполняется в строгом порядке:

#ШагПримечание
1closeWebSocketsкод 1012
2disposePluginHost{ pluginId, generation }
3abortPluginToolCalls"plugin-disposed"
4interruptInteractionsreason: plugin-disposed
5stopServices5 с → degraded
6onDisposeобратный порядок; одна ошибка не останавливает остальные
7drainInvocationsожидание выполняющейся работы, логирование через 5 с
8закрытие better-sqlite3каждый отслеживаемый дескриптор
9finally 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".

ЗначениеЧто это такое
1. эпоха кеша модулей (сервер)registerHooks добавляет ?bbPluginLoad=<rootId>.<epoch> к каждому file:-URL внутри наблюдаемого корня плагина и очищает require.cache; работает только для источников path и builtin. Именно это, а не кеш jiti, заставляет переоценивать весь граф модулей
2. идентичность артефакта хостаВызов randomUUID() для каждой загрузки артефакта хоста. Шаг dispose воркера адресуется по этому идентификатору. Выход воркера и доставка сигналов ограничены им же
3. поколение монтирования фронтендаМонотонный счетчик для каждого клиента. Увеличивается при каждой переинтерпретации и приходит в content script как context.generation

Оба пути используют 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. Они не меняют статус самого плагина.