← reports

#1766 · Replacing a local plugin source destroys its settings silently: remove is the only supported path and it deletes plugin_settings

Bug Priority: unset Effort: unset plugins open on GitHub 2026-08-18 base commit 16ceb3a54

Verdict: REPRODUCED Root-cause confidence: high

TL;DR

A plugin installed from a local directory (path: source) cannot be re-pointed at a different directory: bb plugin install path:<new dir> for the same plugin id fails with "already installed from path:<old dir>; remove it first", and bb plugin update treats path plugins as "pinned". So the only way to move a local plugin to a new checkout is bb plugin remove + bb plugin install. remove is deliberately written to delete the plugin's plugin_settings rows, its plugin_schedules rows and its secrets directory ("configuration goes with the registration"), while keeping kv/data.db. Nothing warns about this: the CLI help says local sources are "left alone", the web UI dialog for local plugins says only "Its source files will stay on disk", and the server writes no log line. After the reinstall the plugin reports running and silently uses its default settings. Reproduced end to end with the CLI on a dev instance and with a failing vitest at the service layer.

Claims vs findings

Claim (from issue)StatusEvidence
Replacing a local plugin destroys all persisted settings silently.VerifiedCLI session step 5: after remove+install, bb plugin config watchdog shows defaults "60"/"1440", token [not set]; no warning, no log line (dev.log only shows "plugin watchdog@0.1.0 loaded").
The CLI offers no in-place replace for a local source; remove+install is the only route.VerifiedStep 3: HTTP 422: plugin id "watchdog" is already installed from path:/tmp/bb1766/checkout-a; remove it first. Code: apps/server/src/services/plugins/plugin-registration.ts:243. bb plugin update returns pinned for path rows: apps/server/src/services/plugins/plugin-updates.ts:273.
remove deletes settings rows, schedules and the secrets dir.Verifiedapps/server/src/services/plugins/plugin-service.ts:1681: deletePluginSchedules, deleteAllPluginSettings, rm(pluginSecretsDir(...)). Vitest asserts getPluginSettingsValues is {} and secrets dir is ENOENT after remove.
Bundle locations "start-server.js ~311140 / ~31757" in bb 0.38.UnverifiedDid not inspect the 0.38 bundle; the quoted source text matches the TS source at HEAD exactly, so the citation is plausible.
Not a reload problem: bb plugin reload preserves settings.VerifiedCLI step 2 and vitest "control" case: settings unchanged after reload.
Not key orphaning: plugin_settings keyed by (plugin_id, key), no path column.Verifiedpackages/db/src/data/plugin-storage.ts:83 reads by pluginId only. Same-path reinstall (allowed) keeps settings, proving the rows are keyed only by id.
Blast radius bounded to remove/install replacement, not every reload.VerifiedSame as above; only remove() calls the delete helpers.
Comment: docs/help never mention settings, schedules or secrets in connection with remove; "left alone" reads as non-destructive.Verifiedapps/cli/src/commands/plugin.ts:1843; guide text at packages/templates/src/templates/bb-guide-plugins.md:221. Additionally the web UI dialog for local plugins omits settings while the managed-plugin dialog mentions them: apps/app/src/views/ToolsView.tsx:364 (screenshot below).
Behaviour also affects a plugin's schedules and secrets.VerifiedSame removal block; vitest checks the secrets dir is gone and the secret reads back as { set: false }.

Environment

Minimal reproduction

