← reports

#1915 · Incompatible plugins unload silently — no error, warning, log line, or startup surface across a minor upgrade

Bug / UX gap Priority: Medium Effort: Small plugins open on GitHub 2026-08-19 · base d81fee6f

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

A plugin that declares an engines.bb range (e.g. >=0.38.0 <0.39.0) stops loading as soon as the bb host is upgraded past that range. The server computes the right verdict (status: "incompatible", statusDetail: "requires bb >=0.38.0 <0.39.0, this is 0.39.0") and stores it in memory, but it never logs it: every successfully loaded plugin gets a plugin <id>@<v> loaded info line and every factory crash gets a plugin <id> failed to load: … warning, yet the pre-factory failure branch (failBeforeFactory in plugin-runtime.ts) only calls setStatus() and returns. Nothing in the app volunteers the state either: the home/thread UI shows no banner or badge, the Extensions nav has no indicator, and the only visible hint is a small red dot on the plugin's logo inside Extensions → Installed plugins (the row's toggle still reads ON). The verdict is only discoverable by explicitly asking (bb plugin list, GET /api/v1/plugins, or opening the plugin's detail page). The plugin's contributions (commands, tools, services, providers) simply vanish. Reproduced live on a dev instance with BB_APP_VERSION=0.38.5 → 0.39.0 and with a failing vitest at the exact code path.

2. Claims vs findings

Claim from the issueStatusEvidence
A plugin declaring bb >=0.38.0 <0.39.0 is unloaded on host 0.39.0VerifiedcheckEngineRange uses semver.satisfies(0.39.0, ">=0.38.0 <0.39.0") → false → failBeforeFactory("incompatible", …); live repro: notify@0.2.1 incompatible (requires bb >=0.38.0 <0.39.0, this is 0.39.0)
No error, no warning, no log lineVerifiedServer log after restart as 0.39.0 contains 11 plugin …@… loaded lines and zero lines mentioning notify (grep -c notify dev.log → 0). Unit test: zero logger calls mention the plugin.
No startup surface / nothing volunteers it in the UIVerifiedHome screen and Extensions nav show nothing (1915-home-after.png). Only a small red dot on the logo in Installed plugins and a banner on the plugin's own detail page, both require navigating there.
The plugin surface reports status: "incompatible" with a statusDetail naming the range when queriedVerifiedGET /api/v1/plugins{"status":"incompatible","statusDetail":"requires bb >=0.38.0 <0.39.0, this is 0.39.0"}; bb plugin list prints the same.
The plugin's underlying process may stay alive while only the binding is goneUnverified (plausible)Depends on the third-party plugin spawning an external process; not part of bb. bb's own bb.backgroundServices are never started for an incompatible plugin, so for bb-managed services the service and the plugin are the same truth.
Same behavior for agent-proxy@0.2.1UnverifiedSame code path; any plugin with a non-satisfied engines.bb behaves identically (the range check is manifest-only).

3. Environment

4. Minimal reproduction

A. Unit test at the exact code path (fails on d81fee6f)

  1. Save issue-1915-silent-incompatible.test.ts to apps/server/test/services/plugins/.
  2. Run from apps/server: pnpm exec vitest run test/services/plugins/issue-1915-silent-incompatible.test.ts
  3. Expected: a log line mentions the plugin when the 0.39.0 service marks it incompatible. Actual: the status is incompatible with the right detail, but after.lines.filter(/notify/) is []:
    RUN  v4.1.1 /home/sawyer/projects/bb/.claude/worktrees/wf_d5c47f31-487-4/apps/server
    
     ❯  @bb/server  test/services/plugins/issue-1915-silent-incompatible.test.ts (1 test | 1 failed) 1660ms
         × logs the load on 0.38.x but nothing at all when 0.39.0 marks it incompatible 1659ms
    
    ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
    
     FAIL   @bb/server  test/services/plugins/issue-1915-silent-incompatible.test.ts > #1915 incompatible plugin after host upgrade > logs the load on 0.38.x but nothing at all when 0.39.0 marks it incompatible
    AssertionError: expected [] to not deeply equal []
    
    Compared values have no visual difference.
    
     ❯ test/services/plugins/issue-1915-silent-incompatible.test.ts:108:26
        106|     // verdict exists is the in-memory status returned by plugins.list…
        107|     const mentions = after.lines.filter((l) => /notify/i.test(l));
        108|     expect(mentions).not.toEqual([]); // <-- fails on d81fee6f
           |                          ^
        109|     await after.service.stop();
        110|   });
    
    ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
    
    
     Test Files  1 failed (1)
          Tests  1 failed (1)
       Start at  15:05:34
       Duration  3.33s (transform 922ms, setup 0ms, import 1.59s, tests 1.66s, environment 0ms)
