Merge remote-tracking branch 'origin/main' into pr-4756-squash-cleanup

# Conflicts:
#	scripts/build-binaries.sh
This commit is contained in:
Mario Zechner
2026-05-23 10:52:28 +02:00
15 changed files with 338 additions and 130 deletions

View File

@@ -13,6 +13,8 @@
### Fixed
- Fixed `pi update` to reconcile git-pinned packages to their configured ref ([#4869](https://github.com/earendil-works/pi/issues/4869)).
- Fixed OpenCode Zen/Go requests to send per-session OpenCode routing headers ([#4847](https://github.com/earendil-works/pi/issues/4847)).
- Fixed Amazon Bedrock provider loading under strict package managers by inheriting the declared `@smithy/node-http-handler` dependency from `@earendil-works/pi-ai` ([#4842](https://github.com/earendil-works/pi/issues/4842)).
- Fixed exported session HTML to escape quote characters in attribute values ([#4832](https://github.com/earendil-works/pi/issues/4832)).
- Fixed GitHub Copilot device-code login to keep opening the verification URL in browser-capable environments while ignoring browser launch failures for headless use.
@@ -20,6 +22,8 @@
- Published a 0.74.2 rescue release that tells Node 20 users to upgrade Node before updating to newer Pi versions ([#4876](https://github.com/earendil-works/pi/issues/4876)).
- Fixed final bash tool cards to avoid rendering duplicate full-output truncation paths ([#4819](https://github.com/earendil-works/pi/issues/4819)).
- Fixed bash tool truncation line counts to ignore the trailing newline as an extra output line ([#4818](https://github.com/earendil-works/pi/issues/4818)).
- Fixed footer home-directory abbreviation to avoid shortening sibling paths that only share the same prefix ([#4878](https://github.com/earendil-works/pi/issues/4878)).
- Fixed macOS Bun release binaries to resolve the native clipboard sidecar so Ctrl+V image paste can load `@mariozechner/clipboard` ([#4307](https://github.com/earendil-works/pi/issues/4307)).
## [0.75.4] - 2026-05-20

View File

@@ -28,8 +28,8 @@ pi install ./relative/path/to/package
pi remove npm:@foo/bar
pi list # show installed packages from settings
pi update # update pi and all non-pinned packages
pi update --extensions # update all non-pinned packages only
pi update # update pi, update packages, and reconcile pinned git refs
pi update --extensions # update packages and reconcile pinned git refs only
pi update --self # update pi only
pi update --self --force # reinstall pi even if current
pi update npm:@foo/bar # update one package
@@ -85,9 +85,10 @@ ssh://git@github.com/user/repo@v1
- HTTPS and SSH URLs are both supported.
- SSH URLs use your configured SSH keys automatically (respects `~/.ssh/config`).
- For non-interactive runs (for example CI), you can set `GIT_TERMINAL_PROMPT=0` to disable credential prompts and set `GIT_SSH_COMMAND` (for example `ssh -o BatchMode=yes -o ConnectTimeout=5`) to fail fast.
- Refs are pinned tags or commits and skip package updates (`pi update`, `pi update --extensions`). Use `pi install git:host/user/repo@new-ref` to move an existing package to a new pinned ref.
- Refs are pinned tags or commits. `pi update` and `pi update --extensions` do not move them to newer refs, but they do reconcile an existing clone to the configured ref.
- Use `pi install git:host/user/repo@new-ref` to update settings and move an existing package to a new pinned ref.
- Cloned to `~/.pi/agent/git/<host>/<path>` (global) or `.pi/git/<host>/<path>` (project).
- Runs `npm install` after clone, pull, or pinned ref change if `package.json` exists.
- When reconciliation changes the checkout, pi resets and cleans the clone, then runs `npm install` if `package.json` exists.
**SSH examples:**
```bash

View File

@@ -129,8 +129,8 @@ pi [options] [@files...] [messages...]
pi install <source> [-l] # Install package, -l for project-local
pi remove <source> [-l] # Remove package
pi uninstall <source> [-l] # Alias for remove
pi update [source|self|pi] # Update pi and packages; skips pinned packages
pi update --extensions # Update packages only
pi update [source|self|pi] # Update pi and packages; reconcile pinned git refs
pi update --extensions # Update packages only; reconcile pinned git refs
pi update --self # Update pi only
pi update --extension <src> # Update one package
pi list # List installed packages

View File

@@ -1047,14 +1047,15 @@ export class DefaultPackageManager implements PackageManager {
for (const entry of sources) {
const parsed = this.parseSource(entry.source);
if (parsed.type === "local" || parsed.pinned) {
continue;
}
// Pinned npm versions are fixed. Pinned git refs are configured checkout targets,
// so include them to reconcile an existing clone when the configured ref changes.
if (parsed.type === "npm") {
npmCandidates.push({ ...entry, parsed });
continue;
if (!parsed.pinned) {
npmCandidates.push({ ...entry, parsed });
}
} else if (parsed.type === "git") {
gitCandidates.push({ ...entry, parsed });
}
gitCandidates.push({ ...entry, parsed });
}
const npmCheckTasks = npmCandidates.map((entry) => async () => ({
@@ -1766,6 +1767,11 @@ export class DefaultPackageManager implements PackageManager {
return;
}
if (source.ref) {
await this.ensureGitRef(targetDir, ["fetch", "origin", source.ref], "FETCH_HEAD");
return;
}
const target = await this.getLocalGitUpdateTarget(targetDir);
await this.ensureGitRef(targetDir, target.fetchArgs, target.ref);
}
@@ -1778,7 +1784,8 @@ export class DefaultPackageManager implements PackageManager {
cwd: targetDir,
timeoutMs: NETWORK_TIMEOUT_MS,
});
const targetHead = await this.runCommandCapture("git", ["rev-parse", ref], {
const commitRef = `${ref}^{commit}`;
const targetHead = await this.runCommandCapture("git", ["rev-parse", commitRef], {
cwd: targetDir,
timeoutMs: NETWORK_TIMEOUT_MS,
});
@@ -1786,7 +1793,7 @@ export class DefaultPackageManager implements PackageManager {
return;
}
await this.runCommand("git", ["reset", "--hard", ref], { cwd: targetDir });
await this.runCommand("git", ["reset", "--hard", commitRef], { cwd: targetDir });
// Clean untracked files (extensions should be pristine)
await this.runCommand("git", ["clean", "-fdx"], { cwd: targetDir });

View File

@@ -129,7 +129,15 @@ function getDefaultAgentDir(): string {
function getAttributionHeaders(
model: Model<any>,
settingsManager: SettingsManager,
sessionId?: string,
): Record<string, string> | undefined {
if (
sessionId &&
(model.provider === "opencode" || model.provider === "opencode-go" || model.baseUrl.includes("opencode.ai"))
) {
return { "x-opencode-session": sessionId, "x-opencode-client": "pi" };
}
if (!isInstallTelemetryEnabled(settingsManager)) {
return undefined;
}
@@ -332,7 +340,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
throw new Error(auth.error);
}
const providerRetrySettings = settingsManager.getProviderRetrySettings();
const attributionHeaders = getAttributionHeaders(model, settingsManager);
const attributionHeaders = getAttributionHeaders(model, settingsManager, options?.sessionId);
return streamSimple(model, context, {
...options,
apiKey: auth.apiKey,

View File

@@ -1,3 +1,4 @@
import { isAbsolute, relative, resolve, sep } from "node:path";
import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
import type { AgentSession } from "../../../core/agent-session.ts";
import type { ReadonlyFooterDataProvider } from "../../../core/footer-data-provider.ts";
@@ -26,6 +27,20 @@ function formatTokens(count: number): string {
return `${Math.round(count / 1000000)}M`;
}
export function formatCwdForFooter(cwd: string, home: string | undefined): string {
if (!home) return cwd;
const resolvedCwd = resolve(cwd);
const resolvedHome = resolve(home);
const relativeToHome = relative(resolvedHome, resolvedCwd);
const isInsideHome =
relativeToHome === "" ||
(relativeToHome !== ".." && !relativeToHome.startsWith(`..${sep}`) && !isAbsolute(relativeToHome));
if (!isInsideHome) return cwd;
return relativeToHome === "" ? "~" : `~${sep}${relativeToHome}`;
}
/**
* Footer component that shows pwd, token stats, and context usage.
* Computes token/context stats from session, gets git branch and extension statuses from provider.
@@ -92,11 +107,7 @@ export class FooterComponent implements Component {
const contextPercent = contextUsage?.percent !== null ? contextPercentValue.toFixed(1) : "?";
// Replace home directory with ~
let pwd = this.session.sessionManager.getCwd();
const home = process.env.HOME || process.env.USERPROFILE;
if (home && pwd.startsWith(home)) {
pwd = `~${pwd.slice(home.length)}`;
}
let pwd = formatCwdForFooter(this.session.sessionManager.getCwd(), process.env.HOME || process.env.USERPROFILE);
// Add git branch if available
const branch = this.footerData.getGitBranch();

View File

@@ -1,4 +1,6 @@
import { createRequire } from "module";
import { dirname, join } from "path";
import { pathToFileURL } from "url";
export type ClipboardModule = {
setText: (text: string) => Promise<void>;
@@ -6,17 +8,25 @@ export type ClipboardModule = {
getImageBinary: () => Promise<Array<number>>;
};
const require = createRequire(import.meta.url);
let clipboard: ClipboardModule | null = null;
type ClipboardRequire = (id: string) => unknown;
const moduleRequire = createRequire(import.meta.url);
const executableDirRequire = createRequire(pathToFileURL(join(dirname(process.execPath), "package.json")).href);
const hasDisplay = process.platform !== "linux" || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
if (!process.env.TERMUX_VERSION && hasDisplay) {
try {
clipboard = require("@mariozechner/clipboard") as ClipboardModule;
} catch {
clipboard = null;
export function loadClipboardNative(
requires: readonly ClipboardRequire[] = [moduleRequire, executableDirRequire],
): ClipboardModule | null {
for (const requireClipboard of requires) {
try {
return requireClipboard("@mariozechner/clipboard") as ClipboardModule;
} catch {
// Try the next resolution root.
}
}
return null;
}
const clipboard = !process.env.TERMUX_VERSION && hasDisplay ? loadClipboardNative() : null;
export { clipboard };

View File

@@ -0,0 +1,31 @@
import { describe, expect, test, vi } from "vitest";
import { type ClipboardModule, loadClipboardNative } from "../src/utils/clipboard-native.ts";
type ClipboardRequire = (id: string) => unknown;
const fakeClipboard: ClipboardModule = {
setText: async () => {},
hasImage: () => true,
getImageBinary: async () => [1, 2, 3],
};
describe("loadClipboardNative", () => {
test("falls back to the next require root", () => {
const primary = vi.fn<ClipboardRequire>(() => {
throw new Error("missing from bundled root");
});
const fallback = vi.fn<ClipboardRequire>(() => fakeClipboard);
expect(loadClipboardNative([primary, fallback])).toBe(fakeClipboard);
expect(primary).toHaveBeenCalledWith("@mariozechner/clipboard");
expect(fallback).toHaveBeenCalledWith("@mariozechner/clipboard");
});
test("returns null when no require root can load clipboard", () => {
const missing = vi.fn<ClipboardRequire>(() => {
throw new Error("missing");
});
expect(loadClipboardNative([missing])).toBeNull();
});
});

View File

@@ -2,7 +2,7 @@ import { visibleWidth } from "@earendil-works/pi-tui";
import { beforeAll, describe, expect, it } from "vitest";
import type { AgentSession } from "../src/core/agent-session.ts";
import type { ReadonlyFooterDataProvider } from "../src/core/footer-data-provider.ts";
import { FooterComponent } from "../src/modes/interactive/components/footer.ts";
import { FooterComponent, formatCwdForFooter } from "../src/modes/interactive/components/footer.ts";
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
type AssistantUsage = {
@@ -73,6 +73,17 @@ function createFooterData(providerCount: number): ReadonlyFooterDataProvider {
return provider;
}
describe("formatCwdForFooter", () => {
it("does not abbreviate sibling paths that share the home prefix", () => {
expect(formatCwdForFooter("/home/user2", "/home/user")).toBe("/home/user2");
});
it("abbreviates the home directory and descendants", () => {
expect(formatCwdForFooter("/home/user", "/home/user")).toBe("~");
expect(formatCwdForFooter("/home/user/project", "/home/user")).toBe("~/project");
});
});
describe("FooterComponent width handling", () => {
beforeAll(() => {
initTheme(undefined, false);

View File

@@ -282,7 +282,7 @@ describe("DefaultPackageManager git update", () => {
});
describe("pinned sources", () => {
it("should not update pinned git sources (with @ref)", async () => {
it("should not move pinned git sources past their configured ref", async () => {
// Create remote repo first to get the initial commit
mkdirSync(remoteDir, { recursive: true });
initGitRepo(remoteDir);
@@ -301,13 +301,77 @@ describe("DefaultPackageManager git update", () => {
// Add new commit to remote
createCommit(remoteDir, "extension.ts", "// v2", "Second commit");
// Update should be skipped for pinned sources
await packageManager.update();
// Should still be on initial commit
expect(getCurrentCommit(installedDir)).toBe(initialCommit);
expect(getFileContent(installedDir, "extension.ts")).toBe("// v1");
});
it("should checkout the configured pinned git ref during full and targeted updates", async () => {
mkdirSync(remoteDir, { recursive: true });
initGitRepo(remoteDir);
const v1Commit = createCommit(remoteDir, "extension.ts", "// v1", "Initial commit");
git(["tag", "v1"], remoteDir);
const v2Commit = createCommit(remoteDir, "extension.ts", "// v2", "Second commit");
git(["tag", "v2"], remoteDir);
mkdirSync(join(agentDir, "git", "github.com", "test"), { recursive: true });
git(["clone", remoteDir, installedDir], tempDir);
git(["checkout", "v1"], installedDir);
expect(getCurrentCommit(installedDir)).toBe(v1Commit);
const pinnedSource = `${gitSource}@v2`;
settingsManager.setPackages([pinnedSource]);
await packageManager.update();
expect(getCurrentCommit(installedDir)).toBe(v2Commit);
expect(getFileContent(installedDir, "extension.ts")).toBe("// v2");
git(["checkout", "v1"], installedDir);
await packageManager.update(pinnedSource);
expect(getCurrentCommit(installedDir)).toBe(v2Commit);
expect(getFileContent(installedDir, "extension.ts")).toBe("// v2");
});
it("should not reset an annotated tag checkout that already matches the configured ref", async () => {
mkdirSync(remoteDir, { recursive: true });
initGitRepo(remoteDir);
const taggedCommit = createCommit(remoteDir, "extension.ts", "// v1", "Initial commit");
git(["tag", "-a", "v1", "-m", "v1"], remoteDir);
mkdirSync(join(agentDir, "git", "github.com", "test"), { recursive: true });
git(["clone", remoteDir, installedDir], tempDir);
git(["checkout", "v1"], installedDir);
expect(getCurrentCommit(installedDir)).toBe(taggedCommit);
settingsManager.setPackages([`${gitSource}@v1`]);
const executedCommands: string[] = [];
const managerWithInternals = packageManager as unknown as {
runCommand: (command: string, args: string[], options?: { cwd?: string }) => Promise<void>;
};
managerWithInternals.runCommand = async (command, args, options) => {
executedCommands.push(`${command} ${args.join(" ")}`);
const result = spawnSync(command, args, {
cwd: options?.cwd,
encoding: "utf-8",
});
if (result.status !== 0) {
throw new Error(`Command failed: ${command} ${args.join(" ")}\n${result.stderr}`);
}
};
await packageManager.update();
expect(executedCommands).toContain("git fetch origin v1");
expect(executedCommands.some((command) => command.startsWith("git reset --hard"))).toBe(false);
expect(executedCommands).not.toContain("git clean -fdx");
expect(getCurrentCommit(installedDir)).toBe(taggedCommit);
});
});
describe("temporary git sources", () => {

View File

@@ -748,7 +748,7 @@ Content`,
if (args[0] === "rev-parse" && args[1] === "HEAD") {
return "old-head";
}
if (args[0] === "rev-parse" && args[1] === "FETCH_HEAD") {
if (args[0] === "rev-parse" && args[1] === "FETCH_HEAD^{commit}") {
return "new-head";
}
throw new Error(`Unexpected runCommandCapture args: ${args.join(" ")}`);
@@ -758,7 +758,9 @@ Content`,
await packageManager.install(source);
expect(runCommandSpy).toHaveBeenCalledWith("git", ["fetch", "origin", "v2"], { cwd: targetDir });
expect(runCommandSpy).toHaveBeenCalledWith("git", ["reset", "--hard", "FETCH_HEAD"], { cwd: targetDir });
expect(runCommandSpy).toHaveBeenCalledWith("git", ["reset", "--hard", "FETCH_HEAD^{commit}"], {
cwd: targetDir,
});
expect(runCommandSpy).toHaveBeenCalledWith("git", ["clean", "-fdx"], { cwd: targetDir });
expect(runCommandSpy).toHaveBeenCalledWith("npm", ["install", "--omit=dev"], { cwd: targetDir });
});
@@ -779,7 +781,7 @@ Content`,
if (args[0] === "rev-parse" && args[1] === "HEAD") {
return "old-head";
}
if (args[0] === "rev-parse" && args[1] === "origin/HEAD") {
if (args[0] === "rev-parse" && args[1] === "origin/HEAD^{commit}") {
return "new-head";
}
throw new Error(`Unexpected runCommandCapture args: ${args.join(" ")}`);
@@ -789,7 +791,9 @@ Content`,
await packageManager.install(source);
expect(runCommandSpy).toHaveBeenCalledWith("git", fetchArgs, { cwd: targetDir });
expect(runCommandSpy).toHaveBeenCalledWith("git", ["reset", "--hard", "origin/HEAD"], { cwd: targetDir });
expect(runCommandSpy).toHaveBeenCalledWith("git", ["reset", "--hard", "origin/HEAD^{commit}"], {
cwd: targetDir,
});
expect(runCommandSpy).toHaveBeenCalledWith("git", ["clean", "-fdx"], { cwd: targetDir });
});
@@ -832,7 +836,7 @@ Content`,
if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "@{upstream}") {
return "origin/main";
}
if (args[0] === "rev-parse" && args[1] === "@{upstream}") {
if (args[0] === "rev-parse" && (args[1] === "@{upstream}" || args[1] === "@{upstream}^{commit}")) {
return "remote-head";
}
if (args[0] === "rev-parse" && args[1] === "HEAD") {
@@ -868,7 +872,7 @@ Content`,
if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "@{upstream}") {
return "origin/main";
}
if (args[0] === "rev-parse" && args[1] === "@{upstream}") {
if (args[0] === "rev-parse" && (args[1] === "@{upstream}" || args[1] === "@{upstream}^{commit}")) {
return "remote-head";
}
if (args[0] === "rev-parse" && args[1] === "HEAD") {
@@ -2057,7 +2061,7 @@ export default function(api) { api.registerTool({ name: "test", description: "te
expect(packageManager.getInstalledPath("npm:legacy-pkg", "user")).toBe(managedPath);
});
it("should batch npm updates per scope and run git updates in parallel while skipping pinned and current packages", async () => {
it("should batch npm updates per scope and run git updates in parallel while skipping pinned npm and current packages", async () => {
const userOldPath = join(agentDir, "npm", "node_modules", "user-old");
const userCurrentPath = join(agentDir, "npm", "node_modules", "user-current");
const userUnknownPath = join(agentDir, "npm", "node_modules", "user-unknown");
@@ -2159,7 +2163,7 @@ export default function(api) { api.registerTool({ name: "test", description: "te
["install", "project-old@latest", "project-missing@latest", "--prefix", join(tempDir, ".pi", "npm")],
undefined,
);
expect(updateGitSpy).toHaveBeenCalledTimes(3);
expect(updateGitSpy).toHaveBeenCalledTimes(4);
expect(maxConcurrentNpmUpdates).toBeGreaterThan(1);
expect(maxConcurrentGitUpdates).toBeGreaterThan(1);
});

View File

@@ -86,6 +86,7 @@ describe("createAgentSession OpenRouter attribution headers", () => {
telemetryEnabled?: boolean;
providerHeaders?: Record<string, string>;
requestHeaders?: Record<string, string>;
sessionId?: string;
} = {},
): Promise<Record<string, string> | undefined> {
const settingsManager = SettingsManager.create(cwd, agentDir);
@@ -112,6 +113,11 @@ describe("createAgentSession OpenRouter attribution headers", () => {
registeredProviders.push(model.provider);
}
const sessionManager = SessionManager.inMemory(cwd);
if (options.sessionId) {
sessionManager.newSession({ id: options.sessionId });
}
const { session } = await createAgentSession({
cwd,
agentDir,
@@ -119,14 +125,17 @@ describe("createAgentSession OpenRouter attribution headers", () => {
authStorage,
modelRegistry,
settingsManager,
sessionManager: SessionManager.inMemory(cwd),
sessionManager,
});
try {
await session.agent.streamFn(
model,
{ messages: [] },
options.requestHeaders ? { headers: options.requestHeaders } : undefined,
{
sessionId: session.sessionId,
...(options.requestHeaders ? { headers: options.requestHeaders } : {}),
},
);
return capturedOptions?.headers;
} finally {
@@ -178,4 +187,26 @@ describe("createAgentSession OpenRouter attribution headers", () => {
expect(headers?.["X-OpenRouter-Title"]).toBe("request-title");
expect(headers?.["X-OpenRouter-Categories"]).toBe("provider-category");
});
it("adds OpenCode session headers", async () => {
const headers = await captureHeaders(createModel("opencode", "https://opencode.ai/zen/v1"), {
sessionId: "opencode-session",
});
expect(headers?.["x-opencode-session"]).toBe("opencode-session");
expect(headers?.["x-opencode-client"]).toBe("pi");
});
it("lets configured OpenCode headers override the defaults", async () => {
const headers = await captureHeaders(createModel("opencode", "https://opencode.ai/zen/v1"), {
sessionId: "opencode-session",
providerHeaders: {
"x-opencode-session": "configured-session",
"x-opencode-client": "configured-client",
},
});
expect(headers?.["x-opencode-session"]).toBe("configured-session");
expect(headers?.["x-opencode-client"]).toBe("configured-client");
});
});