A. CLI, end to end (what the user did)

  1. Start a bb instance and create the fixture plugin in two directories (package.json, server.ts):
    {
      "name": "bb-plugin-watchdog",
      "version": "0.1.0",
      "bb": {
        "name": "Watchdog",
        "description": "Issue 1766 fixture.",
        "branding": { "icon": "Zap" },
        "server": "./server.ts"
      }
    }
    
    export default async function plugin(bb: any) {
      bb.settings.define({
        alertFloorMinutes: { type: "string", label: "Alert floor (min)", default: "60" },
        staleWindowMinutes: { type: "string", label: "Stale window (min)", default: "1440" },
        token: { type: "string", label: "Token", secret: true },
      });
    }
    
  2. Run the script 1766/repro/repro.sh (uses node packages/scripts/dist/commands/run-cli.js as bb; substitute the bb binary on a normal install):
    #!/usr/bin/env bash
    # CLI-level repro for get-bb/bb#1766 against a bb dev instance.
    # Usage: BB_SERVER_URL=http://localhost:19232 bash /tmp/bb1766/repro.sh
    set -u
    cd /home/sawyer/projects/bb/.claude/worktrees/wf_debcf606-e4a-1
    BB="node packages/scripts/dist/commands/run-cli.js"
    run() { echo "\$ bb $*"; $BB "$@"; echo "[exit $?]"; echo; }
    
    echo "=== 1. Install the local plugin from checkout A and tune its settings ==="
    run plugin install --yes path:/tmp/bb1766/checkout-a
    run plugin config watchdog set alertFloorMinutes 1
    run plugin config watchdog set staleWindowMinutes 3
    run plugin config watchdog set token s3cret
    run plugin config watchdog
    
    echo "=== 2. Reload keeps settings (control) ==="
    run plugin reload watchdog
    run plugin config watchdog
    
    echo "=== 3. Try to install the same plugin id from a NEW checkout (B): refused ==="
    run plugin install --yes path:/tmp/bb1766/checkout-b
    
    echo "=== 4. Follow the error's advice: remove, then install from B ==="
    run plugin remove watchdog
    run plugin install --yes path:/tmp/bb1766/checkout-b
    
    echo "=== 5. Settings are back to defaults; plugin reports healthy ==="
    run plugin config watchdog
    run plugin list
    
  3. Observed output (cli-session.txt). Compare step 1 (tuned) with step 5 (after remove + install from the new checkout):
    === 1. Install the local plugin from checkout A and tune its settings ===
    $ bb plugin install --yes path:/tmp/bb1766/checkout-a
    Installing bb-plugin-watchdog@0.1.0 from /tmp/bb1766/checkout-a
    Plugins are full-trust code running inside the BB server. They can read all local BB data, including other plugins' secrets.
    Installed:
    watchdog@0.1.0  running
    [exit 0]
    
    $ bb plugin config watchdog set alertFloorMinutes 1
    alertFloorMinutes = "1"  (string)
      Alert floor (min)
    staleWindowMinutes = "1440"  (string)
      Stale window (min)
    token = [not set]  (string, secret)
      Token
    [exit 0]
    
    $ bb plugin config watchdog set staleWindowMinutes 3
    alertFloorMinutes = "1"  (string)
      Alert floor (min)
    staleWindowMinutes = "3"  (string)
      Stale window (min)
    token = [not set]  (string, secret)
      Token
    [exit 0]
    
    $ bb plugin config watchdog set token s3cret
    alertFloorMinutes = "1"  (string)
      Alert floor (min)
    staleWindowMinutes = "3"  (string)
      Stale window (min)
    token = [set]  (string, secret)
      Token
    [exit 0]
    
    $ bb plugin config watchdog
    alertFloorMinutes = "1"  (string)
      Alert floor (min)
    staleWindowMinutes = "3"  (string)
      Stale window (min)
    token = [set]  (string, secret)
      Token
    [exit 0]
    
    === 2. Reload keeps settings (control) ===
    $ bb plugin reload watchdog
    watchdog@0.1.0  running
    [exit 0]
    
    $ bb plugin config watchdog
    alertFloorMinutes = "1"  (string)
      Alert floor (min)
    staleWindowMinutes = "3"  (string)
      Stale window (min)
    token = [set]  (string, secret)
      Token
    [exit 0]
    
    === 3. Try to install the same plugin id from a NEW checkout (B): refused ===
    $ bb plugin install --yes path:/tmp/bb1766/checkout-b
    Installing bb-plugin-watchdog@0.1.0 from /tmp/bb1766/checkout-b
    Plugins are full-trust code running inside the BB server. They can read all local BB data, including other plugins' secrets.
    Error: HTTP 422: plugin id "watchdog" is already installed from path:/tmp/bb1766/checkout-a; remove it first
    [exit 1]
    
    === 4. Follow the error's advice: remove, then install from B ===
    $ bb plugin remove watchdog
    Removed watchdog.
    [exit 0]
    
    $ bb plugin install --yes path:/tmp/bb1766/checkout-b
    Installing bb-plugin-watchdog@0.1.0 from /tmp/bb1766/checkout-b
    Plugins are full-trust code running inside the BB server. They can read all local BB data, including other plugins' secrets.
    Installed:
    watchdog@0.1.0  running
    [exit 0]
    
    === 5. Settings are back to defaults; plugin reports healthy ===
    $ bb plugin config watchdog
    alertFloorMinutes = "60"  (string)
      Alert floor (min)
    staleWindowMinutes = "1440"  (string)
      Stale window (min)
    token = [not set]  (string, secret)
      Token
    [exit 0]
    
    $ bb plugin list
    ask-user-question@0.1.0  disabled
    automations@0.1.0  running
    connect@0.1.0  running
    custom-instructions@0.1.0  running
    inline-vis@0.1.0  running
    keep-awake@0.1.0  running
    provider-acp@0.1.0  running
    provider-claude-code@0.1.0  running
    provider-codex@0.1.0  running
    provider-pi@0.1.0  running
    provider-retry@0.1.0  disabled
    secrets@0.1.0  running
    side-chat@0.1.0  running
    watchdog@0.1.0  running
    workflows@0.1.0  disabled
    [exit 0]
    
    

    Expected: either an in-place way to point the plugin at checkout-b, or a warning at remove time that settings/secrets/schedules will be lost, or settings preserved across the remove/install of the same id. Actual: Removed watchdog., then watchdog@0.1.0 running with alertFloorMinutes = "60", staleWindowMinutes = "1440", token = [not set].