/**
 * Repro for get-bb/bb#1915: a plugin whose `engines.bb` range stops matching
 * after a host upgrade is marked `incompatible` on startup, but the server
 * writes no log line for it (unlike a successful load, which logs
 * `plugin <id>@<v> loaded`, or a factory failure, which logs
 * `plugin <id> failed to load: ...`).
 */
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createConnection, migrate, type DbConnection } from "@bb/db";
import type { Logger } from "@bb/logger";
import { createPluginService } from "../../../src/services/plugins/plugin-service.js";
import { createNoopTelemetryService } from "../../../src/services/system/telemetry.js";

function capturingLogger(): { logger: Logger; lines: string[] } {
  const lines: string[] = [];
  const push = (level: string) => (msg: unknown) => {
    lines.push(`${level} ${String(msg)}`);
  };
  const logger = {
    debug: push("debug"),
    info: push("info"),
    warn: push("warn"),
    error: push("error"),
  } as unknown as Logger;
  return { logger, lines };
}

async function writeNotifyPlugin(dir: string): Promise<string> {
  const rootDir = join(dir, "bb-plugin-notify");
  await mkdir(rootDir, { recursive: true });
  await writeFile(
    join(rootDir, "package.json"),
    JSON.stringify({
      name: "bb-plugin-notify",
      version: "0.2.1",
      engines: { bb: ">=0.38.0 <0.39.0" },
      bb: {
        name: "Notify",
        description: "Issue 1915 fixture.",
        branding: { icon: "Zap" },
        server: "./server.ts",
      },
    }),
  );
  await writeFile(
    join(rootDir, "server.ts"),
    `export default function plugin() {}`,
  );
  return rootDir;
}

function makeService(db: DbConnection, dataDir: string, appVersion: string) {
  const { logger, lines } = capturingLogger();
  const service = createPluginService({
    telemetry: createNoopTelemetryService(),
    db,
    hub: {
      getDaemonSessionIdForHost: () => null,
      notifyPluginSignal: () => 0,
      notifySystem: () => {},
    },
    logger,
    dataDir,
    appVersion,
    loadTimeoutMs: 2000,
  });
  return { service, lines };
}

describe("#1915 incompatible plugin after host upgrade", () => {
  let db: DbConnection;
  let workDir: string;

  beforeEach(async () => {
    db = createConnection(":memory:");
    migrate(db);
    workDir = await mkdtemp(join(tmpdir(), "bb-1915-"));
  });
  afterEach(async () => {
    await rm(workDir, { recursive: true, force: true });
  });

  it("logs the load on 0.38.x but nothing at all when 0.39.0 marks it incompatible", async () => {
    const rootDir = await writeNotifyPlugin(workDir);

    // bb 0.38.5: the plugin installs and loads; the server logs it.
    const before = makeService(db, join(workDir, "data"), "0.38.5");
    const installed = await before.service.installPath(rootDir);
    expect(installed.status).toBe("running");
    expect(before.lines).toContainEqual("info plugin notify@0.2.1 loaded");
    await before.service.stop();

    // bb 0.39.0 starts against the same database (= user upgraded bb).
    const after = makeService(db, join(workDir, "data"), "0.39.0");
    await after.service.start();
    const entry = after.service.list().find((p) => p.id === "notify");
    expect(entry?.status).toBe("incompatible");
    expect(entry?.statusDetail).toBe(
      "requires bb >=0.38.0 <0.39.0, this is 0.39.0",
    );

    // BUG: no log line mentions the plugin at all. The only place the
    // verdict exists is the in-memory status returned by plugins.list().
    const mentions = after.lines.filter((l) => /notify/i.test(l));
    expect(mentions).not.toEqual([]); // <-- fails on d81fee6f
    await after.service.stop();
  });
});

