From 4af9d21d3b4d664e4a29fcabfec85171077248e3 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 24 Aug 2026 09:34:34 +0200 Subject: [PATCH 1/8] feat(coding-agent): update managed installations in place --- packages/coding-agent/docs/packages.md | 2 +- packages/coding-agent/src/main.ts | 3 +- .../coding-agent/src/package-manager-cli.ts | 217 +++++++++++++++++- .../test/package-command-paths.test.ts | 180 ++++++++++++++- scripts/publish-release-announcement.mjs | 108 +++++---- 5 files changed, 449 insertions(+), 61 deletions(-) diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index 3f141c514..cadeb1867 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -38,7 +38,7 @@ pi update npm:@foo/bar # update one package pi update --extension npm:@foo/bar ``` -These commands manage pi packages and `pi update` can update the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). +These commands manage pi packages and `pi update` can update the pi CLI installation. For experimental installer-managed installations, `pi update` installs the exact checked version into a staged, lockfile-backed release and activates it only after verification, leaving the current release intact if the update fails. Managed installations do not support `--force`; rerun the installer to repair one. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). By default, `install` and `remove` write to user settings (`~/.pi/agent/settings.json`). Use `-l` to write to project settings (`.pi/settings.json`) instead. Project settings can be shared with your team, and pi installs any missing packages automatically on startup after the project is trusted. diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index ac8b8cf71..23b4d42e2 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -64,7 +64,7 @@ import { builtInExtensions } from "./extensions/index.ts"; import { runMigrations, showDeprecationWarnings } from "./migrations.ts"; import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts"; import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts"; -import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts"; +import { cleanupManagedInstall, handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts"; import { isLocalPath, normalizePath, resolvePath } from "./utils/paths.ts"; import { cleanupWindowsSelfUpdateQuarantine } from "./utils/windows-self-update.ts"; @@ -573,6 +573,7 @@ export async function main(args: string[], options?: MainOptions) { if (process.platform === "win32") { cleanupWindowsSelfUpdateQuarantine(getPackageDir()); } + cleanupManagedInstall(); const cwd = process.cwd(); const agentDir = getAgentDir(); diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index 0a05f3159..53239daf8 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -1,6 +1,17 @@ -import { join } from "node:path"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { join, resolve } from "node:path"; import { Markdown, type MarkdownTheme } from "@earendil-works/pi-tui"; import chalk from "chalk"; +import lockfile from "proper-lockfile"; import { selectConfig } from "./cli/config-selector.ts"; import { createProjectTrustContext } from "./cli/project-trust.ts"; import { @@ -23,7 +34,9 @@ import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts"; import { DefaultResourceLoader } from "./core/resource-loader.ts"; import { SettingsManager } from "./core/settings-manager.ts"; import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts"; -import { spawnProcess } from "./utils/child-process.ts"; +import { spawnProcess, spawnProcessSync, waitForChildProcess } from "./utils/child-process.ts"; +import { canonicalizePath, getCwdRelativePath } from "./utils/paths.ts"; +import { getPiUserAgent } from "./utils/pi-user-agent.ts"; import { formatVersionCheckError, getLatestPiRelease, isNewerPackageVersion } from "./utils/version-check.ts"; import { cleanupWindowsSelfUpdateQuarantine, @@ -34,6 +47,179 @@ export type PackageCommand = "install" | "remove" | "update" | "list"; type UpdateTarget = { type: "all" } | { type: "self" } | { type: "extensions"; source?: string } | { type: "models" }; +const DEFAULT_INSTALLER_API_BASE = "https://pi.dev/api/installer/releases"; +const MANAGED_INSTALL_MARKER = "managed-install.json"; +const MANAGED_RELEASE_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +function getActiveManagedInstallRoot(): string | undefined { + const configuredRoot = process.env.PI_MANAGED_INSTALL_ROOT?.trim(); + if (!configuredRoot) return undefined; + + const managedRoot = resolve(configuredRoot); + const releasesDir = canonicalizePath(join(managedRoot, "releases")); + // The launcher environment is inherited by child processes. Do not classify a + // source checkout or another Pi installation launched from managed Pi as managed. + if (getCwdRelativePath(canonicalizePath(getPackageDir()), releasesDir) === undefined) return undefined; + + const markerPath = join(managedRoot, MANAGED_INSTALL_MARKER); + try { + const marker = JSON.parse(readFileSync(markerPath, "utf8")) as { + kind?: unknown; + layout?: unknown; + schemaVersion?: unknown; + }; + if (marker.kind !== "pi-managed-install" || marker.schemaVersion !== 1 || marker.layout !== "releases-v1") { + throw new Error(); + } + } catch { + throw new Error(`Managed install marker is missing or invalid: ${markerPath}`); + } + + return managedRoot; +} + +async function fetchInstallerArtifact(url: string, label: string): Promise { + const response = await fetch(url, { headers: { "User-Agent": getPiUserAgent(VERSION) } }); + if (!response.ok) { + throw new Error(`Could not download managed installer ${label} from ${url}: HTTP ${response.status}`); + } + return await response.text(); +} + +async function runManagedNpmCi(stageDir: string): Promise { + const args = [ + "ci", + "--ignore-scripts", + "--min-release-age=0", + "--omit=dev", + "--include=optional", + "--no-fund", + "--no-audit", + "--loglevel=error", + "--progress=false", + ]; + const code = await waitForChildProcess(spawnProcess("npm", args, { cwd: stageDir, stdio: "inherit" })); + if (code !== 0) throw new Error(`npm ${args.join(" ")} exited with code ${code ?? "unknown"}`); +} + +function verifyManagedRelease(releaseDir: string, expectedVersion: string): void { + const binPath = join( + releaseDir, + "node_modules", + ".bin", + process.platform === "win32" ? `${APP_NAME}.cmd` : APP_NAME, + ); + const result = spawnProcessSync(binPath, ["--version"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error || result.status !== 0) { + const reason = result.error?.message || result.stderr.trim() || `exit code ${result.status ?? "unknown"}`; + throw new Error(`Could not verify managed Pi ${expectedVersion}: ${reason}`); + } + const installedVersion = result.stdout.trim(); + if (installedVersion !== expectedVersion) { + throw new Error(`Managed Pi smoke test returned version ${installedVersion}; expected ${expectedVersion}.`); + } +} + +function activateManagedRelease(managedRoot: string, version: string): void { + const currentPath = join(managedRoot, "current-version"); + const temporaryPath = join(managedRoot, `current-version.tmp.${process.pid}-${Date.now()}`); + try { + writeFileSync(temporaryPath, `${version}\n`); + renameSync(temporaryPath, currentPath); + } finally { + rmSync(temporaryPath, { force: true }); + } +} + +function cleanupManagedStaging(managedRoot: string): void { + const stagingRoot = join(managedRoot, "staging"); + try { + for (const entry of readdirSync(stagingRoot)) { + if (entry.startsWith("update-")) { + rmSync(join(stagingRoot, entry), { force: true, recursive: true }); + } + } + } catch { + // The staging directory does not exist yet or is not writable. + } +} + +export function cleanupManagedInstall(): void { + let managedRoot: string | undefined; + try { + managedRoot = getActiveManagedInstallRoot(); + } catch { + return; + } + if (!managedRoot) return; + + try { + const releaseLock = lockfile.lockSync(join(managedRoot, "update"), { realpath: false }); + try { + cleanupManagedStaging(managedRoot); + } finally { + releaseLock(); + } + } catch { + // A live update owns the staging directory, or cleanup is unavailable. + } +} + +async function runManagedSelfUpdate(managedRoot: string, version: string): Promise { + if (!MANAGED_RELEASE_VERSION_RE.test(version)) { + throw new Error(`Invalid managed release version: ${version}`); + } + + let releaseLock: () => Promise; + try { + releaseLock = await lockfile.lock(join(managedRoot, "update"), { realpath: false }); + } catch (error: unknown) { + if (error instanceof Error && "code" in error && error.code === "ELOCKED") { + throw new Error("Another managed Pi update is already running."); + } + throw error; + } + + let stageDir: string | undefined; + try { + cleanupManagedStaging(managedRoot); + const installerApiBase = (process.env.PI_INSTALLER_API_BASE?.trim() || DEFAULT_INSTALLER_API_BASE).replace( + /\/+$/, + "", + ); + const releaseUrl = `${installerApiBase}/${encodeURIComponent(version)}`; + const stagingRoot = join(managedRoot, "staging"); + const releasesRoot = join(managedRoot, "releases"); + mkdirSync(releasesRoot, { recursive: true }); + const releaseDir = join(releasesRoot, version); + if (existsSync(releaseDir)) { + verifyManagedRelease(releaseDir, version); + activateManagedRelease(managedRoot, version); + return; + } + + mkdirSync(stagingRoot, { recursive: true }); + stageDir = mkdtempSync(join(stagingRoot, "update-")); + const [packageJsonContent, packageLockContent] = await Promise.all([ + fetchInstallerArtifact(`${releaseUrl}/package.json`, "package.json"), + fetchInstallerArtifact(`${releaseUrl}/package-lock.json`, "package-lock.json"), + ]); + writeFileSync(join(stageDir, "package.json"), packageJsonContent); + writeFileSync(join(stageDir, "package-lock.json"), packageLockContent); + + await runManagedNpmCi(stageDir); + verifyManagedRelease(stageDir, version); + renameSync(stageDir, releaseDir); + activateManagedRelease(managedRoot, version); + } finally { + if (stageDir) rmSync(stageDir, { force: true, recursive: true }); + await releaseLock(); + } +} + const SELF_UPDATE_NOTE_MARKDOWN_THEME: MarkdownTheme = { heading: (text) => chalk.bold(chalk.yellow(text)), link: (text) => chalk.cyan(text), @@ -834,10 +1020,37 @@ export async function handlePackageCommand( } } if (updateTargetIncludesSelf(target)) { + const managedInstallRoot = getActiveManagedInstallRoot(); + if (managedInstallRoot && options.force) { + console.error( + chalk.red( + `Managed ${APP_NAME} installations do not support --force; rerun the installer to repair this installation.`, + ), + ); + process.exitCode = 1; + return true; + } const selfUpdatePlan = await getSelfUpdatePlan(options.force); if (!selfUpdatePlan.shouldRun) { return true; } + if (managedInstallRoot) { + if (selfUpdatePlan.note) { + printSelfUpdateNote(selfUpdatePlan.note); + } + try { + console.log(chalk.dim(`Updating managed ${APP_NAME} installation...`)); + await runManagedSelfUpdate(managedInstallRoot, selfUpdatePlan.version); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown managed update error"; + console.error(chalk.red(`Error: ${message}`)); + process.exitCode = 1; + return true; + } + console.log(chalk.green(`Updated ${APP_NAME} from ${VERSION} to ${selfUpdatePlan.version}`)); + return true; + } + const installMethod = detectInstallMethod(); if (process.platform === "win32" && installMethod !== "npm" && installMethod !== "pnpm") { console.error( diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index a804b40e4..1d5343492 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -1,6 +1,17 @@ -import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + utimesSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; +import lockfile from "proper-lockfile"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ENV_AGENT_DIR, PACKAGE_NAME, VERSION } from "../src/config.ts"; import { ModelRuntime } from "../src/core/model-runtime.ts"; @@ -29,6 +40,77 @@ describe("package commands", () => { return `${major}.${minor}.${Number.parseInt(patch, 10) + 1}`; } + function prepareManagedInstall( + targetVersion: string, + npmExitCode = 0, + ): { managedRoot: string; npmRecordPath: string } { + const managedRoot = join(agentDir, "install"); + const activeRelease = join(managedRoot, "releases", VERSION); + const selfPackageDir = join(activeRelease, "node_modules", ...PACKAGE_NAME.split("/")); + mkdirSync(selfPackageDir, { recursive: true }); + writeFileSync(join(activeRelease, "active.txt"), "active"); + writeFileSync(join(managedRoot, "current-version"), `${VERSION}\n`); + writeFileSync( + join(managedRoot, "managed-install.json"), + `${JSON.stringify({ kind: "pi-managed-install", schemaVersion: 1, layout: "releases-v1" })}\n`, + ); + + const binDir = join(tempDir, "managed-bin"); + const fakeNpmPath = join(tempDir, "managed-npm.cjs"); + const npmRecordPath = join(tempDir, "managed-npm-record.json"); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + fakeNpmPath, + `const fs = require("node:fs"); +const path = require("node:path"); +const args = process.argv.slice(2); +fs.writeFileSync(${JSON.stringify(npmRecordPath)}, JSON.stringify(args)); +if (${npmExitCode} !== 0) process.exit(${npmExitCode}); +const binDir = path.join(process.cwd(), "node_modules", ".bin"); +fs.mkdirSync(binDir, { recursive: true }); +const piPath = path.join(binDir, process.platform === "win32" ? "pi.cmd" : "pi"); +fs.writeFileSync( + piPath, + process.platform === "win32" + ? "@echo off\\r\\necho ${targetVersion}\\r\\n" + : "#!/bin/sh\\nprintf '%s\\n' ${targetVersion}\\n", +); +if (process.platform !== "win32") fs.chmodSync(piPath, 0o755); +`, + ); + const npmPath = join(binDir, process.platform === "win32" ? "npm.cmd" : "npm"); + writeFileSync( + npmPath, + process.platform === "win32" + ? `@echo off\r\n"${originalExecPath}" "${fakeNpmPath}" %*\r\n` + : `#!/bin/sh\nexec "${originalExecPath}" "${fakeNpmPath}" "$@"\n`, + ); + chmodSync(npmPath, 0o755); + + vi.stubEnv("PI_INSTALLER_API_BASE", "https://example.test/api/installer/releases"); + vi.stubEnv("PI_MANAGED_INSTALL_ROOT", managedRoot); + process.env.PI_PACKAGE_DIR = selfPackageDir; + process.env.PATH = `${binDir}${delimiter}${originalPath ?? ""}`; + return { managedRoot, npmRecordPath }; + } + + function mockManagedUpdate(targetVersion: string): void { + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url === "https://pi.dev/api/latest-version") { + return Response.json({ packageName: PACKAGE_NAME, version: targetVersion }); + } + const releaseUrl = `https://example.test/api/installer/releases/${targetVersion}`; + if (url === `${releaseUrl}/package.json` || url === `${releaseUrl}/package-lock.json`) { + return Response.json({}); + } + throw new Error(`Unexpected fetch: ${url}`); + }), + ); + } + async function runPackageCommandDirectly(args: string[]): Promise { expect(await handlePackageCommand(args)).toBe(true); } @@ -82,6 +164,7 @@ describe("package commands", () => { afterEach(() => { vi.unstubAllGlobals(); + vi.unstubAllEnvs(); vi.restoreAllMocks(); process.chdir(originalCwd); process.exitCode = originalExitCode; @@ -523,10 +606,103 @@ describe("package commands", () => { } }); - it("uses the update check version for forced self updates even when current", async () => { + it("updates installer-managed Pi through a staged immutable release", async () => { + const targetVersion = getNewerPatchVersion(); + const { managedRoot, npmRecordPath } = prepareManagedInstall(targetVersion); + const abandonedStage = join(managedRoot, "staging", "update-abandoned"); + mkdirSync(abandonedStage, { recursive: true }); + writeFileSync(join(abandonedStage, "partial"), "partial"); + const abandonedLock = join(managedRoot, "update.lock"); + mkdirSync(abandonedLock); + utimesSync(abandonedLock, new Date(0), new Date(0)); + mockManagedUpdate(targetVersion); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); + + expect(readFileSync(join(managedRoot, "current-version"), "utf8")).toBe(`${targetVersion}\n`); + expect(existsSync(join(managedRoot, "releases", targetVersion))).toBe(true); + expect(existsSync(join(managedRoot, "releases", VERSION, "active.txt"))).toBe(true); + expect(readdirSync(join(managedRoot, "staging"))).toEqual([]); + expect(JSON.parse(readFileSync(npmRecordPath, "utf8")) as string[]).toEqual( + expect.arrayContaining(["ci", "--ignore-scripts"]), + ); + expect(logSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain( + `Updated pi from ${VERSION} to ${targetVersion}`, + ); + expect(errorSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + }); + + it("rejects a concurrent managed update", async () => { + const targetVersion = getNewerPatchVersion(); + const { managedRoot, npmRecordPath } = prepareManagedInstall(targetVersion); + const releaseLock = await lockfile.lock(join(managedRoot, "update"), { realpath: false }); + mockManagedUpdate(targetVersion); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); + } finally { + await releaseLock(); + } + + expect(readFileSync(join(managedRoot, "current-version"), "utf8")).toBe(`${VERSION}\n`); + expect(existsSync(npmRecordPath)).toBe(false); + expect(logSpy.mock.calls.map(([message]) => String(message)).join("\n")).not.toContain("Updated pi from"); + expect(errorSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain( + "Another managed Pi update is already running.", + ); + expect(process.exitCode).toBe(1); + }); + + it("rejects forced managed reinstalls", async () => { + const targetVersion = getNewerPatchVersion(); + const { npmRecordPath } = prepareManagedInstall(targetVersion); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(runPackageCommandDirectly(["update", "--self", "--force"])).resolves.toBeUndefined(); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(existsSync(npmRecordPath)).toBe(false); + expect(errorSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain( + "Managed pi installations do not support --force", + ); + expect(process.exitCode).toBe(1); + }); + + it("keeps the managed release active when its update fails", async () => { + const targetVersion = getNewerPatchVersion(); + const { managedRoot } = prepareManagedInstall(targetVersion, 23); + mockManagedUpdate(targetVersion); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); + + expect(readFileSync(join(managedRoot, "current-version"), "utf8")).toBe(`${VERSION}\n`); + expect(existsSync(join(managedRoot, "releases", targetVersion))).toBe(false); + expect(readdirSync(join(managedRoot, "staging"))).toEqual([]); + expect(logSpy.mock.calls.map(([message]) => String(message)).join("\n")).not.toContain("Updated pi from"); + expect(errorSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain("exited with code 23"); + expect(process.exitCode).toBe(1); + }); + + it("keeps npm self-updates non-managed when the managed environment is inherited", async () => { const globalPrefix = join(tempDir, "global-prefix"); const projectPrefix = join(tempDir, "project-prefix"); const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@earendil-works", "pi-coding-agent"); + const inheritedManagedRoot = join(tempDir, "inherited-managed-install"); + mkdirSync(join(inheritedManagedRoot, "releases"), { recursive: true }); + writeFileSync( + join(inheritedManagedRoot, "managed-install.json"), + JSON.stringify({ kind: "pi-managed-install", schemaVersion: 1, layout: "releases-v1" }), + ); + vi.stubEnv("PI_MANAGED_INSTALL_ROOT", inheritedManagedRoot); const fakeNpmPath = join(tempDir, "fake-npm.cjs"); const recordPath = join(tempDir, "self-update.json"); mkdirSync(selfPackageDir, { recursive: true }); diff --git a/scripts/publish-release-announcement.mjs b/scripts/publish-release-announcement.mjs index 79d34e4e9..a0a090f27 100644 --- a/scripts/publish-release-announcement.mjs +++ b/scripts/publish-release-announcement.mjs @@ -57,8 +57,8 @@ function parseArgs(args) { if (!options.version || !STABLE_SEMVER_RE.test(options.version)) { throw new Error("--version must be a stable semver version"); } - if (Boolean(options.installerPackageJson) !== Boolean(options.installerPackageLock)) { - throw new Error("--installer-package-json and --installer-package-lock must be provided together"); + if (!options.installerPackageJson || !options.installerPackageLock) { + throw new Error("--installer-package-json and --installer-package-lock are required"); } return options; } @@ -302,6 +302,57 @@ async function main() { } writeFileSync(latestPath, `${JSON.stringify(release, null, "\t")}\n`); + validateInstallerArtifacts(options.installerPackageJson, options.installerPackageLock, options.version); + const installerReleasePrefix = `${INSTALLER_PREFIX}/releases/${options.version}`; + putObject( + options.bucket, + options.endpoint, + options.installerPackageJson, + `${installerReleasePrefix}/package.json`, + "public, max-age=31536000, immutable", + { missing: true }, + ); + putObject( + options.bucket, + options.endpoint, + options.installerPackageLock, + `${installerReleasePrefix}/package-lock.json`, + "public, max-age=31536000, immutable", + { missing: true }, + ); + putJson( + options.bucket, + options.endpoint, + releasePath, + `${installerReleasePrefix}/metadata.json`, + "public, max-age=31536000, immutable", + { missing: true }, + ); + const installerLatest = await advanceLatestRelease( + options.version, + () => + readLatestRelease( + options.bucket, + options.endpoint, + `${INSTALLER_PREFIX}/latest.json`, + join(temporaryDirectory, "installer-latest-current.json"), + ), + (condition) => + putJson( + options.bucket, + options.endpoint, + latestPath, + `${INSTALLER_PREFIX}/latest.json`, + "no-store", + condition, + ), + ); + console.log( + installerLatest.advanced + ? `Published installer artifacts for Pi ${options.version} through s3://${options.bucket}/${INSTALLER_PREFIX}/latest.json` + : `Pi ${installerLatest.version} is already the latest installer release.`, + ); + const result = await advanceLatestRelease( options.version, () => @@ -326,59 +377,6 @@ async function main() { ? `Announced Pi ${options.version} through s3://${options.bucket}/${RELEASES_PREFIX}/latest.json` : `Pi ${result.version} is already the latest announced release.`, ); - - if (options.installerPackageJson) { - validateInstallerArtifacts(options.installerPackageJson, options.installerPackageLock, options.version); - const installerReleasePrefix = `${INSTALLER_PREFIX}/releases/${options.version}`; - putObject( - options.bucket, - options.endpoint, - options.installerPackageJson, - `${installerReleasePrefix}/package.json`, - "public, max-age=31536000, immutable", - { missing: true }, - ); - putObject( - options.bucket, - options.endpoint, - options.installerPackageLock, - `${installerReleasePrefix}/package-lock.json`, - "public, max-age=31536000, immutable", - { missing: true }, - ); - putJson( - options.bucket, - options.endpoint, - releasePath, - `${installerReleasePrefix}/metadata.json`, - "public, max-age=31536000, immutable", - { missing: true }, - ); - const installerLatest = await advanceLatestRelease( - options.version, - () => - readLatestRelease( - options.bucket, - options.endpoint, - `${INSTALLER_PREFIX}/latest.json`, - join(temporaryDirectory, "installer-latest-current.json"), - ), - (condition) => - putJson( - options.bucket, - options.endpoint, - latestPath, - `${INSTALLER_PREFIX}/latest.json`, - "no-store", - condition, - ), - ); - console.log( - installerLatest.advanced - ? `Published installer artifacts for Pi ${options.version} through s3://${options.bucket}/${INSTALLER_PREFIX}/latest.json` - : `Pi ${installerLatest.version} is already the latest installer release.`, - ); - } } finally { rmSync(temporaryDirectory, { force: true, recursive: true }); } From 80e62761f7251a104f1b21d9c73920c720f0ec00 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 24 Aug 2026 12:27:03 +0200 Subject: [PATCH 2/8] feat(coding-agent): add optional PowerShell tool (#8512) --- packages/coding-agent/README.md | 6 +- .../docs/environment-variables.md | 16 ++-- packages/coding-agent/docs/extensions.md | 11 +-- packages/coding-agent/docs/sdk.md | 9 ++- packages/coding-agent/docs/settings.md | 10 ++- packages/coding-agent/docs/usage.md | 2 +- packages/coding-agent/docs/windows.md | 26 ++++++- packages/coding-agent/src/cli/args.ts | 15 ++-- .../coding-agent/src/core/extensions/index.ts | 3 + .../coding-agent/src/core/extensions/types.ts | 18 +++++ packages/coding-agent/src/core/sdk.ts | 2 + .../coding-agent/src/core/system-prompt.ts | 11 ++- packages/coding-agent/src/core/tools/bash.ts | 76 ++++++++++++++----- packages/coding-agent/src/core/tools/index.ts | 32 +++++++- .../coding-agent/src/core/tools/powershell.ts | 67 ++++++++++++++++ packages/coding-agent/src/index.ts | 13 +++- packages/coding-agent/src/utils/shell.ts | 26 +++++-- .../test/default-tools-setting.test.ts | 13 +++- .../experimental-tool-strict-mode.test.ts | 2 + .../coding-agent/test/powershell-tool.test.ts | 30 ++++++++ ...uiltin-tools-keeps-extension-tools.test.ts | 2 +- .../coding-agent/test/system-prompt.test.ts | 14 ++++ .../tool-system-prompt-contributions.test.ts | 12 ++- 23 files changed, 351 insertions(+), 65 deletions(-) create mode 100644 packages/coding-agent/src/core/tools/powershell.ts create mode 100644 packages/coding-agent/test/powershell-tool.test.ts diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 66a079007..0a6c1e14f 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -584,7 +584,7 @@ cat README.md | pi -p "Summarize this text" | `--no-builtin-tools`, `-nbt` | Disable built-in tools by default but keep extension/custom tools enabled | | `--no-tools`, `-nt` | Disable all tools by default | -Available built-in tools: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls` +Available built-in tools: `read`, `bash`, `powershell` (Windows), `edit`, `write`, `grep`, `find`, `ls` ### Resource Options @@ -682,7 +682,7 @@ pi --thinking high "Solve this complex problem" | `PI_CACHE_RETENTION` | Set to `long` for extended prompt cache (Anthropic: 1h, OpenAI: 24h) | | `VISUAL`, `EDITOR` | Fallback external editor for Ctrl+G when `externalEditor` is unset; defaults to Notepad on Windows and `nano` elsewhere | -Commands run by the LLM-callable bash tool also receive current session metadata: +Commands run by the LLM-callable `bash` and `powershell` tools also receive current session metadata: | Variable | Description | |----------|-------------| @@ -692,7 +692,7 @@ Commands run by the LLM-callable bash tool also receive current session metadata | `PI_MODEL` | Currently selected model ID | | `PI_REASONING_LEVEL` | Current effective reasoning level | -These values are resolved when each command starts. See [Environment Variables](docs/environment-variables.md#bash-tool-session-environment) for semantics, examples, and custom-tool opt-out. +These values are resolved when each command starts. See [Environment Variables](docs/environment-variables.md#shell-tool-session-environment) for semantics, examples, and custom-tool opt-out. --- diff --git a/packages/coding-agent/docs/environment-variables.md b/packages/coding-agent/docs/environment-variables.md index 29c324f63..fe9cafa8b 100644 --- a/packages/coding-agent/docs/environment-variables.md +++ b/packages/coding-agent/docs/environment-variables.md @@ -4,7 +4,7 @@ Pi uses environment variables in three ways: - Variables such as `PI_OFFLINE` configure the Pi process. - Pi sets process markers so child processes can identify Pi as the launching agent. -- Commands run by the LLM-callable bash tool receive `PI_*` variables describing the current session. +- Commands run by the LLM-callable shell tools receive `PI_*` variables describing the current session. Provider API-key variables are documented separately in [Providers](providers.md#environment-variables-or-auth-file). @@ -17,9 +17,9 @@ The CLI and RPC entry points set two process markers: Child processes inherit both markers. They are not session-specific and are not set automatically when Pi is embedded through the SDK. -## Bash Tool Session Environment +## Shell Tool Session Environment -Commands run by the bash tool receive the current Pi session state: +Commands run by the `bash` and `powershell` tools receive the current Pi session state: | Variable | Description | |----------|-------------| @@ -29,7 +29,7 @@ Commands run by the bash tool receive the current Pi session state: | `PI_MODEL` | Currently selected model ID | | `PI_REASONING_LEVEL` | Current effective reasoning level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max` | -The values are resolved when each command starts. Switching models or changing the reasoning level therefore affects the next bash command without restarting Pi. `PI_PROVIDER` and `PI_MODEL` identify the selected Pi model, not a different upstream model that a router may choose internally. +The values are resolved when each command starts. Switching models or changing the reasoning level therefore affects the next shell command without restarting Pi. `PI_PROVIDER` and `PI_MODEL` identify the selected Pi model, not a different upstream model that a router may choose internally. When asked which model or provider is running, inspect these variables instead of inferring the answer from the system prompt: @@ -46,11 +46,11 @@ if [ -n "$PI_SESSION_FILE" ]; then fi ``` -These variables are injected into the LLM-callable bash tool. They are not injected into user-entered `!` or `!!` commands. +These variables are injected into the LLM-callable `bash` and `powershell` tools. They are not injected into user-entered `!` or `!!` commands. -### Custom Bash Tools +### Custom Shell Tools -Bash tools created with `createBashTool()` expose the session environment by default when registered with Pi. Injection happens before `spawnHook`, so a hook receives the variables in `ctx.env`: +Tools created with `createBashTool()` or `createPowerShellTool()` expose the session environment by default when registered with Pi. Injection happens before `spawnHook`, so a hook receives the variables in `ctx.env`: ```typescript const bashTool = createBashTool(cwd, { @@ -64,7 +64,7 @@ const bashTool = createBashTool(cwd, { Disable session metadata independently of the spawn hook: ```typescript -const bashTool = createBashTool(cwd, { +const powershellTool = createPowerShellTool(cwd, { exposeSessionEnvironment: false, spawnHook: (ctx) => ctx, }); diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 2e069acdf..7643856be 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -2059,7 +2059,7 @@ pi.registerTool({ ### Overriding Built-in Tools -Extensions can override built-in tools (`read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`) by registering a tool with the same name. Interactive mode displays a warning when this happens. +Extensions can override built-in tools (`read`, `bash`, `powershell`, `edit`, `write`, `grep`, `find`, `ls`) by registering a tool with the same name. Interactive mode displays a warning when this happens. ```bash # Extension's read tool replaces built-in read @@ -2083,6 +2083,7 @@ See [examples/extensions/tool-override.ts](../examples/extensions/tool-override. Built-in tool implementations: - [read.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/read.ts) - `ReadToolDetails` - [bash.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/bash.ts) - `BashToolDetails` +- [powershell.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/powershell.ts) - `PowerShellToolDetails` - [edit.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/edit.ts) - [write.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/write.ts) - [grep.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/grep.ts) - `GrepToolDetails` @@ -2118,11 +2119,11 @@ pi.registerTool({ }); ``` -**Operations interfaces:** `ReadOperations`, `WriteOperations`, `EditOperations`, `BashOperations`, `LsOperations`, `GrepOperations`, `FindOperations` +**Operations interfaces:** `ReadOperations`, `WriteOperations`, `EditOperations`, `BashOperations`, `PowerShellOperations`, `LsOperations`, `GrepOperations`, `FindOperations` For `user_bash`, extensions can reuse pi's local shell backend via `createLocalBashOperations()` instead of reimplementing local process spawning, shell resolution, and process-tree termination. -The bash tool also supports a spawn hook to adjust the command, cwd, or env before execution: +The `bash` and `powershell` tools also support a spawn hook to adjust the command, cwd, or env before execution: ```typescript import { createBashTool } from "@earendil-works/pi-coding-agent"; @@ -2136,7 +2137,7 @@ const bashTool = createBashTool(cwd, { }); ``` -`createBashTool()` exposes the current session to commands through `PI_SESSION_ID`, `PI_SESSION_FILE`, `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL`. Injection happens before `spawnHook`, so hooks receive these values in `env` and preserve them when they spread the existing environment as above. Set `exposeSessionEnvironment: false` to disable them: +`createBashTool()` and `createPowerShellTool()` expose the current session to commands through `PI_SESSION_ID`, `PI_SESSION_FILE`, `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL`. Injection happens before `spawnHook`, so hooks receive these values in `env` and preserve them when they spread the existing environment as above. Set `exposeSessionEnvironment: false` to disable them: ```typescript const bashTool = createBashTool(cwd, { @@ -2144,7 +2145,7 @@ const bashTool = createBashTool(cwd, { }); ``` -See [Bash tool session environment](environment-variables.md#bash-tool-session-environment) for variable semantics. See [examples/extensions/ssh.ts](../examples/extensions/ssh.ts) for a complete SSH example with `--ssh` flag. +See [Shell tool session environment](environment-variables.md#shell-tool-session-environment) for variable semantics. See [examples/extensions/ssh.ts](../examples/extensions/ssh.ts) for a complete SSH example with `--ssh` flag. ### Output Truncation diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index a4e4f2276..5b75b2f8a 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -519,7 +519,7 @@ const { session } = await createAgentSession({ resourceLoader: loader }); Specify which built-in tools to enable: -- Built-in tool names: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls` +- Built-in tool names: `read`, `bash`, `powershell`, `edit`, `write`, `grep`, `find`, `ls` - Default built-ins: `read`, `bash`, `edit`, `write` - `noTools: "all"` disables all tools - `noTools: "builtin"` disables default built-ins while keeping extension and custom tools enabled @@ -540,6 +540,11 @@ const { session } = await createAgentSession({ tools: ["read", "bash", "grep"], }); +// Use PowerShell instead of Bash on Windows +const { session } = await createAgentSession({ + tools: ["read", "powershell", "edit", "write"], +}); + // Disable one tool while keeping the rest available const { session } = await createAgentSession({ excludeTools: ["ask_question"], @@ -1196,7 +1201,7 @@ SettingsManager // Tool factories createCodingTools createReadOnlyTools -createReadTool, createBashTool, createEditTool, createWriteTool +createReadTool, createBashTool, createPowerShellTool, createEditTool, createWriteTool createGrepTool, createFindTool, createLsTool // Types diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index b23392843..d0ded5f68 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -220,7 +220,7 @@ Windows paths in JSON must use forward slashes or escaped backslashes: |---------|------|---------|-------------| | `defaultTools` | string[] | - | Built-in tools enabled initially. When omitted, Pi uses its standard defaults | -`defaultTools` selects the built-in tools enabled at startup. Extension and SDK custom tools remain enabled: +`defaultTools` selects the built-in tools enabled at startup. Extension and SDK custom tools remain enabled. Available built-ins are `read`, `bash`, `powershell`, `edit`, `write`, `grep`, `find`, and `ls`: ```json { @@ -228,6 +228,14 @@ Windows paths in JSON must use forward slashes or escaped backslashes: } ``` +On Windows, select `powershell` instead of `bash`, or include both: + +```json +{ + "defaultTools": ["read", "powershell", "edit", "write"] +} +``` + An empty array starts with no built-in tools while preserving extension and SDK custom tools. `--tools` replaces this behavior with a strict allowlist for all tools, `--no-tools` disables all tools, and `--no-builtin-tools` disables the built-in defaults. `--exclude-tools` filters the resulting list. A project `defaultTools` array replaces the global array. ### Sessions diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index bbf60db1e..4db22b8c5 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -213,7 +213,7 @@ cat README.md | pi -p "Summarize this text" | `--no-builtin-tools`, `-nbt` | Disable built-in tools but keep extension/custom tools enabled | | `--no-tools`, `-nt` | Disable all tools | -Built-in tools: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`. +Built-in tools: `read`, `bash`, `powershell` (Windows), `edit`, `write`, `grep`, `find`, `ls`. ### Resource Options diff --git a/packages/coding-agent/docs/windows.md b/packages/coding-agent/docs/windows.md index 007f649c1..2517fd946 100644 --- a/packages/coding-agent/docs/windows.md +++ b/packages/coding-agent/docs/windows.md @@ -1,6 +1,6 @@ # Windows Setup -Pi requires a bash shell on Windows. Checked locations (in order): +Pi uses Git Bash by default on Windows. Checked locations (in order): 1. Custom path from `~/.pi/agent/settings.json` 2. Git Bash (`C:\Program Files\Git\bin\bash.exe`) @@ -8,7 +8,29 @@ Pi requires a bash shell on Windows. Checked locations (in order): For most users, [Git for Windows](https://git-scm.com/download/win) is sufficient. -## Custom Shell Path +## PowerShell Tool + +The optional `powershell` tool runs commands through `pwsh.exe` when available, otherwise Windows PowerShell. It starts PowerShell with `-NoProfile -NonInteractive -ExecutionPolicy Bypass`. Administrator-enforced execution policies can still take precedence. + +Use `defaultTools` to replace the model-facing `bash` tool: + +```json +{ + "defaultTools": ["read", "powershell", "edit", "write"] +} +``` + +Or enable both while comparing behavior: + +```json +{ + "defaultTools": ["read", "bash", "powershell", "edit", "write"] +} +``` + +The `!` and `!!` editor commands still use Bash. + +## Custom Bash Path ```json { diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 8e03419e6..8ad5da63e 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -435,12 +435,13 @@ ${chalk.bold("Environment Variables:")} PI_SHARE_VIEWER_URL - Base URL for /share command (default: https://pi.dev/session/) ${chalk.bold("Built-in Tool Names:")} - read - Read file contents - bash - Execute bash commands - edit - Edit files with find/replace - write - Write files (creates/overwrites) - grep - Search file contents (read-only, off by default) - find - Find files by glob pattern (read-only, off by default) - ls - List directory contents (read-only, off by default) + read - Read file contents + bash - Execute bash commands + powershell - Execute PowerShell commands on Windows + edit - Edit files with find/replace + write - Write files (creates/overwrites) + grep - Search file contents (read-only, off by default) + find - Find files by glob pattern (read-only, off by default) + ls - List directory contents (read-only, off by default) `); } diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index 4ef89cff9..90c786041 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -105,6 +105,8 @@ export type { MessageUpdateEvent, ModelSelectEvent, ModelSelectSource, + PowerShellToolCallEvent, + PowerShellToolResultEvent, ProjectTrustContext, ProjectTrustEvent, ProjectTrustEventDecision, @@ -180,6 +182,7 @@ export { isFindToolResult, isGrepToolResult, isLsToolResult, + isPowerShellToolResult, isReadToolResult, isToolCallEventType, isWriteToolResult, diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 9303c0bc4..bade46419 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -78,6 +78,8 @@ import type { GrepToolInput, LsToolDetails, LsToolInput, + PowerShellToolDetails, + PowerShellToolInput, ReadToolDetails, ReadToolInput, WriteToolInput, @@ -876,6 +878,11 @@ export interface BashToolCallEvent extends ToolCallEventBase { input: BashToolInput; } +export interface PowerShellToolCallEvent extends ToolCallEventBase { + toolName: "powershell"; + input: PowerShellToolInput; +} + export interface ReadToolCallEvent extends ToolCallEventBase { toolName: "read"; input: ReadToolInput; @@ -919,6 +926,7 @@ export interface CustomToolCallEvent extends ToolCallEventBase { */ export type ToolCallEvent = | BashToolCallEvent + | PowerShellToolCallEvent | ReadToolCallEvent | EditToolCallEvent | WriteToolCallEvent @@ -942,6 +950,11 @@ export interface BashToolResultEvent extends ToolResultEventBase { details: BashToolDetails | undefined; } +export interface PowerShellToolResultEvent extends ToolResultEventBase { + toolName: "powershell"; + details: PowerShellToolDetails | undefined; +} + export interface ReadToolResultEvent extends ToolResultEventBase { toolName: "read"; details: ReadToolDetails | undefined; @@ -980,6 +993,7 @@ export interface CustomToolResultEvent extends ToolResultEventBase { /** Fired after a tool executes. Can modify result. */ export type ToolResultEvent = | BashToolResultEvent + | PowerShellToolResultEvent | ReadToolResultEvent | EditToolResultEvent | WriteToolResultEvent @@ -992,6 +1006,9 @@ export type ToolResultEvent = export function isBashToolResult(e: ToolResultEvent): e is BashToolResultEvent { return e.toolName === "bash"; } +export function isPowerShellToolResult(e: ToolResultEvent): e is PowerShellToolResultEvent { + return e.toolName === "powershell"; +} export function isReadToolResult(e: ToolResultEvent): e is ReadToolResultEvent { return e.toolName === "read"; } @@ -1032,6 +1049,7 @@ export function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent { * CustomToolCallEvent.toolName is `string` which overlaps with all literals. */ export function isToolCallEventType(toolName: "bash", event: ToolCallEvent): event is BashToolCallEvent; +export function isToolCallEventType(toolName: "powershell", event: ToolCallEvent): event is PowerShellToolCallEvent; export function isToolCallEventType(toolName: "read", event: ToolCallEvent): event is ReadToolCallEvent; export function isToolCallEventType(toolName: "edit", event: ToolCallEvent): event is EditToolCallEvent; export function isToolCallEventType(toolName: "write", event: ToolCallEvent): event is WriteToolCallEvent; diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 8af041a57..adbf102a3 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -23,6 +23,7 @@ import { createFindTool, createGrepTool, createLsTool, + createPowerShellTool, createReadOnlyTools, createReadTool, createWriteTool, @@ -125,6 +126,7 @@ export { createGrepTool, createFindTool, createLsTool, + createPowerShellTool, }; // Helper Functions diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index 3b1aa6334..ec450678a 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -95,14 +95,21 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string { }; const hasBash = tools.includes("bash"); + const hasPowerShell = tools.includes("powershell"); const hasGrep = tools.includes("grep"); const hasFind = tools.includes("find"); const hasLs = tools.includes("ls"); const hasRead = tools.includes("read"); // File exploration guidelines - if (hasBash && !hasGrep && !hasFind && !hasLs) { - addGuideline("Use bash for file operations like ls, rg, find"); + if ((hasBash || hasPowerShell) && !hasGrep && !hasFind && !hasLs) { + if (hasBash && hasPowerShell) { + addGuideline("Use bash or PowerShell for file operations like listing, searching, and finding files"); + } else if (hasPowerShell) { + addGuideline("Use PowerShell for file operations like listing, searching, and finding files"); + } else { + addGuideline("Use bash for file operations like ls, rg, find"); + } } for (const guideline of promptGuidelines ?? []) { diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index 9c745df59..b3139b85d 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -12,6 +12,7 @@ import { getShellConfig, getShellEnv, killProcessTree, + type ShellConfig, trackDetachedChildPid, untrackDetachedChildPid, } from "../../utils/shell.ts"; @@ -39,7 +40,7 @@ function resolveTimeoutMs(timeout: number | undefined): number | undefined { } const bashSchema = Type.Object({ - command: Type.String({ description: "Bash command to execute" }), + command: Type.String({ description: "Shell command to execute" }), timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })), }); @@ -79,24 +80,19 @@ export interface BashOperations { ) => Promise<{ exitCode: number | null }>; } -/** - * Create bash operations using pi's built-in local shell execution backend. - * - * This is useful for extensions that intercept user_bash and still want pi's - * standard local shell behavior while wrapping or rewriting commands. - */ -export function createLocalBashOperations(options?: { shellPath?: string }): BashOperations { +/** Shared process execution used by the built-in shell tools. */ +export function createLocalShellOperations(shellName: string, resolveShellConfig: () => ShellConfig): BashOperations { return { exec: async (command, cwd, { onData, signal, timeout, env }) => { const timeoutMs = resolveTimeoutMs(timeout); if (signal?.aborted) { throw new Error("aborted"); } - const shellConfig = getShellConfig(options?.shellPath); + const shellConfig = resolveShellConfig(); try { await fsAccess(cwd, constants.F_OK); } catch { - throw new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`); + throw new Error(`Working directory does not exist: ${cwd}\nCannot execute ${shellName} commands.`); } const commandFromStdin = shellConfig.commandTransport === "stdin"; @@ -153,6 +149,16 @@ export function createLocalBashOperations(options?: { shellPath?: string }): Bas }; } +/** + * Create bash operations using pi's built-in local shell execution backend. + * + * This is useful for extensions that intercept user_bash and still want pi's + * standard local shell behavior while wrapping or rewriting commands. + */ +export function createLocalBashOperations(options?: { shellPath?: string }): BashOperations { + return createLocalShellOperations("bash", () => getShellConfig(options?.shellPath)); +} + export interface BashSpawnContext { command: string; cwd: string; @@ -205,7 +211,7 @@ export interface BashToolOptions { const BASH_PREVIEW_LINES = 5; const BASH_UPDATE_THROTTLE_MS = 100; -type BashRenderState = { +export type BashRenderState = { startedAt: number | undefined; endedAt: number | undefined; interval: NodeJS.Timeout | undefined; @@ -229,12 +235,12 @@ function formatDuration(ms: number): string { return `${(ms / 1000).toFixed(1)}s`; } -function formatBashCall(args: { command?: string; timeout?: number } | undefined): string { +function formatShellCall(args: { command?: string; timeout?: number } | undefined, prompt: string): string { const command = str(args?.command); const timeout = args?.timeout as number | undefined; const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : ""; const commandDisplay = command === null ? invalidArgText(theme) : command ? command : theme.fg("toolOutput", "..."); - return theme.fg("toolTitle", theme.bold(`$ ${commandDisplay}`)) + timeoutSuffix; + return theme.fg("toolTitle", theme.bold(`${prompt} ${commandDisplay}`)) + timeoutSuffix; } function rebuildBashResultRenderComponent( @@ -319,8 +325,19 @@ function rebuildBashResultRenderComponent( } } -export function createBashToolDefinition( +export interface ShellToolConfig { + name: string; + label: string; + shellName: string; + prompt: string; + promptSnippet: string; + promptGuidelines?: readonly string[]; + tempFilePrefix: string; +} + +export function createShellToolDefinition( cwd: string, + config: ShellToolConfig, options?: BashToolOptions, ): ToolDefinition { const ops = options?.operations ?? createLocalBashOperations({ shellPath: options?.shellPath }); @@ -328,11 +345,11 @@ export function createBashToolDefinition( const exposeSessionEnvironment = options?.exposeSessionEnvironment ?? true; const spawnHook = options?.spawnHook; return { - name: "bash", - label: "bash", - description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`, - promptSnippet: bashToolSystemPromptContribution.snippet, - promptGuidelines: exposeSessionEnvironment ? [...bashToolSystemPromptContribution.guidelines] : undefined, + name: config.name, + label: config.label, + description: `Execute a ${config.shellName} command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`, + promptSnippet: config.promptSnippet, + promptGuidelines: exposeSessionEnvironment && config.promptGuidelines ? [...config.promptGuidelines] : undefined, parameters: bashSchema, constrainedSampling: getExperimentalToolSampling(), async execute( @@ -344,7 +361,7 @@ export function createBashToolDefinition( ) { const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command; const spawnContext = resolveSpawnContext(resolvedCommand, cwd, spawnHook, exposeSessionEnvironment, ctx); - const output = new OutputAccumulator({ tempFilePrefix: "pi-bash" }); + const output = new OutputAccumulator({ tempFilePrefix: config.tempFilePrefix }); let acceptingOutput = true; let updateTimer: NodeJS.Timeout | undefined; let updateDirty = false; @@ -468,7 +485,7 @@ export function createBashToolDefinition( state.endedAt = undefined; } const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); - text.setText(formatBashCall(args)); + text.setText(formatShellCall(args, config.prompt)); return text; }, renderResult(result, options, _theme, context) { @@ -499,6 +516,23 @@ export function createBashToolDefinition( }; } +const bashToolConfig: ShellToolConfig = { + name: "bash", + label: "bash", + shellName: "bash", + prompt: "$", + promptSnippet: bashToolSystemPromptContribution.snippet, + promptGuidelines: bashToolSystemPromptContribution.guidelines, + tempFilePrefix: "pi-bash", +}; + +export function createBashToolDefinition( + cwd: string, + options?: BashToolOptions, +): ToolDefinition { + return createShellToolDefinition(cwd, bashToolConfig, options); +} + export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool { const definition = createBashToolDefinition(cwd, options); const tool = wrapToolDefinition(definition); diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index e55f91406..5e8fd5b36 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -42,6 +42,17 @@ export { type LsToolInput, type LsToolOptions, } from "./ls.ts"; +export { + createLocalPowerShellOperations, + createPowerShellTool, + createPowerShellToolDefinition, + type PowerShellOperations, + type PowerShellSpawnContext, + type PowerShellSpawnHook, + type PowerShellToolDetails, + type PowerShellToolInput, + type PowerShellToolOptions, +} from "./powershell.ts"; export { createReadTool, createReadToolDefinition, @@ -75,17 +86,28 @@ import { createEditTool, createEditToolDefinition, type EditToolOptions } from " import { createFindTool, createFindToolDefinition, type FindToolOptions } from "./find.ts"; import { createGrepTool, createGrepToolDefinition, type GrepToolOptions } from "./grep.ts"; import { createLsTool, createLsToolDefinition, type LsToolOptions } from "./ls.ts"; +import { createPowerShellTool, createPowerShellToolDefinition, type PowerShellToolOptions } from "./powershell.ts"; import { createReadTool, createReadToolDefinition, type ReadToolOptions } from "./read.ts"; import { createWriteTool, createWriteToolDefinition, type WriteToolOptions } from "./write.ts"; export type Tool = AgentTool; export type ToolDef = ToolDefinition; -export type ToolName = "read" | "bash" | "edit" | "write" | "grep" | "find" | "ls"; -export const allToolNames: Set = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]); +export type ToolName = "read" | "bash" | "powershell" | "edit" | "write" | "grep" | "find" | "ls"; +export const allToolNames: Set = new Set([ + "read", + "bash", + "powershell", + "edit", + "write", + "grep", + "find", + "ls", +]); export interface ToolsOptions { read?: ReadToolOptions; bash?: BashToolOptions; + powershell?: PowerShellToolOptions; write?: WriteToolOptions; edit?: EditToolOptions; grep?: GrepToolOptions; @@ -99,6 +121,8 @@ export function createToolDefinition(toolName: ToolName, cwd: string, options?: return createReadToolDefinition(cwd, options?.read); case "bash": return createBashToolDefinition(cwd, options?.bash); + case "powershell": + return createPowerShellToolDefinition(cwd, options?.powershell); case "edit": return createEditToolDefinition(cwd, options?.edit); case "write": @@ -120,6 +144,8 @@ export function createTool(toolName: ToolName, cwd: string, options?: ToolsOptio return createReadTool(cwd, options?.read); case "bash": return createBashTool(cwd, options?.bash); + case "powershell": + return createPowerShellTool(cwd, options?.powershell); case "edit": return createEditTool(cwd, options?.edit); case "write": @@ -157,6 +183,7 @@ export function createAllToolDefinitions(cwd: string, options?: ToolsOptions): R return { read: createReadToolDefinition(cwd, options?.read), bash: createBashToolDefinition(cwd, options?.bash), + powershell: createPowerShellToolDefinition(cwd, options?.powershell), edit: createEditToolDefinition(cwd, options?.edit), write: createWriteToolDefinition(cwd, options?.write), grep: createGrepToolDefinition(cwd, options?.grep), @@ -187,6 +214,7 @@ export function createAllTools(cwd: string, options?: ToolsOptions): Record {} + +export function createLocalPowerShellOperations(): PowerShellOperations { + const operations = createLocalShellOperations("PowerShell", getPowerShellConfig); + return { + exec: (command, cwd, options) => operations.exec(`${UTF8_OUTPUT_PREFIX}${command}`, cwd, options), + }; +} + +const powershellToolConfig: ShellToolConfig = { + name: "powershell", + label: "powershell", + shellName: "PowerShell", + prompt: "PS>", + promptSnippet: powershellToolSystemPromptContribution.snippet, + promptGuidelines: powershellToolSystemPromptContribution.guidelines, + tempFilePrefix: "pi-powershell", +}; + +export function createPowerShellToolDefinition( + cwd: string, + options?: PowerShellToolOptions, +): ReturnType { + return createShellToolDefinition(cwd, powershellToolConfig, { + ...options, + operations: options?.operations ?? createLocalPowerShellOperations(), + }); +} + +export function createPowerShellTool(cwd: string, options?: PowerShellToolOptions): ReturnType { + const definition = createPowerShellToolDefinition(cwd, options); + const tool = wrapToolDefinition(definition); + Object.assign(tool, { + promptSnippet: definition.promptSnippet, + promptGuidelines: definition.promptGuidelines, + }); + return tool; +} diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 6d64d70c8..210b84010 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -107,6 +107,7 @@ export type { MessageRenderOptions, MessageStartEvent, MessageUpdateEvent, + PowerShellToolCallEvent, ProjectTrustContext, ProjectTrustEvent, ProjectTrustEventDecision, @@ -159,6 +160,7 @@ export { isFindToolResult, isGrepToolResult, isLsToolResult, + isPowerShellToolResult, isReadToolResult, isToolCallEventType, isWriteToolResult, @@ -218,6 +220,7 @@ export { createFindTool, createGrepTool, createLsTool, + createPowerShellTool, createReadOnlyTools, createReadTool, createWriteTool, @@ -285,7 +288,9 @@ export { createFindToolDefinition, createGrepToolDefinition, createLocalBashOperations, + createLocalPowerShellOperations, createLsToolDefinition, + createPowerShellToolDefinition, createReadToolDefinition, createWriteToolDefinition, DEFAULT_MAX_BYTES, @@ -307,6 +312,12 @@ export { type LsToolDetails, type LsToolInput, type LsToolOptions, + type PowerShellOperations, + type PowerShellSpawnContext, + type PowerShellSpawnHook, + type PowerShellToolDetails, + type PowerShellToolInput, + type PowerShellToolOptions, type ReadOperations, type ReadToolDetails, type ReadToolInput, @@ -405,4 +416,4 @@ export { parseFrontmatter, stripFrontmatter } from "./utils/frontmatter.ts"; export { convertToPng } from "./utils/image-convert.ts"; export { formatDimensionNote, type ResizedImage, resizeImage } from "./utils/image-resize.ts"; // Shell utilities -export { getShellConfig } from "./utils/shell.ts"; +export { getPowerShellConfig, getShellConfig } from "./utils/shell.ts"; diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts index 2cafa595e..fae75267e 100644 --- a/packages/coding-agent/src/utils/shell.ts +++ b/packages/coding-agent/src/utils/shell.ts @@ -21,11 +21,11 @@ function getBashShellConfig(shell: string): ShellConfig { return isLegacyWslBashPath(shell) ? { shell, args: ["-s"], commandTransport: "stdin" } : { shell, args: ["-c"] }; } -function findBashOnPath(): string | null { +function findExecutableOnPath(executable: string): string | null { if (process.platform === "win32") { // Windows: Use 'where' and verify file exists (where can return non-existent paths) try { - const result = spawnSync("where", ["bash.exe"], { + const result = spawnSync("where", [executable], { encoding: "utf-8", timeout: 5000, windowsHide: true, @@ -44,7 +44,7 @@ function findBashOnPath(): string | null { // Unix: Use 'which' and trust its output (handles Termux and special filesystems) try { - const result = spawnSync("which", ["bash"], { encoding: "utf-8", timeout: 5000 }); + const result = spawnSync("which", [executable], { encoding: "utf-8", timeout: 5000 }); if (result.status === 0 && result.stdout) { const firstMatch = result.stdout.trim().split(/\r?\n/)[0]; if (firstMatch) { @@ -92,7 +92,7 @@ export function getShellConfig(customShellPath?: string): ShellConfig { } // 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.) - const bashOnPath = findBashOnPath(); + const bashOnPath = findExecutableOnPath("bash.exe"); if (bashOnPath) { return getBashShellConfig(bashOnPath); } @@ -111,7 +111,7 @@ export function getShellConfig(customShellPath?: string): ShellConfig { return getBashShellConfig("/bin/bash"); } - const bashOnPath = findBashOnPath(); + const bashOnPath = findExecutableOnPath("bash"); if (bashOnPath) { return getBashShellConfig(bashOnPath); } @@ -119,6 +119,22 @@ export function getShellConfig(customShellPath?: string): ShellConfig { return { shell: "sh", args: ["-c"] }; } +export const POWERSHELL_ARGS = ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command"] as const; + +/** Resolve PowerShell on Windows, preferring PowerShell 7 when available. */ +export function getPowerShellConfig(): ShellConfig { + if (process.platform !== "win32") { + throw new Error("The powershell tool is only available on Windows."); + } + + const shell = findExecutableOnPath("pwsh.exe") ?? findExecutableOnPath("powershell.exe"); + if (!shell) { + throw new Error("No PowerShell executable found. Install PowerShell or add powershell.exe/pwsh.exe to PATH."); + } + + return { shell, args: [...POWERSHELL_ARGS] }; +} + export function getShellEnv(): NodeJS.ProcessEnv { const binDir = getBinDir(); const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH"; diff --git a/packages/coding-agent/test/default-tools-setting.test.ts b/packages/coding-agent/test/default-tools-setting.test.ts index 69adbceb5..5b9331400 100644 --- a/packages/coding-agent/test/default-tools-setting.test.ts +++ b/packages/coding-agent/test/default-tools-setting.test.ts @@ -63,13 +63,22 @@ describe("defaultTools setting", () => { .getAllTools() .map((tool) => tool.name) .sort(), - ).toEqual(["bash", "edit", "find", "grep", "ls", "read", "write"]); + ).toEqual(["bash", "edit", "find", "grep", "ls", "powershell", "read", "write"]); expect(session.getActiveToolNames()).toEqual(["grep", "find"]); expect(session.systemPrompt).toContain("- grep:"); expect(session.systemPrompt).not.toContain("- read:"); session.dispose(); }); + it("can select powershell instead of bash", async () => { + const session = await createSession(["read", "powershell", "edit", "write"]); + + expect(session.getActiveToolNames()).toEqual(["read", "powershell", "edit", "write"]); + expect(session.systemPrompt).toContain("- powershell: Execute PowerShell commands"); + expect(session.systemPrompt).not.toContain("- bash:"); + session.dispose(); + }); + it("keeps extension and SDK custom tools enabled", async () => { const session = await createSession( ["grep"], @@ -143,7 +152,7 @@ describe("defaultTools setting", () => { .getAllTools() .map((tool) => tool.name) .sort(), - ).toEqual(["bash", "edit", "find", "grep", "ls", "read", "write"]); + ).toEqual(["bash", "edit", "find", "grep", "ls", "powershell", "read", "write"]); expect(session.getActiveToolNames()).toEqual(["ls"]); session.dispose(); }); diff --git a/packages/coding-agent/test/experimental-tool-strict-mode.test.ts b/packages/coding-agent/test/experimental-tool-strict-mode.test.ts index 07a00d94c..2e8b0334d 100644 --- a/packages/coding-agent/test/experimental-tool-strict-mode.test.ts +++ b/packages/coding-agent/test/experimental-tool-strict-mode.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { createBashToolDefinition, createEditToolDefinition, + createPowerShellToolDefinition, createReadToolDefinition, createWriteToolDefinition, } from "../src/core/tools/index.ts"; @@ -10,6 +11,7 @@ function createBuiltInTools() { return [ createReadToolDefinition(process.cwd()), createBashToolDefinition(process.cwd()), + createPowerShellToolDefinition(process.cwd()), createEditToolDefinition(process.cwd()), createWriteToolDefinition(process.cwd()), ]; diff --git a/packages/coding-agent/test/powershell-tool.test.ts b/packages/coding-agent/test/powershell-tool.test.ts new file mode 100644 index 000000000..22bc193c8 --- /dev/null +++ b/packages/coding-agent/test/powershell-tool.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { createPowerShellTool } from "../src/core/tools/powershell.ts"; +import { getPowerShellConfig, POWERSHELL_ARGS } from "../src/utils/shell.ts"; + +function getTextOutput(result: { content: Array<{ type: string; text?: string }> }): string { + return result.content + .filter((content) => content.type === "text") + .map((content) => content.text ?? "") + .join("\n"); +} + +describe("powershell tool", () => { + it("uses process-local execution policy bypass", () => { + expect(POWERSHELL_ARGS).toEqual(["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command"]); + }); + + it.skipIf(process.platform !== "win32")("executes PowerShell commands with UTF-8 output", async () => { + const config = getPowerShellConfig(); + expect(config.args).toEqual(POWERSHELL_ARGS); + + const tool = createPowerShellTool(process.cwd()); + const result = await tool.execute("powershell-test", { + command: "Write-Output 'héllo €'; Get-ExecutionPolicy -Scope Process", + }); + const output = getTextOutput(result); + + expect(output).toContain("héllo €"); + expect(output).toContain("Bypass"); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/3592-no-builtin-tools-keeps-extension-tools.test.ts b/packages/coding-agent/test/suite/regressions/3592-no-builtin-tools-keeps-extension-tools.test.ts index 3d900ee36..86ad26a08 100644 --- a/packages/coding-agent/test/suite/regressions/3592-no-builtin-tools-keeps-extension-tools.test.ts +++ b/packages/coding-agent/test/suite/regressions/3592-no-builtin-tools-keeps-extension-tools.test.ts @@ -78,7 +78,7 @@ describe("regression #3592: no-builtin-tools keeps extension tools enabled", () .getAllTools() .map((tool) => tool.name) .sort(), - ).toEqual(["bash", "dynamic_tool", "edit", "find", "grep", "ls", "read", "write"]); + ).toEqual(["bash", "dynamic_tool", "edit", "find", "grep", "ls", "powershell", "read", "write"]); expect(session.getActiveToolNames()).toEqual(["dynamic_tool"]); expect(session.systemPrompt).toContain("- dynamic_tool: Run dynamic test behavior"); expect(session.systemPrompt).not.toContain("- read:"); diff --git a/packages/coding-agent/test/system-prompt.test.ts b/packages/coding-agent/test/system-prompt.test.ts index 6e38fcb0f..41d83207b 100644 --- a/packages/coding-agent/test/system-prompt.test.ts +++ b/packages/coding-agent/test/system-prompt.test.ts @@ -46,6 +46,20 @@ describe("buildSystemPrompt", () => { expect(prompt).toContain("- write:"); }); + test.each([ + [["powershell"], "Use PowerShell for file operations"], + [["bash", "powershell"], "Use bash or PowerShell for file operations"], + ] as const)("uses shell-specific guidance for %j", (selectedTools, expected) => { + const prompt = buildSystemPrompt({ + selectedTools: [...selectedTools], + contextFiles: [], + skills: [], + cwd: process.cwd(), + }); + + expect(prompt).toContain(expected); + }); + test("instructs models to resolve pi docs and examples under absolute base paths", () => { const prompt = buildSystemPrompt({ contextFiles: [], diff --git a/packages/coding-agent/test/tool-system-prompt-contributions.test.ts b/packages/coding-agent/test/tool-system-prompt-contributions.test.ts index 4a2927c16..ed39fa508 100644 --- a/packages/coding-agent/test/tool-system-prompt-contributions.test.ts +++ b/packages/coding-agent/test/tool-system-prompt-contributions.test.ts @@ -4,12 +4,17 @@ import { createEditToolDefinition, editToolSystemPromptContribution } from "../s import { createFindToolDefinition, findToolSystemPromptContribution } from "../src/core/tools/find.ts"; import { createGrepToolDefinition, grepToolSystemPromptContribution } from "../src/core/tools/grep.ts"; import { createLsToolDefinition, lsToolSystemPromptContribution } from "../src/core/tools/ls.ts"; +import { + createPowerShellToolDefinition, + powershellToolSystemPromptContribution, +} from "../src/core/tools/powershell.ts"; import { createReadToolDefinition, readToolSystemPromptContribution } from "../src/core/tools/read.ts"; import { createWriteToolDefinition, writeToolSystemPromptContribution } from "../src/core/tools/write.ts"; const cases = [ ["read", readToolSystemPromptContribution, createReadToolDefinition], ["bash", bashToolSystemPromptContribution, createBashToolDefinition], + ["powershell", powershellToolSystemPromptContribution, createPowerShellToolDefinition], ["edit", editToolSystemPromptContribution, createEditToolDefinition], ["write", writeToolSystemPromptContribution, createWriteToolDefinition], ["grep", grepToolSystemPromptContribution, createGrepToolDefinition], @@ -28,8 +33,11 @@ describe("built-in tool system prompt contributions", () => { }, ); - test("keeps bash session-environment guidance conditional", () => { - const definition = createBashToolDefinition("/workspace", { exposeSessionEnvironment: false }); + test.each([ + ["bash", createBashToolDefinition], + ["powershell", createPowerShellToolDefinition], + ] as const)("keeps %s session-environment guidance conditional", (_name, createDefinition) => { + const definition = createDefinition("/workspace", { exposeSessionEnvironment: false }); expect(definition.promptGuidelines).toBeUndefined(); }); From 7623e8a0f1728aec54b84374c525856fb9471f26 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 24 Aug 2026 12:47:11 +0200 Subject: [PATCH 3/8] docs: audit unreleased changelogs --- packages/agent/CHANGELOG.md | 5 ++++ packages/ai/CHANGELOG.md | 13 ++++++++- packages/coding-agent/CHANGELOG.md | 46 ++++++++++++++++++++++++++++-- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 679110f18..dee760b6a 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Fixed + +- Fixed single-object `edit` tool inputs failing validation by accepting them as one-edit arrays ([#7835](https://github.com/earendil-works/pi/issues/7835)). +- Fixed root Markdown files such as `README.md` and `AGENTS.md` in skill directories being reported as broken skills unless they declare valid skill frontmatter ([#7805](https://github.com/earendil-works/pi/issues/7805)). + ## [0.84.2] - 2026-08-14 ### Fixed diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 49d2ce9e5..9bae0529d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -9,14 +9,25 @@ ### Added - Added provider-neutral `toolChoice` support to simple stream requests. +- Added automatic Anthropic server-side refusal fallback for supported first-party models, including returned-model usage pricing ([#8017](https://github.com/earendil-works/pi/issues/8017)). +- Added configurable OpenAI-compatible thinking-token budget fields for vLLM, Qwen/SGLang, and llama.cpp servers ([#8275](https://github.com/earendil-works/pi/pull/8275) by [@bnsd55](https://github.com/bnsd55)). - Added China-specific ZAI Coding Plan models, including GLM-4.6V vision support, and API-equivalent usage cost estimates for models with published PAYG prices ([#8220](https://github.com/earendil-works/pi/issues/8220)). +- Added `deepseek-v4-pro-0813` to the Qwen Token Plan Individual catalog ([#8194](https://github.com/earendil-works/pi/issues/8194)). + +### Changed + +- Changed built-in xAI models to use the Responses API with encrypted reasoning replay and made Grok 4.6 the default xAI model ([#8124](https://github.com/earendil-works/pi/pull/8124) by [@Jaaneek](https://github.com/Jaaneek)). +- Changed the Anthropic, Azure OpenAI, Google Generative AI, Google Vertex, Mistral, OpenAI Chat Completions, and OpenAI Responses adapters to send Pi's default `User-Agent` unless overridden ([#8305](https://github.com/earendil-works/pi/issues/8305)). ### Fixed - Fixed OpenAI-compatible Chat Completions reasoning replay to preserve and resend assistant-level `reasoning_details` (`reasoning.text`, `reasoning.summary`, and `reasoning.encrypted`) verbatim and in order ([#7994](https://github.com/earendil-works/pi/issues/7994)). - Fixed Anthropic server-side fallback responses being priced with the requested model instead of the returned fallback model ([#8285](https://github.com/earendil-works/pi/issues/8285)). +- Fixed GitHub Copilot login triggering model-policy rate limits by limiting policy updates, retrying model discovery once, and honoring server retry delays ([#7850](https://github.com/earendil-works/pi/issues/7850)). +- Fixed Amazon Bedrock dropping and failing to replay opaque redacted reasoning from non-Anthropic models ([#8314](https://github.com/earendil-works/pi/pull/8314) by [@seiji](https://github.com/seiji)). +- Fixed Z.AI Coding Plan models deriving incomplete reasoning-effort metadata, including missing GLM-5.3 low, high, and max levels ([#8336](https://github.com/earendil-works/pi/issues/8336)). +- Fixed DeepSeek V4 Flash on OpenCode and OpenCode Go omitting its supported low thinking level ([#8181](https://github.com/earendil-works/pi/pull/8181) by [@tianshuang](https://github.com/tianshuang)). - Fixed Azure OpenAI Responses ignoring `toolChoice` in provider-specific stream requests. -- Added `deepseek-v4-pro-0813` to the Qwen Token Plan Individual catalog ([#8194](https://github.com/earendil-works/pi/issues/8194)). - Fixed Amazon Bedrock `after_provider_response`/`onResponse` to forward the raw response headers instead of only the synthesized request id header ([#8234](https://github.com/earendil-works/pi/issues/8234)). - Fixed Kimi OpenAI-compatible usage reporting so top-level `cached_tokens` count as cache reads instead of normal input tokens ([#8075](https://github.com/earendil-works/pi/issues/8075)). - Fixed Google Generative AI and Vertex AI custom models ignoring `thinkingLevelMap`, which dropped extended thinking controls ([#8135](https://github.com/earendil-works/pi/issues/8135)). diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5394de918..247765762 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,12 +2,34 @@ ## [Unreleased] +### New Features + +- **PowerShell tool** — Use optional native PowerShell command execution on Windows. See [PowerShell Tool](docs/windows.md#powershell-tool). +- **Safer managed updates** — Stage, verify, and atomically activate updates for installer-managed installations. See [Install and Manage](docs/packages.md#install-and-manage). +- **Model and thinking controls** — Select thinking levels with `/thinking`, search defaults, keep selections session-scoped, and persist them explicitly with Ctrl+S. See [Models and Thinking](docs/keybindings.md#models-and-thinking). + +### Breaking Changes + +- Renamed the inherited `GoogleThinkingLevel` type to `GoogleApiThinkingLevel` and added `ResolvedGoogleThinkingLevel` for normalized adapter levels. + ### Added +- Added an optional `powershell` tool for Windows, configurable through `defaultTools` and the SDK. See [PowerShell Tool](docs/windows.md#powershell-tool). +- Added a `/thinking` selector and searchable default choices to the model and thinking selectors; Ctrl+S saves the selected model as the global default. See [Models and Thinking](docs/keybindings.md#models-and-thinking). +- Added optional routing session IDs to exported compaction summary helpers so callers can preserve provider routing without enabling prompt cache writes. - Added transcript usage notices for compaction and branch summaries when cache miss notices are enabled. +- Added `session_compact_failed` extension events so compaction failures and aborts expose their reason, retry state, source, and error message to handlers ([#8175](https://github.com/earendil-works/pi/issues/8175)). +- Added inherited provider-neutral `toolChoice` support to simple stream requests. +- Added inherited automatic Anthropic server-side refusal fallback for supported first-party models, including returned-model usage pricing ([#8017](https://github.com/earendil-works/pi/issues/8017)). +- Added inherited configurable OpenAI-compatible thinking-token budget fields for vLLM, Qwen/SGLang, and llama.cpp servers. See [OpenAI Compatibility](docs/models.md#openai-compatibility) ([#8275](https://github.com/earendil-works/pi/pull/8275) by [@bnsd55](https://github.com/bnsd55)). +- Added inherited China-specific ZAI Coding Plan models, including GLM-4.6V vision support and API-equivalent usage cost estimates ([#8220](https://github.com/earendil-works/pi/issues/8220)). +- Added inherited `deepseek-v4-pro-0813` support to the Qwen Token Plan Individual catalog ([#8194](https://github.com/earendil-works/pi/issues/8194)). ### Changed +- Changed experimental installer-managed installations so `pi update` stages, verifies, and atomically activates the selected release in place. See [Install and Manage](docs/packages.md#install-and-manage). +- Changed inherited built-in xAI models to use the Responses API with encrypted reasoning replay and made Grok 4.6 the default xAI model ([#8124](https://github.com/earendil-works/pi/pull/8124) by [@Jaaneek](https://github.com/Jaaneek)). +- Changed inherited Anthropic, Azure OpenAI, Google, Mistral, and OpenAI adapters to send Pi's default `User-Agent` unless overridden ([#8305](https://github.com/earendil-works/pi/issues/8305)). - Changed Windows and WSL keybinding defaults to avoid terminal-reserved shortcuts for image paste, model cycling, editor undo, fullscreen transcript navigation and search, and message queueing ([#8372](https://github.com/earendil-works/pi/issues/8372)). - Changed Bun release archives to ship the native clipboard binary only inside the wrapper package, removing a duplicate platform package from each archive. - Changed package resource glob expansion to use Node.js's built-in implementation with deterministic visible-path matching, reducing the installed runtime dependency tree. @@ -18,18 +40,38 @@ ### Fixed -- Fixed `models.json` typings omitting the documented OpenAI-compatible `compat.supportsFinishReason` provider and model override ([#8460](https://github.com/earendil-works/pi/issues/8460)). +- Fixed failed extension factories leaving event subscriptions, provider registrations, and default flag state active ([#8424](https://github.com/earendil-works/pi/pull/8424) by [@acmerfight](https://github.com/acmerfight)). +- Fixed `models.json` typings omitting the documented OpenAI-compatible `compat.supportsFinishReason` provider and model override ([#8487](https://github.com/earendil-works/pi/pull/8487) by [@petrroll](https://github.com/petrroll)). +- Fixed `/model` and `/thinking` selections being persisted globally unless explicitly saved with Ctrl+S ([#5263](https://github.com/earendil-works/pi/issues/5263)). +- Fixed JSON and RPC `toolcall_start` events omitting the tool call id and name ([#7953](https://github.com/earendil-works/pi/pull/7953) by [@christianklotz](https://github.com/christianklotz)). +- Fixed extensions failing to load when the Node.js CLI runs as a single-executable application ([#8237](https://github.com/earendil-works/pi/issues/8237)). +- Fixed nested Markdown skills inside `.agents/skills/` grouping directories not being discovered. +- Fixed compaction and branch summarization requests exposing tools to providers. +- Fixed single-object `edit` tool inputs failing validation by accepting them as one-edit arrays in both coding-agent and harness edit tools ([#7835](https://github.com/earendil-works/pi/issues/7835)). +- Fixed root Markdown files such as `README.md` and `AGENTS.md` in skill directories being reported as broken skills unless they declare valid skill frontmatter ([#7805](https://github.com/earendil-works/pi/issues/7805)). +- Fixed the default Cerebras model referencing an unavailable Z.AI model. +- Fixed inherited OpenAI-compatible Chat Completions reasoning replay to preserve and resend assistant-level `reasoning_details` verbatim and in order ([#7994](https://github.com/earendil-works/pi/issues/7994)). +- Fixed inherited Anthropic server-side fallback responses being priced with the requested model instead of the returned fallback model ([#8285](https://github.com/earendil-works/pi/issues/8285)). +- Fixed inherited GitHub Copilot login triggering model-policy rate limits by limiting policy updates, retrying model discovery once, and honoring server retry delays ([#7850](https://github.com/earendil-works/pi/issues/7850)). +- Fixed inherited Amazon Bedrock dropping and failing to replay opaque redacted reasoning from non-Anthropic models ([#8314](https://github.com/earendil-works/pi/pull/8314) by [@seiji](https://github.com/seiji)). +- Fixed inherited Z.AI Coding Plan models deriving incomplete reasoning-effort metadata, including missing GLM-5.3 low, high, and max levels ([#8336](https://github.com/earendil-works/pi/issues/8336)). +- Fixed inherited DeepSeek V4 Flash on OpenCode and OpenCode Go omitting its supported low thinking level ([#8181](https://github.com/earendil-works/pi/pull/8181) by [@tianshuang](https://github.com/tianshuang)). +- Fixed inherited Azure OpenAI Responses ignoring `toolChoice` in provider-specific stream requests. +- Fixed inherited Amazon Bedrock response hooks receiving only a synthesized request id instead of the raw response headers ([#8234](https://github.com/earendil-works/pi/issues/8234)). +- Fixed inherited Kimi usage reporting so top-level `cached_tokens` count as cache reads instead of normal input tokens ([#8075](https://github.com/earendil-works/pi/issues/8075)). +- Fixed inherited Google custom models ignoring `thinkingLevelMap`, which dropped extended thinking controls ([#8135](https://github.com/earendil-works/pi/issues/8135)). - Fixed writes to `auth.json` and `models-store.json` overriding administrator-managed file permissions and ACLs ([#7779](https://github.com/earendil-works/pi/issues/7779)). - Fixed UTF-8 BOM markers preventing frontmatter and user configuration files from loading ([#8337](https://github.com/earendil-works/pi/issues/8337)). - Fixed invalid settings files being easy to miss during interactive startup by rendering warnings with the file path inside the TUI ([#7829](https://github.com/earendil-works/pi/issues/7829)). - Fixed the subagent example repeatedly prompting before running project-local agents in trusted repositories ([#8261](https://github.com/earendil-works/pi/issues/8261)). -- Added `session_compact_failed` extension events so compaction failures and aborts expose their reason, retry state, source, and error message to handlers ([#8175](https://github.com/earendil-works/pi/issues/8175)). - Fixed npm package update checks treating older registry versions as available updates, preventing `pi update` from downgrading already-newer installed packages ([#8226](https://github.com/earendil-works/pi/issues/8226)). - Fixed built-in llama.cpp models disappearing from `/model` when `/llama` refreshed a configured server under `PI_OFFLINE`, and included idle-slept `sleeping` router models in the selectable catalog ([#8167](https://github.com/earendil-works/pi/issues/8167)). - Fixed `pi.registerFlag()` accepting default values that do not match the declared flag type ([#8064](https://github.com/earendil-works/pi/issues/8064)). - Fixed Z.AI Coding Plan defaults referencing the removed GLM-5.1 model ([#8096](https://github.com/earendil-works/pi/issues/8096)). - Fixed repeated ambiguous truncated-response recovery being mislabeled as context overflow ([#8130](https://github.com/earendil-works/pi/issues/8130)). - Fixed duplicate fullscreen right-click paste in VS Code-based terminals on Windows ([#8186](https://github.com/earendil-works/pi/issues/8186)). +- Fixed inherited padded text exceeding narrow terminal widths ([#8252](https://github.com/earendil-works/pi/issues/8252)). +- Fixed inherited wrapped Markdown table links leaking color into borders and neighboring cells, including tables inside blockquotes ([#8335](https://github.com/earendil-works/pi/issues/8335)). - Fixed llama.cpp login guidance to direct users to `/llama` before `/model` when no local models are loaded ([#8203](https://github.com/earendil-works/pi/issues/8203)). - Fixed hung pi.dev model catalog requests consuming the entire refresh deadline without retrying ([#8198](https://github.com/earendil-works/pi/issues/8198)). - Fixed inherited Xiaomi model catalogs listing shut-down MiMo V2 models in `/model` and `--list-models` ([#8187](https://github.com/earendil-works/pi/issues/8187)). From 4e58f324fae8ebfa98a3d45181fb248072a2afac Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 24 Aug 2026 12:50:48 +0200 Subject: [PATCH 4/8] Release v0.84.3 --- package-lock.json | 60 +++++++++---------- packages/agent/CHANGELOG.md | 2 +- packages/agent/package.json | 6 +- packages/ai/CHANGELOG.md | 2 +- packages/ai/package.json | 4 +- packages/client/CHANGELOG.md | 2 +- packages/client/package.json | 4 +- packages/coding-agent/CHANGELOG.md | 2 +- .../package-lock.json | 4 +- .../custom-provider-anthropic/package.json | 2 +- .../custom-provider-gitlab-duo/package.json | 2 +- .../extensions/gondolin/package-lock.json | 4 +- .../examples/extensions/gondolin/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 +- .../install-lock/package-lock.json | 52 ++++++++-------- .../coding-agent/install-lock/package.json | 4 +- packages/coding-agent/npm-shrinkwrap.json | 46 +++++++------- packages/coding-agent/package.json | 12 ++-- packages/evals/package.json | 6 +- packages/protocol/CHANGELOG.md | 2 +- packages/protocol/package.json | 2 +- packages/server/CHANGELOG.md | 2 +- packages/server/package.json | 6 +- .../session-backends/sqlite-node/CHANGELOG.md | 2 +- .../session-backends/sqlite-node/package.json | 6 +- packages/telemetry/CHANGELOG.md | 2 +- packages/telemetry/package.json | 2 +- packages/tui/CHANGELOG.md | 2 +- packages/tui/package.json | 2 +- 32 files changed, 128 insertions(+), 128 deletions(-) diff --git a/package-lock.json b/package-lock.json index c2ad9261a..37842d14c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5329,11 +5329,11 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.84.2", + "version": "0.84.3", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.84.2", - "@earendil-works/pi-telemetry": "^0.84.2", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-telemetry": "^0.84.3", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", @@ -5358,12 +5358,12 @@ }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.84.2", + "version": "0.84.3", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@earendil-works/pi-telemetry": "^0.84.2", + "@earendil-works/pi-telemetry": "^0.84.3", "@google/genai": "1.52.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", @@ -5386,10 +5386,10 @@ }, "packages/client": { "name": "@earendil-works/pi-client", - "version": "0.84.2", + "version": "0.84.3", "license": "MIT", "dependencies": { - "@earendil-works/pi-protocol": "^0.84.2" + "@earendil-works/pi-protocol": "^0.84.3" }, "devDependencies": { "shx": "0.4.0", @@ -5401,14 +5401,14 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "0.84.2", + "version": "0.84.3", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.84.2", - "@earendil-works/pi-ai": "^0.84.2", - "@earendil-works/pi-client": "^0.84.2", - "@earendil-works/pi-protocol": "^0.84.2", - "@earendil-works/pi-tui": "^0.84.2", + "@earendil-works/pi-agent-core": "^0.84.3", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-client": "^0.84.3", + "@earendil-works/pi-protocol": "^0.84.3", + "@earendil-works/pi-tui": "^0.84.3", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -5447,32 +5447,32 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.84.2", + "version": "0.84.3", "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.84.2" + "version": "0.84.3" }, "packages/coding-agent/examples/extensions/gondolin": { "name": "pi-extension-gondolin", - "version": "0.84.2", + "version": "0.84.3", "dependencies": { "@earendil-works/gondolin": "0.12.0" } }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.14.2", + "version": "1.14.3", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.84.2", + "version": "0.84.3", "dependencies": { "ms": "2.1.3" }, @@ -5526,10 +5526,10 @@ }, "packages/evals": { "name": "@earendil-works/pi-evals", - "version": "0.84.2", + "version": "0.84.3", "devDependencies": { - "@earendil-works/pi-ai": "^0.84.2", - "@earendil-works/pi-coding-agent": "^0.84.2", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-coding-agent": "^0.84.3", "@types/node": "22.19.19", "shx": "0.4.0", "typescript": "5.9.3", @@ -5539,7 +5539,7 @@ }, "packages/protocol": { "name": "@earendil-works/pi-protocol", - "version": "0.84.2", + "version": "0.84.3", "license": "MIT", "dependencies": { "typebox": "1.3.7" @@ -5554,11 +5554,11 @@ }, "packages/server": { "name": "@earendil-works/pi-server", - "version": "0.84.2", + "version": "0.84.3", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.84.2", - "@earendil-works/pi-protocol": "^0.84.2" + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-protocol": "^0.84.3" }, "devDependencies": { "shx": "0.4.0", @@ -5570,11 +5570,11 @@ }, "packages/session-backends/sqlite-node": { "name": "@earendil-works/pi-session-backend-sqlite-node", - "version": "0.84.2", + "version": "0.84.3", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.84.2", - "@earendil-works/pi-ai": "^0.84.2" + "@earendil-works/pi-agent-core": "^0.84.3", + "@earendil-works/pi-ai": "^0.84.3" }, "devDependencies": { "@vitest/coverage-v8": "4.1.9", @@ -5586,7 +5586,7 @@ }, "packages/telemetry": { "name": "@earendil-works/pi-telemetry", - "version": "0.84.2", + "version": "0.84.3", "license": "MIT", "devDependencies": { "@types/node": "22.19.19", @@ -5598,7 +5598,7 @@ }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.84.2", + "version": "0.84.3", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index dee760b6a..a6bc83770 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.3] - 2026-08-24 ### Fixed diff --git a/packages/agent/package.json b/packages/agent/package.json index b1b81d85c..e472eaac5 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "0.84.2", + "version": "0.84.3", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -35,8 +35,8 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "^0.84.2", - "@earendil-works/pi-telemetry": "^0.84.2", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-telemetry": "^0.84.3", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 9bae0529d..f3281fa01 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.3] - 2026-08-24 ### Breaking Changes diff --git a/packages/ai/package.json b/packages/ai/package.json index 38fc4f46e..70c8d4986 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.84.2", + "version": "0.84.3", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", @@ -62,7 +62,7 @@ "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@earendil-works/pi-telemetry": "^0.84.2", + "@earendil-works/pi-telemetry": "^0.84.3", "@google/genai": "1.52.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md index 49ecd548f..cd2fa5f8c 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.3] - 2026-08-24 ## [0.84.2] - 2026-08-14 diff --git a/packages/client/package.json b/packages/client/package.json index 45d5a103f..5ca649b91 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-client", - "version": "0.84.2", + "version": "0.84.3", "description": "Transport-neutral client for remote pi sessions over framed CBOR bytes", "type": "module", "main": "./dist/index.js", @@ -47,7 +47,7 @@ "node": ">=22.19.0" }, "dependencies": { - "@earendil-works/pi-protocol": "^0.84.2" + "@earendil-works/pi-protocol": "^0.84.3" }, "devDependencies": { "shx": "0.4.0", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 247765762..fde46b8a5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.3] - 2026-08-24 ### 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 272f0fcb7..a5ce47769 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.84.2", + "version": "0.84.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "0.84.2", + "version": "0.84.3", "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 7c8231631..9de50e63b 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.84.2", + "version": "0.84.3", "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 9a8e14ff5..247c4d5b4 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.84.2", + "version": "0.84.3", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/gondolin/package-lock.json b/packages/coding-agent/examples/extensions/gondolin/package-lock.json index df80c87fd..1beb731ef 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package-lock.json +++ b/packages/coding-agent/examples/extensions/gondolin/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-gondolin", - "version": "0.84.2", + "version": "0.84.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-gondolin", - "version": "0.84.2", + "version": "0.84.3", "dependencies": { "@earendil-works/gondolin": "0.12.0" } diff --git a/packages/coding-agent/examples/extensions/gondolin/package.json b/packages/coding-agent/examples/extensions/gondolin/package.json index 450c74f69..eac937af0 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package.json +++ b/packages/coding-agent/examples/extensions/gondolin/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-gondolin", "private": true, - "version": "0.84.2", + "version": "0.84.3", "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 22d485114..6a422b32b 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.14.2", + "version": "1.14.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.14.2", + "version": "1.14.3", "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 57ba99af5..2d37b5c82 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.14.2", + "version": "1.14.3", "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 fc39ce641..b0d0855aa 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.84.2", + "version": "0.84.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "0.84.2", + "version": "0.84.3", "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 907d96bc1..3532ad7a7 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.84.2", + "version": "0.84.3", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index c594cf91f..44ab528ce 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -1,14 +1,14 @@ { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.84.2", + "version": "0.84.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.84.2", + "version": "0.84.3", "dependencies": { - "@earendil-works/pi-coding-agent": "0.84.2" + "@earendil-works/pi-coding-agent": "0.84.3" }, "engines": { "node": ">=22.19.0" @@ -450,12 +450,12 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.2.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.3.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.84.2", - "@earendil-works/pi-telemetry": "^0.84.2", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-telemetry": "^0.84.3", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", @@ -466,13 +466,13 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.2.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.3.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@earendil-works/pi-telemetry": "^0.84.2", + "@earendil-works/pi-telemetry": "^0.84.3", "@google/genai": "1.52.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", @@ -489,26 +489,26 @@ } }, "node_modules/@earendil-works/pi-client": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.2.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.3.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-protocol": "^0.84.2" + "@earendil-works/pi-protocol": "^0.84.3" }, "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.84.2.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.84.3.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.84.2", - "@earendil-works/pi-ai": "^0.84.2", - "@earendil-works/pi-client": "^0.84.2", - "@earendil-works/pi-protocol": "^0.84.2", - "@earendil-works/pi-tui": "^0.84.2", + "@earendil-works/pi-agent-core": "^0.84.3", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-client": "^0.84.3", + "@earendil-works/pi-protocol": "^0.84.3", + "@earendil-works/pi-tui": "^0.84.3", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -536,8 +536,8 @@ } }, "node_modules/@earendil-works/pi-protocol": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.2.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.3.tgz", "license": "MIT", "dependencies": { "typebox": "1.3.7" @@ -547,16 +547,16 @@ } }, "node_modules/@earendil-works/pi-telemetry": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.2.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.3.tgz", "license": "MIT", "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.2.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.3.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/coding-agent/install-lock/package.json b/packages/coding-agent/install-lock/package.json index 4c35a51ec..e7cf5a2af 100644 --- a/packages/coding-agent/install-lock/package.json +++ b/packages/coding-agent/install-lock/package.json @@ -1,10 +1,10 @@ { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.84.2", + "version": "0.84.3", "private": true, "description": "Lockfile root used by the Pi installer and updater.", "dependencies": { - "@earendil-works/pi-coding-agent": "0.84.2" + "@earendil-works/pi-coding-agent": "0.84.3" }, "overrides": { "protobufjs": "7.6.5", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 1b892389f..f65c23df9 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -1,19 +1,19 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.84.2", + "version": "0.84.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent", - "version": "0.84.2", + "version": "0.84.3", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.84.2", - "@earendil-works/pi-ai": "^0.84.2", - "@earendil-works/pi-client": "^0.84.2", - "@earendil-works/pi-protocol": "^0.84.2", - "@earendil-works/pi-tui": "^0.84.2", + "@earendil-works/pi-agent-core": "^0.84.3", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-client": "^0.84.3", + "@earendil-works/pi-protocol": "^0.84.3", + "@earendil-works/pi-tui": "^0.84.3", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -476,12 +476,12 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.2.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.3.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.84.2", - "@earendil-works/pi-telemetry": "^0.84.2", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-telemetry": "^0.84.3", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", @@ -492,13 +492,13 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.2.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.3.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@earendil-works/pi-telemetry": "^0.84.2", + "@earendil-works/pi-telemetry": "^0.84.3", "@google/genai": "1.52.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", @@ -515,19 +515,19 @@ } }, "node_modules/@earendil-works/pi-client": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.2.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.3.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-protocol": "^0.84.2" + "@earendil-works/pi-protocol": "^0.84.3" }, "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-protocol": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.2.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.3.tgz", "license": "MIT", "dependencies": { "typebox": "1.3.7" @@ -537,16 +537,16 @@ } }, "node_modules/@earendil-works/pi-telemetry": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.2.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.3.tgz", "license": "MIT", "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.2.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.3.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 11b6cd28e..02f45a37b 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.84.2", + "version": "0.84.3", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -44,11 +44,11 @@ "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" }, "dependencies": { - "@earendil-works/pi-agent-core": "^0.84.2", - "@earendil-works/pi-ai": "^0.84.2", - "@earendil-works/pi-client": "^0.84.2", - "@earendil-works/pi-protocol": "^0.84.2", - "@earendil-works/pi-tui": "^0.84.2", + "@earendil-works/pi-agent-core": "^0.84.3", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-client": "^0.84.3", + "@earendil-works/pi-protocol": "^0.84.3", + "@earendil-works/pi-tui": "^0.84.3", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", diff --git a/packages/evals/package.json b/packages/evals/package.json index a6fa94da5..b02e89a8e 100644 --- a/packages/evals/package.json +++ b/packages/evals/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-evals", - "version": "0.84.2", + "version": "0.84.3", "private": true, "type": "module", "scripts": { @@ -9,8 +9,8 @@ "test": "vitest run --config vitest.test.config.ts" }, "devDependencies": { - "@earendil-works/pi-ai": "^0.84.2", - "@earendil-works/pi-coding-agent": "^0.84.2", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-coding-agent": "^0.84.3", "@types/node": "22.19.19", "shx": "0.4.0", "typescript": "5.9.3", diff --git a/packages/protocol/CHANGELOG.md b/packages/protocol/CHANGELOG.md index d4328d9ea..487363b2e 100644 --- a/packages/protocol/CHANGELOG.md +++ b/packages/protocol/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.3] - 2026-08-24 ## [0.84.2] - 2026-08-14 diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 1c943ec27..5ac7bf7e7 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-protocol", - "version": "0.84.2", + "version": "0.84.3", "description": "Transport-neutral CBOR protocol for remote pi sessions", "type": "module", "main": "./dist/index.js", diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md index 1725d40d7..587218275 100644 --- a/packages/server/CHANGELOG.md +++ b/packages/server/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.3] - 2026-08-24 ## [0.84.2] - 2026-08-14 diff --git a/packages/server/package.json b/packages/server/package.json index cdbbf338a..b079277bf 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-server", - "version": "0.84.2", + "version": "0.84.3", "description": "experimental server package for pi", "type": "module", "main": "./dist/index.js", @@ -47,8 +47,8 @@ "node": ">=22.19.0" }, "dependencies": { - "@earendil-works/pi-ai": "^0.84.2", - "@earendil-works/pi-protocol": "^0.84.2" + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-protocol": "^0.84.3" }, "devDependencies": { "shx": "0.4.0", diff --git a/packages/session-backends/sqlite-node/CHANGELOG.md b/packages/session-backends/sqlite-node/CHANGELOG.md index 30064cb53..59d3f84f4 100644 --- a/packages/session-backends/sqlite-node/CHANGELOG.md +++ b/packages/session-backends/sqlite-node/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.3] - 2026-08-24 ## [0.84.2] - 2026-08-14 diff --git a/packages/session-backends/sqlite-node/package.json b/packages/session-backends/sqlite-node/package.json index 145fffeac..6849b14e8 100644 --- a/packages/session-backends/sqlite-node/package.json +++ b/packages/session-backends/sqlite-node/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-session-backend-sqlite-node", - "version": "0.84.2", + "version": "0.84.3", "description": "Node sqlite session backend for @earendil-works/pi-agent-core sessions", "type": "module", "main": "./dist/index.js", @@ -34,8 +34,8 @@ "node": ">=22.19.0" }, "dependencies": { - "@earendil-works/pi-ai": "^0.84.2", - "@earendil-works/pi-agent-core": "^0.84.2" + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-agent-core": "^0.84.3" }, "devDependencies": { "@vitest/coverage-v8": "4.1.9", diff --git a/packages/telemetry/CHANGELOG.md b/packages/telemetry/CHANGELOG.md index e9de35b64..91cfb2656 100644 --- a/packages/telemetry/CHANGELOG.md +++ b/packages/telemetry/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.3] - 2026-08-24 ## [0.84.2] - 2026-08-14 diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index 8b6c27780..49ffa4396 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-telemetry", - "version": "0.84.2", + "version": "0.84.3", "description": "Vendor-neutral telemetry contracts and typed schema utilities for pi", "type": "module", "main": "./dist/index.js", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 3bd2a2fda..4b0e494c2 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.3] - 2026-08-24 ### Fixed diff --git a/packages/tui/package.json b/packages/tui/package.json index 034d351ab..7e015439b 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-tui", - "version": "0.84.2", + "version": "0.84.3", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", From 31d4ed586092309b8835edf22e79cb993fb82091 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 24 Aug 2026 12:50:52 +0200 Subject: [PATCH 5/8] Add [Unreleased] section for next cycle --- packages/agent/CHANGELOG.md | 2 ++ packages/ai/CHANGELOG.md | 2 ++ packages/client/CHANGELOG.md | 2 ++ packages/coding-agent/CHANGELOG.md | 2 ++ packages/protocol/CHANGELOG.md | 2 ++ packages/server/CHANGELOG.md | 2 ++ packages/session-backends/sqlite-node/CHANGELOG.md | 2 ++ packages/telemetry/CHANGELOG.md | 2 ++ packages/tui/CHANGELOG.md | 2 ++ 9 files changed, 18 insertions(+) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index a6bc83770..c057c3228 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.3] - 2026-08-24 ### Fixed diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index f3281fa01..6cc3e74da 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.3] - 2026-08-24 ### Breaking Changes diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md index cd2fa5f8c..726fa233d 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.3] - 2026-08-24 ## [0.84.2] - 2026-08-14 diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index fde46b8a5..1761c8d0f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.3] - 2026-08-24 ### New Features diff --git a/packages/protocol/CHANGELOG.md b/packages/protocol/CHANGELOG.md index 487363b2e..f0c58f21c 100644 --- a/packages/protocol/CHANGELOG.md +++ b/packages/protocol/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.3] - 2026-08-24 ## [0.84.2] - 2026-08-14 diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md index 587218275..ab89c9590 100644 --- a/packages/server/CHANGELOG.md +++ b/packages/server/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.3] - 2026-08-24 ## [0.84.2] - 2026-08-14 diff --git a/packages/session-backends/sqlite-node/CHANGELOG.md b/packages/session-backends/sqlite-node/CHANGELOG.md index 59d3f84f4..c9368d125 100644 --- a/packages/session-backends/sqlite-node/CHANGELOG.md +++ b/packages/session-backends/sqlite-node/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.3] - 2026-08-24 ## [0.84.2] - 2026-08-14 diff --git a/packages/telemetry/CHANGELOG.md b/packages/telemetry/CHANGELOG.md index 91cfb2656..e11e6186a 100644 --- a/packages/telemetry/CHANGELOG.md +++ b/packages/telemetry/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.3] - 2026-08-24 ## [0.84.2] - 2026-08-14 diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 4b0e494c2..cc92212ee 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.3] - 2026-08-24 ### Fixed From bfb004d4418ff05c6f909eaaab856cbe75c1fde0 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 24 Aug 2026 12:53:52 +0200 Subject: [PATCH 6/8] fix: extract Windows release ZIPs in CI --- .github/workflows/build-binaries.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index f007f26e1..37facbe2f 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -182,7 +182,14 @@ jobs: esac mkdir -p extracted - tar -xf "${archive}" -C extracted + if [[ "${archive}" == *.zip ]]; then + export ARCHIVE_PATH="$(cygpath -w "${archive}")" + export DESTINATION_PATH="$(cygpath -w extracted)" + powershell.exe -NoProfile -NonInteractive -Command \ + 'Expand-Archive -LiteralPath $env:ARCHIVE_PATH -DestinationPath $env:DESTINATION_PATH -Force' + else + tar -xf "${archive}" -C extracted + fi echo "root=${root}" >> "${GITHUB_OUTPUT}" echo "binary=${binary}" >> "${GITHUB_OUTPUT}" From 97fa14e39cfce78c273a36b2d9e8509cd5bc6b72 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Mon, 24 Aug 2026 13:07:30 +0200 Subject: [PATCH 7/8] fix(coding-agent): reject truncated compaction summaries closes #7048 --- packages/coding-agent/CHANGELOG.md | 2 + .../core/compaction/branch-summarization.ts | 7 +-- .../src/core/compaction/compaction.ts | 24 ++++++++-- .../test/branch-summarization.test.ts | 24 ++++++++++ .../test/compaction-summary-reasoning.test.ts | 33 +++++++++++++ .../7048-compaction-truncated-summary.test.ts | 48 +++++++++++++++++++ 6 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/7048-compaction-truncated-summary.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1761c8d0f..850c304a9 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -66,6 +66,8 @@ - Fixed UTF-8 BOM markers preventing frontmatter and user configuration files from loading ([#8337](https://github.com/earendil-works/pi/issues/8337)). - Fixed invalid settings files being easy to miss during interactive startup by rendering warnings with the file path inside the TUI ([#7829](https://github.com/earendil-works/pi/issues/7829)). - Fixed the subagent example repeatedly prompting before running project-local agents in trusted repositories ([#8261](https://github.com/earendil-works/pi/issues/8261)). +- Added `session_compact_failed` extension events so compaction failures and aborts expose their reason, retry state, source, and error message to handlers ([#8175](https://github.com/earendil-works/pi/issues/8175)). +- Fixed truncated compaction and branch summaries being persisted when generation reaches its output token limit ([#7048](https://github.com/earendil-works/pi/issues/7048)). - Fixed npm package update checks treating older registry versions as available updates, preventing `pi update` from downgrading already-newer installed packages ([#8226](https://github.com/earendil-works/pi/issues/8226)). - Fixed built-in llama.cpp models disappearing from `/model` when `/llama` refreshed a configured server under `PI_OFFLINE`, and included idle-slept `sleeping` router models in the selectable catalog ([#8167](https://github.com/earendil-works/pi/issues/8167)). - Fixed `pi.registerFlag()` accepting default values that do not match the declared flag type ([#8064](https://github.com/earendil-works/pi/issues/8064)). diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index 8b0d6d3c5..d0abd5450 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -16,7 +16,7 @@ import { createCustomMessage, } from "../messages.ts"; import type { ReadonlySessionManager, SessionEntry } from "../session-manager.ts"; -import { completeSummarization, estimateTokens } from "./compaction.ts"; +import { completeSummarization, estimateTokens, getSummarizationFailure } from "./compaction.ts"; import { computeFileLists, createFileOps, @@ -354,8 +354,9 @@ export async function generateBranchSummary( if (response.stopReason === "aborted") { return { aborted: true }; } - if (response.stopReason === "error") { - return { error: response.errorMessage || "Summarization failed" }; + const failure = getSummarizationFailure(response, "Branch summarization"); + if (failure) { + return { error: failure }; } if (response.content.some((block) => block.type === "toolCall")) { return { error: "Branch summarization attempted to call a tool" }; diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index b7df9da34..cd442d4aa 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -538,6 +538,20 @@ const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation mes ${UPDATE_SUMMARIZATION_INSTRUCTIONS}`; +/** + * Returns an error message when a summarization response cannot safely be persisted. + * A length stop contains partial text and must not become a session checkpoint. + */ +export function getSummarizationFailure(response: AssistantMessage, label: string): string | undefined { + if (response.stopReason === "error") { + return `${label} failed: ${response.errorMessage || "Unknown error"}`; + } + if (response.stopReason === "length") { + return `${label} failed: generation hit the token cap and the summary is incomplete`; + } + return undefined; +} + function createSummarizationOptions( model: Model, maxTokens: number, @@ -699,8 +713,9 @@ export async function generateSummaryWithUsage( callbacks, ); - if (response.stopReason === "error") { - throw new Error(`Summarization failed: ${response.errorMessage || "Unknown error"}`); + const failure = getSummarizationFailure(response, "Summarization"); + if (failure) { + throw new Error(failure); } if (response.content.some((block) => block.type === "toolCall")) { throw new Error("Summarization attempted to call a tool"); @@ -983,8 +998,9 @@ async function generateTurnPrefixSummary( callbacks, ); - if (response.stopReason === "error") { - throw new Error(`Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`); + const failure = getSummarizationFailure(response, "Turn prefix summarization"); + if (failure) { + throw new Error(failure); } if (response.content.some((block) => block.type === "toolCall")) { throw new Error("Turn prefix summarization attempted to call a tool"); diff --git a/packages/coding-agent/test/branch-summarization.test.ts b/packages/coding-agent/test/branch-summarization.test.ts index 875dce881..e54da181f 100644 --- a/packages/coding-agent/test/branch-summarization.test.ts +++ b/packages/coding-agent/test/branch-summarization.test.ts @@ -87,4 +87,28 @@ describe("branch summarization", () => { expect(result.error).toBe("Branch summarization attempted to call a tool"); }); + + it("rejects length-limited branch summaries", async () => { + const streamFn: StreamFn = () => { + const stream = createAssistantMessageEventStream(); + queueMicrotask(() => + stream.push({ + type: "done", + reason: "length", + message: { ...response([{ type: "text", text: "partial" }]), stopReason: "length" }, + }), + ); + return stream; + }; + + const result = await generateBranchSummary(entries, { + model, + signal: new AbortController().signal, + streamFn, + }); + + expect(result.error).toBe( + "Branch summarization failed: generation hit the token cap and the summary is incomplete", + ); + }); }); diff --git a/packages/coding-agent/test/compaction-summary-reasoning.test.ts b/packages/coding-agent/test/compaction-summary-reasoning.test.ts index 037469cab..5a4d8e86b 100644 --- a/packages/coding-agent/test/compaction-summary-reasoning.test.ts +++ b/packages/coding-agent/test/compaction-summary-reasoning.test.ts @@ -173,6 +173,39 @@ describe("generateSummary reasoning options", () => { ); }); + it("rejects a length-limited history summary", async () => { + completeSimpleMock.mockResolvedValueOnce({ + ...mockSummaryResponse, + stopReason: "length", + content: [{ type: "text", text: "partial" }], + }); + + await expect(generateSummaryWithUsage(messages, createModel(false), 2000, "test-key")).rejects.toThrow( + "generation hit the token cap", + ); + }); + + it("rejects a length-limited split-turn summary", async () => { + completeSimpleMock.mockResolvedValueOnce({ + ...mockSummaryResponse, + stopReason: "length", + content: [{ type: "text", text: "partial" }], + }); + const preparation: CompactionPreparation = { + firstKeptEntryId: "entry-keep", + messagesToSummarize: [], + turnPrefixMessages: messages, + isSplitTurn: true, + tokensBefore: 100, + fileOps: { read: new Set(), written: new Set(), edited: new Set() }, + settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 }, + }; + + await expect(compact(preparation, createModel(false), "test-key")).rejects.toThrow( + "generation hit the token cap", + ); + }); + it("does not set reasoning when thinking is off", async () => { await generateSummary( messages, diff --git a/packages/coding-agent/test/suite/regressions/7048-compaction-truncated-summary.test.ts b/packages/coding-agent/test/suite/regressions/7048-compaction-truncated-summary.test.ts new file mode 100644 index 000000000..7935951dd --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/7048-compaction-truncated-summary.test.ts @@ -0,0 +1,48 @@ +import { type AssistantMessage, fauxAssistantMessage } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it } from "vitest"; +import { createHarness, type Harness } from "../harness.ts"; + +function seedCompactableSession(harness: Harness): void { + harness.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } }); + const now = Date.now(); + harness.sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "message to compact" }], + timestamp: now - 1000, + }); + const model = harness.getModel(); + const assistant: AssistantMessage = { + ...fauxAssistantMessage("assistant response to compact", { timestamp: now - 500 }), + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 100, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 100, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }; + harness.sessionManager.appendMessage(assistant); + harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages; +} + +describe("#7048 truncated compaction summaries", () => { + let harness: Harness | undefined; + + afterEach(() => { + harness?.cleanup(); + harness = undefined; + }); + + it("does not persist a length-limited summary", async () => { + harness = await createHarness(); + seedCompactableSession(harness); + harness.setResponses([fauxAssistantMessage("partial summar", { stopReason: "length" })]); + + await expect(harness.session.compact()).rejects.toThrow("generation hit the token cap"); + expect(harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction")).toHaveLength(0); + }); +}); From dcd461925db2edf69a43c8135db1180d418afd54 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:29:51 +0200 Subject: [PATCH 8/8] feat: show llama presets if autoload enabled (#8558) --- packages/coding-agent/CHANGELOG.md | 2 +- .../src/extensions/llama/client.ts | 11 +++ .../src/extensions/llama/provider.ts | 39 ++++++++-- .../coding-agent/test/llama-extension.test.ts | 72 +++++++++++++++++++ 4 files changed, 116 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 850c304a9..d3885a628 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -69,7 +69,7 @@ - Added `session_compact_failed` extension events so compaction failures and aborts expose their reason, retry state, source, and error message to handlers ([#8175](https://github.com/earendil-works/pi/issues/8175)). - Fixed truncated compaction and branch summaries being persisted when generation reaches its output token limit ([#7048](https://github.com/earendil-works/pi/issues/7048)). - Fixed npm package update checks treating older registry versions as available updates, preventing `pi update` from downgrading already-newer installed packages ([#8226](https://github.com/earendil-works/pi/issues/8226)). -- Fixed built-in llama.cpp models disappearing from `/model` when `/llama` refreshed a configured server under `PI_OFFLINE`, and included idle-slept `sleeping` router models in the selectable catalog ([#8167](https://github.com/earendil-works/pi/issues/8167)). +- Fixed built-in llama.cpp models disappearing from `/model` when `/llama` refreshed a configured server under `PI_OFFLINE`, and included idle-slept `sleeping` router models plus autoloadable unloaded presets in the selectable catalog ([#8167](https://github.com/earendil-works/pi/issues/8167)). - Fixed `pi.registerFlag()` accepting default values that do not match the declared flag type ([#8064](https://github.com/earendil-works/pi/issues/8064)). - Fixed Z.AI Coding Plan defaults referencing the removed GLM-5.1 model ([#8096](https://github.com/earendil-works/pi/issues/8096)). - Fixed repeated ambiguous truncated-response recovery being mislabeled as context overflow ([#8130](https://github.com/earendil-works/pi/issues/8130)). diff --git a/packages/coding-agent/src/extensions/llama/client.ts b/packages/coding-agent/src/extensions/llama/client.ts index 45cd8f3a6..c071f518c 100644 --- a/packages/coding-agent/src/extensions/llama/client.ts +++ b/packages/coding-agent/src/extensions/llama/client.ts @@ -28,6 +28,10 @@ export interface LlamaModelsResponse { object?: string; } +export interface LlamaServerProps { + models_autoload?: boolean; +} + export interface LlamaModelEvent { model: string; event: string; @@ -189,6 +193,13 @@ export class LlamaClient { return data; } + async props(options: { signal?: AbortSignal } = {}): Promise { + const payload = await this.request("/props", { signal: options.signal }); + if (typeof payload !== "object" || payload === null) return {}; + const { models_autoload: modelsAutoload } = payload as Record; + return typeof modelsAutoload === "boolean" ? { models_autoload: modelsAutoload } : {}; + } + async load(model: string, signal?: AbortSignal): Promise { await this.request("/models/load", { method: "POST", body: JSON.stringify({ model }), signal }); } diff --git a/packages/coding-agent/src/extensions/llama/provider.ts b/packages/coding-agent/src/extensions/llama/provider.ts index 83fa72ddc..6a685938d 100644 --- a/packages/coding-agent/src/extensions/llama/provider.ts +++ b/packages/coding-agent/src/extensions/llama/provider.ts @@ -25,9 +25,25 @@ async function resolveServerUrl( return configured ? normalizeLlamaServerUrl(configured) : undefined; } -function modelIsSelectable(model: LlamaModelInfo): boolean { +function modelIsSelectable(model: LlamaModelInfo, routerAutoload: boolean): boolean { + if (model.status.value === "loaded") return true; // llama.cpp reports idle-slept models as "sleeping"; requests wake them automatically. - return model.status.value === "loaded" || model.status.value === "sleeping"; + if (model.status.value === "sleeping") return true; + // Unloaded presets are routable only when llama.cpp router autoload can load them on first use. + return routerAutoload && model.status.value === "unloaded" && !model.status.failed && model.source === "preset"; +} + +async function routerAutoloadEnabled( + client: LlamaClient, + catalog: readonly LlamaModelInfo[], + signal: AbortSignal, +): Promise { + if (!catalog.some((model) => model.status.value === "unloaded" && model.source === "preset")) return false; + try { + return (await client.props({ signal })).models_autoload === true; + } catch { + return false; + } } function toPiModel(model: LlamaModelInfo, serverUrl: string): Model<"openai-completions"> { @@ -57,14 +73,20 @@ function toPiModel(model: LlamaModelInfo, serverUrl: string): Model<"openai-comp export interface LlamaProviderController { provider: Provider<"openai-completions">; - setCatalog(models: readonly LlamaModelInfo[], serverUrl: string): void; + setCatalog(models: readonly LlamaModelInfo[], serverUrl: string, options?: { routerAutoload?: boolean }): void; } export function createLlamaProvider(): LlamaProviderController { let models: readonly Model<"openai-completions">[] = []; - const setCatalog = (catalog: readonly LlamaModelInfo[], serverUrl: string): void => { - models = catalog.filter((model) => modelIsSelectable(model)).map((model) => toPiModel(model, serverUrl)); + const setCatalog = ( + catalog: readonly LlamaModelInfo[], + serverUrl: string, + options: { routerAutoload?: boolean } = {}, + ): void => { + models = catalog + .filter((model) => modelIsSelectable(model, options.routerAutoload === true)) + .map((model) => toPiModel(model, serverUrl)); }; const provider: Provider<"openai-completions"> = { @@ -135,10 +157,13 @@ export function createLlamaProvider(): LlamaProviderController { if (!context.allowNetwork || context.signal.aborted || context.credential?.type !== "api_key") return; const serverUrl = credentialServerUrl(context.credential); if (!serverUrl) return; - const catalog = await new LlamaClient(serverUrl, context.credential.key).list({ signal: context.signal }); + const client = new LlamaClient(serverUrl, context.credential.key); + const catalog = await client.list({ signal: context.signal }); + if (context.signal.aborted) return; + const routerAutoload = await routerAutoloadEnabled(client, catalog, context.signal); if (context.signal.aborted) return; const refreshed = catalog - .filter((model) => modelIsSelectable(model)) + .filter((model) => modelIsSelectable(model, routerAutoload)) .map((model) => toPiModel(model, serverUrl)); await context.publish({ persist: { models: refreshed, checkedAt: Date.now() }, diff --git a/packages/coding-agent/test/llama-extension.test.ts b/packages/coding-agent/test/llama-extension.test.ts index 7d46c76c9..7c506aafc 100644 --- a/packages/coding-agent/test/llama-extension.test.ts +++ b/packages/coding-agent/test/llama-extension.test.ts @@ -138,6 +138,78 @@ describe("llama.cpp extension", () => { ]); }); + it("exposes unloaded presets only when router autoload is enabled", async () => { + let propsRequests = 0; + const { url } = await listen((request, response) => { + expect(request.headers.authorization).toBe("Bearer local"); + if (request.url === "/models") { + json(response, { + data: [ + { id: "preset", status: { value: "unloaded" }, source: "preset", meta: { n_ctx: 65536 } }, + { id: "failed-preset", status: { value: "unloaded", failed: true }, source: "preset" }, + { id: "cache", status: { value: "unloaded" }, source: "cache" }, + { id: "models-dir", status: { value: "unloaded" }, source: "models_dir" }, + ], + }); + return; + } + if (request.url === "/props") { + propsRequests++; + json(response, { role: "router", models_autoload: true }); + return; + } + response.writeHead(404).end(); + }); + + let cachedEntry: ModelsStoreEntry | undefined; + const controller = createLlamaProvider(); + await controller.provider.refreshModels?.({ + credential: { type: "api_key", key: "local", env: { LLAMA_BASE_URL: url } }, + stored: undefined, + publish: async (publication) => { + if (publication.persist !== undefined && publication.persist !== null) { + cachedEntry = structuredClone(publication.persist); + } + publication.update?.(); + return true; + }, + allowNetwork: true, + signal: new AbortController().signal, + }); + + expect(propsRequests).toBe(1); + expect(controller.provider.getModels().map((model) => model.id)).toEqual(["preset"]); + expect(cachedEntry?.models.map((model) => model.id)).toEqual(["preset"]); + }); + + it("hides unloaded presets when router autoload is disabled", async () => { + const { url } = await listen((request, response) => { + if (request.url === "/models") { + json(response, { data: [{ id: "preset", status: { value: "unloaded" }, source: "preset" }] }); + return; + } + if (request.url === "/props") { + json(response, { role: "router", models_autoload: false }); + return; + } + response.writeHead(404).end(); + }); + + const controller = createLlamaProvider(); + await controller.provider.refreshModels?.({ + credential: { type: "api_key", key: "local", env: { LLAMA_BASE_URL: url } }, + stored: undefined, + publish: async (publication) => { + publication.update?.(); + return true; + }, + allowNetwork: true, + signal: new AbortController().signal, + }); + + expect(controller.provider.getModels()).toEqual([]); + }); + it("stays dormant until configured and stores URL plus optional key", async () => { const { provider } = createLlamaProvider(); const auth = provider.auth.apiKey!;