B. Web UI: the confirmation dialog for local plugins does not mention settings

Watchdog plugin detail page after reinstall from checkout-b, status enabled
Plugin detail page (/extensions/plugins/watchdog) after the reinstall from /tmp/bb1766/checkout-b: enabled, healthy, no hint that configuration was reset.
Remove plugin from bb dialog: Remove watchdog from bb? Its source files will stay on disk.
"…" menu → "Remove from bb" for a local-path plugin. The dialog says only "Its source files will stay on disk." The managed-plugin variant of the same dialog says "delete its managed files and settings" — the local-path variant drops the word "settings" even though the server deletes them in both cases.

C. Failing unit test at the service layer

File: apps/server/test/services/plugins/issue-1766-local-replace-loses-settings.test.ts. Run from apps/server: pnpm exec vitest run test/services/plugins/issue-1766-local-replace-loses-settings.test.ts. Two control cases pass (reload and same-path reinstall keep settings; new-path install is refused). The third case fails on main at the final toMatchObject: settings are back to "60"/"1440" and token: { set: false }.

// Repro for get-bb/bb#1766: replacing a local (path:) plugin from a new
// checkout is only possible via `remove` + `install`, and `remove` deletes
// plugin_settings / schedules / secrets for the id.
import { mkdtemp, mkdir, rm, stat, 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,
  getPluginSettingsValues,
  migrate,
  type DbConnection,
} from "@bb/db";
import type { Logger } from "@bb/logger";
import {
  createPluginService,
  type PluginService,
} from "../../../src/services/plugins/plugin-service.js";
import { pluginSecretsDir } from "../../../src/services/plugins/plugin-settings.js";
import { testLogger } from "../../helpers/test-app.js";
import { createNoopTelemetryService } from "../../../src/services/system/telemetry.js";

const logger = testLogger as unknown as Logger;

const SERVER_SOURCE = `
  export default async function plugin(bb: any) {
    const settings = bb.settings.define({
      alertFloorMinutes: { type: "string", label: "Alert floor (min)", default: "60" },
      staleWindowMinutes: { type: "string", label: "Stale window (min)", default: "1440" },
      token: { type: "string", label: "Token", secret: true },
    });
    (globalThis as any).__watchdog = { settings };
  }
`;

// Same plugin id (package name -> "watchdog") written to two different
// directories, like two checkouts of the same repo.
async function writeCheckout(dir: string): Promise<string> {
  await mkdir(dir, { recursive: true });
  await writeFile(
    join(dir, "package.json"),
    JSON.stringify({
      name: "bb-plugin-watchdog",
      version: "0.1.0",
      bb: {
        name: "Watchdog fixture",
        description: "Issue 1766 fixture.",
        branding: { icon: "Zap" },
        server: "./server.ts",
      },
    }),
  );
  await writeFile(join(dir, "server.ts"), SERVER_SOURCE);
  return dir;
}