B. Live instance (what the reporter saw)

  1. Create a fixture plugin (1915/repro/bb-plugin-notify/) at /tmp/bb-1915-notify:
    {
      "name": "bb-plugin-notify",
      "version": "0.2.1",
      "engines": { "bb": ">=0.38.0 <0.39.0" },
      "bb": { "name": "Notify", "description": "Issue 1915 fixture: declares engines.bb >=0.38.0 <0.39.0", "branding": { "icon": "Bell" }, "server": "./server.ts" }
    }
    // server.ts
    export default function plugin(bb: any) { bb.log.info("notify fixture loaded"); }
  2. Start a dev instance pretending to be bb 0.38.5 and install the plugin:
    BB_APP_VERSION=0.38.5 scripts/bb-dev-app current
    eval "$(scripts/bb-dev-app env)"
    pnpm bb:dev plugin install /tmp/bb-1915-notify --yes
    pnpm bb:dev plugin list | grep -A1 notify
    Output (and the server log):
    notify@0.2.1  running
      source: path:/tmp/bb-1915-notify
    
    [15:07:53] INFO: [server] [plugin:notify] notify fixture loaded
    [15:07:53] INFO: [server] plugin notify@0.2.1 loaded
  3. "Upgrade" bb: restart the same instance (same DB / data dir) as 0.39.0:
    pnpm dev:stop
    BB_APP_VERSION=0.39.0 scripts/bb-dev-app current
    curl -s $BB_SERVER_URL/api/v1/system/version   # {"currentVersion":"0.39.0",...}
    grep -n "plugin" ~/.bb-dev/launchers/<instance>/dev.log | grep -v plugin-sdk
    Expected: a warning such as plugin notify not loaded (incompatible): requires bb >=0.38.0 <0.39.0, this is 0.39.0.
    Actual (every other plugin is logged; notify does not appear anywhere — grep -c notify dev.log0):
    [15:10:06] INFO: [server] plugin automations@0.1.0 loaded
    [15:10:06] INFO: [server] plugin connect@0.1.0 loaded
    [15:10:06] INFO: [server] plugin custom-instructions@0.1.0 loaded
    [15:10:06] INFO: [server] plugin inline-vis@0.1.0 loaded
    [15:10:06] INFO: [server] plugin keep-awake@0.1.0 loaded
    [15:10:06] INFO: [server] plugin provider-acp@0.1.0 loaded
    [15:10:07] INFO: [server] plugin provider-claude-code@0.1.0 loaded
    [15:10:07] INFO: [server] plugin provider-codex@0.1.0 loaded
    [15:10:07] INFO: [server] plugin provider-pi@0.1.0 loaded
    [15:10:07] INFO: [server] plugin secrets@0.1.0 loaded
    [15:10:07] INFO: [server] plugin side-chat@0.1.0 loaded
  4. Only an explicit query reveals it:
    $ pnpm bb:dev plugin list | grep -A1 notify
    notify@0.2.1  incompatible  (requires bb >=0.38.0 <0.39.0, this is 0.39.0)
      source: path:/tmp/bb-1915-notify
    
    $ curl -s $BB_SERVER_URL/api/v1/plugins | jq '.plugins[] | select(.id=="notify")'
    {"id": "notify", "version": "0.2.1", "enabled": true, "status": "incompatible",
     "statusDetail": "requires bb >=0.38.0 <0.39.0, this is 0.39.0", "source": "path:/tmp/bb-1915-notify"}
bb home after upgrade
App home after the "upgrade" to 0.39.0: no banner, no toast, no badge on Extensions. Nothing tells the user a plugin stopped loading.
Installed plugins before
Before (0.38.5): Extensions → Installed plugins. Notify is the last row (cut off at the bottom, plain logo, toggle ON, running).
Installed plugins after
After (0.39.0): same list. The only difference is a small red dot on Notify's logo; the row still reads as enabled (toggle ON) with no text. You must already be on this page to see it.
Notify row zoom
Zoom on the Notify row after the upgrade — the red dot on the logo is the whole list-level signal.
Notify detail after
Clicking the row: the detail page does show an "Incompatible" banner with a generic recovery hint (the actual range from statusDetail is not printed here; it is in the CLI/API).

Repro files and logs: 1915/repro/ (test, fixture plugin, full dev logs for 0.38.5 and 0.39.0, CLI/API output, screenshot scripts, prototype diff).

5. Root cause

Mechanism. On server start, PluginService.start()loadAll()loadOne(row) for every installed row. loadOne reads the manifest and runs the engines gate:

// apps/server/src/services/plugins/plugin-runtime.ts
function failBeforeFactory(status: PluginRuntimeStatus, detail: string): void {
  if (previous !== undefined) {
    setStatus(row.id, "running", `reload failed: ${detail}`);
  } else {
    setStatus(row.id, status, detail);        // <-- in-memory only, no logger call
  }
}
...
const engineProblem = checkEngineRange(manifest) ?? checkPluginSdkRange(manifest);
if (engineProblem) {
  failBeforeFactory("incompatible", engineProblem);
  return;                                      // <-- nothing else happens
}

Permalinks: failBeforeFactory, engines / artifact gate, checkEngineRange, setStatus / publishStatus.

setStatus writes to baseStatuses/statuses maps and notifies per-plugin status listeners (used for bb.sdk readiness), nothing more. Compare the two sibling outcomes that are logged: a successful load ends with logger.info(`plugin ${row.id}@${manifest.version} loaded`) (L1634) and a factory/host-artifact failure with logger.warn(`plugin ${row.id} failed to load: …`) (L1525, L1551). Every branch routed through failBeforeFactory — directory missing, manifest unreadable, engines.bb/engines.bbPluginSdk unsatisfied, packaged-builtin artifact problem — and the hungServices "degraded" branch are silent. Because loadOne returns before createPluginApi, the plugin registers no commands, tools, services, providers, or frontend bundle, so every surface that would normally show the plugin's contributions shows nothing instead — the "vanish" the reporter describes.

