From 3533843dd781dcd233f51854fc883ec246a6a919 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 20 May 2026 16:11:46 +0200 Subject: [PATCH 01/23] Release v0.75.4 --- package-lock.json | 24 +++++++++---------- packages/agent/CHANGELOG.md | 2 +- packages/agent/package.json | 4 ++-- packages/ai/CHANGELOG.md | 2 +- packages/ai/package.json | 2 +- packages/coding-agent/CHANGELOG.md | 2 +- .../package-lock.json | 4 ++-- .../custom-provider-anthropic/package.json | 2 +- .../custom-provider-gitlab-duo/package.json | 2 +- .../extensions/sandbox/package-lock.json | 4 ++-- .../examples/extensions/sandbox/package.json | 2 +- .../extensions/with-deps/package-lock.json | 4 ++-- .../extensions/with-deps/package.json | 2 +- packages/coding-agent/npm-shrinkwrap.json | 24 +++++++++---------- packages/coding-agent/package.json | 8 +++---- packages/tui/CHANGELOG.md | 2 +- packages/tui/package.json | 2 +- 17 files changed, 46 insertions(+), 46 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4bb2dfe86..fb54cf29c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5998,10 +5998,10 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.75.3", + "version": "0.75.4", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.75.3", + "@earendil-works/pi-ai": "^0.75.4", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -6035,7 +6035,7 @@ }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.75.3", + "version": "0.75.4", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -6079,12 +6079,12 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "0.75.3", + "version": "0.75.4", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.75.3", - "@earendil-works/pi-ai": "^0.75.3", - "@earendil-works/pi-tui": "^0.75.3", + "@earendil-works/pi-agent-core": "^0.75.4", + "@earendil-works/pi-ai": "^0.75.4", + "@earendil-works/pi-tui": "^0.75.4", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -6123,25 +6123,25 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.75.3", + "version": "0.75.4", "dependencies": { "@anthropic-ai/sdk": "0.52.0" } }, "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": { "name": "pi-extension-custom-provider-gitlab-duo", - "version": "0.75.3" + "version": "0.75.4" }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.5.3", + "version": "1.5.4", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.75.3", + "version": "0.75.4", "dependencies": { "ms": "2.1.3" }, @@ -6177,7 +6177,7 @@ }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.75.3", + "version": "0.75.4", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 1fbe29158..872b89798 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.75.4] - 2026-05-20 ### Changed diff --git a/packages/agent/package.json b/packages/agent/package.json index d8bf0656c..f5d823e7e 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "0.75.3", + "version": "0.75.4", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -29,7 +29,7 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "^0.75.3", + "@earendil-works/pi-ai": "^0.75.4", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index d4560aa0d..40e1a33ea 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.75.4] - 2026-05-20 ### Changed diff --git a/packages/ai/package.json b/packages/ai/package.json index 16fa3be7a..a10a23df9 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.75.3", + "version": "0.75.4", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5e9053cd4..58aa9fb0c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.75.4] - 2026-05-20 ### New Features diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json index 8ca5ae519..c78ae9206 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-custom-provider", - "version": "0.75.3", + "version": "0.75.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "0.75.3", + "version": "0.75.4", "dependencies": { "@anthropic-ai/sdk": "^0.52.0" } diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json index a2f796cf7..89aa420ab 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-anthropic", "private": true, - "version": "0.75.3", + "version": "0.75.4", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json index cda287b87..2db431cf5 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-gitlab-duo", "private": true, - "version": "0.75.3", + "version": "0.75.4", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/sandbox/package-lock.json b/packages/coding-agent/examples/extensions/sandbox/package-lock.json index 80c450991..ef29b1506 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package-lock.json +++ b/packages/coding-agent/examples/extensions/sandbox/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-sandbox", - "version": "1.5.3", + "version": "1.5.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.5.3", + "version": "1.5.4", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.26" } diff --git a/packages/coding-agent/examples/extensions/sandbox/package.json b/packages/coding-agent/examples/extensions/sandbox/package.json index 1fd94b35e..c33d2d608 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package.json +++ b/packages/coding-agent/examples/extensions/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-sandbox", "private": true, - "version": "1.5.3", + "version": "1.5.4", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/with-deps/package-lock.json b/packages/coding-agent/examples/extensions/with-deps/package-lock.json index a1cdd835a..526356f49 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package-lock.json +++ b/packages/coding-agent/examples/extensions/with-deps/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-with-deps", - "version": "0.75.3", + "version": "0.75.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "0.75.3", + "version": "0.75.4", "dependencies": { "ms": "^2.1.3" }, diff --git a/packages/coding-agent/examples/extensions/with-deps/package.json b/packages/coding-agent/examples/extensions/with-deps/package.json index 2d7cc7286..45f1d3f27 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package.json +++ b/packages/coding-agent/examples/extensions/with-deps/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-with-deps", "private": true, - "version": "0.75.3", + "version": "0.75.4", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 174e6d17d..453f236f5 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -1,17 +1,17 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.75.3", + "version": "0.75.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent", - "version": "0.75.3", + "version": "0.75.4", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.75.3", - "@earendil-works/pi-ai": "^0.75.3", - "@earendil-works/pi-tui": "^0.75.3", + "@earendil-works/pi-agent-core": "^0.75.4", + "@earendil-works/pi-ai": "^0.75.4", + "@earendil-works/pi-tui": "^0.75.4", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -473,11 +473,11 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.75.3", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.75.3.tgz", + "version": "0.75.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.75.4.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.75.3", + "@earendil-works/pi-ai": "^0.75.4", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -487,8 +487,8 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.75.3", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.75.3.tgz", + "version": "0.75.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.75.4.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -509,8 +509,8 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.75.3", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.75.3.tgz", + "version": "0.75.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.75.4.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 23a824223..0c19d3042 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.75.3", + "version": "0.75.4", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -39,9 +39,9 @@ "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" }, "dependencies": { - "@earendil-works/pi-agent-core": "^0.75.3", - "@earendil-works/pi-ai": "^0.75.3", - "@earendil-works/pi-tui": "^0.75.3", + "@earendil-works/pi-agent-core": "^0.75.4", + "@earendil-works/pi-ai": "^0.75.4", + "@earendil-works/pi-tui": "^0.75.4", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 2a235c747..8d074fed6 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.75.4] - 2026-05-20 ### Changed diff --git a/packages/tui/package.json b/packages/tui/package.json index 8248d33f5..213e51bb2 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-tui", - "version": "0.75.3", + "version": "0.75.4", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", From b8326ca93598baaf6222ab6b3922963f859edfe3 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 20 May 2026 16:26:05 +0200 Subject: [PATCH 02/23] Add [Unreleased] section for next cycle --- AGENTS.md | 11 +++++++++-- packages/agent/CHANGELOG.md | 2 ++ packages/ai/CHANGELOG.md | 2 ++ packages/coding-agent/CHANGELOG.md | 2 ++ packages/tui/CHANGELOG.md | 2 ++ 5 files changed, 17 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4f1d1ccbe..67cdf06db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -202,13 +202,20 @@ Create provider file exporting: ``` In the interactive smoke test, verify startup, model/account listing, and at least one real prompt with the intended default provider. Treat failures as release blockers unless the user explicitly accepts the risk. -3. **Run release script**: +3. **Run release script until npm publish**: ```bash npm run release:patch # Fixes and additions npm run release:minor # API breaking changes ``` -The release script handles: version bump, CHANGELOG finalization, commit, tag, publish, and adding new `[Unreleased]` sections. + npm publishing requires the maintainer's npm WebAuthn/security-key 2FA and cannot be completed by an agent alone. If the release script stops at `npm publish` with an npm browser authentication URL, the maintainer must run or approve `npm run publish` locally. Do not rerun the version bump. + +4. **After publish succeeds, finish release bookkeeping**: + - Add fresh `## [Unreleased]` sections to package changelogs. + - Commit with `Add [Unreleased] section for next cycle`. + - Push `main` and the release tag. + +The release script handles the full flow when npm publish auth is already satisfied. If npm requires WebAuthn during publish, continue manually from the existing release commit/tag using the steps above. ## **CRITICAL** Git Rules for Parallel Agents **CRITICAL** diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 872b89798..4ec02d9a8 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.75.4] - 2026-05-20 ### Changed diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 40e1a33ea..3d20b95a5 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.75.4] - 2026-05-20 ### Changed diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 58aa9fb0c..468c4d840 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.75.4] - 2026-05-20 ### New Features diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 8d074fed6..56ea936e4 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.75.4] - 2026-05-20 ### Changed From 3b37c9e0a1f8ee751df062e26f364ff47585e421 Mon Sep 17 00:00:00 2001 From: Julien Chaumond Date: Wed, 20 May 2026 18:12:00 +0200 Subject: [PATCH 03/23] chore: add HF_TOKEN to pi-test.ps1 --no-env unset list Aligns the PowerShell script with pi-test.sh, which already unsets HF_TOKEN. Co-Authored-By: julien-agent --- pi-test.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/pi-test.ps1 b/pi-test.ps1 index 2bbe35bde..610aeaeb2 100644 --- a/pi-test.ps1 +++ b/pi-test.ps1 @@ -31,6 +31,7 @@ if ($noEnv) { "COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN", + "HF_TOKEN", "GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT", From 088987b200c214f5ee11b45781b3eee10b7ef6cd Mon Sep 17 00:00:00 2001 From: Sviatoslav Abakumov Date: Wed, 20 May 2026 23:48:03 +0400 Subject: [PATCH 04/23] fix(coding-agent): list themes by content name --- .../src/modes/interactive/theme/theme.ts | 66 +++++++++++-------- .../coding-agent/test/theme-picker.test.ts | 51 ++++++++++++++ 2 files changed, 89 insertions(+), 28 deletions(-) create mode 100644 packages/coding-agent/test/theme-picker.test.ts diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index 0560f0142..d9bcdf8c2 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -440,20 +440,7 @@ function getBuiltinThemes(): Record { } export function getAvailableThemes(): string[] { - const themes = new Set(Object.keys(getBuiltinThemes())); - const customThemesDir = getCustomThemesDir(); - if (fs.existsSync(customThemesDir)) { - const files = fs.readdirSync(customThemesDir); - for (const file of files) { - if (file.endsWith(".json")) { - themes.add(file.slice(0, -5)); - } - } - } - for (const name of registeredThemes.keys()) { - themes.add(name); - } - return Array.from(themes).sort(); + return getAvailableThemesWithPaths().map(({ name }) => name); } export interface ThemeInfo { @@ -463,35 +450,58 @@ export interface ThemeInfo { export function getAvailableThemesWithPaths(): ThemeInfo[] { const themesDir = getThemesDir(); - const customThemesDir = getCustomThemesDir(); const result: ThemeInfo[] = []; + const seen = new Set(); + const addTheme = (themeInfo: ThemeInfo) => { + if (seen.has(themeInfo.name)) { + return; + } + seen.add(themeInfo.name); + result.push(themeInfo); + }; // Built-in themes for (const name of Object.keys(getBuiltinThemes())) { - result.push({ name, path: path.join(themesDir, `${name}.json`) }); + addTheme({ name, path: path.join(themesDir, `${name}.json`) }); } // Custom themes - if (fs.existsSync(customThemesDir)) { - for (const file of fs.readdirSync(customThemesDir)) { - if (file.endsWith(".json")) { - const name = file.slice(0, -5); - if (!result.some((t) => t.name === name)) { - result.push({ name, path: path.join(customThemesDir, file) }); - } - } - } + for (const themeInfo of getCustomThemeInfos()) { + addTheme(themeInfo); } for (const [name, theme] of registeredThemes.entries()) { - if (!result.some((t) => t.name === name)) { - result.push({ name, path: theme.sourcePath }); - } + addTheme({ name, path: theme.sourcePath }); } return result.sort((a, b) => a.name.localeCompare(b.name)); } +function getCustomThemeInfos(): ThemeInfo[] { + const customThemesDir = getCustomThemesDir(); + const result: ThemeInfo[] = []; + if (!fs.existsSync(customThemesDir)) { + return result; + } + + for (const file of fs.readdirSync(customThemesDir)) { + if (!file.endsWith(".json")) { + continue; + } + const themePath = path.join(customThemesDir, file); + try { + const customTheme = loadThemeFromPath(themePath); + if (customTheme.name) { + result.push({ name: customTheme.name, path: themePath }); + } + } catch { + // Invalid themes are ignored here; the resource loader reports them + // during normal startup/reload. + } + } + return result; +} + function parseThemeJson(label: string, json: unknown): ThemeJson { if (!validateThemeJson.Check(json)) { const errors = Array.from(validateThemeJson.Errors(json)); diff --git a/packages/coding-agent/test/theme-picker.test.ts b/packages/coding-agent/test/theme-picker.test.ts new file mode 100644 index 000000000..7b31854e0 --- /dev/null +++ b/packages/coding-agent/test/theme-picker.test.ts @@ -0,0 +1,51 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getAvailableThemes, + getAvailableThemesWithPaths, + setRegisteredThemes, +} from "../src/modes/interactive/theme/theme.ts"; + +type ThemeFile = { + name: string; + vars?: Record; + colors: Record; +}; + +describe("theme picker", () => { + let tempRoot: string; + + beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), "pi-theme-picker-")); + const agentDir = join(tempRoot, "agent"); + vi.stubEnv("PI_CODING_AGENT_DIR", agentDir); + mkdirSync(join(agentDir, "themes"), { recursive: true }); + setRegisteredThemes([]); + }); + + afterEach(() => { + setRegisteredThemes([]); + rmSync(tempRoot, { recursive: true, force: true }); + vi.unstubAllEnvs(); + }); + + it("uses custom theme content names instead of file names", () => { + const darkTheme = JSON.parse( + readFileSync(new URL("../src/modes/interactive/theme/dark.json", import.meta.url), "utf-8"), + ) as ThemeFile; + const customTheme: ThemeFile = { + ...darkTheme, + name: "bar", + }; + + const themePath = join(process.env.PI_CODING_AGENT_DIR!, "themes", "foo.json"); + writeFileSync(themePath, JSON.stringify(customTheme, null, 2)); + + expect(getAvailableThemes()).toContain("bar"); + expect(getAvailableThemes()).not.toContain("foo"); + expect(getAvailableThemesWithPaths()).toContainEqual({ name: "bar", path: themePath }); + expect(getAvailableThemesWithPaths().some((theme) => theme.name === "foo")).toBe(false); + }); +}); From 385a11bf531cf34f0b5c74085ed1b49625263f0f Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 20 May 2026 22:53:38 +0200 Subject: [PATCH 05/23] docs: document dependency install security --- AGENTS.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 67cdf06db..10f28ef55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,15 @@ - For ad-hoc scripts, write the script to a temporary file (for example under `/tmp`) using `write`, run that file, edit it if needed, and remove it when it is no longer needed. Do not embed multi-line scripts directly in `bash` commands. - NEVER commit unless user asks +## Dependency and Install Security + +- Treat npm dependency and lockfile changes as reviewed code changes. Direct external dependencies must stay pinned to exact versions. +- Use `npm install --ignore-scripts` to hydrate/update `node_modules` locally. Use `npm ci --ignore-scripts` for clean installs/CI-style verification. Do not run lifecycle scripts unless the user explicitly asks. +- If dependency metadata changes, run `npm install --package-lock-only --ignore-scripts` to update `package-lock.json` without installing or running scripts. +- If `packages/coding-agent/npm-shrinkwrap.json` needs regeneration, run `node scripts/generate-coding-agent-shrinkwrap.mjs`; verify with `node scripts/generate-coding-agent-shrinkwrap.mjs --check` or `npm run check`. +- Pre-commit blocks accidental lockfile commits unless `PI_ALLOW_LOCKFILE_CHANGE=1` is set. Do not bypass it unless the user explicitly wants the lockfile change committed. +- New dependencies with lifecycle scripts require review and an explicit allowlist entry in `scripts/generate-coding-agent-shrinkwrap.mjs`; do not add one silently. + ## Contribution Gate - New issues from new contributors are auto-closed by `.github/workflows/issue-gate.yml` From 4868222e3414554987bf4b05fbb393fc65080aa0 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 21 May 2026 00:21:21 +0200 Subject: [PATCH 06/23] chore(tui): replace koffi with Windows VT input helper closes #4480 --- package-lock.json | 14 --- packages/coding-agent/npm-shrinkwrap.json | 14 --- packages/tui/CHANGELOG.md | 4 + .../win32-arm64/win32-console-mode.node | Bin 0 -> 3072 bytes .../win32-x64/win32-console-mode.node | Bin 0 -> 3072 bytes .../tui/native/win32/src/win32-console-mode.c | 53 ++++++++++ packages/tui/package.json | 4 +- packages/tui/src/terminal.ts | 38 ++++--- scripts/build-binaries.sh | 20 ++-- scripts/generate-coding-agent-shrinkwrap.mjs | 1 - scripts/local-release.mjs | 98 ++++++++++++++++-- 11 files changed, 176 insertions(+), 70 deletions(-) create mode 100644 packages/tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node create mode 100644 packages/tui/native/win32/prebuilds/win32-x64/win32-console-mode.node create mode 100644 packages/tui/native/win32/src/win32-console-mode.c diff --git a/package-lock.json b/package-lock.json index fb54cf29c..3e9361400 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3773,17 +3773,6 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/koffi": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.2.tgz", - "integrity": "sha512-owU0MRwv6xkrVqCd+33uw6BaYppkTRXbO/rVdJNI2dvZG0gzyRhYwW25eWtc5pauwK8TGh3AbkFONSezdykfSA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, "node_modules/lodash-es": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", @@ -6190,9 +6179,6 @@ }, "engines": { "node": ">=22.19.0" - }, - "optionalDependencies": { - "koffi": "2.16.2" } } } diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 453f236f5..85a22b1a1 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -516,9 +516,6 @@ "get-east-asian-width": "1.6.0", "marked": "15.0.12" }, - "optionalDependencies": { - "koffi": "2.16.2" - }, "engines": { "node": ">=22.19.0" } @@ -1369,17 +1366,6 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/koffi": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.2.tgz", - "integrity": "sha512-owU0MRwv6xkrVqCd+33uw6BaYppkTRXbO/rVdJNI2dvZG0gzyRhYwW25eWtc5pauwK8TGh3AbkFONSezdykfSA==", - "license": "MIT", - "optional": true, - "hasInstallScript": true, - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 56ea936e4..b1f8a8dcb 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Replaced the optional `koffi` dependency for Windows VT input with a tiny vendored native helper, reducing install size while preserving Shift+Tab handling ([#4480](https://github.com/earendil-works/pi/issues/4480)). + ## [0.75.4] - 2026-05-20 ### Changed diff --git a/packages/tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node b/packages/tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node new file mode 100644 index 0000000000000000000000000000000000000000..42b2c77ccadb03f7ad745fda39583b2e6711d10f GIT binary patch literal 3072 zcmeHIU1%It6#ni^o1_T|tzE22(MeoE#4a^IJ_(&zgB4p1+2BKc*v#%+lhNJTb#}Ja z1Z2UN(t>0}TJ*(MeCRF+5hJ7pCAA8^6x4@2`LOW^^58@5whar?^_)99c2lV$_CX<+ z+&%Yv=V!iq?#;P}4$ngeu!Ux`2`rK1VXEK1{`rQ?eGi=P!{W9}{Y&EDrT)nor)U(s z{IqA~jEv>FdEZD&!z;Ol;~GyrpEPoLTPAvYyW_3YpWOS?SL;WQpN!_4jT5J?D*eRC zD|eWq04pk?k@Dj2^A;QD}s8L@3GaYF!pA zcun>W<#bc3LU3`Ed5T=aph)~#50Pbqcu>q*KkzUIM`JH=Pqc269~!{!HvMmxXUVeH`m&mI^?zp1DHy9w&h#ES*{HM%BQ_X1ex@gD1?5BJAat{yJ@x!Q@OSQ zo6G-v1?tJqD>r)3Xzs_iUBE)u4zp3$Q8h&QE{J_AO&#a1lKy@NKCTH&DxYm;V^v4M zc2i(@O*hZg^jKAhU4zQrYc|+61q=_PJV93_79+_v2oa0`@2pYIW+xgz`Ma-y#UCC- zu&T!%sp&hb99!!&nNx71hxjDFIJ>N;R=Ak;y$_3wM1}T7uEYI z_q7PSAcFfyXa2O0#)yW+7)|n@*9C%|#K#)Yf}d$GTZo5-^4m3iSM{{EZ)F)6`B66; zX|k{CcUKuN6JDb=^ID&Kl6Z_(>dA|W8*{18p~76cL|8-K8}Eg3@i!N`wsPIV{hb#0 zRPnxmAd0!2FV~NA)#ps!a}d>n>+x^L_&}J!ZpkApd8{SPmW;mRa{soo**|ov;H0Ld zpGxQRS!uZ&@Qf!dU#4Cvxf$QdE1S#yMe=v8oU~K)RurV?zYghI>8yOg@%)mNos?eA zajon#ZlT0Yj7HSYdujb8%{cYdcqd!<-Qt%ExMBd@?xMLy`Y$vu(_Ek)*iA9OYmPfK zxF?f$i$r*jdXIQhP|Q3z?G%0Kr3f!&W$MUd9KwU*xtFTG8qbCMG{PR7aJVn|L5GZ1u0dMjw~g2kW83LG_L6M*4Kv1P zG;e$uxOSgxIwz!{D7L#lWoGzxsr*tnO^ zjM}y*i$yv)M)rqCK+Y8R#Mb03$g^V;&y5`%8f?F98@X<3cc21vuRp>Eg8x6>#Y5VG R9l)zNa|aS{ruaWp;CE*fda3{b literal 0 HcmV?d00001 diff --git a/packages/tui/native/win32/prebuilds/win32-x64/win32-console-mode.node b/packages/tui/native/win32/prebuilds/win32-x64/win32-console-mode.node new file mode 100644 index 0000000000000000000000000000000000000000..2c6d86d8725cb88e742a168f0fd9db2e200e4766 GIT binary patch literal 3072 zcmeHIQD_`h6urCK#&qNErmzc93Qp>L1ZkIbH;RHtstJA7t%fA1h`62XjLFFC>@quB zV+4sV5tCshh_w9```-`!sQn7GVhd>vh#J%ng?isadnBG@Sa3 zqt%qEX4`dFnKl%sWh{5L8_C0mjNq4V1aXo(9~{R>Vsy!f4DAALoB#R> zh@}_QUa{n={m+W_6-9VgKi={oxdbjv=^No)L%@h=U-RAjL}v=7cizu^nIT$Sbq72N ze3m)$!!AE}0bu8~*y3s{?)@e@sqf)$ZlD5uh7gF~UG9vk!uv{im;80%G3vSML&7`D zPC>NKDV4KpYau5~9bjS#6Dxq2@V>^bAMmbk@~)lKDcHi>+-$P@DYoWvgR)rs(LKlq zJ|_6UL+$gvSQ=G_%AM4m$W3%|9bSnQO0eRR{{?7g%U)R(<<8E!-1l>)39gnp9x_Do zuKFBk7MNzhGc4Qn(giE{A$6)V=ox_-g0}9+A!cTeqwmzSb}+GlpB4uGcL?vv0zyj-k0m<)xNgb + +#ifndef ENABLE_VIRTUAL_TERMINAL_INPUT +#define ENABLE_VIRTUAL_TERMINAL_INPUT 0x0200 +#endif + +#define NAPI_AUTO_LENGTH ((unsigned long long)-1) + +typedef void* napi_env; +typedef void* napi_value; +typedef void* napi_callback_info; +typedef napi_value (__cdecl *napi_callback)(napi_env, napi_callback_info); +typedef int (__cdecl *napi_create_function_fn)(napi_env, const char*, unsigned long long, napi_callback, void*, napi_value*); +typedef int (__cdecl *napi_set_named_property_fn)(napi_env, napi_value, const char*, napi_value); +typedef int (__cdecl *napi_get_boolean_fn)(napi_env, int, napi_value*); + +static void* node_symbol(const char* name) { + HMODULE module = GetModuleHandleA(0); + void* proc = module ? (void*)GetProcAddress(module, name) : 0; + if (proc) return proc; + + module = GetModuleHandleA("node.dll"); + return module ? (void*)GetProcAddress(module, name) : 0; +} + +static napi_value __cdecl enable_virtual_terminal_input(napi_env env, napi_callback_info info) { + (void)info; + + HANDLE handle = GetStdHandle(STD_INPUT_HANDLE); + DWORD mode = 0; + int enabled = handle != INVALID_HANDLE_VALUE && + GetConsoleMode(handle, &mode) && + SetConsoleMode(handle, mode | ENABLE_VIRTUAL_TERMINAL_INPUT); + + napi_get_boolean_fn napi_get_boolean = (napi_get_boolean_fn)node_symbol("napi_get_boolean"); + napi_value result = 0; + if (napi_get_boolean) napi_get_boolean(env, enabled, &result); + return result; +} + +__declspec(dllexport) napi_value __cdecl napi_register_module_v1(napi_env env, napi_value exports) { + napi_create_function_fn napi_create_function = (napi_create_function_fn)node_symbol("napi_create_function"); + napi_set_named_property_fn napi_set_named_property = (napi_set_named_property_fn)node_symbol("napi_set_named_property"); + + napi_value fn = 0; + if (napi_create_function && + napi_set_named_property && + napi_create_function(env, "enableVirtualTerminalInput", NAPI_AUTO_LENGTH, enable_virtual_terminal_input, 0, &fn) == 0) { + napi_set_named_property(env, exports, "enableVirtualTerminalInput", fn); + } + + return exports; +} diff --git a/packages/tui/package.json b/packages/tui/package.json index 213e51bb2..c684eae75 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -12,6 +12,7 @@ }, "files": [ "dist/**/*", + "native/win32/prebuilds/**/*.node", "README.md" ], "keywords": [ @@ -38,9 +39,6 @@ "get-east-asian-width": "1.6.0", "marked": "15.0.12" }, - "optionalDependencies": { - "koffi": "2.16.2" - }, "devDependencies": { "@xterm/headless": "5.5.0", "@xterm/xterm": "5.5.0", diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 9d24dcdd0..94c56ff61 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -1,6 +1,7 @@ import * as fs from "node:fs"; import { createRequire } from "node:module"; import * as path from "node:path"; +import { fileURLToPath } from "node:url"; import { setKittyProtocolActive } from "./keys.ts"; import { StdinBuffer } from "./stdin-buffer.ts"; @@ -210,23 +211,30 @@ export class ProcessTerminal implements Terminal { private enableWindowsVTInput(): void { if (process.platform !== "win32") return; try { - // Dynamic require to avoid bundling koffi's 74MB of cross-platform - // native binaries into every compiled binary. Koffi is only needed - // on Windows for VT input support. - const koffi = cjsRequire("koffi"); - const k32 = koffi.load("kernel32.dll"); - const GetStdHandle = k32.func("void* __stdcall GetStdHandle(int)"); - const GetConsoleMode = k32.func("bool __stdcall GetConsoleMode(void*, _Out_ uint32_t*)"); - const SetConsoleMode = k32.func("bool __stdcall SetConsoleMode(void*, uint32_t)"); + const arch = process.arch; + if (arch !== "x64" && arch !== "arm64") return; - const STD_INPUT_HANDLE = -10; - const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200; - const handle = GetStdHandle(STD_INPUT_HANDLE); - const mode = new Uint32Array(1); - GetConsoleMode(handle, mode); - SetConsoleMode(handle, mode[0]! | ENABLE_VIRTUAL_TERMINAL_INPUT); + // Dynamic require so non-Windows and bundled/browser paths never load the + // native helper. In the npm package native/ is next to dist/; in compiled + // binary archives native/ is copied next to the executable. + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const nativePath = path.join("native", "win32", "prebuilds", `win32-${arch}`, "win32-console-mode.node"); + const candidates = [ + path.join(moduleDir, "..", nativePath), + path.join(moduleDir, nativePath), + path.join(path.dirname(process.execPath), nativePath), + ]; + for (const modulePath of candidates) { + try { + const helper = cjsRequire(modulePath) as { enableVirtualTerminalInput?: () => boolean }; + helper.enableVirtualTerminalInput?.(); + return; + } catch { + // Try the next possible packaging location. + } + } } catch { - // koffi not available — Shift+Tab won't be distinguishable from Tab + // Native helper not available — Shift+Tab won't be distinguishable from Tab. } } diff --git a/scripts/build-binaries.sh b/scripts/build-binaries.sh index 4b94de970..c1b7b8687 100755 --- a/scripts/build-binaries.sh +++ b/scripts/build-binaries.sh @@ -105,14 +105,10 @@ fi for platform in "${PLATFORMS[@]}"; do echo "Building for $platform..." - # Externalize koffi to avoid embedding all 18 platform .node files (~74MB) - # into every binary. Koffi is only used on Windows for VT input and the - # call site has a try/catch fallback. For Windows builds, we copy the - # appropriate .node file alongside the binary below. if [[ "$platform" == windows-* ]]; then - bun build --compile --external koffi --target=bun-$platform ./dist/bun/cli.js --outfile binaries/$platform/pi.exe + bun build --compile --target=bun-$platform ./dist/bun/cli.js --outfile binaries/$platform/pi.exe else - bun build --compile --external koffi --target=bun-$platform ./dist/bun/cli.js --outfile binaries/$platform/pi + bun build --compile --target=bun-$platform ./dist/bun/cli.js --outfile binaries/$platform/pi fi done @@ -132,17 +128,15 @@ for platform in "${PLATFORMS[@]}"; do cp -r docs binaries/$platform/ cp -r examples binaries/$platform/ - # Copy koffi native module for Windows (needed for VT input support) + # Copy Windows VT input native helper next to compiled Windows binaries. if [[ "$platform" == windows-* ]]; then if [[ "$platform" == "windows-arm64" ]]; then - koffi_arch_dir="win32_arm64" + win32_arch_dir="win32-arm64" else - koffi_arch_dir="win32_x64" + win32_arch_dir="win32-x64" fi - mkdir -p binaries/$platform/node_modules/koffi/build/koffi/$koffi_arch_dir - cp ../../node_modules/koffi/index.js binaries/$platform/node_modules/koffi/ - cp ../../node_modules/koffi/package.json binaries/$platform/node_modules/koffi/ - cp ../../node_modules/koffi/build/koffi/$koffi_arch_dir/koffi.node binaries/$platform/node_modules/koffi/build/koffi/$koffi_arch_dir/ + 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/ fi done diff --git a/scripts/generate-coding-agent-shrinkwrap.mjs b/scripts/generate-coding-agent-shrinkwrap.mjs index a122f1835..7bcf56813 100644 --- a/scripts/generate-coding-agent-shrinkwrap.mjs +++ b/scripts/generate-coding-agent-shrinkwrap.mjs @@ -12,7 +12,6 @@ const shrinkwrapPath = join(codingAgentDir, "npm-shrinkwrap.json"); const internalPackagePrefix = "@earendil-works/pi-"; const allowedInstallScriptPackages = new Map([ ["@google/genai@1.52.0", "preinstall is a no-op in the published package"], - ["koffi@2.16.2", "optional native package ships prebuilt modules used without install scripts"], ["protobufjs@7.5.9", "postinstall only warns about protobufjs version scheme mismatches"], ]); diff --git a/scripts/local-release.mjs b/scripts/local-release.mjs index 5cdc67bc1..782318bd2 100644 --- a/scripts/local-release.mjs +++ b/scripts/local-release.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +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 { spawnSync } from "node:child_process"; @@ -73,6 +73,7 @@ function run(command, args, options = {}) { const result = spawnSync(command, args, { cwd: options.cwd, encoding: "utf8", + shell: process.platform === "win32", stdio: options.capture ? ["inherit", "pipe", "inherit"] : "inherit", }); @@ -123,6 +124,73 @@ function fileSpecifier(fromDirectory, file) { return `file:${relativePath.startsWith(".") ? relativePath : `./${relativePath}`}`; } +function currentBinaryPlatform() { + if (process.platform === "win32") return process.arch === "arm64" ? "windows-arm64" : "windows-x64"; + if (process.platform === "darwin") return process.arch === "arm64" ? "darwin-arm64" : "darwin-x64"; + if (process.platform === "linux") return process.arch === "arm64" ? "linux-arm64" : "linux-x64"; + 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, "."]); + } + return platform; +} + +function createPiShim(installDirectory) { + const binDirectory = join(installDirectory, "node_modules", ".bin"); + if (process.platform === "win32") { + if (existsSync(join(binDirectory, "pi.cmd"))) { + writeFileSync(join(installDirectory, "pi.cmd"), '@ECHO off\r\n"%~dp0node_modules\\.bin\\pi.cmd" %*\r\n'); + writeFileSync(join(installDirectory, "pi.ps1"), '& "$PSScriptRoot/node_modules/.bin/pi.ps1" @args\n'); + return; + } + writeFileSync(join(installDirectory, "pi.cmd"), '@ECHO off\r\n"%~dp0node_modules\\.bin\\pi.exe" %*\r\n'); + writeFileSync(join(installDirectory, "pi.ps1"), '& "$PSScriptRoot/node_modules/.bin/pi.exe" @args\n'); + return; + } + symlinkSync(join("node_modules", ".bin", "pi"), join(installDirectory, "pi")); +} + function packPackage(pkg, tarballDirectory) { const packageJson = readPackageJson(pkg.directory); if (packageJson.name !== pkg.name) { @@ -148,7 +216,8 @@ if (rootPackageJson.name !== "pi-monorepo") { const outDir = prepareOutputDirectory(options, repoRoot); const tarballDirectory = join(outDir, "tarballs"); const nodeInstallDirectory = join(outDir, "node"); -const bunInstallDirectory = join(outDir, "bun"); +const bunInstallDirectory = join(outDir, "bun-install"); +const binaryDirectory = join(outDir, "bun"); mkdirSync(tarballDirectory, { recursive: true }); if (!options.skipCheck) { @@ -166,16 +235,19 @@ for (const pkg of packages) { tarballs.set(pkg.name, tarball); } +let binaryPlatform; if (!options.skipInstall) { + binaryPlatform = buildBunBinaryRelease(binaryDirectory, outDir); + mkdirSync(nodeInstallDirectory, { recursive: true }); const dependencies = Object.fromEntries( packages.map((pkg) => [pkg.name, fileSpecifier(nodeInstallDirectory, tarballs.get(pkg.name))]), ); - const installPackageJson = `${JSON.stringify({ private: true, dependencies }, undefined, "\t")}\n`; + const installPackageJson = `${JSON.stringify({ private: true, dependencies, overrides: dependencies }, undefined, "\t")}\n`; writeFileSync(join(nodeInstallDirectory, "package.json"), installPackageJson); run("npm", ["install", "--omit=dev", "--ignore-scripts"], { cwd: nodeInstallDirectory }); - symlinkSync(join("node_modules", ".bin", "pi"), join(nodeInstallDirectory, "pi")); + createPiShim(nodeInstallDirectory); if (!options.skipBunInstall) { if (!commandExists("bun")) { @@ -185,9 +257,9 @@ if (!options.skipInstall) { const bunDependencies = Object.fromEntries( packages.map((pkg) => [pkg.name, fileSpecifier(bunInstallDirectory, tarballs.get(pkg.name))]), ); - writeFileSync(join(bunInstallDirectory, "package.json"), `${JSON.stringify({ private: true, dependencies: bunDependencies }, undefined, "\t")}\n`); + writeFileSync(join(bunInstallDirectory, "package.json"), `${JSON.stringify({ private: true, dependencies: bunDependencies, overrides: bunDependencies }, undefined, "\t")}\n`); run("bun", ["install", "--production", "--ignore-scripts"], { cwd: bunInstallDirectory }); - symlinkSync(join("node_modules", ".bin", "pi"), join(bunInstallDirectory, "pi")); + createPiShim(bunInstallDirectory); } } @@ -199,15 +271,21 @@ for (const tarball of tarballs.values()) { } if (!options.skipInstall) { + console.log("\nLocal Bun binary release:"); + console.log(` ${binaryDirectory}`); + console.log(` ${join(outDir, `pi-${binaryPlatform}.${String(binaryPlatform).startsWith("windows-") ? "zip" : "tar.gz"}`)}`); + console.log("\nRun the local Bun binary release from outside the repository:"); + console.log(` ${join(binaryDirectory, String(binaryPlatform).startsWith("windows-") ? "pi.exe" : "pi")} --help`); + console.log("\nIsolated npm install:"); console.log(` ${nodeInstallDirectory}`); console.log("\nRun the locally packed npm CLI from outside the repository:"); - console.log(` ${join(nodeInstallDirectory, "pi")} --help`); + console.log(` ${join(nodeInstallDirectory, process.platform === "win32" ? "pi.cmd" : "pi")} --help`); if (!options.skipBunInstall) { - console.log("\nIsolated Bun install:"); + console.log("\nIsolated Bun package install:"); console.log(` ${bunInstallDirectory}`); - console.log("\nRun the locally packed Bun CLI from outside the repository:"); - console.log(` ${join(bunInstallDirectory, "pi")} --help`); + console.log("\nRun the locally packed Bun package CLI from outside the repository:"); + console.log(` ${join(bunInstallDirectory, process.platform === "win32" ? "pi.cmd" : "pi")} --help`); } } From 7dad27e5f2eaa30f25992a6ae400519586e8b568 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 21 May 2026 12:03:55 +0200 Subject: [PATCH 07/23] fix(coding-agent): avoid duplicate bash truncation path closes #4819 --- packages/coding-agent/CHANGELOG.md | 4 ++ packages/coding-agent/src/core/tools/bash.ts | 12 ++++-- .../test/tool-execution-component.test.ts | 37 +++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 468c4d840..61f7ad6ed 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed final bash tool cards to avoid rendering duplicate full-output truncation paths ([#4819](https://github.com/earendil-works/pi/issues/4819)). + ## [0.75.4] - 2026-05-20 ### New Features diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index f70c9470a..b9e551420 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -200,7 +200,15 @@ function rebuildBashResultRenderComponent( const state = component.state; component.clear(); - const output = getTextOutput(result as any, showImages).trim(); + let output = getTextOutput(result as any, showImages).trim(); + const truncation = result.details?.truncation; + const fullOutputPath = result.details?.fullOutputPath; + if (!options.isPartial && truncation?.truncated && fullOutputPath && output.endsWith("]")) { + const footerStart = output.lastIndexOf("\n\n["); + if (footerStart !== -1 && output.slice(footerStart).includes(fullOutputPath)) { + output = output.slice(0, footerStart).trimEnd(); + } + } if (output) { const styledOutput = output @@ -236,8 +244,6 @@ function rebuildBashResultRenderComponent( } } - const truncation = result.details?.truncation; - const fullOutputPath = result.details?.fullOutputPath; if (truncation?.truncated || fullOutputPath) { const warnings: string[] = []; if (fullOutputPath) { diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index 54c75806b..c227515b9 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -123,6 +123,43 @@ describe("ToolExecutionComponent parity", () => { await promise; }); + test("bash renderer does not duplicate final full output truncation details", async () => { + const operations: BashOperations = { + exec: async (_command, _cwd, { onData }) => { + for (let i = 1; i <= 4000; i++) { + onData(Buffer.from(`line-${String(i).padStart(4, "0")}\n`)); + } + return { exitCode: 0 }; + }, + }; + const tool = createBashToolDefinition(process.cwd(), { operations }); + const result = await tool.execute( + "tool-bash-1b", + { command: "generate output" }, + undefined, + undefined, + {} as never, + ); + const component = new ToolExecutionComponent( + "bash", + "tool-bash-1b", + { command: "generate output" }, + {}, + tool, + createFakeTui(), + process.cwd(), + ); + component.setExpanded(true); + component.updateResult({ ...result, isError: false }, false); + + const rendered = stripAnsi(component.render(200).join("\n")); + expect(rendered.match(/Full output:/g)?.length ?? 0).toBe(1); + expect(rendered).toMatch(/line-4000[^\n]*\n[^\S\n]*\n \[Full output:/); + expect(rendered).not.toMatch(/line-4000[^\n]*\n[^\S\n]*\n[^\S\n]*\n \[Full output:/); + expect(rendered).toContain("Truncated: showing 2000 of 4001 lines"); + expect(rendered).not.toContain("[Showing lines 2002-4001 of 4001. Full output:"); + }); + test("does not duplicate built-in headers when passed the active built-in definition", () => { const component = new ToolExecutionComponent( "read", From 60a55a23991a1033243cb6f6d6e29670b9964c11 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 21 May 2026 12:15:39 +0200 Subject: [PATCH 08/23] feat(coding-agent): expose edit tool unified patch closes #4821 --- packages/coding-agent/CHANGELOG.md | 4 ++++ packages/coding-agent/docs/sdk.md | 2 ++ packages/coding-agent/src/core/tools/edit-diff.ts | 10 +++++++++- packages/coding-agent/src/core/tools/edit.ts | 8 ++++++-- packages/coding-agent/test/tools.test.ts | 7 +++++++ 5 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 61f7ad6ed..5d14b69a0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added a standard unified patch to edit tool result details for SDK consumers ([#4821](https://github.com/earendil-works/pi/issues/4821)). + ### Fixed - Fixed final bash tool cards to avoid rendering duplicate full-output truncation paths ([#4819](https://github.com/earendil-works/pi/issues/4819)). diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index 2fa85cd01..8377185e3 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -473,6 +473,8 @@ Specify which built-in tools to enable: - `noTools: "all"` disables all tools - `noTools: "builtin"` disables default built-ins while keeping extension and custom tools enabled +The `edit` tool returns `details.diff` for Pi's TUI display and `details.patch` as a standard unified patch for SDK consumers. + ```typescript import { createAgentSession } from "@earendil-works/pi-coding-agent"; diff --git a/packages/coding-agent/src/core/tools/edit-diff.ts b/packages/coding-agent/src/core/tools/edit-diff.ts index 691d882ac..f280bf199 100644 --- a/packages/coding-agent/src/core/tools/edit-diff.ts +++ b/packages/coding-agent/src/core/tools/edit-diff.ts @@ -259,8 +259,16 @@ export function applyEditsToNormalizedContent( return { baseContent, newContent }; } +/** Generate a standard unified patch. */ +export function generateUnifiedPatch(path: string, oldContent: string, newContent: string, contextLines = 4): string { + return Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, { + context: contextLines, + headerOptions: Diff.FILE_HEADERS_ONLY, + }); +} + /** - * Generate a unified diff string with line numbers and context. + * Generate a display-oriented diff string with line numbers and context. * Returns both the diff string and the first changed line number (in the new file). */ export function generateDiffString( diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index e56563785..5d915d45b 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -13,6 +13,7 @@ import { type EditDiffError, type EditDiffResult, generateDiffString, + generateUnifiedPatch, normalizeToLF, restoreLineEndings, stripBom, @@ -57,8 +58,10 @@ type LegacyEditToolInput = EditToolInput & { }; export interface EditToolDetails { - /** Unified diff of the changes made */ + /** Display-oriented diff of the changes made */ diff: string; + /** Standard unified patch of the changes made */ + patch: string; /** Line number of the first change in the new file (for editor navigation) */ firstChangedLine?: number; } @@ -394,6 +397,7 @@ export function createEditToolDefinition( } const diffResult = generateDiffString(baseContent, newContent); + const patch = generateUnifiedPatch(path, baseContent, newContent); resolve({ content: [ { @@ -401,7 +405,7 @@ export function createEditToolDefinition( text: `Successfully replaced ${edits.length} block(s) in ${path}.`, }, ], - details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine }, + details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine }, }); } catch (error: unknown) { // Clean up abort handler. diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 70d4ec2d5..1d850e498 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -1,3 +1,4 @@ +import { applyPatch } from "diff"; import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; @@ -239,6 +240,12 @@ describe("Coding Agent Tools", () => { expect(result.details.diff).toBeDefined(); expect(typeof result.details.diff).toBe("string"); expect(result.details.diff).toContain("testing"); + expect(result.details.patch).toContain("--- "); + expect(result.details.patch).toContain("+++ "); + expect(result.details.patch).toContain("@@"); + expect(result.details.patch).toContain("-Hello, world!"); + expect(result.details.patch).toContain("+Hello, testing!"); + expect(applyPatch(originalContent, result.details.patch)).toBe("Hello, testing!"); }); it("should fail if text not found", async () => { From f953067814ceed5fc9366db577333c460c9c5e4f Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 21 May 2026 16:54:56 +0200 Subject: [PATCH 09/23] fix(coding-agent): correct bash truncation line count closes #4818 --- packages/coding-agent/CHANGELOG.md | 1 + .../src/core/tools/output-accumulator.ts | 12 +++++++--- .../coding-agent/src/core/tools/truncate.ts | 15 +++++++++++-- packages/coding-agent/test/tools.test.ts | 22 +++++++++++++++++++ 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5d14b69a0..611f82e29 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - 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)). ## [0.75.4] - 2026-05-20 diff --git a/packages/coding-agent/src/core/tools/output-accumulator.ts b/packages/coding-agent/src/core/tools/output-accumulator.ts index 78fcfe9e0..e13fe458e 100644 --- a/packages/coding-agent/src/core/tools/output-accumulator.ts +++ b/packages/coding-agent/src/core/tools/output-accumulator.ts @@ -45,8 +45,10 @@ export class OutputAccumulator { private tailStartsAtLineBoundary = true; private totalRawBytes = 0; private totalDecodedBytes = 0; - private totalLines = 1; + private completedLines = 0; + private totalLines = 0; private currentLineBytes = 0; + private hasOpenLine = false; private finished = false; private tempFilePath: string | undefined; @@ -164,10 +166,14 @@ export class OutputAccumulator { } if (newlines === 0) { this.currentLineBytes += bytes; + this.hasOpenLine = true; } else { - this.totalLines += newlines; - this.currentLineBytes = byteLength(text.slice(lastNewline + 1)); + this.completedLines += newlines; + const tail = text.slice(lastNewline + 1); + this.currentLineBytes = byteLength(tail); + this.hasOpenLine = tail.length > 0; } + this.totalLines = this.completedLines + (this.hasOpenLine ? 1 : 0); } private trimTail(): void { diff --git a/packages/coding-agent/src/core/tools/truncate.ts b/packages/coding-agent/src/core/tools/truncate.ts index 18ac5d74d..c638ee480 100644 --- a/packages/coding-agent/src/core/tools/truncate.ts +++ b/packages/coding-agent/src/core/tools/truncate.ts @@ -44,6 +44,17 @@ export interface TruncationOptions { maxBytes?: number; } +function splitLinesForCounting(content: string): string[] { + if (content.length === 0) { + return []; + } + const lines = content.split("\n"); + if (content.endsWith("\n")) { + lines.pop(); + } + return lines; +} + /** * Format bytes as human-readable size. */ @@ -69,7 +80,7 @@ export function truncateHead(content: string, options: TruncationOptions = {}): const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; const totalBytes = Buffer.byteLength(content, "utf-8"); - const lines = content.split("\n"); + const lines = splitLinesForCounting(content); const totalLines = lines.length; // Check if no truncation needed @@ -159,7 +170,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}): const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; const totalBytes = Buffer.byteLength(content, "utf-8"); - const lines = content.split("\n"); + const lines = splitLinesForCounting(content); const totalLines = lines.length; // Check if no truncation needed diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 1d850e498..c5e8fa6fb 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -580,6 +580,28 @@ describe("Coding Agent Tools", () => { expect(getTextOutput(result)).toContain("line 4999"); }); + it("should not count a trailing newline as an extra truncated bash output line", async () => { + const operations: BashOperations = { + exec: async (_command, _cwd, { onData }) => { + for (let i = 1; i <= 4000; i++) { + onData(Buffer.from(`line-${String(i).padStart(4, "0")}\n`, "utf-8")); + } + return { exitCode: 0 }; + }, + }; + const bash = createBashTool(testDir, { operations }); + + const result = await bash.execute("test-call-trailing-newline-line-count", { command: "many-lines" }); + const output = getTextOutput(result); + + expect(result.details?.truncation?.totalLines).toBe(4000); + expect(result.details?.truncation?.outputLines).toBe(2000); + expect(output).toContain("line-2001"); + expect(output).toContain("line-4000"); + expect(output).toMatch(/\[Showing lines 2001-4000 of 4000\. Full output: /); + expect(output).not.toContain("4001"); + }); + it("should decode UTF-8 characters split across output chunks", async () => { const euro = Buffer.from("€\n", "utf-8"); const operations: BashOperations = { From 2171cefbaa92c414f6567377f8febebea4078624 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 21 May 2026 18:35:48 +0200 Subject: [PATCH 10/23] test(coding-agent): update bash truncation expectation --- packages/coding-agent/test/tool-execution-component.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index c227515b9..9a3d16395 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -156,8 +156,8 @@ describe("ToolExecutionComponent parity", () => { expect(rendered.match(/Full output:/g)?.length ?? 0).toBe(1); expect(rendered).toMatch(/line-4000[^\n]*\n[^\S\n]*\n \[Full output:/); expect(rendered).not.toMatch(/line-4000[^\n]*\n[^\S\n]*\n[^\S\n]*\n \[Full output:/); - expect(rendered).toContain("Truncated: showing 2000 of 4001 lines"); - expect(rendered).not.toContain("[Showing lines 2002-4001 of 4001. Full output:"); + expect(rendered).toContain("Truncated: showing 2000 of 4000 lines"); + expect(rendered).not.toContain("[Showing lines 2001-4000 of 4000. Full output:"); }); test("does not duplicate built-in headers when passed the active built-in definition", () => { From 136716420471a32f04958f186618c5659f3cc90b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 21 May 2026 18:34:39 +0000 Subject: [PATCH 11/23] chore: approve contributor AJM10565 --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index faefe5aea..f53db6a67 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -215,3 +215,5 @@ mattiacerutti pr josephyoung pr mbazso pr + +AJM10565 pr From b0c5554902ac94edc36b99e62318a327aa88d2da Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 21 May 2026 20:43:48 +0200 Subject: [PATCH 12/23] docs: document safe development install closes #4868 --- README.md | 2 +- packages/coding-agent/CHANGELOG.md | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 750b3a61a..b9a45021a 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines and [AGENTS.m ## Development ```bash -npm install # Install all dependencies +npm install --ignore-scripts # Install all dependencies without running lifecycle scripts npm run build # Build all packages npm run check # Lint, format, and type check ./test.sh # Run tests (skips LLM-dependent tests without API keys) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 611f82e29..1a3bb51cb 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,10 @@ - Added a standard unified patch to edit tool result details for SDK consumers ([#4821](https://github.com/earendil-works/pi/issues/4821)). +### Changed + +- Changed the root development install documentation to use `npm install --ignore-scripts` ([#4868](https://github.com/earendil-works/pi/issues/4868)). + ### Fixed - Fixed final bash tool cards to avoid rendering duplicate full-output truncation paths ([#4819](https://github.com/earendil-works/pi/issues/4819)). From 11c3da4f74bbd4580fbc8e0ca9caaf038c801655 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 21 May 2026 23:27:40 +0200 Subject: [PATCH 13/23] fix(ai): set bedrock claude default max tokens closes #4848 --- packages/ai/CHANGELOG.md | 4 +++ packages/ai/src/providers/amazon-bedrock.ts | 3 +- .../ai/test/bedrock-thinking-payload.test.ts | 34 +++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 3d20b95a5..841f3fedf 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Amazon Bedrock Claude requests to send the model output token cap by default, matching Anthropic requests and avoiding Bedrock's 4096-token default truncation ([#4848](https://github.com/earendil-works/pi/issues/4848)). + ## [0.75.4] - 2026-05-20 ### Changed diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index 5d89ac564..429098a60 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -183,12 +183,13 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt try { const client = new BedrockRuntimeClient(config); const cacheRetention = resolveCacheRetention(options.cacheRetention); + const inferenceMaxTokens = options.maxTokens ?? (isAnthropicClaudeModel(model) ? model.maxTokens : undefined); let commandInput = { modelId: model.id, messages: convertMessages(context, model, cacheRetention), system: buildSystemPrompt(context.systemPrompt, model, cacheRetention), inferenceConfig: { - ...(options.maxTokens !== undefined && { maxTokens: options.maxTokens }), + ...(inferenceMaxTokens !== undefined && { maxTokens: inferenceMaxTokens }), ...(options.temperature !== undefined && { temperature: options.temperature }), }, toolConfig: convertToolConfig(context.tools, options.toolChoice), diff --git a/packages/ai/test/bedrock-thinking-payload.test.ts b/packages/ai/test/bedrock-thinking-payload.test.ts index 55b9e82b7..46a5277b5 100644 --- a/packages/ai/test/bedrock-thinking-payload.test.ts +++ b/packages/ai/test/bedrock-thinking-payload.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { getModel } from "../src/models.ts"; import { type BedrockOptions, streamBedrock } from "../src/providers/amazon-bedrock.ts"; import type { Context, Model } from "../src/types.ts"; +import { hasBedrockCredentials } from "./bedrock-utils.ts"; interface BedrockThinkingPayload { additionalModelRequestFields?: { @@ -112,6 +113,39 @@ describe("Bedrock thinking payload", () => { }); }); +describe.skipIf(!hasBedrockCredentials())("Bedrock Claude max tokens E2E", () => { + it( + "uses the model maxTokens cap instead of Bedrock's 4096-token default for adaptive Claude models", + { retry: 2, timeout: 180000 }, + async () => { + const baseModel = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-6"); + const model: Model<"bedrock-converse-stream"> = { + ...baseModel, + maxTokens: 6000, + }; + + const response = await streamBedrock( + model, + { + systemPrompt: "You are a deterministic text generator. Follow the requested output format exactly.", + messages: [ + { + role: "user", + content: + "Output exactly 5200 repetitions of the token alpha, separated by single spaces. Do not number them. Do not use markdown. Do not add any other text.", + timestamp: Date.now(), + }, + ], + }, + { reasoning: "low" }, + ).result(); + + expect(response.stopReason, response.errorMessage).not.toBe("error"); + expect(response.usage.output).toBeGreaterThan(4096); + }, + ); +}); + describe("Application inference profile support", () => { it("uses adaptive thinking when model.name contains the model name but ARN does not", async () => { const baseModel = getModel("amazon-bedrock", "global.anthropic.claude-opus-4-6-v1"); From ced73b396a280facbecc89d52259a88f9e0815ad Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 22 May 2026 00:28:49 +0200 Subject: [PATCH 14/23] docs: note Node 20 rescue release Closes #4876 --- packages/coding-agent/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1a3bb51cb..32fcca39d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,7 @@ ### Fixed +- 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)). From c554364c2a82915c8c2b991507f252fc024a2217 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Wed, 20 May 2026 09:30:58 +0200 Subject: [PATCH 15/23] feat(ai): refactor device code login for copilot --- packages/ai/CHANGELOG.md | 4 + packages/ai/src/cli.ts | 5 + packages/ai/src/index.ts | 1 + packages/ai/src/utils/oauth/device-code.ts | 80 +++++++++ packages/ai/src/utils/oauth/github-copilot.ts | 155 ++++++------------ packages/ai/src/utils/oauth/index.ts | 1 + packages/ai/src/utils/oauth/types.ts | 8 + packages/ai/test/github-copilot-oauth.test.ts | 95 ++++++++--- packages/ai/test/oauth-device-code.test.ts | 55 +++++++ .../interactive/components/login-dialog.ts | 33 +++- .../src/modes/interactive/interactive-mode.ts | 8 +- 11 files changed, 316 insertions(+), 129 deletions(-) create mode 100644 packages/ai/src/utils/oauth/device-code.ts create mode 100644 packages/ai/test/oauth-device-code.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 841f3fedf..9802972f9 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -13,6 +13,10 @@ - Changed source syntax to avoid TypeScript constructs that require JavaScript emit, keeping the package compatible with Node.js strip-only TypeScript checks. - Removed the package-level development watch scripts now that the root TypeScript check validates strip-only-compatible sources. +### Added + +- Added first-class OAuth device-code callback metadata, shared polling support, and GitHub Copilot OAuth integration. + ### Fixed - Fixed OpenAI-compatible `streamSimple()` requests to stop sending model-derived default output token caps, avoiding context-window reservation failures on servers such as vLLM while preserving explicit `maxTokens` and required Anthropic `max_tokens` handling ([#4675](https://github.com/earendil-works/pi/issues/4675)). diff --git a/packages/ai/src/cli.ts b/packages/ai/src/cli.ts index 38ee7e346..442ee8ea6 100644 --- a/packages/ai/src/cli.ts +++ b/packages/ai/src/cli.ts @@ -42,6 +42,11 @@ async function login(providerId: OAuthProviderId): Promise { if (info.instructions) console.log(info.instructions); console.log(); }, + onDeviceCode: (info) => { + console.log(`\nOpen this URL in your browser:\n${info.verificationUri}`); + console.log(`Enter code: ${info.userCode}`); + console.log(); + }, onPrompt: async (p) => { return await promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`); }, diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index fd06fe81b..ed7aeaa87 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -32,6 +32,7 @@ export * from "./utils/json-parse.ts"; export type { OAuthAuthInfo, OAuthCredentials, + OAuthDeviceCodeInfo, OAuthLoginCallbacks, OAuthPrompt, OAuthProvider, diff --git a/packages/ai/src/utils/oauth/device-code.ts b/packages/ai/src/utils/oauth/device-code.ts new file mode 100644 index 000000000..95dba2e78 --- /dev/null +++ b/packages/ai/src/utils/oauth/device-code.ts @@ -0,0 +1,80 @@ +const CANCEL_MESSAGE = "Login cancelled"; +const TIMEOUT_MESSAGE = "Device flow timed out"; +const SLOW_DOWN_TIMEOUT_MESSAGE = + "Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again."; +const MINIMUM_INTERVAL_MS = 1000; +// RFC 8628 section 3.2: if the authorization server omits `interval`, the client must use 5 seconds. +const DEFAULT_POLL_INTERVAL_SECONDS = 5; +// RFC 8628 section 3.5: `slow_down` means the polling interval must increase by 5 seconds. +const SLOW_DOWN_INTERVAL_INCREMENT_MS = 5000; + +export type OAuthDeviceCodePollResult = + | { status: "pending" } + | { status: "slow_down" } + | { status: "complete"; accessToken: string } + | { status: "failed"; message: string }; + +export type OAuthDeviceCodePollOptions = { + intervalSeconds?: number; + expiresInSeconds?: number; + poll: () => Promise; + signal?: AbortSignal; +}; + +function abortableSleep(ms: number, signal: AbortSignal | undefined, cancelMessage: string): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error(cancelMessage)); + return; + } + + const onAbort = () => { + clearTimeout(timeout); + reject(new Error(cancelMessage)); + }; + const timeout = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +export async function pollOAuthDeviceCodeFlow(options: OAuthDeviceCodePollOptions): Promise { + const deadline = + typeof options.expiresInSeconds === "number" + ? Date.now() + options.expiresInSeconds * 1000 + : Number.POSITIVE_INFINITY; + let intervalMs = Math.max( + MINIMUM_INTERVAL_MS, + Math.floor((options.intervalSeconds ?? DEFAULT_POLL_INTERVAL_SECONDS) * 1000), + ); + + let slowDownResponses = 0; + while (Date.now() < deadline) { + if (options.signal?.aborted) { + throw new Error(CANCEL_MESSAGE); + } + + const remainingMs = deadline - Date.now(); + await abortableSleep(Math.min(intervalMs, remainingMs), options.signal, CANCEL_MESSAGE); + + const result = await options.poll(); + if (result.status === "complete") { + return result.accessToken; + } + if (result.status === "pending") { + continue; + } + if (result.status === "slow_down") { + slowDownResponses += 1; + // RFC 8628 section 3.5: apply this increase to this and all subsequent requests. + intervalMs = Math.max(MINIMUM_INTERVAL_MS, intervalMs + SLOW_DOWN_INTERVAL_INCREMENT_MS); + continue; + } + throw new Error(result.message); + } + + throw new Error(slowDownResponses > 0 ? SLOW_DOWN_TIMEOUT_MESSAGE : TIMEOUT_MESSAGE); +} diff --git a/packages/ai/src/utils/oauth/github-copilot.ts b/packages/ai/src/utils/oauth/github-copilot.ts index 6182ab910..c4ce36358 100644 --- a/packages/ai/src/utils/oauth/github-copilot.ts +++ b/packages/ai/src/utils/oauth/github-copilot.ts @@ -4,7 +4,8 @@ import { getModels } from "../../models.ts"; import type { Api, Model } from "../../types.ts"; -import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts"; +import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; +import type { OAuthCredentials, OAuthDeviceCodeInfo, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts"; type CopilotCredentials = OAuthCredentials & { enterpriseUrl?: string; @@ -20,14 +21,11 @@ const COPILOT_HEADERS = { "Copilot-Integration-Id": "vscode-chat", } as const; -const INITIAL_POLL_INTERVAL_MULTIPLIER = 1.2; -const SLOW_DOWN_POLL_INTERVAL_MULTIPLIER = 1.4; - type DeviceCodeResponse = { device_code: string; user_code: string; verification_uri: string; - interval: number; + interval?: number; expires_in: number; }; @@ -40,7 +38,6 @@ type DeviceTokenSuccessResponse = { type DeviceTokenErrorResponse = { error: string; error_description?: string; - interval?: number; }; export function normalizeDomain(input: string): string | null { @@ -129,7 +126,7 @@ async function startDeviceFlow(domain: string): Promise { typeof deviceCode !== "string" || typeof userCode !== "string" || typeof verificationUri !== "string" || - typeof interval !== "number" || + (interval !== undefined && typeof interval !== "number") || typeof expiresIn !== "number" ) { throw new Error("Invalid device code response fields"); @@ -144,95 +141,48 @@ async function startDeviceFlow(domain: string): Promise { }; } -/** - * Sleep that can be interrupted by an AbortSignal - */ -function abortableSleep(ms: number, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(new Error("Login cancelled")); - return; - } - - const timeout = setTimeout(resolve, ms); - - signal?.addEventListener( - "abort", - () => { - clearTimeout(timeout); - reject(new Error("Login cancelled")); - }, - { once: true }, - ); - }); -} - -async function pollForGitHubAccessToken( - domain: string, - deviceCode: string, - intervalSeconds: number, - expiresIn: number, - signal?: AbortSignal, -) { +async function pollForGitHubAccessToken(domain: string, device: DeviceCodeResponse, signal?: AbortSignal) { const urls = getUrls(domain); - const deadline = Date.now() + expiresIn * 1000; - let intervalMs = Math.max(1000, Math.floor(intervalSeconds * 1000)); - let intervalMultiplier = INITIAL_POLL_INTERVAL_MULTIPLIER; - let slowDownResponses = 0; + return pollOAuthDeviceCodeFlow({ + intervalSeconds: device.interval, + expiresInSeconds: device.expires_in, + signal, + poll: async () => { + const raw = await fetchJson(urls.accessTokenUrl, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "GitHubCopilotChat/0.35.0", + }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + device_code: device.device_code, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); - while (Date.now() < deadline) { - if (signal?.aborted) { - throw new Error("Login cancelled"); - } - - const remainingMs = deadline - Date.now(); - const waitMs = Math.min(Math.ceil(intervalMs * intervalMultiplier), remainingMs); - await abortableSleep(waitMs, signal); - - const raw = await fetchJson(urls.accessTokenUrl, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - "User-Agent": "GitHubCopilotChat/0.35.0", - }, - body: new URLSearchParams({ - client_id: CLIENT_ID, - device_code: deviceCode, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }), - }); - - if (raw && typeof raw === "object" && typeof (raw as DeviceTokenSuccessResponse).access_token === "string") { - return (raw as DeviceTokenSuccessResponse).access_token; - } - - if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") { - const { error, error_description: description, interval } = raw as DeviceTokenErrorResponse; - if (error === "authorization_pending") { - continue; + if (raw && typeof raw === "object" && typeof (raw as DeviceTokenSuccessResponse).access_token === "string") { + return { status: "complete", accessToken: (raw as DeviceTokenSuccessResponse).access_token }; } - if (error === "slow_down") { - slowDownResponses += 1; - intervalMs = - typeof interval === "number" && interval > 0 ? interval * 1000 : Math.max(1000, intervalMs + 5000); - intervalMultiplier = SLOW_DOWN_POLL_INTERVAL_MULTIPLIER; - continue; + if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") { + const { error, error_description: description } = raw as DeviceTokenErrorResponse; + if (error === "authorization_pending") { + return { status: "pending" }; + } + + if (error === "slow_down") { + return { status: "slow_down" }; + } + + const descriptionSuffix = description ? `: ${description}` : ""; + return { status: "failed", message: `Device flow failed: ${error}${descriptionSuffix}` }; } - const descriptionSuffix = description ? `: ${description}` : ""; - throw new Error(`Device flow failed: ${error}${descriptionSuffix}`); - } - } - - if (slowDownResponses > 0) { - throw new Error( - "Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again.", - ); - } - - throw new Error("Device flow timed out"); + return { status: "failed", message: "Invalid device token response" }; + }, + }); } /** @@ -319,13 +269,13 @@ async function enableAllGitHubCopilotModels( /** * Login with GitHub Copilot OAuth (device code flow) * - * @param options.onAuth - Callback with URL and optional instructions (user code) + * @param options.onDeviceCode - Callback with URL and user code * @param options.onPrompt - Callback to prompt user for input * @param options.onProgress - Optional progress callback * @param options.signal - Optional AbortSignal for cancellation */ export async function loginGitHubCopilot(options: { - onAuth: (url: string, instructions?: string) => void; + onDeviceCode: (info: OAuthDeviceCodeInfo) => void; onPrompt: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise; onProgress?: (message: string) => void; signal?: AbortSignal; @@ -348,15 +298,14 @@ export async function loginGitHubCopilot(options: { const domain = enterpriseDomain || "github.com"; const device = await startDeviceFlow(domain); - options.onAuth(device.verification_uri, `Enter code: ${device.user_code}`); + options.onDeviceCode({ + userCode: device.user_code, + verificationUri: device.verification_uri, + intervalSeconds: device.interval, + expiresInSeconds: device.expires_in, + }); - const githubAccessToken = await pollForGitHubAccessToken( - domain, - device.device_code, - device.interval, - device.expires_in, - options.signal, - ); + const githubAccessToken = await pollForGitHubAccessToken(domain, device, options.signal); const credentials = await refreshGitHubCopilotToken(githubAccessToken, enterpriseDomain ?? undefined); // Enable all models after successful login @@ -370,8 +319,12 @@ export const githubCopilotOAuthProvider: OAuthProviderInterface = { name: "GitHub Copilot", async login(callbacks: OAuthLoginCallbacks): Promise { + if (!callbacks.onDeviceCode) { + throw new Error("GitHub Copilot OAuth requires a device code callback"); + } + return loginGitHubCopilot({ - onAuth: (url, instructions) => callbacks.onAuth({ url, instructions }), + onDeviceCode: callbacks.onDeviceCode, onPrompt: callbacks.onPrompt, onProgress: callbacks.onProgress, signal: callbacks.signal, diff --git a/packages/ai/src/utils/oauth/index.ts b/packages/ai/src/utils/oauth/index.ts index 3a3a01b16..55910322b 100644 --- a/packages/ai/src/utils/oauth/index.ts +++ b/packages/ai/src/utils/oauth/index.ts @@ -9,6 +9,7 @@ // Anthropic export { anthropicOAuthProvider, loginAnthropic, refreshAnthropicToken } from "./anthropic.ts"; +export * from "./device-code.ts"; // GitHub Copilot export { getGitHubCopilotBaseUrl, diff --git a/packages/ai/src/utils/oauth/types.ts b/packages/ai/src/utils/oauth/types.ts index a1426d815..3220dcf9d 100644 --- a/packages/ai/src/utils/oauth/types.ts +++ b/packages/ai/src/utils/oauth/types.ts @@ -23,6 +23,13 @@ export type OAuthAuthInfo = { instructions?: string; }; +export type OAuthDeviceCodeInfo = { + userCode: string; + verificationUri: string; + intervalSeconds?: number; + expiresInSeconds?: number; +}; + export type OAuthSelectOption = { id: string; label: string; @@ -35,6 +42,7 @@ export type OAuthSelectPrompt = { export interface OAuthLoginCallbacks { onAuth: (info: OAuthAuthInfo) => void; + onDeviceCode?: (info: OAuthDeviceCodeInfo) => void; onPrompt: (prompt: OAuthPrompt) => Promise; onProgress?: (message: string) => void; onManualCodeInput?: () => Promise; diff --git a/packages/ai/test/github-copilot-oauth.test.ts b/packages/ai/test/github-copilot-oauth.test.ts index 085b1cf00..1fb92b1eb 100644 --- a/packages/ai/test/github-copilot-oauth.test.ts +++ b/packages/ai/test/github-copilot-oauth.test.ts @@ -29,7 +29,62 @@ describe("GitHub Copilot OAuth device flow", () => { vi.useRealTimers(); }); - it("waits before the first poll and increases the safety margin after slow_down", async () => { + it("reports device-code details through onDeviceCode", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-09T00:00:00Z")); + + const fetchMock = vi.fn(async (input: unknown): Promise => { + const url = getUrl(input); + + if (url.endsWith("/login/device/code")) { + return jsonResponse({ + device_code: "device-code", + user_code: "ABCD-EFGH", + verification_uri: "https://github.com/login/device", + interval: 1, + expires_in: 900, + }); + } + + if (url.endsWith("/login/oauth/access_token")) { + return jsonResponse({ access_token: "ghu_refresh_token" }); + } + + if (url.includes("/copilot_internal/v2/token")) { + return jsonResponse({ + token: "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;", + expires_at: 9999999999, + }); + } + + if (url.includes("/models/") && url.endsWith("/policy")) { + return new Response("", { status: 200 }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + vi.stubGlobal("fetch", fetchMock); + + const onDeviceCode = vi.fn(); + const loginPromise = loginGitHubCopilot({ + onDeviceCode, + onPrompt: async () => "", + }); + + await vi.advanceTimersByTimeAsync(0); + + expect(onDeviceCode).toHaveBeenCalledWith({ + userCode: "ABCD-EFGH", + verificationUri: "https://github.com/login/device", + intervalSeconds: 1, + expiresInSeconds: 900, + }); + await vi.advanceTimersByTimeAsync(1000); + await loginPromise; + }); + + it("waits before the first poll and increases the interval after slow_down", async () => { vi.useFakeTimers(); const startTime = new Date("2026-03-09T00:00:00Z"); vi.setSystemTime(startTime); @@ -37,7 +92,7 @@ describe("GitHub Copilot OAuth device flow", () => { const accessTokenPollTimes: number[] = []; const accessTokenResponses = [ jsonResponse({ error: "authorization_pending", error_description: "pending" }), - jsonResponse({ error: "slow_down", error_description: "slow down", interval: 10 }), + jsonResponse({ error: "slow_down", error_description: "slow down" }), jsonResponse({ access_token: "ghu_refresh_token" }), ]; @@ -95,7 +150,7 @@ describe("GitHub Copilot OAuth device flow", () => { vi.stubGlobal("fetch", fetchMock); const loginPromise = loginGitHubCopilot({ - onAuth: () => {}, + onDeviceCode: () => {}, onPrompt: async () => "", onProgress: () => {}, }); @@ -103,28 +158,28 @@ describe("GitHub Copilot OAuth device flow", () => { await vi.advanceTimersByTimeAsync(0); expect(accessTokenPollTimes).toHaveLength(0); - await vi.advanceTimersByTimeAsync(5999); + await vi.advanceTimersByTimeAsync(4999); expect(accessTokenPollTimes).toHaveLength(0); await vi.advanceTimersByTimeAsync(1); expect(accessTokenPollTimes).toHaveLength(1); - await vi.advanceTimersByTimeAsync(5999); + await vi.advanceTimersByTimeAsync(4999); expect(accessTokenPollTimes).toHaveLength(1); await vi.advanceTimersByTimeAsync(1); expect(accessTokenPollTimes).toHaveLength(2); - await vi.advanceTimersByTimeAsync(13999); + await vi.advanceTimersByTimeAsync(9999); expect(accessTokenPollTimes).toHaveLength(2); await vi.advanceTimersByTimeAsync(1); await loginPromise; expect(accessTokenPollTimes).toEqual([ - startTime.getTime() + 6000, - startTime.getTime() + 12000, - startTime.getTime() + 26000, + startTime.getTime() + 5000, + startTime.getTime() + 10000, + startTime.getTime() + 20000, ]); }); @@ -135,8 +190,8 @@ describe("GitHub Copilot OAuth device flow", () => { const accessTokenPollTimes: number[] = []; const accessTokenResponses = [ - jsonResponse({ error: "slow_down", error_description: "slow down", interval: 10 }), - jsonResponse({ error: "slow_down", error_description: "still too fast", interval: 15 }), + jsonResponse({ error: "slow_down", error_description: "slow down" }), + jsonResponse({ error: "slow_down", error_description: "still too fast" }), jsonResponse({ error: "authorization_pending", error_description: "pending" }), ]; @@ -168,28 +223,28 @@ describe("GitHub Copilot OAuth device flow", () => { vi.stubGlobal("fetch", fetchMock); const loginPromise = loginGitHubCopilot({ - onAuth: () => {}, + onDeviceCode: () => {}, onPrompt: async () => "", }); const rejection = expect(loginPromise).rejects.toThrow( /Device flow timed out after one or more slow_down responses/, ); - await vi.advanceTimersByTimeAsync(6000); - expect(accessTokenPollTimes).toEqual([startTime.getTime() + 6000]); + await vi.advanceTimersByTimeAsync(5000); + expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000]); - await vi.advanceTimersByTimeAsync(14000); - expect(accessTokenPollTimes).toEqual([startTime.getTime() + 6000, startTime.getTime() + 20000]); + await vi.advanceTimersByTimeAsync(10000); + expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 15000]); - await vi.advanceTimersByTimeAsync(4999); - expect(accessTokenPollTimes).toEqual([startTime.getTime() + 6000, startTime.getTime() + 20000]); + await vi.advanceTimersByTimeAsync(9999); + expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 15000]); await vi.advanceTimersByTimeAsync(1); await rejection; expect(accessTokenPollTimes).toEqual([ - startTime.getTime() + 6000, - startTime.getTime() + 20000, + startTime.getTime() + 5000, + startTime.getTime() + 15000, startTime.getTime() + 25000, ]); }); diff --git a/packages/ai/test/oauth-device-code.test.ts b/packages/ai/test/oauth-device-code.test.ts new file mode 100644 index 000000000..40ef4fd81 --- /dev/null +++ b/packages/ai/test/oauth-device-code.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { pollOAuthDeviceCodeFlow } from "../src/utils/oauth/device-code.ts"; + +describe("OAuth device-code polling", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("waits before the first poll and returns the completed value", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-09T00:00:00Z")); + + const pollTimes: number[] = []; + const poll = vi.fn(async () => { + pollTimes.push(Date.now()); + return pollTimes.length === 1 + ? { status: "pending" as const } + : { status: "complete" as const, accessToken: "token" }; + }); + + const resultPromise = pollOAuthDeviceCodeFlow({ + intervalSeconds: 2, + expiresInSeconds: 30, + poll, + }); + + await vi.advanceTimersByTimeAsync(1999); + expect(pollTimes).toEqual([]); + + await vi.advanceTimersByTimeAsync(1); + expect(pollTimes).toEqual([new Date("2026-03-09T00:00:02Z").getTime()]); + + await vi.advanceTimersByTimeAsync(2000); + await expect(resultPromise).resolves.toBe("token"); + expect(pollTimes).toEqual([ + new Date("2026-03-09T00:00:02Z").getTime(), + new Date("2026-03-09T00:00:04Z").getTime(), + ]); + }); + + it("cancels an in-flight wait", async () => { + vi.useFakeTimers(); + const controller = new AbortController(); + + const resultPromise = pollOAuthDeviceCodeFlow({ + intervalSeconds: 5, + expiresInSeconds: 30, + poll: async () => ({ status: "pending" }), + signal: controller.signal, + }); + + controller.abort(); + await expect(resultPromise).rejects.toThrow("Login cancelled"); + }); +}); diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index 80560f142..5a2c365af 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -1,4 +1,4 @@ -import { getOAuthProviders } from "@earendil-works/pi-ai/oauth"; +import { getOAuthProviders, type OAuthDeviceCodeInfo } from "@earendil-works/pi-ai/oauth"; import { Container, type Focusable, getKeybindings, Input, Spacer, Text, type TUI } from "@earendil-works/pi-tui"; import { exec } from "child_process"; import { theme } from "../theme/theme.ts"; @@ -86,7 +86,7 @@ export class LoginDialogComponent extends Container implements Focusable { /** * Called by onAuth callback - show URL and optional instructions */ - showAuth(url: string, instructions?: string): void { + showAuth(url: string, instructions?: string, options: { autoOpenBrowser?: boolean } = {}): void { this.contentContainer.clear(); this.contentContainer.addChild(new Spacer(1)); const linkedUrl = `\x1b]8;;${url}\x07${url}\x1b]8;;\x07`; @@ -101,11 +101,34 @@ export class LoginDialogComponent extends Container implements Focusable { this.contentContainer.addChild(new Text(theme.fg("warning", instructions), 1, 0)); } - // Try to open browser + if (options.autoOpenBrowser ?? true) { + this.openUrl(url); + } + this.tui.requestRender(); + } + + /** + * Called by onDeviceCode callback - show URL and user code. + */ + showDeviceCode(info: OAuthDeviceCodeInfo): void { + this.contentContainer.clear(); + this.contentContainer.addChild(new Spacer(1)); + const linkedUrl = `\x1b]8;;${info.verificationUri}\x07${info.verificationUri}\x1b]8;;\x07`; + this.contentContainer.addChild(new Text(theme.fg("accent", linkedUrl), 1, 0)); + + const clickHint = process.platform === "darwin" ? "Cmd+click to open" : "Ctrl+click to open"; + const hyperlink = `\x1b]8;;${info.verificationUri}\x07${clickHint}\x1b]8;;\x07`; + this.contentContainer.addChild(new Text(theme.fg("dim", hyperlink), 1, 0)); + this.contentContainer.addChild(new Spacer(1)); + this.contentContainer.addChild(new Text(theme.fg("warning", `Enter code: ${info.userCode}`), 1, 0)); + + // Do not open device-code URLs automatically. These flows need to work in headless environments. + this.tui.requestRender(); + } + + private openUrl(url: string): void { const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; exec(`${openCmd} "${url}"`); - - this.tui.requestRender(); } /** diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index aa8db52b7..3068c89cf 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -4823,13 +4823,15 @@ export class InteractiveMode { manualCodeReject = undefined; } }); - } else if (providerId === "github-copilot") { - // GitHub Copilot polls after onAuth - dialog.showWaiting("Waiting for browser authentication..."); } // For Anthropic: onPrompt is called immediately after }, + onDeviceCode: (info) => { + dialog.showDeviceCode(info); + dialog.showWaiting("Waiting for authentication..."); + }, + onPrompt: async (prompt: { message: string; placeholder?: string }) => { return dialog.showPrompt(prompt.message, prompt.placeholder); }, From 1a2a536dbaf929d6c4e8a999f6ffd0967d8b09f6 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 22 May 2026 11:11:38 +0200 Subject: [PATCH 16/23] chore: update PR prompt template --- .pi/prompts/pr.md | 28 +- packages/ai/src/models.generated.ts | 650 ++++++++++------------------ 2 files changed, 246 insertions(+), 432 deletions(-) diff --git a/.pi/prompts/pr.md b/.pi/prompts/pr.md index 800d45245..b1b2617ea 100644 --- a/.pi/prompts/pr.md +++ b/.pi/prompts/pr.md @@ -8,22 +8,20 @@ For each PR URL, do the following in order: 1. Add the `inprogress` label to the PR via GitHub CLI before analysis starts. If adding the label fails, report that explicitly and continue. 2. Read the PR page in full. Include description, all comments, all commits, and all changed files. 3. Identify any linked issues referenced in the PR body, comments, commit messages, or cross links. Read each issue in full, including all comments. -4. Analyze the PR diff. Read all relevant code files in full with no truncation from the current main branch and compare against the diff. Do not fetch PR file blobs unless a file is missing on main or the diff context is insufficient. Include related code paths that are not in the diff but are required to validate behavior. -5. Check for a changelog entry in the relevant `packages/*/CHANGELOG.md` files. Report whether an entry exists. If missing, state that a changelog entry is required before merge and that you will add it if the user decides to merge. Follow the changelog format rules in AGENTS.md. Verify: - - Entry uses correct section (`### Breaking Changes`, `### Added`, `### Fixed`, etc.) - - External contributions include PR link and author: `Fixed foo ([#123](https://github.com/earendil-works/pi-mono/pull/123) by [@user](https://github.com/user))` - - Breaking changes are in `### Breaking Changes`, not just `### Fixed` +4. Analyze the PR diff. Read all relevant code files in full with no truncation and compare against the diff. Do not fetch PR file blobs unless a file is missing on main or the diff context is insufficient. Include related code paths that are not in the diff but are required to validate behavior. +5. Do not check for a changelog entry. Per CONTRIBUTING.md, contributor PRs must not edit `CHANGELOG.md` — the maintainer adds the entry when merging. 6. Check if packages/coding-agent/README.md, packages/coding-agent/docs/*.md, packages/coding-agent/examples/**/*.md require modification. This is usually the case when existing features have been changed, or new features have been added. 7. Provide a structured review with these sections: - - Good: solid choices or improvements - - Bad: concrete issues, regressions, missing tests, or risks - - Ugly: subtle or high impact problems -8. Add Questions or Assumptions if anything is unclear. -9. Add Change summary and Tests. + - What it does: one short paragraph describing the change and its intent. + - Good: solid choices or improvements. + - Bad: concrete issues, regressions, missing tests, or risks. + - Ugly: subtle or high impact problems. + - Tests: what is covered, what is missing, and whether existing tests are adequate. + - Open questions for you: only things blocking a merge decision that need the user's input. Omit the section entirely if there are none. Output format per PR: PR: -Changelog: +What it does: - ... Good: - ... @@ -31,11 +29,9 @@ Bad: - ... Ugly: - ... -Questions or Assumptions: -- ... -Change summary: -- ... Tests: - ... +Open questions for you: +- ... -If no issues are found, say so under Bad and Ugly. \ No newline at end of file +If no issues are found, say so under Bad and Ugly. diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index dbd7d4951..782c4b97f 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -3278,6 +3278,42 @@ export const MODELS = { contextWindow: 256000, maxTokens: 16384, } satisfies Model<"openai-completions">, + "@cf/ibm-granite/granite-4.0-h-micro": { + id: "@cf/ibm-granite/granite-4.0-h-micro", + name: "Granite 4.0 H Micro", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + compat: {"sendSessionAffinityHeaders":true}, + reasoning: false, + input: ["text"], + cost: { + input: 0.017, + output: 0.112, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131000, + maxTokens: 131000, + } satisfies Model<"openai-completions">, + "@cf/meta/llama-3.3-70b-instruct-fp8-fast": { + id: "@cf/meta/llama-3.3-70b-instruct-fp8-fast", + name: "Llama 3.3 70B Instruct fp8 Fast", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + compat: {"sendSessionAffinityHeaders":true}, + reasoning: false, + input: ["text"], + cost: { + input: 0.293, + output: 2.253, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 24000, + maxTokens: 24000, + } satisfies Model<"openai-completions">, "@cf/meta/llama-4-scout-17b-16e-instruct": { id: "@cf/meta/llama-4-scout-17b-16e-instruct", name: "Llama 4 Scout 17B 16E Instruct", @@ -3293,9 +3329,27 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 128000, + contextWindow: 131000, maxTokens: 16384, } satisfies Model<"openai-completions">, + "@cf/mistralai/mistral-small-3.1-24b-instruct": { + id: "@cf/mistralai/mistral-small-3.1-24b-instruct", + name: "Mistral Small 3.1 24B Instruct", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + compat: {"sendSessionAffinityHeaders":true}, + reasoning: false, + input: ["text"], + cost: { + input: 0.351, + output: 0.555, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "@cf/moonshotai/kimi-k2.5": { id: "@cf/moonshotai/kimi-k2.5", name: "Kimi K2.5", @@ -3329,7 +3383,7 @@ export const MODELS = { cacheRead: 0.16, cacheWrite: 0, }, - contextWindow: 256000, + contextWindow: 262144, maxTokens: 256000, } satisfies Model<"openai-completions">, "@cf/nvidia/nemotron-3-120b-a12b": { @@ -3386,6 +3440,24 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, + "@cf/qwen/qwen3-30b-a3b-fp8": { + id: "@cf/qwen/qwen3-30b-a3b-fp8", + name: "Qwen3 30B A3b fp8", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + compat: {"sendSessionAffinityHeaders":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0.0509, + output: 0.335, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "@cf/zai-org/glm-4.7-flash": { id: "@cf/zai-org/glm-4.7-flash", name: "GLM-4.7-Flash", @@ -3396,7 +3468,7 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.06, + input: 0.0605, output: 0.4, cacheRead: 0, cacheWrite: 0, @@ -4169,57 +4241,6 @@ export const MODELS = { } satisfies Model<"openai-completions">, }, "google": { - "gemini-1.5-flash": { - id: "gemini-1.5-flash", - name: "Gemini 1.5 Flash", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.075, - output: 0.3, - cacheRead: 0.01875, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 8192, - } satisfies Model<"google-generative-ai">, - "gemini-1.5-flash-8b": { - id: "gemini-1.5-flash-8b", - name: "Gemini 1.5 Flash-8B", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.0375, - output: 0.15, - cacheRead: 0.01, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 8192, - } satisfies Model<"google-generative-ai">, - "gemini-1.5-pro": { - id: "gemini-1.5-pro", - name: "Gemini 1.5 Pro", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: false, - input: ["text", "image"], - cost: { - input: 1.25, - output: 5, - cacheRead: 0.3125, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 8192, - } satisfies Model<"google-generative-ai">, "gemini-2.0-flash": { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash", @@ -4239,7 +4260,7 @@ export const MODELS = { } satisfies Model<"google-generative-ai">, "gemini-2.0-flash-lite": { id: "gemini-2.0-flash-lite", - name: "Gemini 2.0 Flash Lite", + name: "Gemini 2.0 Flash-Lite", api: "google-generative-ai", provider: "google", baseUrl: "https://generativelanguage.googleapis.com/v1beta", @@ -4273,7 +4294,7 @@ export const MODELS = { } satisfies Model<"google-generative-ai">, "gemini-2.5-flash-lite": { id: "gemini-2.5-flash-lite", - name: "Gemini 2.5 Flash Lite", + name: "Gemini 2.5 Flash-Lite", api: "google-generative-ai", provider: "google", baseUrl: "https://generativelanguage.googleapis.com/v1beta", @@ -4288,91 +4309,6 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 65536, } satisfies Model<"google-generative-ai">, - "gemini-2.5-flash-lite-preview-06-17": { - id: "gemini-2.5-flash-lite-preview-06-17", - name: "Gemini 2.5 Flash Lite Preview 06-17", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.1, - output: 0.4, - cacheRead: 0.025, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-2.5-flash-lite-preview-09-2025": { - id: "gemini-2.5-flash-lite-preview-09-2025", - name: "Gemini 2.5 Flash Lite Preview 09-25", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.1, - output: 0.4, - cacheRead: 0.025, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-2.5-flash-preview-04-17": { - id: "gemini-2.5-flash-preview-04-17", - name: "Gemini 2.5 Flash Preview 04-17", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.15, - output: 0.6, - cacheRead: 0.0375, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-2.5-flash-preview-05-20": { - id: "gemini-2.5-flash-preview-05-20", - name: "Gemini 2.5 Flash Preview 05-20", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.15, - output: 0.6, - cacheRead: 0.0375, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-2.5-flash-preview-09-2025": { - id: "gemini-2.5-flash-preview-09-2025", - name: "Gemini 2.5 Flash Preview 09-25", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.3, - output: 2.5, - cacheRead: 0.075, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, "gemini-2.5-pro": { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", @@ -4390,40 +4326,6 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 65536, } satisfies Model<"google-generative-ai">, - "gemini-2.5-pro-preview-05-06": { - id: "gemini-2.5-pro-preview-05-06", - name: "Gemini 2.5 Pro Preview 05-06", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.31, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-2.5-pro-preview-06-05": { - id: "gemini-2.5-pro-preview-06-05", - name: "Gemini 2.5 Pro Preview 06-05", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.31, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, "gemini-3-flash-preview": { id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview", @@ -4457,8 +4359,8 @@ export const MODELS = { cacheRead: 0.2, cacheWrite: 0, }, - contextWindow: 1000000, - maxTokens: 64000, + contextWindow: 1048576, + maxTokens: 65536, } satisfies Model<"google-generative-ai">, "gemini-3.1-flash-lite": { id: "gemini-3.1-flash-lite", @@ -4584,60 +4486,9 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 65536, } satisfies Model<"google-generative-ai">, - "gemini-live-2.5-flash": { - id: "gemini-live-2.5-flash", - name: "Gemini Live 2.5 Flash", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.5, - output: 2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 8000, - } satisfies Model<"google-generative-ai">, - "gemini-live-2.5-flash-preview-native-audio": { - id: "gemini-live-2.5-flash-preview-native-audio", - name: "Gemini Live 2.5 Flash Preview Native Audio", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text"], - cost: { - input: 0.5, - output: 2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemma-3-27b-it": { - id: "gemma-3-27b-it", - name: "Gemma 3 27B", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"google-generative-ai">, "gemma-4-26b-a4b-it": { id: "gemma-4-26b-a4b-it", - name: "Gemma 4 26B", + name: "Gemma 4 26B A4B IT", api: "google-generative-ai", provider: "google", baseUrl: "https://generativelanguage.googleapis.com/v1beta", @@ -4650,12 +4501,12 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 256000, - maxTokens: 8192, + contextWindow: 262144, + maxTokens: 32768, } satisfies Model<"google-generative-ai">, "gemma-4-31b-it": { id: "gemma-4-31b-it", - name: "Gemma 4 31B", + name: "Gemma 4 31B IT", api: "google-generative-ai", provider: "google", baseUrl: "https://generativelanguage.googleapis.com/v1beta", @@ -4668,8 +4519,8 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 256000, - maxTokens: 8192, + contextWindow: 262144, + maxTokens: 32768, } satisfies Model<"google-generative-ai">, }, "google-vertex": { @@ -7511,6 +7362,24 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 65536, } satisfies Model<"google-generative-ai">, + "gemini-3.5-flash": { + id: "gemini-3.5-flash", + name: "Gemini 3.5 Flash", + api: "google-generative-ai", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.5, + output: 9, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, "glm-5": { id: "glm-5", name: "GLM-5", @@ -7833,6 +7702,23 @@ export const MODELS = { contextWindow: 1050000, maxTokens: 128000, } satisfies Model<"openai-responses">, + "grok-build-0.1": { + id: "grok-build-0.1", + name: "Grok Build 0.1", + api: "openai-completions", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 2, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"openai-completions">, "kimi-k2.5": { id: "kimi-k2.5", name: "Kimi K2.5", @@ -7884,23 +7770,6 @@ export const MODELS = { contextWindow: 204800, maxTokens: 131072, } satisfies Model<"openai-completions">, - "minimax-m2.5-free": { - id: "minimax-m2.5-free", - name: "MiniMax M2.5 Free", - api: "anthropic-messages", - provider: "opencode", - baseUrl: "https://opencode.ai/zen", - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 204800, - maxTokens: 131072, - } satisfies Model<"anthropic-messages">, "minimax-m2.7": { id: "minimax-m2.7", name: "MiniMax M2.7", @@ -8544,23 +8413,6 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"openai-completions">, - "arcee-ai/trinity-large-preview": { - id: "arcee-ai/trinity-large-preview", - name: "Arcee AI: Trinity Large Preview", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.15, - output: 0.44999999999999996, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "arcee-ai/trinity-large-thinking": { id: "arcee-ai/trinity-large-thinking", name: "Arcee AI: Trinity Large Thinking", @@ -9023,7 +8875,7 @@ export const MODELS = { cacheRead: 0.024999999999999998, cacheWrite: 0.08333333333333334, }, - contextWindow: 1048576, + contextWindow: 1000000, maxTokens: 8192, } satisfies Model<"openai-completions">, "google/gemini-2.0-flash-lite-001": { @@ -9393,9 +9245,9 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.3, - output: 2.5, - cacheRead: 0.06, + input: 0.075, + output: 0.625, + cacheRead: 0.015, cacheWrite: 0, }, contextWindow: 262144, @@ -11940,7 +11792,7 @@ export const MODELS = { cost: { input: 0.39, output: 2.34, - cacheRead: 0.195, + cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, @@ -12023,13 +11875,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.32, + input: 0.317, output: 3.1999999999999997, cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 81920, + maxTokens: 262140, } satisfies Model<"openai-completions">, "qwen/qwen3.6-35b-a3b": { id: "qwen/qwen3.6-35b-a3b", @@ -12099,6 +11951,23 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 65536, } satisfies Model<"openai-completions">, + "qwen/qwen3.7-max": { + id: "qwen/qwen3.7-max", + name: "Qwen: Qwen3.7 Max", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 2.5, + output: 7.5, + cacheRead: 0, + cacheWrite: 3.125, + }, + contextWindow: 1000000, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "rekaai/reka-edge": { id: "rekaai/reka-edge", name: "Reka Edge", @@ -12176,13 +12045,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.09999999999999999, + input: 0.09, output: 0.3, - cacheRead: 0, + cacheRead: 0.02, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 65536, + maxTokens: 16384, } satisfies Model<"openai-completions">, "tencent/hy3-preview": { id: "tencent/hy3-preview", @@ -12286,6 +12155,23 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 4096, } satisfies Model<"openai-completions">, + "x-ai/grok-build-0.1": { + id: "x-ai/grok-build-0.1", + name: "xAI: Grok Build 0.1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 2, + cacheRead: 0.19999999999999998, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "xiaomi/mimo-v2-flash": { id: "xiaomi/mimo-v2-flash", name: "Xiaomi: MiMo-V2-Flash", @@ -12567,13 +12453,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0, - output: 0, - cacheRead: 0, + input: 0.98, + output: 3.08, + cacheRead: 0.182, cacheWrite: 0, }, - contextWindow: 202800, - maxTokens: 202800, + contextWindow: 202752, + maxTokens: 4096, } satisfies Model<"openai-completions">, "z-ai/glm-5v-turbo": { id: "z-ai/glm-5v-turbo", @@ -13358,6 +13244,23 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 64000, } satisfies Model<"anthropic-messages">, + "alibaba/qwen3.7-max": { + id: "alibaba/qwen3.7-max", + name: "Qwen 3.7 Max", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2.5, + output: 7.5, + cacheRead: 0.5, + cacheWrite: 3.125, + }, + contextWindow: 991000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, "anthropic/claude-3-haiku": { id: "anthropic/claude-3-haiku", name: "Claude 3 Haiku", @@ -14397,6 +14300,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 64000, } satisfies Model<"anthropic-messages">, + "mistral/mistral-medium-3.5": { + id: "mistral/mistral-medium-3.5", + name: "Mistral Medium Latest", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 1.5, + output: 7.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"anthropic-messages">, "mistral/mistral-small": { id: "mistral/mistral-small", name: "Mistral Small", @@ -15412,6 +15332,23 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 1000000, } satisfies Model<"anthropic-messages">, + "xai/grok-build-0.1": { + id: "xai/grok-build-0.1", + name: "Grok Build 0.1", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 2, + cacheRead: 0.19999999999999998, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"anthropic-messages">, "xiaomi/mimo-v2-flash": { id: "xiaomi/mimo-v2-flash", name: "MiMo V2 Flash", @@ -15703,108 +15640,6 @@ export const MODELS = { } satisfies Model<"anthropic-messages">, }, "xai": { - "grok-2": { - id: "grok-2", - name: "Grok 2", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2, - output: 10, - cacheRead: 2, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-2-1212": { - id: "grok-2-1212", - name: "Grok 2 (1212)", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2, - output: 10, - cacheRead: 2, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-2-latest": { - id: "grok-2-latest", - name: "Grok 2 Latest", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2, - output: 10, - cacheRead: 2, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-2-vision": { - id: "grok-2-vision", - name: "Grok 2 Vision", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2, - output: 10, - cacheRead: 2, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "grok-2-vision-1212": { - id: "grok-2-vision-1212", - name: "Grok 2 Vision (1212)", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2, - output: 10, - cacheRead: 2, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "grok-2-vision-latest": { - id: "grok-2-vision-latest", - name: "Grok 2 Vision Latest", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2, - output: 10, - cacheRead: 2, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "grok-3": { id: "grok-3", name: "Grok 3", @@ -15848,8 +15683,8 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 2, - output: 6, + input: 1.25, + output: 2.5, cacheRead: 0.2, cacheWrite: 0, }, @@ -15865,8 +15700,8 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 2, - output: 6, + input: 1.25, + output: 2.5, cacheRead: 0.2, cacheWrite: 0, }, @@ -15890,22 +15725,22 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 30000, } satisfies Model<"openai-completions">, - "grok-beta": { - id: "grok-beta", - name: "Grok Beta", + "grok-build-0.1": { + id: "grok-build-0.1", + name: "Grok Build 0.1", api: "openai-completions", provider: "xai", baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text"], + reasoning: true, + input: ["text", "image"], cost: { - input: 5, - output: 15, - cacheRead: 5, + input: 1, + output: 2, + cacheRead: 0.2, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 4096, + contextWindow: 256000, + maxTokens: 256000, } satisfies Model<"openai-completions">, "grok-code-fast-1": { id: "grok-code-fast-1", @@ -15924,23 +15759,6 @@ export const MODELS = { contextWindow: 32768, maxTokens: 8192, } satisfies Model<"openai-completions">, - "grok-vision-beta": { - id: "grok-vision-beta", - name: "Grok Vision Beta", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 5, - output: 15, - cacheRead: 5, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 4096, - } satisfies Model<"openai-completions">, }, "xiaomi": { "mimo-v2-flash": { From bf56a86e1e8ab6a57c1299fb9b2ab4adc5265d0b Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 22 May 2026 11:13:26 +0200 Subject: [PATCH 17/23] fix(coding-agent): reconcile git package refs closes #4870 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/README.md | 2 +- packages/coding-agent/docs/packages.md | 4 +- .../coding-agent/src/core/package-manager.ts | 35 ++++-- .../coding-agent/test/package-manager.test.ts | 107 ++++++++++++++++++ 5 files changed, 139 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 32fcca39d..ce69a3aa7 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,7 @@ ### Fixed +- Fixed git package installs to reconcile existing checkouts to the requested ref and update package settings without losing filters ([#4870](https://github.com/earendil-works/pi/issues/4870)). - 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)). diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 79f284a07..00fca81e3 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -408,7 +408,7 @@ pi update npm:@foo/pi-tools # update one package pi config # enable/disable extensions, skills, prompts, themes ``` -Packages install to `~/.pi/agent/git/` (git) or `~/.pi/agent/npm/` (npm). Use `-l` for project-local installs (`.pi/git/`, `.pi/npm/`). Git packages install dependencies with `npm install --omit=dev` by default, so runtime deps must be listed under `dependencies`; when `npmCommand` is configured, git packages use plain `install` for compatibility with wrappers. If you use a Node version manager and want package installs to reuse a stable npm context, set `npmCommand` in `settings.json`, for example `["mise", "exec", "node@20", "--", "npm"]`. +Packages install to `~/.pi/agent/git/` (git) or `~/.pi/agent/npm/` (npm). Use `-l` for project-local installs (`.pi/git/`, `.pi/npm/`). Git `@ref` values are pinned tags or commits; pinned packages are skipped by `pi update`, so use `pi install git:host/user/repo@new-ref` to move an existing package to a new ref. Git packages install dependencies with `npm install --omit=dev` by default, so runtime deps must be listed under `dependencies`; when `npmCommand` is configured, git packages use plain `install` for compatibility with wrappers. If you use a Node version manager and want package installs to reuse a stable npm context, set `npmCommand` in `settings.json`, for example `["mise", "exec", "node@20", "--", "npm"]`. Create a package by adding a `pi` key to `package.json`: diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index db5ab6cc7..1f3c69a6e 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -85,9 +85,9 @@ 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 pin the package and skip package updates (`pi update`, `pi update --extensions`). +- 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. - Cloned to `~/.pi/agent/git//` (global) or `.pi/git//` (project). -- Runs `npm install` after clone or pull if `package.json` exists. +- Runs `npm install` after clone, pull, or pinned ref change if `package.json` exists. **SSH examples:** ```bash diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 90c41942e..b8c333bcf 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -778,9 +778,21 @@ export class DefaultPackageManager implements PackageManager { scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings(); const currentPackages = currentSettings.packages ?? []; const normalizedSource = this.normalizePackageSourceForSettings(source, scope); - const exists = currentPackages.some((existing) => this.packageSourcesMatch(existing, source, scope)); - if (exists) { - return false; + const matchIndex = currentPackages.findIndex((existing) => this.packageSourcesMatch(existing, source, scope)); + if (matchIndex !== -1) { + const existing = currentPackages[matchIndex]; + if (this.getPackageSourceString(existing) === normalizedSource) { + return false; + } + const nextPackages = [...currentPackages]; + nextPackages[matchIndex] = + typeof existing === "string" ? normalizedSource : { ...existing, source: normalizedSource }; + if (scope === "project") { + this.settingsManager.setProjectPackages(nextPackages); + } else { + this.settingsManager.setPackages(nextPackages); + } + return true; } const nextPackages = [...currentPackages, normalizedSource]; if (scope === "project") { @@ -1723,6 +1735,12 @@ export class DefaultPackageManager implements PackageManager { private async installGit(source: GitSource, scope: SourceScope): Promise { const targetDir = this.getGitInstallPath(source, scope); if (existsSync(targetDir)) { + 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); return; } const gitRoot = this.getGitInstallRoot(scope); @@ -1749,23 +1767,26 @@ export class DefaultPackageManager implements PackageManager { } const target = await this.getLocalGitUpdateTarget(targetDir); + await this.ensureGitRef(targetDir, target.fetchArgs, target.ref); + } + private async ensureGitRef(targetDir: string, fetchArgs: string[], ref: string): Promise { // Fetch only the ref we will reset to, avoiding unrelated branch/tag noise. - await this.runCommand("git", target.fetchArgs, { cwd: targetDir }); + await this.runCommand("git", fetchArgs, { cwd: targetDir }); const localHead = await this.runCommandCapture("git", ["rev-parse", "HEAD"], { cwd: targetDir, timeoutMs: NETWORK_TIMEOUT_MS, }); - const refreshedTargetHead = await this.runCommandCapture("git", ["rev-parse", target.ref], { + const targetHead = await this.runCommandCapture("git", ["rev-parse", ref], { cwd: targetDir, timeoutMs: NETWORK_TIMEOUT_MS, }); - if (localHead.trim() === refreshedTargetHead.trim()) { + if (localHead.trim() === targetHead.trim()) { return; } - await this.runCommand("git", ["reset", "--hard", target.ref], { cwd: targetDir }); + await this.runCommand("git", ["reset", "--hard", ref], { cwd: targetDir }); // Clean untracked files (extensions should be pristine) await this.runCommand("git", ["clean", "-fdx"], { cwd: targetDir }); diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 9466d7338..9f3476a87 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -25,6 +25,16 @@ class MockSpawnedProcess extends EventEmitter { } } +interface PackageManagerInternals { + runCommand(command: string, args: string[], options?: { cwd?: string }): Promise; + runCommandCapture( + command: string, + args: string[], + options?: { cwd?: string; timeoutMs?: number; env?: Record }, + ): Promise; + getLocalGitUpdateTarget(installedPath: string): Promise<{ ref: string; head: string; fetchArgs: string[] }>; +} + // Helper to check if a resource is enabled const isEnabled = (r: ResolvedResource, pathMatch: string, matchFn: "endsWith" | "includes" = "endsWith") => { const normalizedPath = normalizeForMatch(r.path); @@ -693,6 +703,62 @@ Content`, expect(runCommandSpy).toHaveBeenCalledWith("npm", ["install", "--omit=dev"], { cwd: targetDir }); }); + it("should reconcile an existing git checkout to a pinned ref during install", async () => { + const source = "git:github.com/user/repo@v2"; + const targetDir = join(agentDir, "git", "github.com", "user", "repo"); + mkdirSync(targetDir, { recursive: true }); + writeFileSync(join(targetDir, "package.json"), JSON.stringify({ name: "repo", version: "1.0.0" })); + + const managerWithInternals = packageManager as unknown as PackageManagerInternals; + vi.spyOn(managerWithInternals, "runCommandCapture").mockImplementation(async (_command, args) => { + if (args[0] === "rev-parse" && args[1] === "HEAD") { + return "old-head"; + } + if (args[0] === "rev-parse" && args[1] === "FETCH_HEAD") { + return "new-head"; + } + throw new Error(`Unexpected runCommandCapture args: ${args.join(" ")}`); + }); + const runCommandSpy = vi.spyOn(managerWithInternals, "runCommand").mockResolvedValue(undefined); + + 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", ["clean", "-fdx"], { cwd: targetDir }); + expect(runCommandSpy).toHaveBeenCalledWith("npm", ["install", "--omit=dev"], { cwd: targetDir }); + }); + + it("should reconcile an existing git checkout to its update target when installing without a ref", async () => { + const source = "git:github.com/user/repo"; + const targetDir = join(agentDir, "git", "github.com", "user", "repo"); + const fetchArgs = ["fetch", "--prune", "--no-tags", "origin", "+refs/heads/main:refs/remotes/origin/main"]; + mkdirSync(targetDir, { recursive: true }); + + const managerWithInternals = packageManager as unknown as PackageManagerInternals; + vi.spyOn(managerWithInternals, "getLocalGitUpdateTarget").mockResolvedValue({ + ref: "origin/HEAD", + head: "new-head", + fetchArgs, + }); + vi.spyOn(managerWithInternals, "runCommandCapture").mockImplementation(async (_command, args) => { + if (args[0] === "rev-parse" && args[1] === "HEAD") { + return "old-head"; + } + if (args[0] === "rev-parse" && args[1] === "origin/HEAD") { + return "new-head"; + } + throw new Error(`Unexpected runCommandCapture args: ${args.join(" ")}`); + }); + const runCommandSpy = vi.spyOn(managerWithInternals, "runCommand").mockResolvedValue(undefined); + + 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", ["clean", "-fdx"], { cwd: targetDir }); + }); + it("should use plain install for git package dependencies when npmCommand is configured", async () => { settingsManager = SettingsManager.inMemory({ npmCommand: ["pnpm"], @@ -1061,6 +1127,47 @@ Content`, expect(removed).toBe(true); expect(settingsManager.getGlobalSettings().packages ?? []).toHaveLength(0); }); + + it("should return false when adding the same git source with the same ref", () => { + const first = packageManager.addSourceToSettings("git:github.com/user/repo@v1"); + expect(first).toBe(true); + + const second = packageManager.addSourceToSettings("git:github.com/user/repo@v1"); + expect(second).toBe(false); + expect(settingsManager.getGlobalSettings().packages).toEqual(["git:github.com/user/repo@v1"]); + }); + + it("should update the ref when adding the same git source with a different ref", () => { + packageManager.addSourceToSettings("git:github.com/user/repo@v1"); + + const updated = packageManager.addSourceToSettings("git:github.com/user/repo@v2"); + expect(updated).toBe(true); + expect(settingsManager.getGlobalSettings().packages).toEqual(["git:github.com/user/repo@v2"]); + }); + + it("should preserve package filters when replacing a package source ref", () => { + settingsManager.setPackages([ + { + source: "git:github.com/user/repo@v1", + extensions: ["extensions/main.ts"], + skills: [], + prompts: ["prompts/review.md"], + themes: ["themes/dark.json"], + }, + ]); + + const updated = packageManager.addSourceToSettings("git:github.com/user/repo@v2"); + expect(updated).toBe(true); + expect(settingsManager.getGlobalSettings().packages).toEqual([ + { + source: "git:github.com/user/repo@v2", + extensions: ["extensions/main.ts"], + skills: [], + prompts: ["prompts/review.md"], + themes: ["themes/dark.json"], + }, + ]); + }); }); describe("HTTPS git URL parsing (old behavior)", () => { From c100620bf447349ae7a4866bc1cb6757cc9f67c4 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 22 May 2026 11:25:15 +0200 Subject: [PATCH 18/23] fix(coding-agent): Clean up Path Handling (#4873) --- packages/coding-agent/src/config.ts | 9 +-- .../src/core/agent-session-runtime.ts | 3 +- .../src/core/agent-session-services.ts | 5 +- .../coding-agent/src/core/agent-session.ts | 8 ++- .../coding-agent/src/core/auth-storage.ts | 3 +- .../src/core/export-html/index.ts | 14 ++-- .../src/core/extensions/loader.ts | 45 ++++--------- .../coding-agent/src/core/model-registry.ts | 3 +- .../coding-agent/src/core/package-manager.ts | 18 ++--- .../coding-agent/src/core/prompt-templates.ts | 25 ++----- .../coding-agent/src/core/resource-loader.ts | 67 +++++++++++-------- packages/coding-agent/src/core/sdk.ts | 5 +- .../coding-agent/src/core/session-manager.ts | 56 +++++++++------- .../coding-agent/src/core/settings-manager.ts | 19 ++---- packages/coding-agent/src/core/skills.ts | 29 +++----- .../coding-agent/src/core/tools/path-utils.ts | 26 +------ packages/coding-agent/src/main.ts | 11 ++- packages/coding-agent/src/utils/paths.ts | 57 ++++++++++++++-- .../test/extensions-discovery.test.ts | 25 +++++++ .../coding-agent/test/package-manager.test.ts | 34 ++++++++++ packages/coding-agent/test/path-utils.test.ts | 16 ++++- packages/coding-agent/test/paths.test.ts | 60 ++++++++++++++++- .../coding-agent/test/resource-loader.test.ts | 39 +++++++++++ 23 files changed, 363 insertions(+), 214 deletions(-) diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index e55fa5f3c..dd091804f 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -3,6 +3,7 @@ import { homedir } from "os"; import { basename, dirname, join, resolve, sep, win32 } from "path"; import { fileURLToPath } from "url"; import { spawnProcessSync } from "./utils/child-process.ts"; +import { normalizePath } from "./utils/paths.ts"; // ============================================================================= // Package Detection @@ -330,9 +331,7 @@ export function getPackageDir(): string { // Allow override via environment variable (useful for Nix/Guix where store paths tokenize poorly) const envDir = process.env.PI_PACKAGE_DIR; if (envDir) { - if (envDir === "~") return homedir(); - if (envDir.startsWith("~/")) return homedir() + envDir.slice(1); - return envDir; + return normalizePath(envDir); } if (isBunBinary) { @@ -454,9 +453,7 @@ export const ENV_AGENT_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_DIR`; export const ENV_SESSION_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_SESSION_DIR`; export function expandTildePath(path: string): string { - if (path === "~") return homedir(); - if (path.startsWith("~/")) return homedir() + path.slice(1); - return path; + return normalizePath(path); } const DEFAULT_SHARE_VIEWER_URL = "https://pi.dev/session/"; diff --git a/packages/coding-agent/src/core/agent-session-runtime.ts b/packages/coding-agent/src/core/agent-session-runtime.ts index b4756d4cf..baab11b09 100644 --- a/packages/coding-agent/src/core/agent-session-runtime.ts +++ b/packages/coding-agent/src/core/agent-session-runtime.ts @@ -1,5 +1,6 @@ import { copyFileSync, existsSync, mkdirSync } from "node:fs"; import { basename, join, resolve } from "node:path"; +import { resolvePath } from "../utils/paths.ts"; import type { AgentSession } from "./agent-session.ts"; import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agent-session-services.ts"; import type { ReplacedSessionContext, SessionShutdownEvent, SessionStartEvent } from "./extensions/index.ts"; @@ -337,7 +338,7 @@ export class AgentSessionRuntime { * @throws {MissingSessionCwdError} When the imported session cwd cannot be resolved and no override is provided. */ async importFromJsonl(inputPath: string, cwdOverride?: string): Promise<{ cancelled: boolean }> { - const resolvedPath = resolve(inputPath); + const resolvedPath = resolvePath(inputPath); if (!existsSync(resolvedPath)) { throw new SessionImportFileNotFoundError(resolvedPath); } diff --git a/packages/coding-agent/src/core/agent-session-services.ts b/packages/coding-agent/src/core/agent-session-services.ts index 852776a97..adf6f9e0c 100644 --- a/packages/coding-agent/src/core/agent-session-services.ts +++ b/packages/coding-agent/src/core/agent-session-services.ts @@ -2,6 +2,7 @@ import { join } from "node:path"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { Model } from "@earendil-works/pi-ai"; import { getAgentDir } from "../config.ts"; +import { resolvePath } from "../utils/paths.ts"; import { AuthStorage } from "./auth-storage.ts"; import type { SessionStartEvent, ToolDefinition } from "./extensions/index.ts"; import { ModelRegistry } from "./model-registry.ts"; @@ -129,8 +130,8 @@ function applyExtensionFlagValues( export async function createAgentSessionServices( options: CreateAgentSessionServicesOptions, ): Promise { - const cwd = options.cwd; - const agentDir = options.agentDir ?? getAgentDir(); + const cwd = resolvePath(options.cwd); + const agentDir = options.agentDir ? resolvePath(options.agentDir) : getAgentDir(); const authStorage = options.authStorage ?? AuthStorage.create(join(agentDir, "auth.json")); const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir); const modelRegistry = options.modelRegistry ?? ModelRegistry.create(authStorage, join(agentDir, "models.json")); diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index d18591b72..cf13594b8 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -14,7 +14,7 @@ */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { basename, dirname, resolve } from "node:path"; +import { basename, dirname } from "node:path"; import type { Agent, AgentEvent, @@ -35,6 +35,7 @@ import { } from "@earendil-works/pi-ai"; import { theme } from "../modes/interactive/theme/theme.ts"; import { stripFrontmatter } from "../utils/frontmatter.ts"; +import { resolvePath } from "../utils/paths.ts"; import { sleep } from "../utils/sleep.ts"; import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.ts"; import { type BashResult, executeBashWithOperations } from "./bash-executor.ts"; @@ -2993,7 +2994,10 @@ export class AgentSession { * @returns The resolved output file path. */ exportToJsonl(outputPath?: string): string { - const filePath = resolve(outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`); + const filePath = resolvePath( + outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`, + process.cwd(), + ); const dir = dirname(filePath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index ae2996c43..7630fc80f 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -18,6 +18,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "f import { dirname, join } from "path"; import lockfile from "proper-lockfile"; import { getAgentDir } from "../config.ts"; +import { normalizePath } from "../utils/paths.ts"; import { resolveConfigValue } from "./resolve-config-value.ts"; export type ApiKeyCredential = { @@ -53,7 +54,7 @@ export class FileAuthStorageBackend implements AuthStorageBackend { private authPath: string; constructor(authPath: string = join(getAgentDir(), "auth.json")) { - this.authPath = authPath; + this.authPath = normalizePath(authPath); } private ensureParentDir(): void { diff --git a/packages/coding-agent/src/core/export-html/index.ts b/packages/coding-agent/src/core/export-html/index.ts index a831ae42d..3762c2640 100644 --- a/packages/coding-agent/src/core/export-html/index.ts +++ b/packages/coding-agent/src/core/export-html/index.ts @@ -3,6 +3,7 @@ import { existsSync, readFileSync, writeFileSync } from "fs"; import { basename, join } from "path"; import { APP_NAME, getExportTemplateDir } from "../../config.ts"; import { getResolvedThemeColors, getThemeExportColors } from "../../modes/interactive/theme/theme.ts"; +import { normalizePath, resolvePath } from "../../utils/paths.ts"; import type { ToolDefinition } from "../extensions/types.ts"; import type { SessionEntry } from "../session-manager.ts"; import { SessionManager } from "../session-manager.ts"; @@ -270,7 +271,7 @@ export async function exportSessionToHtml( const html = generateHtml(sessionData, opts.themeName); - let outputPath = opts.outputPath; + let outputPath = opts.outputPath ? normalizePath(opts.outputPath) : undefined; if (!outputPath) { const sessionBasename = basename(sessionFile, ".jsonl"); outputPath = `${APP_NAME}-session-${sessionBasename}.html`; @@ -286,12 +287,13 @@ export async function exportSessionToHtml( */ export async function exportFromFile(inputPath: string, options?: ExportOptions | string): Promise { const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {}; + const resolvedInputPath = resolvePath(inputPath); - if (!existsSync(inputPath)) { - throw new Error(`File not found: ${inputPath}`); + if (!existsSync(resolvedInputPath)) { + throw new Error(`File not found: ${resolvedInputPath}`); } - const sm = SessionManager.open(inputPath); + const sm = SessionManager.open(resolvedInputPath); const sessionData: SessionData = { header: sm.getHeader(), @@ -303,9 +305,9 @@ export async function exportFromFile(inputPath: string, options?: ExportOptions const html = generateHtml(sessionData, opts.themeName); - let outputPath = opts.outputPath; + let outputPath = opts.outputPath ? normalizePath(opts.outputPath) : undefined; if (!outputPath) { - const inputBasename = basename(inputPath, ".jsonl"); + const inputBasename = basename(resolvedInputPath, ".jsonl"); outputPath = `${APP_NAME}-session-${inputBasename}.html`; } diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index 60ad625dc..6a7d8abb5 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -5,7 +5,6 @@ import * as fs from "node:fs"; import { createRequire } from "node:module"; -import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import * as _bundledPiAgentCore from "@earendil-works/pi-agent-core"; @@ -24,6 +23,7 @@ import { CONFIG_DIR_NAME, getAgentDir, isBunBinary } from "../../config.ts"; // NOTE: This import works because loader.ts exports are NOT re-exported from index.ts, // avoiding a circular dependency. Extensions can import from @earendil-works/pi-coding-agent. import * as _bundledPiCodingAgent from "../../index.ts"; +import { resolvePath } from "../../utils/paths.ts"; import { createEventBus, type EventBus } from "../event-bus.ts"; import type { ExecOptions } from "../exec.ts"; import { execCommand } from "../exec.ts"; @@ -115,31 +115,6 @@ function getAliases(): Record { return _aliases; } -const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; - -function normalizeUnicodeSpaces(str: string): string { - return str.replace(UNICODE_SPACES, " "); -} - -function expandPath(p: string): string { - const normalized = normalizeUnicodeSpaces(p); - if (normalized.startsWith("~/")) { - return path.join(os.homedir(), normalized.slice(2)); - } - if (normalized.startsWith("~")) { - return path.join(os.homedir(), normalized.slice(1)); - } - return normalized; -} - -function resolvePath(extPath: string, cwd: string): string { - const expanded = expandPath(extPath); - if (path.isAbsolute(expanded)) { - return expanded; - } - return path.resolve(cwd, expanded); -} - type HandlerFn = (...args: unknown[]) => Promise; /** @@ -396,7 +371,7 @@ async function loadExtension( eventBus: EventBus, runtime: ExtensionRuntime, ): Promise<{ extension: Extension | null; error: string | null }> { - const resolvedPath = resolvePath(extensionPath, cwd); + const resolvedPath = resolvePath(extensionPath, cwd, { normalizeUnicodeSpaces: true }); try { const factory = await loadExtensionModule(resolvedPath); @@ -426,7 +401,8 @@ export async function loadExtensionFromFactory( extensionPath = "", ): Promise { const extension = createExtension(extensionPath, extensionPath); - const api = createExtensionAPI(extension, runtime, cwd, eventBus); + const resolvedCwd = resolvePath(cwd); + const api = createExtensionAPI(extension, runtime, resolvedCwd, eventBus); await factory(api); return extension; } @@ -437,11 +413,12 @@ export async function loadExtensionFromFactory( export async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise { const extensions: Extension[] = []; const errors: Array<{ path: string; error: string }> = []; + const resolvedCwd = resolvePath(cwd); const resolvedEventBus = eventBus ?? createEventBus(); const runtime = createExtensionRuntime(); for (const extPath of paths) { - const { extension, error } = await loadExtension(extPath, cwd, resolvedEventBus, runtime); + const { extension, error } = await loadExtension(extPath, resolvedCwd, resolvedEventBus, runtime); if (error) { errors.push({ path: extPath, error }); @@ -578,6 +555,8 @@ export async function discoverAndLoadExtensions( agentDir: string = getAgentDir(), eventBus?: EventBus, ): Promise { + const resolvedCwd = resolvePath(cwd); + const resolvedAgentDir = resolvePath(agentDir); const allPaths: string[] = []; const seen = new Set(); @@ -592,16 +571,16 @@ export async function discoverAndLoadExtensions( }; // 1. Project-local extensions: cwd/${CONFIG_DIR_NAME}/extensions/ - const localExtDir = path.join(cwd, CONFIG_DIR_NAME, "extensions"); + const localExtDir = path.join(resolvedCwd, CONFIG_DIR_NAME, "extensions"); addPaths(discoverExtensionsInDir(localExtDir)); // 2. Global extensions: agentDir/extensions/ - const globalExtDir = path.join(agentDir, "extensions"); + const globalExtDir = path.join(resolvedAgentDir, "extensions"); addPaths(discoverExtensionsInDir(globalExtDir)); // 3. Explicitly configured paths for (const p of configuredPaths) { - const resolved = resolvePath(p, cwd); + const resolved = resolvePath(p, resolvedCwd, { normalizeUnicodeSpaces: true }); if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) { // Check for package.json with pi manifest or index.ts const entries = resolveExtensionEntries(resolved); @@ -617,5 +596,5 @@ export async function discoverAndLoadExtensions( addPaths([resolved]); } - return loadExtensions(allPaths, cwd, eventBus); + return loadExtensions(allPaths, resolvedCwd, eventBus); } diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index 4d0404632..6e3a10313 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -25,6 +25,7 @@ import { type Static, Type } from "typebox"; import { Compile } from "typebox/compile"; import type { TLocalizedValidationError } from "typebox/error"; import { getAgentDir } from "../config.ts"; +import { normalizePath } from "../utils/paths.ts"; import type { AuthStatus, AuthStorage } from "./auth-storage.ts"; import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "./provider-display-names.ts"; import { @@ -339,7 +340,7 @@ export class ModelRegistry { private constructor(authStorage: AuthStorage, modelsJsonPath: string | undefined) { this.authStorage = authStorage; - this.modelsJsonPath = modelsJsonPath; + this.modelsJsonPath = modelsJsonPath ? normalizePath(modelsJsonPath) : undefined; this.loadModels(); } diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index b8c333bcf..994adb566 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -30,7 +30,7 @@ import { minimatch } from "minimatch"; import { CONFIG_DIR_NAME } from "../config.ts"; import { spawnProcess, spawnProcessSync } from "../utils/child-process.ts"; import { type GitSource, parseGitUrl } from "../utils/git.ts"; -import { canonicalizePath, isLocalPath, markPathIgnoredByCloudSync } from "../utils/paths.ts"; +import { canonicalizePath, isLocalPath, markPathIgnoredByCloudSync, resolvePath } from "../utils/paths.ts"; import { isStdoutTakenOver } from "./output-guard.ts"; import type { PackageSource, SettingsManager } from "./settings-manager.ts"; @@ -763,8 +763,8 @@ export class DefaultPackageManager implements PackageManager { private progressCallback: ProgressCallback | undefined; constructor(options: PackageManagerOptions) { - this.cwd = options.cwd; - this.agentDir = options.agentDir; + this.cwd = resolvePath(options.cwd); + this.agentDir = resolvePath(options.agentDir); this.settingsManager = options.settingsManager; } @@ -1968,19 +1968,11 @@ export class DefaultPackageManager implements PackageManager { } private resolvePath(input: string): string { - const trimmed = input.trim(); - if (trimmed === "~") return getHomeDir(); - if (trimmed.startsWith("~/")) return join(getHomeDir(), trimmed.slice(2)); - if (trimmed.startsWith("~")) return join(getHomeDir(), trimmed.slice(1)); - return resolve(this.cwd, trimmed); + return resolvePath(input, this.cwd, { homeDir: getHomeDir(), trim: true }); } private resolvePathFromBase(input: string, baseDir: string): string { - const trimmed = input.trim(); - if (trimmed === "~") return getHomeDir(); - if (trimmed.startsWith("~/")) return join(getHomeDir(), trimmed.slice(2)); - if (trimmed.startsWith("~")) return join(getHomeDir(), trimmed.slice(1)); - return resolve(baseDir, trimmed); + return resolvePath(input, baseDir, { homeDir: getHomeDir(), trim: true }); } private collectPackageResources( diff --git a/packages/coding-agent/src/core/prompt-templates.ts b/packages/coding-agent/src/core/prompt-templates.ts index b646896d2..581a34eb9 100644 --- a/packages/coding-agent/src/core/prompt-templates.ts +++ b/packages/coding-agent/src/core/prompt-templates.ts @@ -1,8 +1,8 @@ import { existsSync, readdirSync, readFileSync, statSync } from "fs"; -import { homedir } from "os"; -import { basename, dirname, isAbsolute, join, resolve, sep } from "path"; +import { basename, dirname, join, resolve, sep } from "path"; import { CONFIG_DIR_NAME } from "../config.ts"; import { parseFrontmatter } from "../utils/frontmatter.ts"; +import { resolvePath } from "../utils/paths.ts"; import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts"; /** @@ -185,19 +185,6 @@ export interface LoadPromptTemplatesOptions { includeDefaults: boolean; } -function normalizePath(input: string): string { - const trimmed = input.trim(); - if (trimmed === "~") return homedir(); - if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2)); - if (trimmed.startsWith("~")) return join(homedir(), trimmed.slice(1)); - return trimmed; -} - -function resolvePromptPath(p: string, cwd: string): string { - const normalized = normalizePath(p); - return isAbsolute(normalized) ? normalized : resolve(cwd, normalized); -} - /** * Load all prompt templates from: * 1. Global: agentDir/prompts/ @@ -205,14 +192,14 @@ function resolvePromptPath(p: string, cwd: string): string { * 3. Explicit prompt paths */ export function loadPromptTemplates(options: LoadPromptTemplatesOptions): PromptTemplate[] { - const resolvedCwd = options.cwd; - const resolvedAgentDir = options.agentDir; + const resolvedCwd = resolvePath(options.cwd); + const resolvedAgentDir = resolvePath(options.agentDir); const promptPaths = options.promptPaths; const includeDefaults = options.includeDefaults; const templates: PromptTemplate[] = []; - const globalPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir; + const globalPromptsDir = join(resolvedAgentDir, "prompts"); const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts"); const isUnderPath = (target: string, root: string): boolean => { @@ -252,7 +239,7 @@ export function loadPromptTemplates(options: LoadPromptTemplatesOptions): Prompt // 3. Load explicit prompt paths for (const rawPath of promptPaths) { - const resolvedPath = resolvePromptPath(rawPath, resolvedCwd); + const resolvedPath = resolvePath(rawPath, resolvedCwd, { trim: true }); if (!existsSync(resolvedPath)) { continue; } diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index 57d90f97b..eb043cd56 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -1,5 +1,4 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; -import { homedir } from "node:os"; import { join, resolve, sep } from "node:path"; import chalk from "chalk"; import { CONFIG_DIR_NAME } from "../config.ts"; @@ -8,7 +7,7 @@ import type { ResourceDiagnostic } from "./diagnostics.ts"; export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.ts"; -import { canonicalizePath, isLocalPath } from "../utils/paths.ts"; +import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts"; import { createEventBus, type EventBus } from "./event-bus.ts"; import { createExtensionRuntime, loadExtensionFromFactory, loadExtensions } from "./extensions/loader.ts"; import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.ts"; @@ -77,8 +76,8 @@ export function loadProjectContextFiles(options: { cwd: string; agentDir: string; }): Array<{ path: string; content: string }> { - const resolvedCwd = options.cwd; - const resolvedAgentDir = options.agentDir; + const resolvedCwd = resolvePath(options.cwd); + const resolvedAgentDir = resolvePath(options.agentDir); const contextFiles: Array<{ path: string; content: string }> = []; const seenPaths = new Set(); @@ -205,8 +204,8 @@ export class DefaultResourceLoader implements ResourceLoader { private lastThemePaths: string[]; constructor(options: DefaultResourceLoaderOptions) { - this.cwd = options.cwd; - this.agentDir = options.agentDir; + this.cwd = resolvePath(options.cwd); + this.agentDir = resolvePath(options.agentDir); this.settingsManager = options.settingsManager ?? SettingsManager.create(this.cwd, this.agentDir); this.eventBus = options.eventBus ?? createEventBus(); this.packageManager = new DefaultPackageManager({ @@ -409,8 +408,11 @@ export class DefaultResourceLoader implements ResourceLoader { } for (const p of this.additionalExtensionPaths) { - if (isLocalPath(p) && !existsSync(p)) { - extensionsResult.errors.push({ path: p, error: `Extension path does not exist: ${p}` }); + if (isLocalPath(p)) { + const resolved = this.resolveResourcePath(p); + if (!existsSync(resolved)) { + extensionsResult.errors.push({ path: resolved, error: `Extension path does not exist: ${resolved}` }); + } } } this.extensionsResult = this.extensionsOverride ? this.extensionsOverride(extensionsResult) : extensionsResult; @@ -423,8 +425,11 @@ export class DefaultResourceLoader implements ResourceLoader { this.lastSkillPaths = skillPaths; this.updateSkillsFromPaths(skillPaths, metadataByPath); for (const p of this.additionalSkillPaths) { - if (isLocalPath(p) && !existsSync(p) && !this.skillDiagnostics.some((d) => d.path === p)) { - this.skillDiagnostics.push({ type: "error", message: "Skill path does not exist", path: p }); + if (isLocalPath(p)) { + const resolved = this.resolveResourcePath(p); + if (!existsSync(resolved) && !this.skillDiagnostics.some((d) => d.path === resolved)) { + this.skillDiagnostics.push({ type: "error", message: "Skill path does not exist", path: resolved }); + } } } @@ -435,8 +440,15 @@ export class DefaultResourceLoader implements ResourceLoader { this.lastPromptPaths = promptPaths; this.updatePromptsFromPaths(promptPaths, metadataByPath); for (const p of this.additionalPromptTemplatePaths) { - if (isLocalPath(p) && !existsSync(p) && !this.promptDiagnostics.some((d) => d.path === p)) { - this.promptDiagnostics.push({ type: "error", message: "Prompt template path does not exist", path: p }); + if (isLocalPath(p)) { + const resolved = this.resolveResourcePath(p); + if (!existsSync(resolved) && !this.promptDiagnostics.some((d) => d.path === resolved)) { + this.promptDiagnostics.push({ + type: "error", + message: "Prompt template path does not exist", + path: resolved, + }); + } } } @@ -447,8 +459,9 @@ export class DefaultResourceLoader implements ResourceLoader { this.lastThemePaths = themePaths; this.updateThemesFromPaths(themePaths, metadataByPath); for (const p of this.additionalThemePaths) { - if (!existsSync(p) && !this.themeDiagnostics.some((d) => d.path === p)) { - this.themeDiagnostics.push({ type: "error", message: "Theme path does not exist", path: p }); + const resolved = this.resolveResourcePath(p); + if (!existsSync(resolved) && !this.themeDiagnostics.some((d) => d.path === resolved)) { + this.themeDiagnostics.push({ type: "error", message: "Theme path does not exist", path: resolved }); } } @@ -478,10 +491,15 @@ export class DefaultResourceLoader implements ResourceLoader { private normalizeExtensionPaths( entries: Array<{ path: string; metadata: PathMetadata }>, ): Array<{ path: string; metadata: PathMetadata }> { - return entries.map((entry) => ({ - path: this.resolveResourcePath(entry.path), - metadata: entry.metadata, - })); + return entries.map((entry) => { + const metadata = entry.metadata.baseDir + ? { ...entry.metadata, baseDir: this.resolveResourcePath(entry.metadata.baseDir) } + : entry.metadata; + return { + path: this.resolveResourcePath(entry.path), + metadata, + }; + }); } private updateSkillsFromPaths(skillPaths: string[], metadataByPath?: Map): void { @@ -674,16 +692,7 @@ export class DefaultResourceLoader implements ResourceLoader { } private resolveResourcePath(p: string): string { - const trimmed = p.trim(); - let expanded = trimmed; - if (trimmed === "~") { - expanded = homedir(); - } else if (trimmed.startsWith("~/")) { - expanded = join(homedir(), trimmed.slice(2)); - } else if (trimmed.startsWith("~")) { - expanded = join(homedir(), trimmed.slice(1)); - } - return resolve(this.cwd, expanded); + return resolvePath(p, this.cwd, { trim: true }); } private loadThemes( @@ -704,7 +713,7 @@ export class DefaultResourceLoader implements ResourceLoader { } for (const p of paths) { - const resolved = resolve(this.cwd, p); + const resolved = this.resolveResourcePath(p); if (!existsSync(resolved)) { diagnostics.push({ type: "warning", message: "theme path does not exist", path: resolved }); continue; diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 7caa0fa11..f19581e41 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -2,6 +2,7 @@ import { join } from "node:path"; import { Agent, type AgentMessage, type ThinkingLevel } from "@earendil-works/pi-agent-core"; import { clampThinkingLevel, type Message, type Model, streamSimple } from "@earendil-works/pi-ai"; import { getAgentDir } from "../config.ts"; +import { resolvePath } from "../utils/paths.ts"; import { AgentSession } from "./agent-session.ts"; import { formatNoModelsAvailableMessage } from "./auth-guidance.ts"; import { AuthStorage } from "./auth-storage.ts"; @@ -191,8 +192,8 @@ function getAttributionHeaders( * ``` */ export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise { - const cwd = options.cwd ?? options.sessionManager?.getCwd() ?? process.cwd(); - const agentDir = options.agentDir ?? getDefaultAgentDir(); + const cwd = resolvePath(options.cwd ?? options.sessionManager?.getCwd() ?? process.cwd()); + const agentDir = options.agentDir ? resolvePath(options.agentDir) : getDefaultAgentDir(); let resourceLoader = options.resourceLoader; // Use provided or create AuthStorage and ModelRegistry diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 533916f34..e7d123141 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -16,6 +16,7 @@ import { import { readdir, readFile, stat } from "fs/promises"; import { join, resolve } from "path"; import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts"; +import { normalizePath, resolvePath } from "../utils/paths.ts"; import { type BashExecutionMessage, type CustomMessage, @@ -425,8 +426,10 @@ export function buildSessionContext( * Encodes cwd into a safe directory name under ~/.pi/agent/sessions/. */ export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string { - const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; - const sessionDir = join(agentDir, "sessions", safePath); + const resolvedCwd = resolvePath(cwd); + const resolvedAgentDir = resolvePath(agentDir); + const safePath = `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; + const sessionDir = join(resolvedAgentDir, "sessions", safePath); if (!existsSync(sessionDir)) { mkdirSync(sessionDir, { recursive: true }); } @@ -435,9 +438,10 @@ export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultA /** Exported for testing */ export function loadEntriesFromFile(filePath: string): FileEntry[] { - if (!existsSync(filePath)) return []; + const resolvedFilePath = normalizePath(filePath); + if (!existsSync(resolvedFilePath)) return []; - const content = readFileSync(filePath, "utf8"); + const content = readFileSync(resolvedFilePath, "utf8"); const entries: FileEntry[] = []; const lines = content.trim().split("\n"); @@ -478,10 +482,11 @@ function isValidSessionFile(filePath: string): boolean { /** Exported for testing */ export function findMostRecentSession(sessionDir: string): string | null { + const resolvedSessionDir = normalizePath(sessionDir); try { - const files = readdirSync(sessionDir) + const files = readdirSync(resolvedSessionDir) .filter((f) => f.endsWith(".jsonl")) - .map((f) => join(sessionDir, f)) + .map((f) => join(resolvedSessionDir, f)) .filter(isValidSessionFile) .map((path) => ({ path, mtime: statSync(path).mtime })) .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); @@ -717,11 +722,11 @@ export class SessionManager { private leafId: string | null = null; private constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) { - this.cwd = cwd; - this.sessionDir = sessionDir; + this.cwd = resolvePath(cwd); + this.sessionDir = normalizePath(sessionDir); this.persist = persist; - if (persist && sessionDir && !existsSync(sessionDir)) { - mkdirSync(sessionDir, { recursive: true }); + if (persist && this.sessionDir && !existsSync(this.sessionDir)) { + mkdirSync(this.sessionDir, { recursive: true }); } if (sessionFile) { @@ -733,7 +738,7 @@ export class SessionManager { /** Switch to a different session file (used for resume and branching) */ setSessionFile(sessionFile: string): void { - this.sessionFile = resolve(sessionFile); + this.sessionFile = resolvePath(sessionFile); if (existsSync(this.sessionFile)) { this.fileEntries = loadEntriesFromFile(this.sessionFile); @@ -1304,7 +1309,7 @@ export class SessionManager { * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions//). */ static create(cwd: string, sessionDir?: string): SessionManager { - const dir = sessionDir ?? getDefaultSessionDir(cwd); + const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd); return new SessionManager(cwd, dir, undefined, true); } @@ -1315,13 +1320,14 @@ export class SessionManager { * @param cwdOverride Optional cwd override instead of the session header cwd. */ static open(path: string, sessionDir?: string, cwdOverride?: string): SessionManager { + const resolvedPath = resolvePath(path); // Extract cwd from session header if possible, otherwise use process.cwd() - const entries = loadEntriesFromFile(path); + const entries = loadEntriesFromFile(resolvedPath); const header = entries.find((e) => e.type === "session") as SessionHeader | undefined; const cwd = cwdOverride ?? header?.cwd ?? process.cwd(); // If no sessionDir provided, derive from file's parent directory - const dir = sessionDir ?? resolve(path, ".."); - return new SessionManager(cwd, dir, path, true); + const dir = sessionDir ? normalizePath(sessionDir) : resolve(resolvedPath, ".."); + return new SessionManager(cwd, dir, resolvedPath, true); } /** @@ -1330,7 +1336,7 @@ export class SessionManager { * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions//). */ static continueRecent(cwd: string, sessionDir?: string): SessionManager { - const dir = sessionDir ?? getDefaultSessionDir(cwd); + const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd); const mostRecent = findMostRecentSession(dir); if (mostRecent) { return new SessionManager(cwd, dir, mostRecent, true); @@ -1351,17 +1357,19 @@ export class SessionManager { * @param sessionDir Optional session directory. If omitted, uses default for targetCwd. */ static forkFrom(sourcePath: string, targetCwd: string, sessionDir?: string): SessionManager { - const sourceEntries = loadEntriesFromFile(sourcePath); + const resolvedSourcePath = resolvePath(sourcePath); + const resolvedTargetCwd = resolvePath(targetCwd); + const sourceEntries = loadEntriesFromFile(resolvedSourcePath); if (sourceEntries.length === 0) { - throw new Error(`Cannot fork: source session file is empty or invalid: ${sourcePath}`); + throw new Error(`Cannot fork: source session file is empty or invalid: ${resolvedSourcePath}`); } const sourceHeader = sourceEntries.find((e) => e.type === "session") as SessionHeader | undefined; if (!sourceHeader) { - throw new Error(`Cannot fork: source session has no header: ${sourcePath}`); + throw new Error(`Cannot fork: source session has no header: ${resolvedSourcePath}`); } - const dir = sessionDir ?? getDefaultSessionDir(targetCwd); + const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(resolvedTargetCwd); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } @@ -1378,8 +1386,8 @@ export class SessionManager { version: CURRENT_SESSION_VERSION, id: newSessionId, timestamp, - cwd: targetCwd, - parentSession: sourcePath, + cwd: resolvedTargetCwd, + parentSession: resolvedSourcePath, }; appendFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`); @@ -1390,7 +1398,7 @@ export class SessionManager { } } - return new SessionManager(targetCwd, dir, newSessionFile, true); + return new SessionManager(resolvedTargetCwd, dir, newSessionFile, true); } /** @@ -1400,7 +1408,7 @@ export class SessionManager { * @param onProgress Optional callback for progress updates (loaded, total) */ static async list(cwd: string, sessionDir?: string, onProgress?: SessionListProgress): Promise { - const dir = sessionDir ?? getDefaultSessionDir(cwd); + const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd); const sessions = await listSessionsFromDir(dir, onProgress); sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); return sessions; diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 2fedad359..40f97cd32 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -1,9 +1,9 @@ import type { Transport } from "@earendil-works/pi-ai"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; -import { homedir } from "os"; import { dirname, join } from "path"; import lockfile from "proper-lockfile"; import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts"; +import { normalizePath, resolvePath } from "../utils/paths.ts"; import { DEFAULT_HTTP_IDLE_TIMEOUT_MS, parseHttpIdleTimeoutMs } from "./http-dispatcher.ts"; export interface CompactionSettings { @@ -161,8 +161,10 @@ export class FileSettingsStorage implements SettingsStorage { private projectSettingsPath: string; constructor(cwd: string, agentDir: string) { - this.globalSettingsPath = join(agentDir, "settings.json"); - this.projectSettingsPath = join(cwd, CONFIG_DIR_NAME, "settings.json"); + const resolvedCwd = resolvePath(cwd); + const resolvedAgentDir = resolvePath(agentDir); + this.globalSettingsPath = join(resolvedAgentDir, "settings.json"); + this.projectSettingsPath = join(resolvedCwd, CONFIG_DIR_NAME, "settings.json"); } private acquireLockSyncWithRetry(path: string): () => void { @@ -577,16 +579,7 @@ export class SettingsManager { getSessionDir(): string | undefined { const sessionDir = this.settings.sessionDir; - if (!sessionDir) { - return sessionDir; - } - if (sessionDir === "~") { - return homedir(); - } - if (sessionDir.startsWith("~/")) { - return join(homedir(), sessionDir.slice(2)); - } - return sessionDir; + return sessionDir ? normalizePath(sessionDir) : sessionDir; } getDefaultProvider(): string | undefined { diff --git a/packages/coding-agent/src/core/skills.ts b/packages/coding-agent/src/core/skills.ts index 675da3a78..c104c8564 100644 --- a/packages/coding-agent/src/core/skills.ts +++ b/packages/coding-agent/src/core/skills.ts @@ -1,10 +1,9 @@ import { existsSync, readdirSync, readFileSync, statSync } from "fs"; import ignore from "ignore"; -import { homedir } from "os"; -import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "path"; +import { basename, dirname, join, relative, resolve, sep } from "path"; import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts"; import { parseFrontmatter } from "../utils/frontmatter.ts"; -import { canonicalizePath } from "../utils/paths.ts"; +import { canonicalizePath, resolvePath } from "../utils/paths.ts"; import type { ResourceDiagnostic } from "./diagnostics.ts"; import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts"; @@ -381,28 +380,16 @@ export interface LoadSkillsOptions { includeDefaults: boolean; } -function normalizePath(input: string): string { - const trimmed = input.trim(); - if (trimmed === "~") return homedir(); - if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2)); - if (trimmed.startsWith("~")) return join(homedir(), trimmed.slice(1)); - return trimmed; -} - -function resolveSkillPath(p: string, cwd: string): string { - const normalized = normalizePath(p); - return isAbsolute(normalized) ? normalized : resolve(cwd, normalized); -} - /** * Load skills from all configured locations. * Returns skills and any validation diagnostics. */ export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult { - const { cwd, agentDir, skillPaths, includeDefaults } = options; + const { agentDir, skillPaths, includeDefaults } = options; // Resolve agentDir - if not provided, use default from config - const resolvedAgentDir = agentDir ?? getAgentDir(); + const resolvedCwd = resolvePath(options.cwd); + const resolvedAgentDir = resolvePath(agentDir ?? getAgentDir()); const skillMap = new Map(); const realPathSet = new Set(); @@ -442,11 +429,11 @@ export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult { if (includeDefaults) { addSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", true)); - addSkills(loadSkillsFromDirInternal(resolve(cwd, CONFIG_DIR_NAME, "skills"), "project", true)); + addSkills(loadSkillsFromDirInternal(resolve(resolvedCwd, CONFIG_DIR_NAME, "skills"), "project", true)); } const userSkillsDir = join(resolvedAgentDir, "skills"); - const projectSkillsDir = resolve(cwd, CONFIG_DIR_NAME, "skills"); + const projectSkillsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "skills"); const isUnderPath = (target: string, root: string): boolean => { const normalizedRoot = resolve(root); @@ -466,7 +453,7 @@ export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult { }; for (const rawPath of skillPaths) { - const resolvedPath = resolveSkillPath(rawPath, cwd); + const resolvedPath = resolvePath(rawPath, resolvedCwd, { trim: true }); if (!existsSync(resolvedPath)) { allDiagnostics.push({ type: "warning", message: "skill path does not exist", path: resolvedPath }); continue; diff --git a/packages/coding-agent/src/core/tools/path-utils.ts b/packages/coding-agent/src/core/tools/path-utils.ts index 0c10b52ec..1ed1e2bc9 100644 --- a/packages/coding-agent/src/core/tools/path-utils.ts +++ b/packages/coding-agent/src/core/tools/path-utils.ts @@ -1,12 +1,7 @@ import { accessSync, constants } from "node:fs"; -import * as os from "node:os"; -import { isAbsolute, resolve as resolvePath } from "node:path"; +import { normalizePath, resolvePath } from "../../utils/paths.ts"; -const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; const NARROW_NO_BREAK_SPACE = "\u202F"; -function normalizeUnicodeSpaces(str: string): string { - return str.replace(UNICODE_SPACES, " "); -} function tryMacOSScreenshotPath(filePath: string): string { return filePath.replace(/ (AM|PM)\./gi, `${NARROW_NO_BREAK_SPACE}$1.`); @@ -32,19 +27,8 @@ function fileExists(filePath: string): boolean { } } -function normalizeAtPrefix(filePath: string): string { - return filePath.startsWith("@") ? filePath.slice(1) : filePath; -} - export function expandPath(filePath: string): string { - const normalized = normalizeUnicodeSpaces(normalizeAtPrefix(filePath)); - if (normalized === "~") { - return os.homedir(); - } - if (normalized.startsWith("~/")) { - return os.homedir() + normalized.slice(1); - } - return normalized; + return normalizePath(filePath, { normalizeUnicodeSpaces: true, stripAtPrefix: true }); } /** @@ -52,11 +36,7 @@ export function expandPath(filePath: string): string { * Handles ~ expansion and absolute paths. */ export function resolveToCwd(filePath: string, cwd: string): string { - const expanded = expandPath(filePath); - if (isAbsolute(expanded)) { - return expanded; - } - return resolvePath(cwd, expanded); + return resolvePath(filePath, cwd, { normalizeUnicodeSpaces: true, stripAtPrefix: true }); } export function resolveReadPath(filePath: string, cwd: string): string { diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 47f53c3db..5395e0451 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -5,7 +5,6 @@ * createAgentSession() options. The SDK does the heavy lifting. */ -import { resolve } from "node:path"; import { createInterface } from "node:readline"; import { type ImageContent, modelsAreEqual } from "@earendil-works/pi-ai"; import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui"; @@ -46,7 +45,7 @@ import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts"; import { ExtensionSelectorComponent } from "./modes/interactive/components/extension-selector.ts"; import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts"; import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts"; -import { isLocalPath } from "./utils/paths.ts"; +import { isLocalPath, normalizePath, resolvePath } from "./utils/paths.ts"; import { cleanupWindowsSelfUpdateQuarantine } from "./utils/windows-self-update.ts"; /** @@ -147,9 +146,9 @@ type ResolvedSession = * If it looks like a path, use as-is. Otherwise try to match as session ID prefix. */ async function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: string): Promise { - // If it looks like a file path, use as-is + // If it looks like a file path, resolve it before handing it to the session manager. if (sessionArg.includes("/") || sessionArg.includes("\\") || sessionArg.endsWith(".jsonl")) { - return { type: "path", path: sessionArg }; + return { type: "path", path: resolvePath(sessionArg, cwd) }; } // Try to match as session ID in current project first @@ -381,7 +380,7 @@ function buildSessionOptions( } function resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | undefined { - return paths?.map((value) => (isLocalPath(value) ? resolve(cwd, value) : value)); + return paths?.map((value) => (isLocalPath(value) ? resolvePath(value, cwd) : value)); } async function promptForMissingSessionCwd( @@ -501,7 +500,7 @@ export async function main(args: string[], options?: MainOptions) { // sessionDir lookup during session selection. const envSessionDir = process.env[ENV_SESSION_DIR]; const sessionDir = - parsed.sessionDir ?? + (parsed.sessionDir ? normalizePath(parsed.sessionDir) : undefined) ?? (envSessionDir ? expandTildePath(envSessionDir) : undefined) ?? startupSettingsManager.getSessionDir(); let sessionManager = await createSessionManager(parsed, cwd, sessionDir, startupSettingsManager); diff --git a/packages/coding-agent/src/utils/paths.ts b/packages/coding-agent/src/utils/paths.ts index fd8d176e7..7f1013446 100644 --- a/packages/coding-agent/src/utils/paths.ts +++ b/packages/coding-agent/src/utils/paths.ts @@ -1,7 +1,24 @@ import { realpathSync } from "node:fs"; -import { isAbsolute, relative, resolve as resolvePath, sep } from "node:path"; +import { homedir } from "node:os"; +import { isAbsolute, join, resolve as nodeResolvePath, relative, sep } from "node:path"; +import { fileURLToPath } from "node:url"; import { spawnProcessSync } from "./child-process.ts"; +const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; + +export interface PathInputOptions { + /** Trim leading/trailing whitespace before normalization. */ + trim?: boolean; + /** Expand leading `~` to a home directory. Defaults to true. */ + expandTilde?: boolean; + /** Home directory used for `~` expansion. Defaults to `os.homedir()`. */ + homeDir?: string; + /** Strip a leading `@`, used for CLI @file paths. */ + stripAtPrefix?: boolean; + /** Normalize unicode space variants to regular spaces. */ + normalizeUnicodeSpaces?: boolean; +} + /** * Resolve a path to its canonical (real) form, following symlinks. * Falls back to the raw path if resolution fails (e.g. the target does @@ -18,12 +35,12 @@ export function canonicalizePath(path: string): string { /** * Returns true if the value is NOT a package source (npm:, git:, etc.) - * or a URL protocol. Bare names and relative paths without ./ prefix + * or a remote URL protocol. Bare names, relative paths, and file: URLs * are considered local. */ export function isLocalPath(value: string): boolean { const trimmed = value.trim(); - // Known non-local prefixes + // Known non-local prefixes. file: URLs are local paths and are intentionally resolved by resolvePath(). if ( trimmed.startsWith("npm:") || trimmed.startsWith("git:") || @@ -37,13 +54,39 @@ export function isLocalPath(value: string): boolean { return true; } -function resolveAgainstCwd(filePath: string, cwd: string): string { - return isAbsolute(filePath) ? resolvePath(filePath) : resolvePath(cwd, filePath); +export function normalizePath(input: string, options: PathInputOptions = {}): string { + let normalized = options.trim ? input.trim() : input; + if (options.normalizeUnicodeSpaces) { + normalized = normalized.replace(UNICODE_SPACES, " "); + } + if (options.stripAtPrefix && normalized.startsWith("@")) { + normalized = normalized.slice(1); + } + + if (options.expandTilde ?? true) { + const home = options.homeDir ?? homedir(); + if (normalized === "~") return home; + if (normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))) { + return join(home, normalized.slice(2)); + } + } + + if (/^file:\/\//.test(normalized)) { + return fileURLToPath(normalized); + } + + return normalized; +} + +export function resolvePath(input: string, baseDir: string = process.cwd(), options: PathInputOptions = {}): string { + const normalized = normalizePath(input, options); + const normalizedBaseDir = normalizePath(baseDir); + return isAbsolute(normalized) ? nodeResolvePath(normalized) : nodeResolvePath(normalizedBaseDir, normalized); } export function getCwdRelativePath(filePath: string, cwd: string): string | undefined { const resolvedCwd = resolvePath(cwd); - const resolvedPath = resolveAgainstCwd(filePath, resolvedCwd); + const resolvedPath = resolvePath(filePath, resolvedCwd); const relativePath = relative(resolvedCwd, resolvedPath); const isInsideCwd = relativePath === "" || @@ -53,7 +96,7 @@ export function getCwdRelativePath(filePath: string, cwd: string): string | unde } export function formatPathRelativeToCwdOrAbsolute(filePath: string, cwd: string): string { - const absolutePath = resolveAgainstCwd(filePath, cwd); + const absolutePath = resolvePath(filePath, cwd); return (getCwdRelativePath(absolutePath, cwd) ?? absolutePath).split(sep).join("/"); } diff --git a/packages/coding-agent/test/extensions-discovery.test.ts b/packages/coding-agent/test/extensions-discovery.test.ts index 3613e9026..b1b1232f9 100644 --- a/packages/coding-agent/test/extensions-discovery.test.ts +++ b/packages/coding-agent/test/extensions-discovery.test.ts @@ -123,6 +123,31 @@ describe("extensions discovery", () => { expect(result.extensions[0].path).toContain("main.ts"); }); + it("keeps package.json pi extension entries with leading tilde package-relative", async () => { + const subdir = path.join(extensionsDir, "tilde-package"); + const directExtensionPath = path.join(subdir, "~entry.ts"); + const slashExtensionPath = path.join(subdir, "~", "entry.ts"); + fs.mkdirSync(path.join(subdir, "~"), { recursive: true }); + fs.writeFileSync(directExtensionPath, extensionCode); + fs.writeFileSync(slashExtensionPath, extensionCode); + fs.writeFileSync( + path.join(subdir, "package.json"), + JSON.stringify({ + name: "tilde-package", + pi: { + extensions: ["~entry.ts", "~/entry.ts"], + }, + }), + ); + + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions.map((extension) => extension.path).sort()).toEqual( + [directExtensionPath, slashExtensionPath].sort(), + ); + }); + it("package.json can declare multiple extensions", async () => { const subdir = path.join(extensionsDir, "my-package"); fs.mkdirSync(subdir); diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 9f3476a87..30068da1e 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -584,6 +584,40 @@ Content`, ); }); + it("should keep pi manifest entries with leading tilde package-relative", async () => { + const pkgDir = join(tempDir, "tilde-manifest-package"); + const directExtensionPath = join(pkgDir, "~extensions", "main.ts"); + const slashExtensionPath = join(pkgDir, "~", "extensions", "alt.ts"); + const directSkillPath = join(pkgDir, "~skills", "direct-skill", "SKILL.md"); + const slashSkillPath = join(pkgDir, "~", "skills", "slash-skill", "SKILL.md"); + + mkdirSync(join(pkgDir, "~extensions"), { recursive: true }); + mkdirSync(join(pkgDir, "~", "extensions"), { recursive: true }); + mkdirSync(join(pkgDir, "~skills", "direct-skill"), { recursive: true }); + mkdirSync(join(pkgDir, "~", "skills", "slash-skill"), { recursive: true }); + writeFileSync(directExtensionPath, "export default function() {}"); + writeFileSync(slashExtensionPath, "export default function() {}"); + writeFileSync(directSkillPath, "---\nname: direct-skill\ndescription: Direct\n---\nContent"); + writeFileSync(slashSkillPath, "---\nname: slash-skill\ndescription: Slash\n---\nContent"); + writeFileSync( + join(pkgDir, "package.json"), + JSON.stringify({ + name: "tilde-manifest-package", + pi: { + extensions: ["~extensions/main.ts", "~/extensions/alt.ts"], + skills: ["~skills", "~/skills"], + }, + }), + ); + + const result = await packageManager.resolveExtensionSources([pkgDir]); + + expect(result.extensions.some((r) => r.path === directExtensionPath && r.enabled)).toBe(true); + expect(result.extensions.some((r) => r.path === slashExtensionPath && r.enabled)).toBe(true); + expect(result.skills.some((r) => r.path === directSkillPath && r.enabled)).toBe(true); + expect(result.skills.some((r) => r.path === slashSkillPath && r.enabled)).toBe(true); + }); + it("should handle directories with auto-discovery layout", async () => { const pkgDir = join(tempDir, "auto-pkg"); mkdirSync(join(pkgDir, "extensions"), { recursive: true }); diff --git a/packages/coding-agent/test/path-utils.test.ts b/packages/coding-agent/test/path-utils.test.ts index 85c2605f4..77a8345fb 100644 --- a/packages/coding-agent/test/path-utils.test.ts +++ b/packages/coding-agent/test/path-utils.test.ts @@ -16,6 +16,11 @@ describe("path-utils", () => { expect(result).not.toContain("~/"); }); + it("should keep tilde-prefixed filenames literal", () => { + expect(expandPath("~draft.md")).toBe("~draft.md"); + expect(expandPath("@~draft.md")).toBe("~draft.md"); + }); + it("should normalize Unicode spaces", () => { // Non-breaking space (U+00A0) should become regular space const withNBSP = "file\u00A0name.txt"; @@ -26,14 +31,21 @@ describe("path-utils", () => { describe("resolveToCwd", () => { it("should resolve absolute paths as-is", () => { - const result = resolveToCwd("/absolute/path/file.txt", "/some/cwd"); - expect(result).toBe("/absolute/path/file.txt"); + const absolutePath = resolve(tmpdir(), "absolute", "path", "file.txt"); + const result = resolveToCwd(absolutePath, resolve(tmpdir(), "some", "cwd")); + expect(result).toBe(absolutePath); }); it("should resolve relative paths against cwd", () => { const result = resolveToCwd("relative/file.txt", "/some/cwd"); expect(result).toBe(resolve("/some/cwd", "relative/file.txt")); }); + + it("should resolve tilde-prefixed filenames against cwd", () => { + const cwd = join(tmpdir(), "pi-path-utils-cwd"); + expect(resolveToCwd("~draft.md", cwd)).toBe(resolve(cwd, "~draft.md")); + expect(resolveToCwd("@~draft.md", cwd)).toBe(resolve(cwd, "~draft.md")); + }); }); describe("resolveReadPath", () => { diff --git a/packages/coding-agent/test/paths.test.ts b/packages/coding-agent/test/paths.test.ts index 01151635d..accf99898 100644 --- a/packages/coding-agent/test/paths.test.ts +++ b/packages/coding-agent/test/paths.test.ts @@ -1,8 +1,9 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { homedir, tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; -import { canonicalizePath, getCwdRelativePath, isLocalPath } from "../src/utils/paths.ts"; +import { canonicalizePath, getCwdRelativePath, isLocalPath, normalizePath, resolvePath } from "../src/utils/paths.ts"; let tempDir: string; @@ -73,6 +74,55 @@ describe("getCwdRelativePath", () => { }); }); +describe("resolvePath", () => { + it("expands only home tilde shortcuts", () => { + const cwd = join(tmpdir(), "pi-paths-cwd"); + expect(normalizePath("~")).toBe(homedir()); + expect(normalizePath("~/file.txt")).toBe(join(homedir(), "file.txt")); + expect(resolvePath("~draft.md", cwd)).toBe(resolve(cwd, "~draft.md")); + expect(normalizePath("~draft.md")).toBe("~draft.md"); + }); + + it("resolves relative paths against the base directory", () => { + const cwd = join(tmpdir(), "pi-paths-cwd"); + expect(resolvePath("subdir/file.txt", cwd)).toBe(resolve(cwd, "subdir/file.txt")); + expect(resolvePath("subdir/file.txt", pathToFileURL(cwd).href)).toBe(resolve(cwd, "subdir/file.txt")); + }); + + it("accepts file URLs", () => { + const dir = createTempDir(); + const filePath = join(dir, "file with spaces.txt"); + expect(resolvePath(pathToFileURL(filePath).href, join(dir, "base"))).toBe(resolve(filePath)); + }); + + it("throws for invalid file URLs", () => { + expect(() => resolvePath("file:///%E0%A4%A")).toThrow(); + }); + + it("preserves POSIX absolute paths with literal percent sequences", () => { + if (process.platform === "win32") { + return; + } + + const dir = createTempDir(); + for (const filePath of [join(dir, "report%2026.md"), join(dir, "foo%2Fbar"), join(dir, "malformed%A.md")]) { + expect(resolvePath(filePath, join(dir, "base"))).toBe(resolve(filePath)); + } + }); + + it("does not treat Windows file URL pathname strings as native paths", () => { + if (process.platform !== "win32") { + return; + } + + const dir = createTempDir(); + const filePath = join(dir, "dir", "SKILL.md"); + const pathname = pathToFileURL(filePath).pathname; + expect(pathname).toMatch(/^\/[A-Za-z]:/); + expect(resolvePath(pathname, "E:\\project")).toBe(resolve(pathname)); + }); +}); + describe("isLocalPath", () => { it("returns true for bare names", () => { expect(isLocalPath("my-package")).toBe(true); @@ -82,6 +132,10 @@ describe("isLocalPath", () => { expect(isLocalPath("./foo")).toBe(true); }); + it("returns true for file URLs", () => { + expect(isLocalPath("file:///tmp/foo")).toBe(true); + }); + it("returns false for npm: protocol", () => { expect(isLocalPath("npm:package")).toBe(false); }); diff --git a/packages/coding-agent/test/resource-loader.test.ts b/packages/coding-agent/test/resource-loader.test.ts index f023855a3..693f05c9e 100644 --- a/packages/coding-agent/test/resource-loader.test.ts +++ b/packages/coding-agent/test/resource-loader.test.ts @@ -1,6 +1,7 @@ import { mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.ts"; import { ExtensionRunner } from "../src/core/extensions/runner.ts"; @@ -405,6 +406,44 @@ Extra prompt content`, expect(loadedPrompt?.sourceInfo?.source).toBe("extension:extra"); expect(loadedPrompt?.sourceInfo?.path).toBe(promptPath); }); + + it("should load extension resources returned as file URLs", async () => { + const extraSkillDir = join(tempDir, "extra skills", "file-url-skill"); + mkdirSync(extraSkillDir, { recursive: true }); + const skillPath = join(extraSkillDir, "SKILL.md"); + writeFileSync( + skillPath, + `--- +name: file-url-skill +description: File URL skill +--- +Extra content`, + ); + + const loader = new DefaultResourceLoader({ cwd, agentDir }); + await loader.reload(); + + loader.extendResources({ + skillPaths: [ + { + path: pathToFileURL(extraSkillDir).href, + metadata: { + source: "extension:file-url", + scope: "temporary", + origin: "top-level", + baseDir: extraSkillDir, + }, + }, + ], + }); + + const { skills, diagnostics } = loader.getSkills(); + expect(diagnostics).toEqual([]); + const loadedSkill = skills.find((skill) => skill.name === "file-url-skill"); + expect(loadedSkill).toBeDefined(); + expect(loadedSkill?.filePath).toBe(skillPath); + expect(loadedSkill?.sourceInfo?.source).toBe("extension:file-url"); + }); }); describe("noSkills option", () => { From 7426ce977d2bbbf7d289d4a897a5e351ce8dfce7 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 22 May 2026 12:04:01 +0200 Subject: [PATCH 19/23] Tighten AGENTS.md and extract LLM provider checklist to skill - Tighten all sections (279 -> 159 lines) without dropping rules. - Reorganize: git rules moved next to issues/PRs. - Defer contributor gate details to CONTRIBUTING.md. - Replace stale npx tsx test command with node (strip-only mode); add ./test.sh + e2e warning. - Releasing: explicit WebAuthn briefing step, no bash timeout, stop on partial publish failure. - Move LLM provider checklist to .pi/skills/add-llm-provider.md. --- .pi/skills/add-llm-provider.md | 57 +++++++ AGENTS.md | 304 ++++++++++----------------------- 2 files changed, 149 insertions(+), 212 deletions(-) create mode 100644 .pi/skills/add-llm-provider.md diff --git a/.pi/skills/add-llm-provider.md b/.pi/skills/add-llm-provider.md new file mode 100644 index 000000000..97cd9a3ec --- /dev/null +++ b/.pi/skills/add-llm-provider.md @@ -0,0 +1,57 @@ +--- +name: add-llm-provider +description: Checklist for adding a new LLM provider to packages/ai. Covers core types, provider implementation, lazy registration, model generation, the full test matrix, coding-agent wiring, and docs. +--- + +# Adding a New LLM Provider (packages/ai) + +A new provider touches multiple files. Work through these steps in order. + +## 1. Core Types (`packages/ai/src/types.ts`) + +- Add API identifier to `Api` type union (e.g. `"bedrock-converse-stream"`). +- Create options interface extending `StreamOptions`. +- Add mapping to `ApiOptionsMap`. +- Add provider name to `KnownProvider` type union. + +## 2. Provider Implementation (`packages/ai/src/providers/`) + +Create a provider file exporting: + +- `stream()` returning `AssistantMessageEventStream`. +- `streamSimple()` for `SimpleStreamOptions` mapping. +- Provider-specific options interface. +- Message/tool conversion functions. +- Response parsing that emits standardized events (`text`, `tool_call`, `thinking`, `usage`, `stop`). + +## 3. Provider Exports and Lazy Registration + +- Add a package subpath export in `packages/ai/package.json` pointing at `./dist/providers/.js`. +- Add `export type` re-exports in `packages/ai/src/index.ts` for provider option types that should remain available from the root entry. +- Register the provider in `packages/ai/src/providers/register-builtins.ts` via lazy loader wrappers; do not statically import provider implementation modules there. +- Add credential detection in `packages/ai/src/env-api-keys.ts`. + +## 4. Model Generation (`packages/ai/scripts/generate-models.ts`) + +- Add logic to fetch/parse models from the provider source. +- Map to the standardized `Model` interface. + +## 5. Tests (`packages/ai/test/`) + +- Always add the provider to `stream.test.ts` with at least one representative model, even if it reuses an existing API impl such as `openai-completions`. +- Add the provider to the broader matrix where applicable: `tokens.test.ts`, `abort.test.ts`, `empty.test.ts`, `context-overflow.test.ts`, `unicode-surrogate.test.ts`, `tool-call-without-result.test.ts`, `image-tool-result.test.ts`, `total-tokens.test.ts`, `cross-provider-handoff.test.ts`. +- For `cross-provider-handoff.test.ts`, add at least one provider/model pair. If the provider exposes multiple model families (e.g. GPT and Claude), add at least one pair per family. +- For non-standard auth, create a utility (e.g. `bedrock-utils.ts`) with credential detection. + +## 6. Coding Agent (`packages/coding-agent/`) + +- `src/core/model-resolver.ts`: add default model ID to `defaultModelPerProvider`. +- `src/core/provider-display-names.ts`: add API-key login display name so `/login` and related UI show the provider for built-in API-key auth. +- `src/cli/args.ts`: add env var documentation. +- `README.md`: add provider setup instructions. +- `docs/providers.md`: add setup instructions, env var, and `auth.json` key. + +## 7. Documentation + +- `packages/ai/README.md`: add to providers table, document options/auth, add env vars. +- `packages/ai/CHANGELOG.md`: add entry under `## [Unreleased]`. diff --git a/AGENTS.md b/AGENTS.md index 10f28ef55..144d2c60a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,202 +4,118 @@ - Keep answers short and concise - No emojis in commits, issues, PR comments, or code -- No fluff or cheerful filler text -- Technical prose only, be kind but direct (e.g., "Thanks @user" not "Thanks so much @user!") +- No fluff or cheerful filler text (e.g., "Thanks @user" not "Thanks so much @user!") +- Technical prose only, be direct - When the user asks a question, answer it first before making edits or running implementation commands. ## Code Quality -- Read files in full before making wide-ranging changes, before editing files you have not already fully inspected, and when the user asks you to investigate or audit something. Do not rely only on search snippets for broad changes. -- No `any` types unless absolutely necessary -- Single-line helper functions with a single call site are forbidden; inline them instead. -- Check node_modules for external API type definitions instead of guessing -- **NEVER use inline imports** - no `await import("./foo.js")`, no `import("pkg").Type` in type positions, no dynamic imports for types. Always use standard top-level imports. -- NEVER remove or downgrade code to fix type errors from outdated dependencies; upgrade the dependency instead -- Use only erasable TypeScript syntax compatible with Node strip-only mode in TypeScript checked by the root config (`packages/*/src`, `packages/*/test`, and `packages/coding-agent/examples`). Do not use constructor parameter properties, `enum`, `namespace`/`module`, `import =`, `export =`, or other TypeScript constructs that require JavaScript emit. Use explicit fields and constructor assignments instead of parameter properties. -- Always ask before removing functionality or code that appears to be intentional -- Do not preserve backward compatibility unless the user explicitly asks for it -- Never hardcode key checks with, eg. `matchesKey(keyData, "ctrl+x")`. All keybindings must be configurable. Add default to matching object (`DEFAULT_EDITOR_KEYBINDINGS` or `DEFAULT_APP_KEYBINDINGS`) -- NEVER modify `packages/ai/src/models.generated.ts` directly. Update `packages/ai/scripts/generate-models.ts` instead. +- Read files in full before wide-ranging changes, before editing files you have not fully inspected, and when asked to investigate or audit. Do not rely on search snippets for broad changes. +- No `any` unless absolutely necessary. +- Inline single-line helpers that have only one call site. +- Check node_modules for external API types; don't guess. +- **No inline imports** (`await import()`, `import("pkg").Type`, dynamic type imports). Top-level imports only. +- Never remove or downgrade code to fix type errors from outdated deps; upgrade the dep instead. +- Use only erasable TypeScript syntax (Node strip-only mode) in code checked by the root config (`packages/*/src`, `packages/*/test`, `packages/coding-agent/examples`): no parameter properties, `enum`, `namespace`/`module`, `import =`, `export =`, or other constructs needing JS emit. Use explicit fields with constructor assignments. +- Always ask before removing functionality or code that appears intentional. +- Do not preserve backward compatibility unless the user asks for it. +- Never hardcode key checks (e.g. `matchesKey(keyData, "ctrl+x")`). Add defaults to `DEFAULT_EDITOR_KEYBINDINGS` or `DEFAULT_APP_KEYBINDINGS` so they stay configurable. +- Never modify `packages/ai/src/models.generated.ts` directly; update `packages/ai/scripts/generate-models.ts` instead. ## Commands -- After code changes (not documentation changes): `npm run check` (get full output, no tail). Fix all errors, warnings, and infos before committing. -- Note: `npm run check` does not run tests. -- NEVER run: `npm run build`, `npm test` -- Only run specific tests if user instructs: `npx tsx ../../node_modules/vitest/dist/cli.js --run test/specific.test.ts` -- Run tests from the package root, not the repo root. -- If you create or modify a test file, you MUST run that test file and iterate until it passes. -- When writing tests, run them, identify issues in either the test or implementation, and iterate until fixed. -- For `packages/coding-agent/test/suite/`, use `test/suite/harness.ts` plus the faux provider. Do not use real provider APIs, real API keys, or paid tokens. -- Put issue-specific regressions under `packages/coding-agent/test/suite/regressions/` and name them `-.test.ts`. -- For ad-hoc scripts, write the script to a temporary file (for example under `/tmp`) using `write`, run that file, edit it if needed, and remove it when it is no longer needed. Do not embed multi-line scripts directly in `bash` commands. -- NEVER commit unless user asks +- After code changes (not docs): `npm run check` (full output, no tail). Fix all errors, warnings, and infos before committing. Does not run tests. +- Never run `npm run build` or `npm test` unless requested by the user. +- Never run the full vitest suite directly: it includes e2e tests that activate when endpoint/auth env vars are present. For all non-e2e tests, run `./test.sh` from the repo root. Otherwise run specific tests from the package root: `node ../../node_modules/vitest/dist/cli.js --run test/specific.test.ts`. +- If you create or modify a test file, run it and iterate on test or implementation until it passes. +- For `packages/coding-agent/test/suite/`, use `test/suite/harness.ts` + the faux provider. No real provider APIs, keys, or paid tokens. +- Put issue-specific regressions under `packages/coding-agent/test/suite/regressions/` named `-.test.ts`. +- For ad-hoc scripts, `write` them to a temp file (e.g. `/tmp`), run, edit if needed, remove when done. Don't embed multi-line scripts in `bash` commands. +- Never commit unless the user asks. ## Dependency and Install Security -- Treat npm dependency and lockfile changes as reviewed code changes. Direct external dependencies must stay pinned to exact versions. -- Use `npm install --ignore-scripts` to hydrate/update `node_modules` locally. Use `npm ci --ignore-scripts` for clean installs/CI-style verification. Do not run lifecycle scripts unless the user explicitly asks. -- If dependency metadata changes, run `npm install --package-lock-only --ignore-scripts` to update `package-lock.json` without installing or running scripts. -- If `packages/coding-agent/npm-shrinkwrap.json` needs regeneration, run `node scripts/generate-coding-agent-shrinkwrap.mjs`; verify with `node scripts/generate-coding-agent-shrinkwrap.mjs --check` or `npm run check`. -- Pre-commit blocks accidental lockfile commits unless `PI_ALLOW_LOCKFILE_CHANGE=1` is set. Do not bypass it unless the user explicitly wants the lockfile change committed. -- New dependencies with lifecycle scripts require review and an explicit allowlist entry in `scripts/generate-coding-agent-shrinkwrap.mjs`; do not add one silently. +- Treat npm dep and lockfile changes as reviewed code. Direct external deps stay pinned to exact versions. +- Hydrate/update locally with `npm install --ignore-scripts`; clean/CI-style with `npm ci --ignore-scripts`. Don't run lifecycle scripts unless the user asks. +- If dep metadata changes, refresh `package-lock.json` with `npm install --package-lock-only --ignore-scripts`. +- If `packages/coding-agent/npm-shrinkwrap.json` needs regen, run `node scripts/generate-coding-agent-shrinkwrap.mjs` (verify with `--check` or `npm run check`). New deps with lifecycle scripts require review and an explicit allowlist entry in that script; never add one silently. +- Pre-commit blocks lockfile commits unless `PI_ALLOW_LOCKFILE_CHANGE=1`. Don't bypass unless the user wants the lockfile change committed. -## Contribution Gate +## Git -- New issues from new contributors are auto-closed by `.github/workflows/issue-gate.yml` -- New PRs from new contributors without PR rights are auto-closed by `.github/workflows/pr-gate.yml` -- Maintainer approval comments are handled by `.github/workflows/approve-contributor.yml` -- Maintainers review auto-closed issues daily -- Issues that do not meet the quality bar in `CONTRIBUTING.md` are not reopened and do not receive a reply -- `lgtmi` approves future issues -- `lgtm` approves future issues and rights to submit PRs +Multiple pi sessions may be running in this cwd at the same time, each modifying different files. Git operations that touch unstaged, staged, or untracked files outside your own changes will stomp on other sessions' work. Follow these rules: + +Committing: + +- Only commit files YOU changed in THIS session. +- Stage explicit paths (`git add `); never `git add -A` / `git add .`. +- Before committing, run `git status` and verify you are only staging your files. +- `packages/ai/src/models.generated.ts` may always be included alongside your files. + +Never run (destroys other agents' work or bypasses checks): + +- `git reset --hard`, `git checkout .`, `git clean -fd`, `git stash`, `git add -A`, `git add .`, `git commit --no-verify`. + +If rebase conflicts occur: + +- Resolve conflicts only in files you modified. +- If a conflict is in a file you did not modify, abort and ask the user. +- Never force push. + +## Issues and PRs + +See `CONTRIBUTING.md` for the contributor gate (auto-close workflows, `lgtm`/`lgtmi`, quality bar). When creating issues: -- Add `pkg:*` labels to indicate which package(s) the issue affects - - Available labels: `pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:tui` -- If an issue spans multiple packages, add all relevant labels +- Add `pkg:*` labels for affected packages (`pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:tui`); use all that apply. When posting issue/PR comments: -- Write the full comment to a temp file and use `gh issue comment --body-file` or `gh pr comment --body-file` -- Never pass multi-line markdown directly via `--body` in shell commands -- Preview the exact comment text before posting -- Post exactly one final comment unless the user explicitly asks for multiple comments -- If a comment is malformed, delete it immediately, then post one corrected comment -- Keep comments concise, technical, and in the user's tone +- Write the comment to a temp file and post with `gh issue/pr comment --body-file` (never multi-line markdown via `--body`). +- Keep comments concise, technical, in the user's tone. +- End every AI-posted comment with the AI-generated disclaimer line specified by the originating prompt (e.g. `This comment is AI-generated by `/wr``). When closing issues via commit: -- Include `fixes #` or `closes #` in the commit message -- This automatically closes the issue when the commit is merged - -## PR Workflow - -- Analyze PRs without pulling locally first -- If the user approves: create a feature branch, pull PR, rebase on main, apply adjustments, commit, merge into main, push, close PR, and leave a comment in the user's tone -- You never open PRs yourself. We work in feature branches until everything is according to the user's requirements, then merge into main, and push. +- Include `fixes #` or `closes #` in the message so merging auto-closes the issue. For multiple issues, repeat the keyword per issue (`closes #1, closes #2`); a shared keyword (`closes #1, #2`) only closes the first. ## Testing pi Interactive Mode with tmux -To test pi's TUI in a controlled terminal environment: +Run the TUI in a controlled terminal (from the repo root): ```bash -# Create tmux session with specific dimensions tmux new-session -d -s pi-test -x 80 -y 24 - -# Start pi from source -tmux send-keys -t pi-test "cd /Users/badlogic/workspaces/pi-mono && ./pi-test.sh" Enter - -# Wait for startup, then capture output -sleep 3 && tmux capture-pane -t pi-test -p - -# Send input +tmux send-keys -t pi-test "./pi-test.sh" Enter +sleep 3 && tmux capture-pane -t pi-test -p # capture after startup tmux send-keys -t pi-test "your prompt here" Enter - -# Send special keys -tmux send-keys -t pi-test Escape -tmux send-keys -t pi-test C-o # ctrl+o - -# Cleanup +tmux send-keys -t pi-test Escape # special keys (also C-o for ctrl+o, etc.) tmux kill-session -t pi-test ``` ## Changelog -Location: `packages/*/CHANGELOG.md` (each package has its own) +Location: `packages/*/CHANGELOG.md` (one per package). -### Format +Sections under `## [Unreleased]`: `### Breaking Changes` (API changes requiring migration), `### Added`, `### Changed`, `### Fixed`, `### Removed`. -Use these sections under `## [Unreleased]`: +Rules: -- `### Breaking Changes` - API changes requiring migration -- `### Added` - New features -- `### Changed` - Changes to existing functionality -- `### Fixed` - Bug fixes -- `### Removed` - Removed features +- All new entries go under `## [Unreleased]`. Read the full section first and append to existing subsections; never duplicate them. +- Released version sections (e.g. `## [0.12.2]`) are immutable; never modify them. -### Rules +Attribution: -- Before adding entries, read the full `[Unreleased]` section to see which subsections already exist -- New entries ALWAYS go under `## [Unreleased]` section -- Append to existing subsections (e.g., `### Fixed`), do not create duplicates -- NEVER modify already-released version sections (e.g., `## [0.12.2]`) -- Each version section is immutable once released - -### Attribution - -- **Internal changes (from issues)**: `Fixed foo bar ([#123](https://github.com/earendil-works/pi-mono/issues/123))` -- **External contributions**: `Added feature X ([#456](https://github.com/earendil-works/pi-mono/pull/456) by [@username](https://github.com/username))` - -## Adding a New LLM Provider (packages/ai) - -Adding a new provider requires changes across multiple files: - -### 1. Core Types (`packages/ai/src/types.ts`) - -- Add API identifier to `Api` type union (e.g., `"bedrock-converse-stream"`) -- Create options interface extending `StreamOptions` -- Add mapping to `ApiOptionsMap` -- Add provider name to `KnownProvider` type union - -### 2. Provider Implementation (`packages/ai/src/providers/`) - -Create provider file exporting: - -- `stream()` function returning `AssistantMessageEventStream` -- `streamSimple()` for `SimpleStreamOptions` mapping -- Provider-specific options interface -- Message/tool conversion functions -- Response parsing emitting standardized events (`text`, `tool_call`, `thinking`, `usage`, `stop`) - -### 3. Provider Exports and Lazy Registration - -- Add a package subpath export in `packages/ai/package.json` pointing at `./dist/providers/.js` -- Add `export type` re-exports in `packages/ai/src/index.ts` for provider option types that should remain available from the root entry -- Register the provider in `packages/ai/src/providers/register-builtins.ts` via lazy loader wrappers, do not statically import provider implementation modules there -- Add credential detection in `packages/ai/src/env-api-keys.ts` - -### 4. Model Generation (`packages/ai/scripts/generate-models.ts`) - -- Add logic to fetch/parse models from provider source -- Map to standardized `Model` interface - -### 5. Tests (`packages/ai/test/`) - -- Always add the provider to `stream.test.ts` with at least one representative model, even if it reuses an existing API implementation such as `openai-completions`. -- Add the provider to the broader provider matrix where applicable: `tokens.test.ts`, `abort.test.ts`, `empty.test.ts`, `context-overflow.test.ts`, `unicode-surrogate.test.ts`, `tool-call-without-result.test.ts`, `image-tool-result.test.ts`, `total-tokens.test.ts`, `cross-provider-handoff.test.ts`. -- For `cross-provider-handoff.test.ts`, add at least one provider/model pair. If the provider exposes multiple model families (for example GPT and Claude), add at least one pair per family. -- For non-standard auth, create utility (e.g., `bedrock-utils.ts`) with credential detection. - -### 6. Coding Agent (`packages/coding-agent/`) - -- `src/core/model-resolver.ts`: Add default model ID to `defaultModelPerProvider` -- `src/core/provider-display-names.ts`: Add API-key login display name so `/login` and related UI show the provider for built-in API-key auth. -- `src/cli/args.ts`: Add env var documentation -- `README.md`: Add provider setup instructions -- `docs/providers.md`: Add setup instructions, env var, and `auth.json` key - -### 7. Documentation - -- `packages/ai/README.md`: Add to providers table, document options/auth, add env vars -- `packages/ai/CHANGELOG.md`: Add entry under `## [Unreleased]` +- Internal (from issues): `Fixed foo bar ([#123](https://github.com/earendil-works/pi-mono/issues/123))` +- External contributions: `Added feature X ([#456](https://github.com/earendil-works/pi-mono/pull/456) by [@username](https://github.com/username))` ## Releasing -**Lockstep versioning**: All packages always share the same version number. Every release updates all packages together. +**Lockstep versioning**: all packages share one version; every release updates all together. `patch` = fixes + additions, `minor` = breaking changes. No major releases. -**Version semantics** (no major releases): +1. **Update CHANGELOGs**: ask the user whether they ran the `/cl` prompt on the latest commit on `main`. If not, they must run `/cl` first to audit and update each package's `[Unreleased]` section before releasing. -- `patch`: Bug fixes and new features -- `minor`: API breaking changes - -### Steps - -1. **Update CHANGELOGs**: Ensure all changes since last release are documented in the `[Unreleased]` section of each affected package's CHANGELOG.md - -2. **Soft gate: local release smoke test**: Before running the real release script, build an unpublished local release and manually smoke test it from outside the repository so it cannot accidentally resolve workspace files: +2. **Local smoke test**: build an unpublished release and smoke test from outside the repo (so it can't resolve workspace files): ```bash npm run release:local -- --out /tmp/pi-local-release --force cd /tmp @@ -209,71 +125,35 @@ Create provider file exporting: /tmp/pi-local-release/bun/pi --help /tmp/pi-local-release/bun/pi --version ``` - In the interactive smoke test, verify startup, model/account listing, and at least one real prompt with the intended default provider. Treat failures as release blockers unless the user explicitly accepts the risk. + Verify startup, model/account listing, and at least one real prompt with the intended default provider. Failures are release blockers unless the user explicitly accepts the risk. -3. **Run release script until npm publish**: - ```bash - npm run release:patch # Fixes and additions - npm run release:minor # API breaking changes +3. **Brief the user on the WebAuthn flow before running anything**. Print exactly the following message and then stop and wait for the user to confirm in their next message: + + ``` + Before I run the release script, read this carefully: + + - `npm publish` uses WebAuthn 2FA. + - A login URL will appear in the live bash output in this TUI. I will NOT see it until the command exits. + - You must watch the bash output, cmd/ctrl-click the URL, log in in the browser, and select the "don't ask again for N minutes" option so publish can continue. + - This may happen more than once during the release. + + Reply "ready" once you have read this and are watching the bash output. I will not run the release script until you do. ``` - npm publishing requires the maintainer's npm WebAuthn/security-key 2FA and cannot be completed by an agent alone. If the release script stops at `npm publish` with an npm browser authentication URL, the maintainer must run or approve `npm run publish` locally. Do not rerun the version bump. + Do not proceed to step 4 until the user explicitly confirms. -4. **After publish succeeds, finish release bookkeeping**: +4. **Run the release script**: + ```bash + npm run release:patch # fixes + additions + npm run release:minor # breaking changes + ``` + Do not pass a `timeout` to the bash tool for this call. If publish fails partway, stop and report to the user what happened (which package failed, the error output) along with possible solutions. Never rerun the version bump on your own. + +5. **After publish succeeds**: - Add fresh `## [Unreleased]` sections to package changelogs. - Commit with `Add [Unreleased] section for next cycle`. - Push `main` and the release tag. -The release script handles the full flow when npm publish auth is already satisfied. If npm requires WebAuthn during publish, continue manually from the existing release commit/tag using the steps above. +## User Override -## **CRITICAL** Git Rules for Parallel Agents **CRITICAL** - -Multiple agents may work on different files in the same worktree simultaneously. You MUST follow these rules: - -### Committing - -- **ONLY commit files YOU changed in THIS session** -- ALWAYS include `fixes #` or `closes #` in the commit message when there is a related issue or PR -- NEVER use `git add -A` or `git add .` - these sweep up changes from other agents -- ALWAYS use `git add ` listing only files you modified -- Before committing, run `git status` and verify you are only staging YOUR files -- Track which files you created/modified/deleted during the session -- It is always fine to include `packages/ai/src/models.generated.ts` in a commit alongside the actual files you want to commit - -### Forbidden Git Operations - -These commands can destroy other agents' work: - -- `git reset --hard` - destroys uncommitted changes -- `git checkout .` - destroys uncommitted changes -- `git clean -fd` - deletes untracked files -- `git stash` - stashes ALL changes including other agents' work -- `git add -A` / `git add .` - stages other agents' uncommitted work -- `git commit --no-verify` - bypasses required checks and is never allowed - -### Safe Workflow - -```bash -# 1. Check status first -git status - -# 2. Add ONLY your specific files -git add packages/ai/src/providers/transform-messages.ts -git add packages/ai/CHANGELOG.md - -# 3. Commit -git commit -m "fix(ai): description" - -# 4. Push (pull --rebase if needed, but NEVER reset/checkout) -git pull --rebase && git push -``` - -### If Rebase Conflicts Occur - -- Resolve conflicts in YOUR files only -- If conflict is in a file you didn't modify, abort and ask the user -- NEVER force push - -### User override - -If the user instructions conflict with rules set out here, ask for confirmation that they want to override the rules. Only then execute their instructions. +If the user's instructions conflict with any rule in this document, ask for explicit confirmation before overriding. Only then execute their instructions. From baf4028fb97254d359bde48fd1c0a8c27e639a7c Mon Sep 17 00:00:00 2001 From: haoqixu Date: Fri, 22 May 2026 18:33:26 +0800 Subject: [PATCH 20/23] fix(coding-agent): use the right basedir for patterns --- .../src/modes/interactive/components/config-selector.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/interactive/components/config-selector.ts b/packages/coding-agent/src/modes/interactive/components/config-selector.ts index d4f167268..93ef4bea0 100644 --- a/packages/coding-agent/src/modes/interactive/components/config-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/config-selector.ts @@ -567,7 +567,7 @@ class ResourceList implements Component, Focusable { private getResourcePattern(item: ResourceItem): string { const scope = item.metadata.scope as "user" | "project"; - const baseDir = this.getTopLevelBaseDir(scope); + const baseDir = item.metadata.baseDir ?? this.getTopLevelBaseDir(scope); return relative(baseDir, item.path); } From b3ed545938952944560d47b10789d025364df691 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 22 May 2026 15:47:57 +0200 Subject: [PATCH 21/23] fix(export-html): escape quotes in exported attributes closes #4832 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/core/export-html/template.js | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ce69a3aa7..304bc4365 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,7 @@ ### Fixed +- Fixed exported session HTML to escape quote characters in attribute values ([#4832](https://github.com/earendil-works/pi/issues/4832)). - Fixed git package installs to reconcile existing checkouts to the requested ref and update package settings without losing filters ([#4870](https://github.com/earendil-works/pi/issues/4870)). - 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)). diff --git a/packages/coding-agent/src/core/export-html/template.js b/packages/coding-agent/src/core/export-html/template.js index 07ab3e254..fe5c6d91c 100644 --- a/packages/coding-agent/src/core/export-html/template.js +++ b/packages/coding-agent/src/core/export-html/template.js @@ -605,9 +605,12 @@ } function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; + return String(text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); } /** From c841a6c78fbbb12e3ef6014c3044faa15aa7b259 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 22 May 2026 15:50:52 +0200 Subject: [PATCH 22/23] Clean up OAuth device-code callbacks --- AGENTS.md | 1 + packages/ai/CHANGELOG.md | 5 +++ packages/ai/src/cli.ts | 9 ++++ packages/ai/src/utils/oauth/github-copilot.ts | 4 -- packages/ai/src/utils/oauth/types.ts | 4 +- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/custom-provider.md | 42 ++++++++++++++----- .../interactive/components/login-dialog.ts | 8 +++- 8 files changed, 56 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 144d2c60a..065f2541f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,7 @@ - No fluff or cheerful filler text (e.g., "Thanks @user" not "Thanks so much @user!") - Technical prose only, be direct - When the user asks a question, answer it first before making edits or running implementation commands. +- When responding to user feedback or an analysis, explicitly say whether you agree or disagree before saying what you changed. ## Code Quality diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 9802972f9..768a0525e 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,8 +2,13 @@ ## [Unreleased] +### Breaking Changes + +- Changed `OAuthLoginCallbacks` to require `onDeviceCode` and `onSelect`, so OAuth providers can rely on pi supplying device-code and selection UI callbacks. + ### Fixed +- Fixed GitHub Copilot OAuth login to rely on the required device-code callback without a runtime callback availability guard. - Fixed Amazon Bedrock Claude requests to send the model output token cap by default, matching Anthropic requests and avoiding Bedrock's 4096-token default truncation ([#4848](https://github.com/earendil-works/pi/issues/4848)). ## [0.75.4] - 2026-05-20 diff --git a/packages/ai/src/cli.ts b/packages/ai/src/cli.ts index 442ee8ea6..21699dbdb 100644 --- a/packages/ai/src/cli.ts +++ b/packages/ai/src/cli.ts @@ -50,6 +50,15 @@ async function login(providerId: OAuthProviderId): Promise { onPrompt: async (p) => { return await promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`); }, + onSelect: async (p) => { + console.log(`\n${p.message}`); + for (let i = 0; i < p.options.length; i++) { + console.log(` ${i + 1}. ${p.options[i].label}`); + } + const choice = await promptFn(`Enter number (1-${p.options.length}):`); + const index = parseInt(choice, 10) - 1; + return p.options[index]?.id; + }, onProgress: (msg) => console.log(msg), }); diff --git a/packages/ai/src/utils/oauth/github-copilot.ts b/packages/ai/src/utils/oauth/github-copilot.ts index c4ce36358..d5adb58a7 100644 --- a/packages/ai/src/utils/oauth/github-copilot.ts +++ b/packages/ai/src/utils/oauth/github-copilot.ts @@ -319,10 +319,6 @@ export const githubCopilotOAuthProvider: OAuthProviderInterface = { name: "GitHub Copilot", async login(callbacks: OAuthLoginCallbacks): Promise { - if (!callbacks.onDeviceCode) { - throw new Error("GitHub Copilot OAuth requires a device code callback"); - } - return loginGitHubCopilot({ onDeviceCode: callbacks.onDeviceCode, onPrompt: callbacks.onPrompt, diff --git a/packages/ai/src/utils/oauth/types.ts b/packages/ai/src/utils/oauth/types.ts index 3220dcf9d..008be405e 100644 --- a/packages/ai/src/utils/oauth/types.ts +++ b/packages/ai/src/utils/oauth/types.ts @@ -42,12 +42,12 @@ export type OAuthSelectPrompt = { export interface OAuthLoginCallbacks { onAuth: (info: OAuthAuthInfo) => void; - onDeviceCode?: (info: OAuthDeviceCodeInfo) => void; + onDeviceCode: (info: OAuthDeviceCodeInfo) => void; onPrompt: (prompt: OAuthPrompt) => Promise; onProgress?: (message: string) => void; onManualCodeInput?: () => Promise; /** Show an interactive selector and return the selected option id, or undefined on cancel. */ - onSelect?: (prompt: OAuthSelectPrompt) => Promise; + onSelect: (prompt: OAuthSelectPrompt) => Promise; signal?: AbortSignal; } diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 304bc4365..33cf2967a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,6 +13,7 @@ ### Fixed - 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. - Fixed git package installs to reconcile existing checkouts to the requested ref and update package settings without losing filters ([#4870](https://github.com/earendil-works/pi/issues/4870)). - 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)). diff --git a/packages/coding-agent/docs/custom-provider.md b/packages/coding-agent/docs/custom-provider.md index 09eb6e6e3..e7fecc2d2 100644 --- a/packages/coding-agent/docs/custom-provider.md +++ b/packages/coding-agent/docs/custom-provider.md @@ -263,17 +263,28 @@ pi.registerProvider("corporate-ai", { name: "Corporate AI (SSO)", async login(callbacks: OAuthLoginCallbacks): Promise { - // Option 1: Browser-based OAuth - callbacks.onAuth({ url: "https://sso.corp.com/authorize?..." }); - - // Option 2: Device code flow - callbacks.onDeviceCode({ - userCode: "ABCD-1234", - verificationUri: "https://sso.corp.com/device" + const method = await callbacks.onSelect({ + message: "Select login method:", + options: [ + { id: "browser", label: "Browser OAuth" }, + { id: "device", label: "Device code" } + ] }); + if (!method) throw new Error("Login cancelled"); - // Option 3: Prompt for token/code - const code = await callbacks.onPrompt({ message: "Enter SSO code:" }); + let code: string; + if (method === "device") { + callbacks.onDeviceCode({ + userCode: "ABCD-1234", + verificationUri: "https://sso.corp.com/device", + intervalSeconds: 5, + expiresInSeconds: 900 + }); + code = await pollDeviceCodeUntilComplete(); + } else { + callbacks.onAuth({ url: "https://sso.corp.com/authorize?..." }); + code = await callbacks.onPrompt({ message: "Enter SSO code:" }); + } // Exchange for tokens (your implementation) const tokens = await exchangeCodeForTokens(code); @@ -322,10 +333,21 @@ interface OAuthLoginCallbacks { onAuth(params: { url: string }): void; // Show device code (for device authorization flow) - onDeviceCode(params: { userCode: string; verificationUri: string }): void; + onDeviceCode(params: { + userCode: string; + verificationUri: string; + intervalSeconds?: number; + expiresInSeconds?: number; + }): void; // Prompt user for input (for manual token entry) onPrompt(params: { message: string }): Promise; + + // Show an interactive selector, e.g. to choose browser OAuth vs device code + onSelect(params: { + message: string; + options: { id: string; label: string }[]; + }): Promise; } ``` diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index 5a2c365af..ee3f45cbf 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -122,13 +122,17 @@ export class LoginDialogComponent extends Container implements Focusable { this.contentContainer.addChild(new Spacer(1)); this.contentContainer.addChild(new Text(theme.fg("warning", `Enter code: ${info.userCode}`), 1, 0)); - // Do not open device-code URLs automatically. These flows need to work in headless environments. + this.openUrl(info.verificationUri); this.tui.requestRender(); } private openUrl(url: string): void { const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; - exec(`${openCmd} "${url}"`); + try { + exec(`${openCmd} "${url}"`, () => {}); + } catch { + // Ignore browser launch failures. The URL remains visible for manual opening/copying. + } } /** From 7002c68f8b60bdcc806840615665e896aa7a9e0a Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 22 May 2026 16:49:17 +0200 Subject: [PATCH 23/23] fix(ai): declare Bedrock Smithy HTTP handler dependency closes #4842 --- package-lock.json | 1 + packages/ai/CHANGELOG.md | 1 + packages/ai/package.json | 1 + packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/npm-shrinkwrap.json | 1 + 5 files changed, 5 insertions(+) diff --git a/package-lock.json b/package-lock.json index 3e9361400..cab14c6d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6031,6 +6031,7 @@ "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.1", + "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 768a0525e..7932d9dc5 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - Fixed GitHub Copilot OAuth login to rely on the required device-code callback without a runtime callback availability guard. +- Fixed Amazon Bedrock provider loading under strict package managers by declaring its direct `@smithy/node-http-handler` dependency ([#4842](https://github.com/earendil-works/pi/issues/4842)). - Fixed Amazon Bedrock Claude requests to send the model output token cap by default, matching Anthropic requests and avoiding Bedrock's 4096-token default truncation ([#4848](https://github.com/earendil-works/pi/issues/4848)). ## [0.75.4] - 2026-05-20 diff --git a/packages/ai/package.json b/packages/ai/package.json index a10a23df9..525c5f6fb 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -69,6 +69,7 @@ "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@smithy/node-http-handler": "4.7.3", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.1", "http-proxy-agent": "7.0.2", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 33cf2967a..43643b5f0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,7 @@ ### Fixed +- 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. - Fixed git package installs to reconcile existing checkouts to the requested ref and update package settings without losing filters ([#4870](https://github.com/earendil-works/pi/issues/4870)). diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 85a22b1a1..b9041ce93 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -493,6 +493,7 @@ "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@smithy/node-http-handler": "4.7.3", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.1", "http-proxy-agent": "7.0.2",