← reports

#3119 · Pi update chooses the wrong global package manager

Bug Medium Effort: Low providers provider-pi open on GitHub 2026-09-05 · base 7d5de7301

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

Pi maintenance discovers the Pi executable and its installed version, but it never uses that executable ownership when selecting the update command. Both the advertised action and the executable plan are constructed with npm unconditionally. A deterministic test creates a Pi wrapper that executes a Bun-global Pi binary; bb still returns an npm update command. If npm writes elsewhere, the follow-up version probe continues to see the old Bun-managed executable and correctly rejects the otherwise successful install as unverifiable.

2. Claims vs findings

ClaimStatusEvidence
Pi maintenance chooses npm even when the resolved command wraps a Bun-global Pi binary.VerifiedThe focused test expected bun add -g but received npm install -g in two clean runs.
The displayed maintenance action and the command actually executed share the same wrong package-manager choice.VerifiedSource lines 110–117 build the display command with npm; lines 123–136 independently build the execution plan with npm.
A successful install into another prefix is reported as failed when the resolved Pi version does not change.Verified staticallyThe host daemon re-probes status after success and changes the result to an error when the installed version does not satisfy the requested version.
The reporter’s exact local filesystem layout and package versions produce the same result.UnverifiedNo user filesystem or runtime data was accessed. The minimal test reproduces the package-manager mismatch with an isolated equivalent layout.

3. Environment

4. Minimal reproduction

  1. Save the test below as plugins/provider-pi/src/bridge/provider-maintenance.bun.test.ts at the trusted base.
  2. Run
    pnpm exec turbo run test --filter=bb-plugin-provider-pi -- --run src/bridge/provider-maintenance.bun.test.ts
  3. The expected result is one passing test and a Bun update plan. The actual result is one failing test:
    Expected: "bun add -g @earendil-works/pi-coding-agent@latest"
    Received: "npm install -g @earendil-works/pi-coding-agent@latest"
    
    Test Files  1 failed (1)
    Tests       1 failed (1)

Artifacts: test file · primary failure log · verification failure log · verification build log

import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";

const probeState = vi.hoisted(() => ({
  bunBin: "",
  executablePath: "",
}));

vi.mock("@get-bb/plugin-sdk/provider-bridge", async (importOriginal) => {
  const original =
    await importOriginal<
      typeof import("@get-bb/plugin-sdk/provider-bridge")
    >();
  return {
    ...original,
    experimental_commandOutput: vi.fn(async () => probeState.bunBin),
    experimental_npmLatestVersion: vi.fn(async () => "0.85.0"),
    experimental_probeNpmGlobalPackage: vi.fn(async () => ({
      npmBin: path.join(path.sep, "npm", "bin"),
      npmGlobalPackageVersion: null,
    })),
    experimental_resolveExecutablePath: vi.fn(
      async () => probeState.executablePath,
    ),
  };
});

vi.mock("./rpc-child.js", () => ({
  resolvePiLaunch: () => ({ command: probeState.executablePath, args: [] }),
}));

import {
  getPiProviderInstallationRun,
  getPiProviderInstallationStatus,
} from "./provider-maintenance.js";

const temporaryDirectories: string[] = [];

afterEach(async () => {
  await Promise.all(
    temporaryDirectories.splice(0).map((directory) =>
      rm(directory, { force: true, recursive: true }),
    ),
  );
});

