#3119 · Pi update chooses the wrong global package manager
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
| Claim | Status | Evidence |
|---|---|---|
| Pi maintenance chooses npm even when the resolved command wraps a Bun-global Pi binary. | Verified | The 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. | Verified | Source 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 statically | The 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. | Unverified | No user filesystem or runtime data was accessed. The minimal test reproduces the package-manager mismatch with an isolated equivalent layout. |
3. Environment
- Trusted base:
get-bb/bb@7d5de7301544de3073996b4f8a8c33b311ecb2db, matching GitHub’smainhead at investigation start. - macOS Darwin 25.6.0 arm64; Node v22.22.3; pnpm 9.15.0; Bun 1.3.9.
- Primary checkout: BB-managed clean worktree. Verification checkout: a separate clean clone under a fresh temporary directory at the same commit.
- No server, ports, persistent data directory, provider account, or real global package installation was used. The reproduction is unit-level and confines all fake executables to a fresh temporary directory.
pnpm install --frozen-lockfile --prefer-offlineandpnpm exec turbo run buildcompleted before reproduction in both checkouts.
4. Minimal reproduction
- Save the test below as
plugins/provider-pi/src/bridge/provider-maintenance.bun.test.tsat the trusted base. - Run
pnpm exec turbo run test --filter=bb-plugin-provider-pi -- --run src/bridge/provider-maintenance.bun.test.ts
- 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