diff --git a/packages/ai/test/fireworks-models.test.ts b/packages/ai/test/fireworks-models.test.ts index 0260a910..6d9bebc6 100644 --- a/packages/ai/test/fireworks-models.test.ts +++ b/packages/ai/test/fireworks-models.test.ts @@ -3,7 +3,7 @@ import type { AddressInfo } from "node:net"; import { Type } from "typebox"; import { afterEach, describe, expect, it } from "vitest"; import { findEnvKeys, getEnvApiKey } from "../src/env-api-keys.ts"; -import { getModel } from "../src/models.ts"; +import { getModel, getModels } from "../src/models.ts"; import { streamAnthropic } from "../src/providers/anthropic.ts"; import type { Context, Model, Tool } from "../src/types.ts"; @@ -38,12 +38,14 @@ describe("Fireworks models", () => { }); it("registers the Fire Pass turbo router model", () => { - const model = getModel("fireworks", "accounts/fireworks/routers/kimi-k2p5-turbo"); + const model = getModels("fireworks").find( + (candidate) => candidate.id.startsWith("accounts/fireworks/routers/") && candidate.id.endsWith("-turbo"), + ); expect(model).toBeDefined(); - expect(model.api).toBe("anthropic-messages"); - expect(model.baseUrl).toBe("https://api.fireworks.ai/inference"); - expect(model.input).toEqual(["text", "image"]); + expect(model?.api).toBe("anthropic-messages"); + expect(model?.baseUrl).toBe("https://api.fireworks.ai/inference"); + expect(model?.input).toEqual(["text", "image"]); }); it("resolves FIREWORKS_API_KEY from the environment", () => { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2cf41048..74983eff 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index 1f3c69a6..33527191 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -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//` (global) or `.pi/git//` (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 diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 65081090..ae69afaf 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -129,8 +129,8 @@ pi [options] [@files...] [messages...] pi install [-l] # Install package, -l for project-local pi remove [-l] # Remove package pi uninstall [-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 # Update one package pi list # List installed packages diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 994adb56..06933bb2 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -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 }); diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index f19581e4..b79261ad 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -129,7 +129,15 @@ function getDefaultAgentDir(): string { function getAttributionHeaders( model: Model, settingsManager: SettingsManager, + sessionId?: string, ): Record | 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, diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts index a48898a5..47ff58ae 100644 --- a/packages/coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -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(); diff --git a/packages/coding-agent/src/utils/clipboard-native.ts b/packages/coding-agent/src/utils/clipboard-native.ts index bcdbdfa8..f8aeb547 100644 --- a/packages/coding-agent/src/utils/clipboard-native.ts +++ b/packages/coding-agent/src/utils/clipboard-native.ts @@ -1,4 +1,6 @@ import { createRequire } from "module"; +import { dirname, join } from "path"; +import { pathToFileURL } from "url"; export type ClipboardModule = { setText: (text: string) => Promise; @@ -6,17 +8,25 @@ export type ClipboardModule = { getImageBinary: () => Promise>; }; -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 }; diff --git a/packages/coding-agent/test/clipboard-native.test.ts b/packages/coding-agent/test/clipboard-native.test.ts new file mode 100644 index 00000000..a9c0c201 --- /dev/null +++ b/packages/coding-agent/test/clipboard-native.test.ts @@ -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(() => { + throw new Error("missing from bundled root"); + }); + const fallback = vi.fn(() => 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(() => { + throw new Error("missing"); + }); + + expect(loadClipboardNative([missing])).toBeNull(); + }); +}); diff --git a/packages/coding-agent/test/footer-width.test.ts b/packages/coding-agent/test/footer-width.test.ts index 2d912647..d5586d7d 100644 --- a/packages/coding-agent/test/footer-width.test.ts +++ b/packages/coding-agent/test/footer-width.test.ts @@ -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); diff --git a/packages/coding-agent/test/git-update.test.ts b/packages/coding-agent/test/git-update.test.ts index 0f574ba6..ea8fae4d 100644 --- a/packages/coding-agent/test/git-update.test.ts +++ b/packages/coding-agent/test/git-update.test.ts @@ -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; + }; + 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", () => { diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 30068da1..b464ca7d 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -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); }); diff --git a/packages/coding-agent/test/sdk-openrouter-attribution.test.ts b/packages/coding-agent/test/sdk-openrouter-attribution.test.ts index 70bb8709..c7032103 100644 --- a/packages/coding-agent/test/sdk-openrouter-attribution.test.ts +++ b/packages/coding-agent/test/sdk-openrouter-attribution.test.ts @@ -86,6 +86,7 @@ describe("createAgentSession OpenRouter attribution headers", () => { telemetryEnabled?: boolean; providerHeaders?: Record; requestHeaders?: Record; + sessionId?: string; } = {}, ): Promise | 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"); + }); }); diff --git a/scripts/build-binaries.sh b/scripts/build-binaries.sh index dfc34793..00cc4818 100755 --- a/scripts/build-binaries.sh +++ b/scripts/build-binaries.sh @@ -4,11 +4,14 @@ # Mirrors .github/workflows/build-binaries.yml # # Usage: -# ./scripts/build-binaries.sh [--skip-deps] [--platform ] +# ./scripts/build-binaries.sh [--skip-install] [--skip-deps] [--skip-build] [--platform ] [--out ] # # Options: +# --skip-install Skip npm ci # --skip-deps Skip installing cross-platform dependencies +# --skip-build Skip npm run build # --platform Build only for specified platform (darwin-arm64, darwin-x64, linux-x64, linux-arm64, windows-x64, windows-arm64) +# --out Output directory (default: packages/coding-agent/binaries) # # Output: # packages/coding-agent/binaries/ @@ -23,19 +26,34 @@ set -euo pipefail cd "$(dirname "$0")/.." +SKIP_INSTALL=false SKIP_DEPS=false +SKIP_BUILD=false PLATFORM="" +OUTPUT_DIR="" while [[ $# -gt 0 ]]; do case $1 in + --skip-install) + SKIP_INSTALL=true + shift + ;; --skip-deps) SKIP_DEPS=true shift ;; + --skip-build) + SKIP_BUILD=true + shift + ;; --platform) PLATFORM="$2" shift 2 ;; + --out) + OUTPUT_DIR="$2" + shift 2 + ;; *) echo "Unknown option: $1" exit 1 @@ -56,8 +74,19 @@ if [[ -n "$PLATFORM" ]]; then esac fi -echo "==> Installing dependencies..." -npm ci --ignore-scripts +if [[ -z "$OUTPUT_DIR" ]]; then + OUTPUT_DIR="packages/coding-agent/binaries" +fi +if [[ "$OUTPUT_DIR" != /* ]]; then + OUTPUT_DIR="$(pwd)/$OUTPUT_DIR" +fi + +if [[ "$SKIP_INSTALL" == "false" ]]; then + echo "==> Installing dependencies..." + npm ci --ignore-scripts +else + echo "==> Skipping npm ci (--skip-install)" +fi if [[ "$SKIP_DEPS" == "false" ]]; then echo "==> Installing cross-platform native bindings..." @@ -66,35 +95,29 @@ if [[ "$SKIP_DEPS" == "false" ]]; then # Use --force to bypass platform checks (os/cpu restrictions in package.json) # Install all in one command to avoid npm removing packages from previous installs npm install --no-save --package-lock=false --force --ignore-scripts \ - @mariozechner/clipboard-darwin-arm64@0.3.2 \ - @mariozechner/clipboard-darwin-x64@0.3.2 \ - @mariozechner/clipboard-linux-x64-gnu@0.3.2 \ - @mariozechner/clipboard-linux-arm64-gnu@0.3.2 \ - @mariozechner/clipboard-win32-x64-msvc@0.3.2 \ - @mariozechner/clipboard-win32-arm64-msvc@0.3.2 \ - @img/sharp-darwin-arm64@0.34.5 \ - @img/sharp-darwin-x64@0.34.5 \ - @img/sharp-linux-x64@0.34.5 \ - @img/sharp-linux-arm64@0.34.5 \ - @img/sharp-win32-x64@0.34.5 \ - @img/sharp-win32-arm64@0.34.5 \ - @img/sharp-libvips-darwin-arm64@1.2.4 \ - @img/sharp-libvips-darwin-x64@1.2.4 \ - @img/sharp-libvips-linux-x64@1.2.4 \ - @img/sharp-libvips-linux-arm64@1.2.4 + @mariozechner/clipboard-darwin-arm64@0.3.6 \ + @mariozechner/clipboard-darwin-x64@0.3.6 \ + @mariozechner/clipboard-linux-x64-gnu@0.3.6 \ + @mariozechner/clipboard-linux-arm64-gnu@0.3.6 \ + @mariozechner/clipboard-win32-x64-msvc@0.3.6 \ + @mariozechner/clipboard-win32-arm64-msvc@0.3.6 else echo "==> Skipping cross-platform native bindings (--skip-deps)" fi -echo "==> Building all packages..." -npm run build +if [[ "$SKIP_BUILD" == "false" ]]; then + echo "==> Building all packages..." + npm run build +else + echo "==> Skipping package build (--skip-build)" +fi echo "==> Building binaries..." cd packages/coding-agent # Clean previous builds -rm -rf binaries -mkdir -p binaries/{darwin-arm64,darwin-x64,linux-x64,linux-arm64,windows-x64,windows-arm64} +rm -rf "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR"/{darwin-arm64,darwin-x64,linux-x64,linux-arm64,windows-x64,windows-arm64} # Determine which platforms to build if [[ -n "$PLATFORM" ]]; then @@ -109,9 +132,9 @@ for platform in "${PLATFORMS[@]}"; do # explicit build entrypoints. The runtime can still use new URL(...), but the # worker must be present in the compiled executable. if [[ "$platform" == windows-* ]]; then - bun build --compile --target=bun-$platform ./dist/bun/cli.js ./dist/utils/image-resize-worker.js --outfile binaries/$platform/pi.exe + bun build --compile --target=bun-$platform ./dist/bun/cli.js ./dist/utils/image-resize-worker.js --outfile "$OUTPUT_DIR/$platform/pi.exe" else - bun build --compile --target=bun-$platform ./dist/bun/cli.js ./dist/utils/image-resize-worker.js --outfile binaries/$platform/pi + bun build --compile --target=bun-$platform ./dist/bun/cli.js ./dist/utils/image-resize-worker.js --outfile "$OUTPUT_DIR/$platform/pi" fi done @@ -119,17 +142,41 @@ echo "==> Creating release archives..." # Copy shared files to each platform directory for platform in "${PLATFORMS[@]}"; do - cp package.json binaries/$platform/ - cp README.md binaries/$platform/ - cp CHANGELOG.md binaries/$platform/ - cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm binaries/$platform/ - mkdir -p binaries/$platform/theme - cp dist/modes/interactive/theme/*.json binaries/$platform/theme/ - mkdir -p binaries/$platform/assets - cp dist/modes/interactive/assets/* binaries/$platform/assets/ - cp -r dist/core/export-html binaries/$platform/ - cp -r docs binaries/$platform/ - cp -r examples binaries/$platform/ + cp package.json "$OUTPUT_DIR/$platform/" + cp README.md "$OUTPUT_DIR/$platform/" + cp CHANGELOG.md "$OUTPUT_DIR/$platform/" + cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm "$OUTPUT_DIR/$platform/" + mkdir -p "$OUTPUT_DIR/$platform/theme" + cp dist/modes/interactive/theme/*.json "$OUTPUT_DIR/$platform/theme/" + mkdir -p "$OUTPUT_DIR/$platform/assets" + cp dist/modes/interactive/assets/* "$OUTPUT_DIR/$platform/assets/" + cp -r dist/core/export-html "$OUTPUT_DIR/$platform/" + cp -r docs "$OUTPUT_DIR/$platform/" + cp -r examples "$OUTPUT_DIR/$platform/" + + case "$platform" in + darwin-arm64) + clipboard_native_package="clipboard-darwin-arm64" + ;; + darwin-x64) + clipboard_native_package="clipboard-darwin-x64" + ;; + linux-x64) + clipboard_native_package="clipboard-linux-x64-gnu" + ;; + linux-arm64) + clipboard_native_package="clipboard-linux-arm64-gnu" + ;; + windows-x64) + clipboard_native_package="clipboard-win32-x64-msvc" + ;; + windows-arm64) + clipboard_native_package="clipboard-win32-arm64-msvc" + ;; + esac + mkdir -p "$OUTPUT_DIR/$platform/node_modules/@mariozechner" + cp -r ../../node_modules/@mariozechner/clipboard "$OUTPUT_DIR/$platform/node_modules/@mariozechner/" + cp -r ../../node_modules/@mariozechner/$clipboard_native_package "$OUTPUT_DIR/$platform/node_modules/@mariozechner/" # Copy Windows VT input native helper next to compiled Windows binaries. if [[ "$platform" == windows-* ]]; then @@ -138,47 +185,47 @@ for platform in "${PLATFORMS[@]}"; do else win32_arch_dir="win32-x64" fi - mkdir -p binaries/$platform/native/win32/prebuilds/$win32_arch_dir - cp ../tui/native/win32/prebuilds/$win32_arch_dir/win32-console-mode.node binaries/$platform/native/win32/prebuilds/$win32_arch_dir/ + mkdir -p "$OUTPUT_DIR/$platform/native/win32/prebuilds/$win32_arch_dir" + cp ../tui/native/win32/prebuilds/$win32_arch_dir/win32-console-mode.node "$OUTPUT_DIR/$platform/native/win32/prebuilds/$win32_arch_dir/" fi done # Create archives -cd binaries +cd "$OUTPUT_DIR" for platform in "${PLATFORMS[@]}"; do if [[ "$platform" == windows-* ]]; then # Windows (zip) echo "Creating pi-$platform.zip..." - (cd $platform && zip -r ../pi-$platform.zip .) + (cd "$platform" && zip -r ../pi-$platform.zip .) else # Unix platforms (tar.gz) - use wrapper directory for mise compatibility echo "Creating pi-$platform.tar.gz..." - mv $platform pi && tar -czf pi-$platform.tar.gz pi && mv pi $platform + mv "$platform" pi && tar -czf pi-$platform.tar.gz pi && mv pi "$platform" fi done # Extract archives for easy local testing echo "==> Extracting archives for testing..." for platform in "${PLATFORMS[@]}"; do - rm -rf $platform + rm -rf "$platform" if [[ "$platform" == windows-* ]]; then - mkdir -p $platform && (cd $platform && unzip -q ../pi-$platform.zip) + mkdir -p "$platform" && (cd "$platform" && unzip -q ../pi-$platform.zip) else - tar -xzf pi-$platform.tar.gz && mv pi $platform + tar -xzf pi-$platform.tar.gz && mv pi "$platform" fi done echo "" echo "==> Build complete!" -echo "Archives available in packages/coding-agent/binaries/" +echo "Archives available in $OUTPUT_DIR/" ls -lh *.tar.gz *.zip 2>/dev/null || true echo "" echo "Extracted directories for testing:" for platform in "${PLATFORMS[@]}"; do if [[ "$platform" == windows-* ]]; then - echo " binaries/$platform/pi.exe" + echo " $OUTPUT_DIR/$platform/pi.exe" else - echo " binaries/$platform/pi" + echo " $OUTPUT_DIR/$platform/pi" fi done diff --git a/scripts/local-release.mjs b/scripts/local-release.mjs index 782318bd..3f35e80c 100644 --- a/scripts/local-release.mjs +++ b/scripts/local-release.mjs @@ -2,7 +2,7 @@ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { isAbsolute, join, relative, resolve } from "node:path"; import { spawnSync } from "node:child_process"; const packages = [ @@ -131,48 +131,25 @@ function currentBinaryPlatform() { throw new Error(`Unsupported binary platform: ${process.platform} ${process.arch}`); } -function copyBinaryAssets(targetDirectory) { - const codingAgentDirectory = join(repoRoot, "packages", "coding-agent"); - const distDirectory = join(codingAgentDirectory, "dist"); - cpSync(join(codingAgentDirectory, "package.json"), join(targetDirectory, "package.json")); - cpSync(join(codingAgentDirectory, "README.md"), join(targetDirectory, "README.md")); - cpSync(join(codingAgentDirectory, "CHANGELOG.md"), join(targetDirectory, "CHANGELOG.md")); - cpSync(join(repoRoot, "node_modules", "@silvia-odwyer", "photon-node", "photon_rs_bg.wasm"), join(targetDirectory, "photon_rs_bg.wasm")); - cpSync(join(distDirectory, "modes", "interactive", "theme"), join(targetDirectory, "theme"), { recursive: true }); - cpSync(join(distDirectory, "modes", "interactive", "assets"), join(targetDirectory, "assets"), { recursive: true }); - cpSync(join(distDirectory, "core", "export-html"), join(targetDirectory, "export-html"), { recursive: true }); - cpSync(join(codingAgentDirectory, "docs"), join(targetDirectory, "docs"), { recursive: true }); - cpSync(join(codingAgentDirectory, "examples"), join(targetDirectory, "examples"), { recursive: true }); -} - -function copyWindowsConsoleModeHelper(targetDirectory, platform) { - if (!platform.startsWith("windows-")) return; - const win32Arch = platform === "windows-arm64" ? "win32-arm64" : "win32-x64"; - const relativeNativePath = join("native", "win32", "prebuilds", win32Arch); - mkdirSync(join(targetDirectory, relativeNativePath), { recursive: true }); - cpSync( - join(repoRoot, "packages", "tui", "native", "win32", "prebuilds", win32Arch, "win32-console-mode.node"), - join(targetDirectory, relativeNativePath, "win32-console-mode.node"), - ); -} - function buildBunBinaryRelease(targetDirectory, archiveDirectory) { if (!commandExists("bun")) { throw new Error("Bun is required for the local binary release build."); } const platform = currentBinaryPlatform(); - mkdirSync(targetDirectory, { recursive: true }); - const executableName = platform.startsWith("windows-") ? "pi.exe" : "pi"; - const executablePath = join(targetDirectory, executableName); - const target = `bun-${platform}`; - run("bun", ["build", "--compile", `--target=${target}`, join(repoRoot, "packages", "coding-agent", "dist", "bun", "cli.js"), "--outfile", executablePath]); - copyBinaryAssets(targetDirectory); - copyWindowsConsoleModeHelper(targetDirectory, platform); - if (platform.startsWith("windows-")) { - run("powershell", ["-NoProfile", "-Command", `Compress-Archive -Path '${join(targetDirectory, "*").replaceAll("'", "''")}' -DestinationPath '${join(archiveDirectory, `pi-${platform}.zip`).replaceAll("'", "''")}' -Force`]); - } else { - run("tar", ["-czf", join(archiveDirectory, `pi-${platform}.tar.gz`), "-C", targetDirectory, "."]); - } + const binaryBuildDirectory = join(archiveDirectory, "binary-build"); + run("./scripts/build-binaries.sh", [ + "--skip-install", + "--skip-deps", + "--skip-build", + "--platform", + platform, + "--out", + binaryBuildDirectory, + ]); + rmSync(targetDirectory, { force: true, recursive: true }); + cpSync(join(binaryBuildDirectory, platform), targetDirectory, { recursive: true }); + const archiveName = platform.startsWith("windows-") ? `pi-${platform}.zip` : `pi-${platform}.tar.gz`; + cpSync(join(binaryBuildDirectory, archiveName), join(archiveDirectory, archiveName)); return platform; }