6. Host tier — @get-bb/plugin-sdk/host
The host tier is plugin code that runs in a child process on a host machine (the core machine or an enrolled remote host). Node is allowed here. @get-bb/plugin-sdk/host is the authoring surface: defineHostEntry builds the entry's default export, defineHostRole and defineEnvironmentProvider build the rows the bootstrap selects, defineHostCommands / implementHostCommands / signalEmitter give typed commands and signals, and roleStorage / roleTempStorage open the role's private directory. SDK source: packages/plugin-sdk/src/host/index.ts. Kernel: packages/kernel-host (spec 05). Built wins; every deviation is marked.
Status legend in the tables: built (shipped and exercised by a kernel caller), served (the role process answers it, no kernel caller found), no-op, absent, unwired.
6.1 defineHostEntry and the bootstrap
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
defineHostEntry |
(input: HostEntryInput) => Required<HostEntry> |
The default export of host.ts. Fills apiVersion: 1; turns role rows into roles[role][name]; derives commands as the union of the rpc rows' tables; defaults signals to {} and dispose to a no-op. Two rows with the same (role, name) → conflict; one command name in two rpc rows → conflict. |
05 §4.7, §6.2 | built; HostEntryInput has no commands (SDK README dev. 8) |
HostEntryInput |
{ roles?: HostRoleDefinition[] | HostEntry["roles"]; signals?: Record<string, HostSignalDef>; dispose?: () => Promise<void> | void } |
roles is either rows from defineHostRole or the map itself (a provider bridge is { "provider-bridge": { codex: defineBridge(…) } }). signals keys must equal contributes.hostSignals. |
05 §6.2 | built |
HostEntry |
{ apiVersion: 1; roles?: Partial<Record<HostRoleName, Record<string, unknown>>>; commands?; signals?; dispose? } |
kernel-host's erased shape of the default export (port/environment-provider.ts); commands and signals are Record<string, unknown>. In a test, narrow before calling: const handler: unknown = entry.commands["host-greet"]; if (typeof handler !== "function") throw new Error("no host-greet"); await handler({ name: "bb" }, ctx.context) (hello-slot host.test.ts); the table validates input and output itself (§6.3), so invalid_input is observable there. |
05 §6.2 | built; defineHostEntry returns Required<HostEntry>, no typed index |
The bootstrap is one program for every launch.kind: "module" role, including provider bridges: bin/bb-host-role.mjs → local/bootstrap.ts#main (05 §6.2). What it does, in order:
- Parses the kernel-owned argv:
--plugin --generation --artifact --role --name --export --data-dir --temp-dir --cwd [--env <envId> --write-roots <json> | --root <RootRef json>] --limits <RoleLimits json>. umask 077,chdir(--cwd)(the scope root, or the temp dir for scopenone), ignores SIGINT, installs SIGTERM →default.dispose()→exit(0).- Registers a resolver hook so the entry's bare
@get-bb/plugin-sdk/*imports resolve from the host's own install, not the artifact cache (local/sdk-resolve.ts). - Imports
<artifact>/meta.json#entry(defaulthost.mjs) and selects the export: the named export--exportif present, elsedefault.roles[role][name]; neither → exit 1. provider-bridge: the export must be adefineBridge()object; printsrole/readyand hands stdio torunBridge. Every other role:serveRoleprintsrole/readythen runs a JSON-RPC 2.0 line loop on stdio (@bb/bridge-kitpeer;maxLineBytesfrom--limits).- Dispatch in
serveRole:env/provision|reconnect|destroy|summarizewhen the export has aprovisionfunction (anEnvironmentProvider);<pluginId>/<command>→ the export's property of that name called as(params ?? {}, ctx);role/dispose→ abortlifecycle; anything else → JSON-RPCMETHOD_NOT_FOUND. stdin EOF abortslifecycleand ends the process.
Deviations from 05 §6.2 in the built bootstrap (local/bootstrap.ts): the export is duck-typed (an object with provision is a provider; kind: "provider-bridge" is a bridge; anything else is a command table), not validated with a Standard Schema; the spec's "or commands for the rpc role" fallback is not implemented, so only roles[role][name] is ever selected (D14); the child does not self-exit on idle — idle eviction is the supervisor's (§6.7). launch.kind: "exec" spawns command args directly with the same cwd/env/stdio discipline and no bootstrap code.
6.2 Roles
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
defineHostRole |
<C extends HostCommandsContract>(input: HostRoleInput<C>) => HostRoleDefinition |
One row. environment-provider: refuses a provider whose .name differs from the row name (invalid_contract). Command roles: the export is implementHostCommands(contract, commands). provider-bridge is not a defineHostRole input — pass the map to defineHostEntry with a defineBridge() object (@get-bb/plugin-sdk/bridge). |
05 §6.1 | built |
HostRoleInput<C> |
{ role: "environment-provider"; name; provider: EnvironmentProvider } | { role: CommandRoleName; name; contract: C; commands: HostCommandHandlers<C> } |
The two row shapes. | 05 §6.1 | built |
HostRoleDefinition |
{ role: HostRoleName; name: string; export: EnvironmentProvider | HostCommandTable } |
What defineHostEntry places at roles[role][name]. |
— | built |
HostRoleName |
"provider-bridge" | "environment-provider" | "skill-discovery" | "terminal-backend" | "ai-service" | "rpc" |
The closed list (HOST_ROLE_NAMES, port/schemas.ts); an unknown role is a manifest error. No open-target role (D14). |
05 §2.10, §6.1 | built |
CommandRoleName |
Extract<HostRoleName, "skill-discovery" | "terminal-backend" | "ai-service" | "rpc"> |
The roles whose export is a command table. | 05 §6.1 | built |
What each role is for (05 §6.1) and what drives it today:
| Role | Purpose | Scope | Driven by today |
|---|---|---|---|
provider-bridge |
A provider plugin's bridge child (protocol v3, 06 §6.2); name is the flat provider id (codex). Never relayed to core. |
env | built: bridges/driver.ts |
environment-provider |
Provisions, reconnects, destroys and summarizes environments; provider id <pluginId>/<name>. |
none (gets the env on provision) | built: local/environments.ts calls env/provision, env/reconnect, env/destroy, notifies env/cancel; env/summarize is served by the bootstrap with no kernel-host caller found |
skill-discovery |
A command role the skills plugin uses for staging/listing skills in a scope (11b). |
env or root | served by the bootstrap's <pluginId>/<command> dispatch; no first-party driver in kernel-host |
terminal-backend |
Alternative pty source for panel-terminal over ctx.openLane("pty"). |
env | served as a command role only; openLane is absent (§6.4), so the pty path has no built transport |
ai-service |
Plugin-declared commands a server half calls through hostClient (e.g. inference.complete). |
none | served; the server-side ctx.hostClient is unwired (§6.3) |
rpc |
The owning plugin's general commands and signals; the only rows whose tables feed entry.commands. |
none, env or root | served; ctx.hostClient unwired in the product, wired end to end by createTestPlugin(...).host.role(name) (@get-bb/plugin-sdk/testing) |
One process per (pluginId, generation, role, name, scope); the scope comes from the caller that starts the role (RoleSpec.scope), not from the manifest.
6.3 Host commands and signals
The contract object lives in the plugin's contracts module and is the one thing both ends validate against (05 §4.7). The manifest carries names only (§6.8).
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
defineHostCommands |
({ id, commands: Record<string, HostCommandDef>, signals?: Record<string, HostSignalDef> }) => HostCommandsContract<C, S> |
id is the plugin id (^[a-z0-9][a-z0-9-]{0,63}$); command and signal names are kebab-case ^[a-z][a-z0-9-]*$; omitted signals = {}. Wire method names are <id>/<command>. Re-exported from /contracts and /server. |
05 §4.7 | built |
HostCommandDef<In, Out> |
{ readonly input: StandardSchemaV1; readonly output: StandardSchemaV1 } |
One command's schemas. | 05 §4.7 | built |
HostSignalDef<P> |
{ readonly payload: StandardSchemaV1 } |
One signal's payload schema. | 05 §4.7 | built |
HostCommandsContract<C, S> |
{ readonly id: string; readonly commands: C; readonly signals: S } |
The contract object. | 05 §4.7 | built |
implementHostCommands |
<C>(contract: C, handlers: HostCommandHandlers<C>) => HostCommandTable |
Builds the table the bootstrap dispatches to. Missing handler or a handler for an undeclared name → invalid_contract at definition time. Each entry validates params with input (invalid_input), calls the handler, validates the result with output (invalid_output), and returns the JSON round-trip of the validated output. |
05 §4.7 | built |
HostCommandHandler<D> |
(input: InferOutput<D["input"]>, ctx: RoleContext) => Promise<InferInput<D["output"]>> |
A typed handler; it receives the parsed input. | 05 §4.7 | built |
HostCommandHandlers<C> |
{ [N in keyof C["commands"]]: HostCommandHandler<…> } |
The handler record defineHostRole takes. |
— | built |
HostCommandTable |
Record<string, (params: JsonValue, ctx: RoleContext) => Promise<JsonValue>> |
The erased table at roles.rpc[name] and entry.commands. |
05 §4.7 | built |
signalEmitter |
<C>(contract: C, ctx: Pick<RoleContext, "emitSignal">) => (name, payload) => Promise<void> |
Validates payload against signals[name].payload (invalid_input; unknown name → invalid_contract) and calls ctx.emitSignal with the JSON round-trip. On the wire it is the role/signal notification, which rpc/role-client.ts parses. |
05 §4.7 | built |
The server side (@get-bb/plugin-sdk/server, server/host-client.ts): ctx.hostClient(contract, name) returns TypedHostClient<C> — call(command, input, opts?: HostCallOptions) validates input before the frame leaves core and output after rpc.res arrives; onSignal(name, listener) validates the payload and logs instead of delivering a payload outside the schema (hostClient.onSignal: payload rejected by the signal schema); onExit(listener) receives HostExit { roleId, exitCode, signal }. HostCallOptions is { hostId?: string | null; timeoutMs?: number | null } (null = the core's own host / the loader's default deadline). name must be a contributes.hostRoles[].name of this plugin (precondition otherwise).
As built: the loader's hostClient factory is unwired in the product — a call answers service_unavailable (D15; as-built §6 "Loader hostClient unwired"). Host tiers still start. In tests createTestPlugin wires host.role(name) to the same roles[role][name][command] dispatch the bootstrap uses and relays signals and exits. Also as built: 05 §4.7's rule that the loader refuses a host entry whose commands/signals exports differ from contributes.hostCommands/hostSignals has no implementation in kernel-loader, kernel-host or plugin-build; the manifest lists are parsed (kernel-loader/src/manifest.ts), each hostSignals name is subscribed for relay (loader/load.ts), and an undeclared <pluginId>/<command> is answered by the role process with METHOD_NOT_FOUND.
// contracts.ts — schemas only (examples/plugins/hello-slot/src/contracts.ts)
export const helloHost = defineHostCommands({
id: "hello-slot",
commands: { "host-greet": { input: z.strictObject({ name: z.string() }), output: hostGreetingSchema } },
});
// host.ts — the role process answers it; ctx is the RoleContext of §6.4
export default defineHostEntry({
roles: [defineHostRole({ role: "rpc", name: "echo", contract: helloHost,
commands: { "host-greet": async ({ name }, ctx) => ({ greeting: `Hello, ${name}`, pid: process.pid, role: ctx.name }) } })],
});Server-side calls at dc07292bf. Core wires ctx.hostClient through createHostClients(...).clientFor (packages/core/src/core.ts); createRoleSpecs is shared by ctx.hostClient, kernel/host-runtime, and kernel/bridge-driver. The "unwired" status in D15 describes 6f3d4592f.
6.4 RoleContext
HostRuntime (port/host-runtime.ts, 05 §2.1) is the kernel's nine-member port, held by core and never by a plugin: fs (stat/read/write/list/search/mkdir/move/remove/exists/browse, every call scoped), exec (run buffered, spawn streaming), vcs (git, repoInfo, lock — a host-wide named mutex), pty (open/attach/list), watch (set, events), environments (provision/reconnect/destroy/cancel/loaded), artifacts (ensure/has/gc), roles (start/attach/list/stop), events (append, flush-before-result); plus hostId and info. A role process gets the narrowed, scope-bound form below (bindScope; pty and watch are absent inside a role process by design, BindablePorts). Built from local/bootstrap.ts#roleContextFor.
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
pluginId, generation, role, name |
string, string, HostRoleName, string |
Identity from the argv. | 05 §6.3 | built |
paths |
{ dataDir: string; tempDir: string } |
plugins/host/<id>/ and plugins/host/<id>/tmp under the host data dir, 0700; tmp is wiped on every role start (local/roles.ts). |
05 §6.3, §2.12 | built |
lifecycle |
{ signal: AbortSignal } |
Aborts on role/dispose or stdin EOF. SIGTERM runs default.dispose() directly. Nothing in kernel-host sends role/dispose today. |
05 §6.3 | built |
scope |
{ kind: "env"; envId; root; writeRoots } | { kind: "root"; ref: RootRef; root } | { kind: "none" } |
Where the role was started. With none the ports below are bound to the plugin root ({ kind: "plugin", pluginId } = dataDir). |
05 §6.3 | built |
fs |
ScopedFs (stat, read, write, list, search, mkdir, move, remove, exists, browse) |
The fs port with the scope bound; contained to the scope root and writeRoots; escaping → host/unknown_root. |
05 §2.3, §6.3 | built |
exec |
ScopedExec (run(spec), spawn(spec)) |
ExecSpec { command, args, cwd, env, stdin: "closed" | "pipe", timeoutMs, maxOutputBytes, processGroup }; cwd must be inside the scope. |
05 §2.4 | built |
vcs |
ScopedVcs (git(args, opts), repoInfo(), lock(name, fn)) |
lock is keyed on the repo's common dir and <pluginId>/<lockName>. |
05 §2.5 | built |
process |
{ killByCwd(root, { graceMs }): Promise<number>; spawnGroup(spec): Promise<ExecProcess> } |
killByCwd SIGTERMs every process rooted under root, waits, SIGKILLs survivors, up to 5 rounds, returns the count; spawnGroup is exec.spawn (process group when the platform supports it). |
05 §5.5, §7 | built |
watch |
(paths, { recursive, ignore, debounceMs }, handler) => () => void |
Would deliver WatchEvents. |
05 §6.3 | no-op: returns a no-op unsubscribe, no events (D14; kernel-host README follow-ups) |
openLane |
(kind: "pty" | "exec") => Promise<Lane> |
Lanes for custom handles and terminal-backend. |
05 §6.3, §4.9 | absent from the built type (D14) |
emitSignal |
(name: string, payload: JsonValue) => void |
role/signal notification to the host; use signalEmitter so the payload is validated first. |
05 §4.7 | built |
emitEvent |
(event: Omit<HostEventInput, "actor">) => void |
role/event notification; the kernel stamps actor: { kind: "plugin", id: pluginId } and appends through EventsPort (parsed in local/environments.ts). |
05 §2.11, §6.3 | built |
retain |
() => Lease ({ release() }) |
Keeps the process past the idle timeout. | 05 §6.4 | no-op in the child; leases are held host-side on RoleInstance.retain() (D14) |
log |
{ info(msg), warn(msg), error(msg) } |
Writes [info]/[warn]/[error] lines to stderr; the supervisor keeps the last maxStderrLines, 16 KiB per line, and reports them on exit. The role/log notification exists in roleNotifications but the built log does not use it. |
05 §6.3 | built |
6.5 Environment providers
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
defineEnvironmentProvider |
<Options extends JsonObject>(provider: EnvironmentProvider<Options>) => EnvironmentProvider<Options> |
Identity with one check: name matches ^[a-z][a-z0-9-]*$ (invalid_contract). The provider id is <pluginId>/<name>. |
05 §5.2, §5.3 | built |
EnvironmentProvider<Options> |
{ name; label; ownsRoot: boolean; options: StandardSchemaV1<Options>; provision(req, ctx); reconnect(req, ctx); destroy(req, ctx); summarize(handle, ctx) } |
options is required: core validates API input and renders the picker from it. ownsRoot: true → the row is managed (bb created the directory and destroys it). |
05 §5.2 | built |
provision |
({ envId, options, source: ProjectSource, signal: AbortSignal, progress: ProgressSink }, ctx) => Promise<EnvironmentHandle> |
The bootstrap validates options with the provider's schema first (INVALID_PARAMS on issues), runs the call under an AbortController keyed by envId; the env/cancel notification aborts it. |
05 §5.2 | built (env/provision, env/cancel) |
reconnect |
({ envId, handle }, ctx) => Promise<EnvironmentHandle> |
After a host restart: re-validate and re-lock; return the (possibly refreshed) handle. | 05 §5.2 | built (env/reconnect) |
destroy |
({ envId, handle, signal, progress }, ctx) => Promise<void> |
Remove what provision made; abortable like provision. |
05 §5.2, §5.5 | built (env/destroy) |
summarize |
(handle, ctx) => Promise<EnvironmentSummary> |
The chip-sized description the UI reads. | 05 §5.2 | served (env/summarize in the bootstrap); no kernel-host caller found |
EnvironmentHandle |
{ kind: "local-path"; root: string; writeRoots: string[]; persisted: JsonObject } | { kind: "custom"; writeRoots: string[]; persisted: JsonObject } |
local-path: the kernel serves fs/exec/vcs/pty natively at root. custom: the provider would serve them over role RPC and lanes; no v1 implementation, and openLane is absent. persisted is opaque to core and handed back on reconnect/destroy. |
05 §5.2 | local-path built; custom shape only |
EnvironmentSummary |
{ label: string; path: string | null; isRepo; isWorktree; branch | null; baseBranch | null; defaultBranch | null } |
Persisted as the environment row's path + vcs columns. |
05 §5.1 | built |
ProgressSink |
{ step(key, text, status: "started" | "completed" | "failed"): void; output(line): void } |
role/progress notifications; they land in the thread timeline as environment/provision-progress events. |
05 §5.2 | built |
ProjectSource |
{ id; projectId; hostId; path; gitRemoteUrl: string | null; isDefault: boolean } |
The source the environment is provisioned from; path is absolute on the host. |
05 §5.0 | built |
Containment rules a provider must follow (05 §5.2, §5.5; examples/plugins/hello-slot/src/host.ts):
- A persisted handle is input: the row may be stale, tampered with, or mis-routed from another provider. Re-derive the expected root from
envIdand refuse a handle whosekindorrootdiffers (precondition) before any filesystem call. - Do filesystem work through
roleStorage(ctx)(§6.6) or the scoped ports; both refuse a path that escapes their root. A provider role's scope isnone, soctx.fs/exec/vcsare bound to the plugin root (dataDir). - Destroy order: the kernel stops the env's bridges and ptys → the provider calls
ctx.process.killByCwd(root, { graceMs: 2000 })→ the provider removes the directory → core appliesdestroy.completed. A result lost to a disconnect becomesdestroy.lost; reconcile answers it on the next session. - Per-environment mutation serialization (05 §4.5) is not implemented on the host;
ctx.vcs.lockis the mutex you have (packages/kernel-host/README.md, "Deviations" part 2).
// a provider that owns a directory under the role's data dir (abridged from hello-slot `scratch`)
export const scratch = defineEnvironmentProvider<Record<string, never>>({
name: "scratch", label: "Scratch directory", ownsRoot: true, options: z.strictObject({}),
async provision({ envId, progress }, ctx) {
const storage = roleStorage(ctx); const rel = `scratch/${envId}`; // envId checked to be one path segment
progress.step("mkdir", "creating", "started"); await storage.mkdir(rel); progress.step("mkdir", "created", "completed");
return { kind: "local-path", root: storage.path(rel), writeRoots: [storage.path(rel)], persisted: {} };
},
async reconnect({ envId, handle }, ctx) { /* refuse a handle whose root ≠ storage.path(`scratch/${envId}`) */ return handle; },
async destroy({ envId, handle }, ctx) { await roleStorage(ctx).remove(`scratch/${envId}`, { recursive: true }); },
async summarize(handle) { return { label: "scratch", path: handle.kind === "local-path" ? handle.root : null, isRepo: false, isWorktree: false, branch: null, baseBranch: null, defaultBranch: null }; },
});Changes at dc07292bf (kernel-host/src/port/environment-provider.ts, host-runtime.ts): provision receives source: ProjectSource | null — null when the project has no source on this host; a provider that needs one refuses with host/source_required (env-local's local-dir and git-worktree do). The role answers env/describe {} → {label, ownsRoot, optionsSchema} (the bootstrap converts the zod options to JSON Schema) for provider discovery (05 §6.5). EnvironmentsPort gains summarize({providerId, handle}) → EnvironmentSummary and describe({providerId}) → ProviderDescriptor. Provision progress reaches core as env.progress {envId, step, output} notifications; the host no longer appends an environment/provision-progress event.
6.6 Host storage
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
roleStorage |
(ctx: Pick<RoleContext, "paths">) => HostStorage |
createHostStorage(ctx.paths.dataDir): the role's private dir (plugins/host/<id>/, 0700) as a Storage port. |
05 §2.12 | built |
roleTempStorage |
(ctx: Pick<RoleContext, "paths">) => HostStorage |
Same over ctx.paths.tempDir; cleared on every role start. |
05 §2.12, 00 §3.7 | built |
HostStorage |
= Storage (kernel-store) |
One implementation on every machine: kernel-store's createLocalStorage (R36 M4; kernel-host README deviation "HostStorage is kernel-store's createLocalStorage"). Never reachable over the remote-facing port. |
05 §2.12 | built |
Every HostStorage method (packages/kernel-store/src/storage.ts); paths are relative to the root, and every call contains them lexically and physically — a path that escapes the root or crosses a symlink out of it is forbidden (storage-local.ts):
| Method | Signature (abridged) | What it does |
|---|---|---|
root |
readonly string |
The absolute root. |
scope(dir) |
(dir: StorageScope) => Storage |
A narrower Storage at <root>/<dir>; dir must match ^(plugins\/(store|packages|src|data|host)(\/<id>)?|[a-z-]+)$. |
path(rel) |
(rel: string) => string |
The absolute path, for import(), exec cwd and handle roots only — never for fs calls outside the port. |
read / write |
read(path): Promise<Uint8Array>; write(path, bytes, { mode: 0o600 | 0o644; atomic: boolean }) |
atomic = temp + fsync + rename; every option is explicit. |
rename / mkdir / list / stat / remove / copyTree |
rename(from, to); mkdir(path) (recursive); list(dir): StorageEntry[]; stat(path): StorageStat | null; remove(path, { recursive }); copyTree(from, to) |
The directory primitives. StorageEntry { name, kind, bytes, mtime }; StorageStat adds mode. |
appendLog |
(path, line) => Promise<void> |
One JSONL line; rotates past 8 MiB, keeps five files per stem. |
unpack |
(tarball: Uint8Array | ReadableStream, dest, { readOnly }) => Promise<{ files: string[] }> |
Tar (gzip detected from magic bytes); refuses links and contains every entry. |
watch |
(path, cb: (e: StorageWatchEvent) => void) => Disposer |
{ kind: "create" | "update" | "delete"; path }. |
sqlite / sqliteReadOnly |
sqlite(path): Promise<SqliteHandle>; sqliteReadOnly(path): SqliteReadOnlyHandle |
An async read/write handle (exec/run/get/all/batch/close) owned by the caller; a synchronous query_only handle (get/all/close). |
6.7 Limits and process hygiene
Role-RPC admission per (pluginId, generation) on a host (port/schemas.ts, local/roles.ts#admit, local/role-rpc.ts, rpc/role-client.ts; 05 §4.7): 256 active calls (service_unavailable past it), 32 MiB in-flight input (host/too_large), 8 MiB per result (host/too_large, checked on both the local and remote client), 30 s default per call (timeout); timeoutMs: null on the remote client waits indefinitely for a retained long call.
RoleLimits (manifest limits, defaults filled by the loader, DEFAULT_ROLE_LIMITS): maxRssBytes 1 GiB, idleMs 300 000, startTimeoutMs 10 000, stopGraceMs 5 000, maxLineBytes 1 MiB, maxStderrLines 1 000. The supervisor (local/roles.ts): waits for the role/ready line within startTimeoutMs else SIGKILL + host/role_start_failed; SIGKILLs a process that emits a stdout line over maxLineBytes; relays only lines that start with {; stops an idle process after idleMs when no host-side lease is held (any line in either direction resets the clock; idleMs: 0 disables); stop = leader SIGTERM → group → SIGKILL after stopGraceMs; never restarts — the next call that needs the role starts it again; every exit is forwarded to core as roles.exited {roleId, pluginId, role, name, exit, stderr}. maxRssBytes is carried in the spec but no enforcement was found in local/roles.ts.
Process env of a role child (local/shell-env.ts#roleProcessEnv, local/process.ts#sanitizeInheritedEnv; 05 §7, R18), in four layers: (1) the sanitized inherited env (05 §7: every BB_*, NODE_ENV, and npm_config_* dropped); (2) the login-shell PATH, captured once per host boot; (3) RoleSpec.env, the role's own non-BB_* keys; (4) the kernel overlay BB_CLI, BB_HOST_ID, and BB_SERVER_URL when the host has one. RoleSpec.env may carry no BB_* key at all (roleSpecSchema refine; stricter than 05 §7's BB_BRIDGE_RECORD exception). The per-thread names (BB_THREAD_ID, BB_PROJECT_ID, BB_ENVIRONMENT_ID, BB_THREAD_STORAGE) and plugin-contributed BB_* values from threads/agent-config are merged at bridge spawn only (mergeThreadEnv; a contributed kernel-owned name is overwritten and logged) — a role process is not a thread and never sees them. KERNEL_ENV_NAMES is the kernel-owned list. Also: umask 077; own process group where supported; cwd = scope root or the temp dir; call ctx.process.killByCwd before removing any directory (05 §5.5).
6.8 Manifest keys for the host tier
package.json#bb (01 §2.2, §2.6; kernel-loader/src/manifest.ts; R9):
| Key | Shape | Rule |
|---|---|---|
host |
"./src/host.ts" |
Source of the host entry; built to dist/host.mjs (artifact meta.json#entry, default host.mjs). |
contracts |
"./src/contracts.ts" |
The module that exports the defineHostCommands object; also what the server half imports. |
contributes.hostRoles[] |
{ role: HostRoleName; name: ^[a-z0-9][a-z0-9-]*$; launch?: { kind: "module"; export } | { kind: "exec"; command; args }; limits?: Partial<RoleLimits> } |
launch defaults to { kind: "module", export: <name> }, so with a defineHostEntry default export no named export is needed; limits defaults are filled once. Duplicate (role, name) is refused. providers[].bridge must name a provider-bridge row. |
contributes.hostCommands |
string[] |
Names only (^[a-z][a-zA-Z0-9-]*$ in the loader; defineHostCommands itself requires kebab-case). Schemas are code. No entry-vs-manifest comparison is built (§6.3). |
contributes.hostSignals |
string[] |
Names only; the loader subscribes each for relay to hostClient.onSignal. |
There is no hostRoots key and no bridgeEntries key (R9; 05 §6.5). Declared roles are not capabilities: a host can run a plugin's roles once artifacts.ensure of its host entry succeeded there (05 §6.5).
6.9 Types index
| Type | One line |
|---|---|
CommandRoleName |
skill-discovery | terminal-backend | ai-service | rpc — roles whose export is a HostCommandTable (§6.2). |
EnvironmentHandle |
local-path { root, writeRoots, persisted } or custom { writeRoots, persisted } (§6.5). |
EnvironmentProvider<Options> |
The provider role contract: name, label, ownsRoot, options, provision, reconnect, destroy, summarize (§6.5). |
EnvironmentSummary |
{ label, path, isRepo, isWorktree, branch, baseBranch, defaultBranch } (§6.5). |
HostCommandDef |
{ input, output } Standard Schemas for one command (§6.3). |
HostCommandHandler<D> |
(input, ctx: RoleContext) => Promise<output> (§6.3). |
HostCommandHandlers<C> |
Handler record keyed by the contract's command names (§6.3). |
HostCommandTable |
Erased Record<string, (params: JsonValue, ctx) => Promise<JsonValue>> the bootstrap dispatches to (§6.3). |
HostCommandsContract<C, S> |
{ id, commands, signals } from defineHostCommands (§6.3). |
HostEntry |
kernel-host's shape of the default export: { apiVersion: 1; roles?; commands?; signals?; dispose? } (§6.1). |
HostEntryInput |
{ roles?, signals?, dispose? } for defineHostEntry (§6.1). |
HostRoleDefinition |
{ role, name, export } one row (§6.2). |
HostRoleInput<C> |
The provider-row or command-row input of defineHostRole (§6.2). |
HostRoleName |
The closed six-role list (§6.2). |
HostSignalDef |
{ payload } Standard Schema for one signal (§6.3). |
HostStorage |
= Storage; the role's private dir as a port (§6.6). |
ProgressSink |
{ step(key, text, status), output(line) } (§6.5). |
ProjectSource |
{ id, projectId, hostId, path, gitRemoteUrl, isDefault } (§6.5). |
RoleContext |
The role process's context (§6.4). |
RootRef |
{ kind: "source"; sourceId; path } | { kind: "thread-storage"; threadId } | { kind: "plugin"; pluginId } — a contained root; source carries the trusted path core filled in (D15, deviation from 05 §2.2). |
Testing (@get-bb/plugin-sdk/testing, covered in its own section): fakeRoleContext(init) builds a RoleContext whose fs/exec/vcs/process throw host/unsupported unless supplied and records signals, events and logs; createTestPlugin(...).host.role(name) dispatches roles[role][name][command] exactly as the bootstrap does.