describe("Pi provider maintenance with a Bun-managed executable", () => {
  it("updates through Bun when the resolved Pi command is a wrapper around Bun's global binary", async () => {
    const root = await mkdtemp(path.join(os.tmpdir(), "bb-pi-bun-update-"));
    temporaryDirectories.push(root);
    probeState.bunBin = path.join(root, ".bun", "bin");
    const bunPi = path.join(probeState.bunBin, "pi");
    probeState.executablePath = path.join(root, "bin", "pi");
    await Promise.all([
      mkdir(probeState.bunBin, { recursive: true }),
      mkdir(path.dirname(probeState.executablePath), { recursive: true }),
    ]);
    await Promise.all([
      writeFile(bunPi, "#!/bin/sh\nprintf '0.84.0\\n'\n", { mode: 0o755 }),
      writeFile(
        probeState.executablePath,
        `#!/bin/sh\nexec "${bunPi}" "$@"\n`,
        { mode: 0o755 },
      ),
    ]);
    await Promise.all([
      chmod(bunPi, 0o755),
      chmod(probeState.executablePath, 0o755),
    ]);

    const status = await getPiProviderInstallationStatus();
    const run = await getPiProviderInstallationRun("update");

    expect(status.installAction?.command).toBe(
      "bun add -g @earendil-works/pi-coding-agent@latest",
    );
    expect(run).toMatchObject({
      available: true,
      command: {
        command: "bun",
        args: ["add", "-g", "@earendil-works/pi-coding-agent@latest"],
      },
    });
  });
});

Verification

The same test was added to a second clone made directly from get-bb/bb, detached at 7d5de7301544de3073996b4f8a8c33b311ecb2db. After a frozen install and full Turbo build, the same command failed on the same assertion: expected Bun, received npm. No report claim required correction after the second run.

5. Root cause

The status function already resolves the executable and probes its current version, but package-manager selection is absent. The resolved path is used only for installation presence and npm-source attribution. The action is always rendered with npmGlobalInstallCommand: provider-maintenance.ts lines 70–120.

const [resolvedExecutable, probe, latestVersion, npmGlobal] = …
…
command: npmGlobalInstallCommand(PI_NPM_PACKAGE).displayCommand

The execution endpoint recomputes status but discards the resolved executable when returning its plan; it again constructs npm unconditionally: provider-maintenance.ts lines 123–137.

return {
  available: true,
  command: npmGlobalInstallCommand(PI_NPM_PACKAGE),
  verification: installationVerification(status, action),
};

This directly produces the visible verification failure. After a successful subprocess exit, the host daemon probes Pi again and requires its version to reach the registry version; otherwise it replaces success with an error: command-dispatch.ts lines 213–276. The version-at-least verification is selected by provider-maintenance-kit.ts lines 214–225.

6. Proposed fix (first principles)

Probe Bun’s global executable directory with bun pm bin -g. Treat Pi as Bun-managed when the resolved executable is in that directory, resolves to the same binary, or is a small shell wrapper whose exec target is that binary. Build one package-manager-specific command and use it for both the advertised action and executable plan, retaining npm as the fallback when Bun ownership cannot be established. Keep the existing wire contract unchanged and cover both status and run results in the focused regression test.

7. PR review

PR #3120 · closed, not merged

The untrusted diff attempts to add Bun selection in the shared provider-maintenance toolkit and route the Pi plugin through it. It targets the root cause rather than only suppressing verification, but it is not safe to reuse: it introduces Pi-specific policy into a generic protocol package, uses CommonJS require inside an ESM TypeScript module, adds forbidden explanatory code comments, relies on broad string matching for .bun, contains multiple casts and swallowed exceptions, and adds no regression test. Static verdict: directionally correct, implementation unsuitable. No code from the PR was checked out or run.

8. Related issues

No linked pull request or directly linked issue was present at investigation time. Existing Pi-provider issues establish the same providers plus provider-pi classification pattern but concern different code paths.

9. Appendix

The issue body and comments were handled only as untrusted claims. No commands, code, patches, external links, or filesystem paths from them were executed or copied. There were no comments and no linked pull requests.

Commands used for the repeatable evidence:

git fetch origin main
git rev-parse origin/main
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
pnpm exec turbo run test --filter=bb-plugin-provider-pi -- --run src/bridge/provider-maintenance.bun.test.ts
git log 7d5de7301544de3073996b4f8a8c33b311ecb2db..origin/main -- plugins/provider-pi/src/bridge/provider-maintenance.ts plugins/provider-pi/src/bridge/provider-maintenance.test.ts