describe("issue #1766: local plugin replace destroys settings", () => {
  let db: DbConnection;
  let workDir: string;
  let dataDir: string;
  let service: PluginService;
  let checkoutA: string;
  let checkoutB: string;

  beforeEach(async () => {
    db = createConnection(":memory:");
    migrate(db);
    workDir = await mkdtemp(join(tmpdir(), "bb-issue-1766-"));
    dataDir = join(workDir, "data");
    checkoutA = await writeCheckout(join(workDir, "checkout-a"));
    checkoutB = await writeCheckout(join(workDir, "checkout-b"));
    service = createPluginService({
      telemetry: createNoopTelemetryService(),
      db,
      hub: {
        getDaemonSessionIdForHost: () => null,
        notifyPluginSignal: () => 0,
        notifySystem: () => {},
      },
      logger,
      dataDir,
      appVersion: "0.9.0",
      loadTimeoutMs: 2000,
    });
  });

  afterEach(async () => {
    await service.stop();
    await rm(workDir, { recursive: true, force: true });
  });

  async function installAndTune(): Promise<void> {
    const entry = await service.installPath(checkoutA);
    expect(entry.status).toBe("running");
    await service.updateSettings("watchdog", {
      alertFloorMinutes: "1",
      staleWindowMinutes: "3",
      token: "s3cret",
    });
    expect(getPluginSettingsValues(db, "watchdog")).toEqual({
      alertFloorMinutes: JSON.stringify("1"),
      staleWindowMinutes: JSON.stringify("3"),
    });
    await expect(
      stat(join(pluginSecretsDir(dataDir, "watchdog"), "token")),
    ).resolves.toBeDefined();
  }

  it("control: reload and same-path reinstall keep tuned settings", async () => {
    await installAndTune();
    await service.reload("watchdog");
    expect((await service.getSettings("watchdog"))?.values).toMatchObject({
      alertFloorMinutes: "1",
      staleWindowMinutes: "3",
      token: { set: true },
    });
    // Re-installing from the SAME path is allowed and preserves settings.
    await service.installPath(checkoutA);
    expect((await service.getSettings("watchdog"))?.values).toMatchObject({
      alertFloorMinutes: "1",
      staleWindowMinutes: "3",
      token: { set: true },
    });
  });

  it("installing the same plugin id from a NEW checkout is refused; there is no in-place replace", async () => {
    await installAndTune();
    await expect(service.installPath(checkoutB)).rejects.toThrow(
      /already installed from path:.*checkout-a; remove it first/,
    );
    // The refusal is harmless: settings survive.
    expect(getPluginSettingsValues(db, "watchdog")).toEqual({
      alertFloorMinutes: JSON.stringify("1"),
      staleWindowMinutes: JSON.stringify("3"),
    });
  });

  it("BUG: remove + install from the new checkout silently resets to defaults", async () => {
    await installAndTune();

    // The only supported route to move a path: plugin: remove, then install.
    expect(await service.remove("watchdog")).toBe(true);
    // Removal wiped plugin_settings rows and the secrets dir for the id.
    expect(getPluginSettingsValues(db, "watchdog")).toEqual({});
    await expect(
      stat(pluginSecretsDir(dataDir, "watchdog")),
    ).rejects.toMatchObject({ code: "ENOENT" });

    const entry = await service.installPath(checkoutB);
    expect(entry.status).toBe("running");
    const view = await service.getSettings("watchdog");
    // What the operator tuned. This assertion FAILS on main: the plugin is
    // back on defaults ("60" / "1440", token unset) with no warning.
    expect(view?.values).toMatchObject({
      alertFloorMinutes: "1",
      staleWindowMinutes: "3",
      token: { set: true },
    });
  });
});

Output (vitest-output.txt):

 RUN  v4.1.1 /home/sawyer/projects/bb/.claude/worktrees/wf_debcf606-e4a-1/apps/server

 ✓  @bb/server  test/services/plugins/issue-1766-local-replace-loses-settings.test.ts > issue #1766: local plugin replace destroys settings > control: reload and same-path reinstall keep tuned settings 162ms
 ✓  @bb/server  test/services/plugins/issue-1766-local-replace-loses-settings.test.ts > issue #1766: local plugin replace destroys settings > installing the same plugin id from a NEW checkout is refused; there is no in-place replace 68ms
 ×  @bb/server  test/services/plugins/issue-1766-local-replace-loses-settings.test.ts > issue #1766: local plugin replace destroys settings > BUG: remove + install from the new checkout silently resets to defaults 83ms
   → expected { alertFloorMinutes: '60', …(2) } to match object { alertFloorMinutes: '1', …(2) }

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL   @bb/server  test/services/plugins/issue-1766-local-replace-loses-settings.test.ts > issue #1766: local plugin replace destroys settings > BUG: remove + install from the new checkout silently resets to defaults
AssertionError: expected { alertFloorMinutes: '60', …(2) } to match object { alertFloorMinutes: '1', …(2) }