Why nothing ambient shows it. The app projects status into a single per-row signal (pluginRowSignal in plugin-status.ts) rendered as a small badge on the logo in InstalledPluginsTab ("a row earns at most one signal", comment) and as a banner on PluginDetail. There is no aggregate "N plugins need attention" indicator on the Extensions nav item, no toast on the plugins-changed broadcast, and bb status does not include plugin health. The CLI/API expose the status only on demand (bb plugin list, GET /api/v1/plugins).

History. The engines gate has been silent since it was introduced (plugin system #480 / marketplaces #636 added setStatus(row.id, "incompatible", engineProblem); return; with no log). failBeforeFactory later generalized that branch (#716) without adding one. Note that dev builds report 0.0.0 and skip the gate, so developers never see this path locally unless they set BB_APP_VERSION.

Deeper issue. Incompatibility is a persistent condition (it will recur on every start until the plugin or host changes), but it is modeled exactly like a transient runtime status: in-memory, un-logged, un-aggregated. Also, the installed-list row keeps the enable toggle ON for an incompatible plugin, which reads as "working" at a glance.

6. Proposed fix (first principles)

  1. Server (minimum, root-level): log every non-running outcome in loadOne, i.e. inside failBeforeFactory and the hungServices branch, at warn, mirroring the existing success/failure lines. Prototype (verified: the repro test passes with it; proposed-fix.diff):
    diff --git a/apps/server/src/services/plugins/plugin-runtime.ts b/apps/server/src/services/plugins/plugin-runtime.ts
    index 37c719add..6bfffbeef 100644
    --- a/apps/server/src/services/plugins/plugin-runtime.ts
    +++ b/apps/server/src/services/plugins/plugin-runtime.ts
    @@ -1284,8 +1284,10 @@ export function createPluginRuntime(context: PluginRuntimeContext) {
         ): void {
           if (previous !== undefined) {
             setStatus(row.id, "running", `reload failed: ${detail}`);
    +        logger.warn(`plugin ${row.id} reload failed (kept previous instance): ${detail}`);
           } else {
             setStatus(row.id, status, detail);
    +        logger.warn(`plugin ${row.id} not loaded (${status}): ${detail}`);
           }
         }
         const hung = hungServices.get(row.id);
    
    Risk: none functionally; only log volume (one line per affected plugin per start/reload). Consider one aggregate line at the end of loadAll() too: plugins: 11 loaded, 1 incompatible (notify), 0 errored.
  2. App: surface an aggregate "needs attention" count from plugins.list() (statuses incompatible/error/missing) on the Extensions nav item or as a dismissible banner on first load after the status appears; the data is already in the plugins query. Keep the existing per-row signal. Optionally render statusDetail (the actual range) in the detail banner instead of only the generic sentence.
  3. CLI: make bb status (or a new bb plugin doctor) print non-running plugins, so agents and scripts can discover the state without parsing the full list. Document in the CLI guide/skill per AGENTS.md.
  4. Do not conflate plugin status with any external process the plugin manages — as the reporter notes, those are separate truths; the log line and banner should say "plugin not loaded: incompatible", nothing about services.

No HOST_DAEMON_PROTOCOL_VERSION bump is needed for (1); (2)/(3) add no wire fields if they reuse the existing status/statusDetail.

7. PR review

No open PRs are linked to this issue.

8. Related issues

9. Appendix

Commands run

git checkout d81fee6f && pnpm install --frozen-lockfile --prefer-offline && pnpm exec turbo run build
cd apps/server && pnpm exec vitest run test/services/plugins/issue-1915-silent-incompatible.test.ts   # fails (1915/repro/vitest-output.txt)
BB_APP_VERSION=0.38.5 scripts/bb-dev-app current
pnpm bb:dev plugin install /tmp/bb-1915-notify --yes && pnpm bb:dev plugin list
pnpm dev:stop && BB_APP_VERSION=0.39.0 scripts/bb-dev-app current
curl -s http://localhost:22376/api/v1/system/version
grep -c notify ~/.bb-dev/launchers/projects-bb-.claude-worktrees-wf_d5c47f31-487-4/dev.log   # 0
pnpm bb:dev plugin list ; curl -s http://localhost:22376/api/v1/plugins
dev-browser --headless run shot-before.js / shot-installed.js / shot-row-zoom.js   # screenshots
# prototype fix applied, test re-run (passes), then reverted with git
pnpm dev:stop; rm -rf ~/.bb-dev/<instance>; ss -ltn | grep -E ":14376|:22376|:30376"   # ports free

Artifacts