- Expected
+ Received

  {
-   "alertFloorMinutes": "1",
-   "staleWindowMinutes": "3",
+   "alertFloorMinutes": "60",
+   "staleWindowMinutes": "1440",
    "token": {
-     "set": true,
+     "set": false,
    },
  }

 ❯ test/services/plugins/issue-1766-local-replace-loses-settings.test.ts:154:26
    152|     // What the operator tuned. This assertion FAILS on main: the plug…
    153|     // back on defaults ("60" / "1440", token unset) with no warning.
    154|     expect(view?.values).toMatchObject({
       |                          ^
    155|       alertFloorMinutes: "1",
    156|       staleWindowMinutes: "3",

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯


 Test Files  1 failed (1)
      Tests  1 failed | 2 passed (3)
   Start at  04:37:25
   Duration  2.85s (transform 1.40s, setup 0ms, import 2.46s, tests 314ms, environment 0ms)

Root cause

Two independent decisions combine into the data loss:

  1. Registration refuses a same-id install from a different path, and there is no update path for path: plugins. apps/server/src/services/plugins/plugin-registration.ts:243rowMatchesInstallSource compares row.sourcePath === intent.canonicalPath for path intents (apps/server/src/services/plugins/plugin-registration.ts:152); any other path throws "already installed from …; remove it first". bb plugin update is a no-op for path rows (apps/server/src/services/plugins/plugin-updates.ts:273).
      function assertInstallRegistrationAvailable(existing, identity, pluginId) {
        if (existing === undefined) return;
        if (!rowMatchesInstallSource(existing, identity.provenance, identity.sourceIntent)) {
          throw new Error(
            `plugin id "${pluginId}" is already installed from ${existing.source}; remove it first`,
          );
        }
        ...
      }
      // rowMatchesInstallSource, for path: sources
      if (intent.kind === "path") return row.sourcePath === intent.canonicalPath;
        if (args.row.sourceKind === "path" || args.row.sourceKind === "builtin") {
          return { outcome: "pinned", current: installed };
        }
  2. remove() deletes configuration by design. apps/server/src/services/plugins/plugin-service.ts:1666. The comment states the rationale: "a future same-id plugin must not inherit secrets". That is a sound security rule for a different plugin that happens to reuse an id, but it is applied unconditionally, including to the same plugin being re-registered from a new directory by the same operator, and it takes non-secret settings and schedules along with the secrets. kv rows and data.db are explicitly kept, so bb already distinguishes "plugin data" from "registration configuration"; the issue is which bucket settings land in and that the user is never told.
        async remove(id) {
          return withPluginOperationLock(REGISTRATION_MUTATION_KEY, async () => {
            const row = getInstalledPlugin(deps.db, id);
            await withLifecycleLock(id, () => disposeOne(id));
            ...
            const removed = row
              ? row.sourceKind === "builtin"
                ? markInstalledPluginRemoved(deps.db, id)
                : deleteInstalledPlugin(deps.db, id)
              : false;
            if (removed && row) {
              forgetMutableRoot(row.rootDir);
              // Configuration goes with the registration (a future same-id plugin
              // must not inherit secrets); kv rows and data.db are plugin data and
              // survive a remove/reinstall cycle. Schedule rows belong to the
              // registration too.
              deletePluginSchedules(deps.db, id);
              deleteAllPluginSettings(deps.db, id);
              await rm(pluginSecretsDir(deps.dataDir, id), {
                recursive: true,
                force: true,
              });
              // ... path: sources are the user's directory and are never deleted.

Why it is silent. The DELETE route (apps/server/src/routes/plugins.ts:507) returns { ok: true }, remove() emits no log line, the CLI prints Removed <id>., and both user-facing descriptions understate the effect: the CLI help (apps/cli/src/commands/plugin.ts:1843) and the app dialog for local plugins (apps/app/src/views/ToolsView.tsx:364):

  plugin
    .command("remove <id>")
    .description(
      "Remove an installed plugin (git:/npm: managed files are deleted; local path sources are left alone)",
    )
                description={
                  pluginIsLocalSource(deleteTarget)
                    ? `Remove "${deleteTarget.id}" from bb? Its source files will stay on disk.`
                    : `Uninstall "${deleteTarget.id}" and delete its managed files and settings?`
                }

Note the UI dialog for managed (git/npm) plugins does say "and settings", so the local-path branch is the one that is inaccurate — precisely the branch a user hits when they reinstall from a new checkout.

Deeper issue. A path plugin's identity in bb is (plugin id, canonical path). Moving a checkout is a routine operation for local plugin authors and operators, but bb has no "re-point" primitive; it forces users through the destructive uninstall path and describes that path as non-destructive for exactly this source kind. The plugin's own SDK-visible settings.onChange also does not fire (the plugin instance is disposed before deletion), so the plugin cannot observe or defend against the reset.

Proposed fix (first principles)

Confident. Smallest complete change (server owns product policy, so all of this is in apps/server + CLI/app text):

  1. Add an in-place path replace. In assertInstallRegistrationAvailable / registerInstalled, when the existing row is sourceKind === "path", provenance is direct, and the new intent is also path, allow the registration: dispose the old instance, upsertInstalledPlugin with the new source/sourcePath/rootDir, forgetMutableRoot(old rootDir), then loadOne. Settings, secrets and schedules are keyed by plugin id only, so they are preserved automatically. Same-path reinstall already takes this route today, so it is a relaxation of one predicate, not a new flow. Expose it as-is (path plugins are the author's own directory, and the "already installed" refusal today protects nothing that the same-path reinstall does not already allow) or behind bb plugin install --replace / POST /plugins { replace: true } if the refusal should stay the default.
    // apps/server/src/services/plugins/plugin-registration.ts (installPathSource / registerInstalled)
    // Treat a same-id, same-kind (path -> path) install as an in-place source move:
    //   - keep plugin_settings, secrets dir and schedules (they are keyed by plugin id only)
    //   - dispose the old instance, upsert the row with the new sourcePath/rootDir, loadOne()
    //   - forgetMutableRoot(oldRootDir)
    // i.e. in assertInstallRegistrationAvailable, do NOT throw when
    //   existing.sourceKind === "path" && intent.kind === "path" && provenance is "direct",
    // and let upsertInstalledPlugin overwrite source/sourcePath/rootDir.
    // Gate it behind an explicit flag if you want the old refusal to stay the default:
    //   bb plugin install --replace path:/new/checkout   (POST /plugins { source, replace: true })
  2. Make remove loud and honest. Update the CLI description and the guide/skill text (apps/cli/src/commands/plugin.ts:1846, packages/templates/src/templates/bb-guide-plugins.md:221) and the local-plugin dialog copy in ToolsView.tsx to state that settings, secrets and schedules are deleted (kv/data.db kept). Add a logger.info in remove() listing what was deleted. Optionally require --yes when settings/secrets exist, mirroring plugin install's confirmation.
  3. Optionally, keep the "must not inherit secrets" rule but scope it: continue to delete the secrets dir on remove, but retain plugin_settings/schedules; the risk profile of non-secret settings is far lower and the current comment only justifies deleting secrets.

What could go wrong: (1) changes the meaning of "already installed" for path sources: a user installing a different local plugin that reuses an id would silently replace the first one — mitigated by the --replace flag or by keeping the refusal but printing a hint that --replace exists. (2) The install-with-lock path already handles disposal and rollback (registerInstalled reloads the previous row on failure), so a failed replace leaves the old registration loaded. (3) No wire change to the host daemon; no HOST_DAEMON_PROTOCOL_VERSION bump needed. (4) Per AGENTS.md, a new CLI flag must be reflected in the guide/skill in the same change.

PR review

No linked pull requests at the time of this report.

Related issues

Appendix

Commands run
gh issue view 1766 --comments
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
grep -rn "deleteAllPluginSettings\|deletePluginSchedules\|pluginSecretsDir" apps/server/src
git log --oneline -S"deleteAllPluginSettings" -- apps/server/src      # -> 66ddc6018 only
cd apps/server && pnpm exec vitest run test/services/plugins/issue-1766-local-replace-loses-settings.test.ts
scripts/bb-dev-app current                                            # App :11232 Server :19232
export BB_SERVER_URL=http://localhost:19232 BB_HOST_DAEMON_PORT=27232 BB_PROJECT_ID=proj_personal
bash /tmp/bb1766/repro.sh                                             # CLI session above
dev-browser --browser bb1766 --headless run /tmp/bb1766/remove-dialog.js   # screenshots
pnpm dev:stop
Server log excerpt during the CLI session (no remove/settings log line)
[04:38:48] INFO: [server] plugin watchdog@0.1.0 loaded      # install from checkout-a
[04:38:51] INFO: [server] plugin watchdog@0.1.0 loaded      # reload
[04:38:53] INFO: [server] plugin watchdog@0.1.0 loaded      # install from checkout-b (after remove; nothing logged for the remove)
Repro artifacts