From c554364c2a82915c8c2b991507f252fc024a2217 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Wed, 20 May 2026 09:30:58 +0200 Subject: [PATCH 001/352] feat(ai): refactor device code login for copilot --- packages/ai/CHANGELOG.md | 4 + packages/ai/src/cli.ts | 5 + packages/ai/src/index.ts | 1 + packages/ai/src/utils/oauth/device-code.ts | 80 +++++++++ packages/ai/src/utils/oauth/github-copilot.ts | 155 ++++++------------ packages/ai/src/utils/oauth/index.ts | 1 + packages/ai/src/utils/oauth/types.ts | 8 + packages/ai/test/github-copilot-oauth.test.ts | 95 ++++++++--- packages/ai/test/oauth-device-code.test.ts | 55 +++++++ .../interactive/components/login-dialog.ts | 33 +++- .../src/modes/interactive/interactive-mode.ts | 8 +- 11 files changed, 316 insertions(+), 129 deletions(-) create mode 100644 packages/ai/src/utils/oauth/device-code.ts create mode 100644 packages/ai/test/oauth-device-code.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 841f3fed..9802972f 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -13,6 +13,10 @@ - Changed source syntax to avoid TypeScript constructs that require JavaScript emit, keeping the package compatible with Node.js strip-only TypeScript checks. - Removed the package-level development watch scripts now that the root TypeScript check validates strip-only-compatible sources. +### Added + +- Added first-class OAuth device-code callback metadata, shared polling support, and GitHub Copilot OAuth integration. + ### Fixed - Fixed OpenAI-compatible `streamSimple()` requests to stop sending model-derived default output token caps, avoiding context-window reservation failures on servers such as vLLM while preserving explicit `maxTokens` and required Anthropic `max_tokens` handling ([#4675](https://github.com/earendil-works/pi/issues/4675)). diff --git a/packages/ai/src/cli.ts b/packages/ai/src/cli.ts index 38ee7e34..442ee8ea 100644 --- a/packages/ai/src/cli.ts +++ b/packages/ai/src/cli.ts @@ -42,6 +42,11 @@ async function login(providerId: OAuthProviderId): Promise { if (info.instructions) console.log(info.instructions); console.log(); }, + onDeviceCode: (info) => { + console.log(`\nOpen this URL in your browser:\n${info.verificationUri}`); + console.log(`Enter code: ${info.userCode}`); + console.log(); + }, onPrompt: async (p) => { return await promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`); }, diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index fd06fe81..ed7aeaa8 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -32,6 +32,7 @@ export * from "./utils/json-parse.ts"; export type { OAuthAuthInfo, OAuthCredentials, + OAuthDeviceCodeInfo, OAuthLoginCallbacks, OAuthPrompt, OAuthProvider, diff --git a/packages/ai/src/utils/oauth/device-code.ts b/packages/ai/src/utils/oauth/device-code.ts new file mode 100644 index 00000000..95dba2e7 --- /dev/null +++ b/packages/ai/src/utils/oauth/device-code.ts @@ -0,0 +1,80 @@ +const CANCEL_MESSAGE = "Login cancelled"; +const TIMEOUT_MESSAGE = "Device flow timed out"; +const SLOW_DOWN_TIMEOUT_MESSAGE = + "Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again."; +const MINIMUM_INTERVAL_MS = 1000; +// RFC 8628 section 3.2: if the authorization server omits `interval`, the client must use 5 seconds. +const DEFAULT_POLL_INTERVAL_SECONDS = 5; +// RFC 8628 section 3.5: `slow_down` means the polling interval must increase by 5 seconds. +const SLOW_DOWN_INTERVAL_INCREMENT_MS = 5000; + +export type OAuthDeviceCodePollResult = + | { status: "pending" } + | { status: "slow_down" } + | { status: "complete"; accessToken: string } + | { status: "failed"; message: string }; + +export type OAuthDeviceCodePollOptions = { + intervalSeconds?: number; + expiresInSeconds?: number; + poll: () => Promise; + signal?: AbortSignal; +}; + +function abortableSleep(ms: number, signal: AbortSignal | undefined, cancelMessage: string): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error(cancelMessage)); + return; + } + + const onAbort = () => { + clearTimeout(timeout); + reject(new Error(cancelMessage)); + }; + const timeout = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +export async function pollOAuthDeviceCodeFlow(options: OAuthDeviceCodePollOptions): Promise { + const deadline = + typeof options.expiresInSeconds === "number" + ? Date.now() + options.expiresInSeconds * 1000 + : Number.POSITIVE_INFINITY; + let intervalMs = Math.max( + MINIMUM_INTERVAL_MS, + Math.floor((options.intervalSeconds ?? DEFAULT_POLL_INTERVAL_SECONDS) * 1000), + ); + + let slowDownResponses = 0; + while (Date.now() < deadline) { + if (options.signal?.aborted) { + throw new Error(CANCEL_MESSAGE); + } + + const remainingMs = deadline - Date.now(); + await abortableSleep(Math.min(intervalMs, remainingMs), options.signal, CANCEL_MESSAGE); + + const result = await options.poll(); + if (result.status === "complete") { + return result.accessToken; + } + if (result.status === "pending") { + continue; + } + if (result.status === "slow_down") { + slowDownResponses += 1; + // RFC 8628 section 3.5: apply this increase to this and all subsequent requests. + intervalMs = Math.max(MINIMUM_INTERVAL_MS, intervalMs + SLOW_DOWN_INTERVAL_INCREMENT_MS); + continue; + } + throw new Error(result.message); + } + + throw new Error(slowDownResponses > 0 ? SLOW_DOWN_TIMEOUT_MESSAGE : TIMEOUT_MESSAGE); +} diff --git a/packages/ai/src/utils/oauth/github-copilot.ts b/packages/ai/src/utils/oauth/github-copilot.ts index 6182ab91..c4ce3635 100644 --- a/packages/ai/src/utils/oauth/github-copilot.ts +++ b/packages/ai/src/utils/oauth/github-copilot.ts @@ -4,7 +4,8 @@ import { getModels } from "../../models.ts"; import type { Api, Model } from "../../types.ts"; -import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts"; +import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; +import type { OAuthCredentials, OAuthDeviceCodeInfo, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts"; type CopilotCredentials = OAuthCredentials & { enterpriseUrl?: string; @@ -20,14 +21,11 @@ const COPILOT_HEADERS = { "Copilot-Integration-Id": "vscode-chat", } as const; -const INITIAL_POLL_INTERVAL_MULTIPLIER = 1.2; -const SLOW_DOWN_POLL_INTERVAL_MULTIPLIER = 1.4; - type DeviceCodeResponse = { device_code: string; user_code: string; verification_uri: string; - interval: number; + interval?: number; expires_in: number; }; @@ -40,7 +38,6 @@ type DeviceTokenSuccessResponse = { type DeviceTokenErrorResponse = { error: string; error_description?: string; - interval?: number; }; export function normalizeDomain(input: string): string | null { @@ -129,7 +126,7 @@ async function startDeviceFlow(domain: string): Promise { typeof deviceCode !== "string" || typeof userCode !== "string" || typeof verificationUri !== "string" || - typeof interval !== "number" || + (interval !== undefined && typeof interval !== "number") || typeof expiresIn !== "number" ) { throw new Error("Invalid device code response fields"); @@ -144,95 +141,48 @@ async function startDeviceFlow(domain: string): Promise { }; } -/** - * Sleep that can be interrupted by an AbortSignal - */ -function abortableSleep(ms: number, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(new Error("Login cancelled")); - return; - } - - const timeout = setTimeout(resolve, ms); - - signal?.addEventListener( - "abort", - () => { - clearTimeout(timeout); - reject(new Error("Login cancelled")); - }, - { once: true }, - ); - }); -} - -async function pollForGitHubAccessToken( - domain: string, - deviceCode: string, - intervalSeconds: number, - expiresIn: number, - signal?: AbortSignal, -) { +async function pollForGitHubAccessToken(domain: string, device: DeviceCodeResponse, signal?: AbortSignal) { const urls = getUrls(domain); - const deadline = Date.now() + expiresIn * 1000; - let intervalMs = Math.max(1000, Math.floor(intervalSeconds * 1000)); - let intervalMultiplier = INITIAL_POLL_INTERVAL_MULTIPLIER; - let slowDownResponses = 0; + return pollOAuthDeviceCodeFlow({ + intervalSeconds: device.interval, + expiresInSeconds: device.expires_in, + signal, + poll: async () => { + const raw = await fetchJson(urls.accessTokenUrl, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "GitHubCopilotChat/0.35.0", + }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + device_code: device.device_code, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); - while (Date.now() < deadline) { - if (signal?.aborted) { - throw new Error("Login cancelled"); - } - - const remainingMs = deadline - Date.now(); - const waitMs = Math.min(Math.ceil(intervalMs * intervalMultiplier), remainingMs); - await abortableSleep(waitMs, signal); - - const raw = await fetchJson(urls.accessTokenUrl, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - "User-Agent": "GitHubCopilotChat/0.35.0", - }, - body: new URLSearchParams({ - client_id: CLIENT_ID, - device_code: deviceCode, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }), - }); - - if (raw && typeof raw === "object" && typeof (raw as DeviceTokenSuccessResponse).access_token === "string") { - return (raw as DeviceTokenSuccessResponse).access_token; - } - - if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") { - const { error, error_description: description, interval } = raw as DeviceTokenErrorResponse; - if (error === "authorization_pending") { - continue; + if (raw && typeof raw === "object" && typeof (raw as DeviceTokenSuccessResponse).access_token === "string") { + return { status: "complete", accessToken: (raw as DeviceTokenSuccessResponse).access_token }; } - if (error === "slow_down") { - slowDownResponses += 1; - intervalMs = - typeof interval === "number" && interval > 0 ? interval * 1000 : Math.max(1000, intervalMs + 5000); - intervalMultiplier = SLOW_DOWN_POLL_INTERVAL_MULTIPLIER; - continue; + if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") { + const { error, error_description: description } = raw as DeviceTokenErrorResponse; + if (error === "authorization_pending") { + return { status: "pending" }; + } + + if (error === "slow_down") { + return { status: "slow_down" }; + } + + const descriptionSuffix = description ? `: ${description}` : ""; + return { status: "failed", message: `Device flow failed: ${error}${descriptionSuffix}` }; } - const descriptionSuffix = description ? `: ${description}` : ""; - throw new Error(`Device flow failed: ${error}${descriptionSuffix}`); - } - } - - if (slowDownResponses > 0) { - throw new Error( - "Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again.", - ); - } - - throw new Error("Device flow timed out"); + return { status: "failed", message: "Invalid device token response" }; + }, + }); } /** @@ -319,13 +269,13 @@ async function enableAllGitHubCopilotModels( /** * Login with GitHub Copilot OAuth (device code flow) * - * @param options.onAuth - Callback with URL and optional instructions (user code) + * @param options.onDeviceCode - Callback with URL and user code * @param options.onPrompt - Callback to prompt user for input * @param options.onProgress - Optional progress callback * @param options.signal - Optional AbortSignal for cancellation */ export async function loginGitHubCopilot(options: { - onAuth: (url: string, instructions?: string) => void; + onDeviceCode: (info: OAuthDeviceCodeInfo) => void; onPrompt: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise; onProgress?: (message: string) => void; signal?: AbortSignal; @@ -348,15 +298,14 @@ export async function loginGitHubCopilot(options: { const domain = enterpriseDomain || "github.com"; const device = await startDeviceFlow(domain); - options.onAuth(device.verification_uri, `Enter code: ${device.user_code}`); + options.onDeviceCode({ + userCode: device.user_code, + verificationUri: device.verification_uri, + intervalSeconds: device.interval, + expiresInSeconds: device.expires_in, + }); - const githubAccessToken = await pollForGitHubAccessToken( - domain, - device.device_code, - device.interval, - device.expires_in, - options.signal, - ); + const githubAccessToken = await pollForGitHubAccessToken(domain, device, options.signal); const credentials = await refreshGitHubCopilotToken(githubAccessToken, enterpriseDomain ?? undefined); // Enable all models after successful login @@ -370,8 +319,12 @@ export const githubCopilotOAuthProvider: OAuthProviderInterface = { name: "GitHub Copilot", async login(callbacks: OAuthLoginCallbacks): Promise { + if (!callbacks.onDeviceCode) { + throw new Error("GitHub Copilot OAuth requires a device code callback"); + } + return loginGitHubCopilot({ - onAuth: (url, instructions) => callbacks.onAuth({ url, instructions }), + onDeviceCode: callbacks.onDeviceCode, onPrompt: callbacks.onPrompt, onProgress: callbacks.onProgress, signal: callbacks.signal, diff --git a/packages/ai/src/utils/oauth/index.ts b/packages/ai/src/utils/oauth/index.ts index 3a3a01b1..55910322 100644 --- a/packages/ai/src/utils/oauth/index.ts +++ b/packages/ai/src/utils/oauth/index.ts @@ -9,6 +9,7 @@ // Anthropic export { anthropicOAuthProvider, loginAnthropic, refreshAnthropicToken } from "./anthropic.ts"; +export * from "./device-code.ts"; // GitHub Copilot export { getGitHubCopilotBaseUrl, diff --git a/packages/ai/src/utils/oauth/types.ts b/packages/ai/src/utils/oauth/types.ts index a1426d81..3220dcf9 100644 --- a/packages/ai/src/utils/oauth/types.ts +++ b/packages/ai/src/utils/oauth/types.ts @@ -23,6 +23,13 @@ export type OAuthAuthInfo = { instructions?: string; }; +export type OAuthDeviceCodeInfo = { + userCode: string; + verificationUri: string; + intervalSeconds?: number; + expiresInSeconds?: number; +}; + export type OAuthSelectOption = { id: string; label: string; @@ -35,6 +42,7 @@ export type OAuthSelectPrompt = { export interface OAuthLoginCallbacks { onAuth: (info: OAuthAuthInfo) => void; + onDeviceCode?: (info: OAuthDeviceCodeInfo) => void; onPrompt: (prompt: OAuthPrompt) => Promise; onProgress?: (message: string) => void; onManualCodeInput?: () => Promise; diff --git a/packages/ai/test/github-copilot-oauth.test.ts b/packages/ai/test/github-copilot-oauth.test.ts index 085b1cf0..1fb92b1e 100644 --- a/packages/ai/test/github-copilot-oauth.test.ts +++ b/packages/ai/test/github-copilot-oauth.test.ts @@ -29,7 +29,62 @@ describe("GitHub Copilot OAuth device flow", () => { vi.useRealTimers(); }); - it("waits before the first poll and increases the safety margin after slow_down", async () => { + it("reports device-code details through onDeviceCode", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-09T00:00:00Z")); + + const fetchMock = vi.fn(async (input: unknown): Promise => { + const url = getUrl(input); + + if (url.endsWith("/login/device/code")) { + return jsonResponse({ + device_code: "device-code", + user_code: "ABCD-EFGH", + verification_uri: "https://github.com/login/device", + interval: 1, + expires_in: 900, + }); + } + + if (url.endsWith("/login/oauth/access_token")) { + return jsonResponse({ access_token: "ghu_refresh_token" }); + } + + if (url.includes("/copilot_internal/v2/token")) { + return jsonResponse({ + token: "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;", + expires_at: 9999999999, + }); + } + + if (url.includes("/models/") && url.endsWith("/policy")) { + return new Response("", { status: 200 }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + vi.stubGlobal("fetch", fetchMock); + + const onDeviceCode = vi.fn(); + const loginPromise = loginGitHubCopilot({ + onDeviceCode, + onPrompt: async () => "", + }); + + await vi.advanceTimersByTimeAsync(0); + + expect(onDeviceCode).toHaveBeenCalledWith({ + userCode: "ABCD-EFGH", + verificationUri: "https://github.com/login/device", + intervalSeconds: 1, + expiresInSeconds: 900, + }); + await vi.advanceTimersByTimeAsync(1000); + await loginPromise; + }); + + it("waits before the first poll and increases the interval after slow_down", async () => { vi.useFakeTimers(); const startTime = new Date("2026-03-09T00:00:00Z"); vi.setSystemTime(startTime); @@ -37,7 +92,7 @@ describe("GitHub Copilot OAuth device flow", () => { const accessTokenPollTimes: number[] = []; const accessTokenResponses = [ jsonResponse({ error: "authorization_pending", error_description: "pending" }), - jsonResponse({ error: "slow_down", error_description: "slow down", interval: 10 }), + jsonResponse({ error: "slow_down", error_description: "slow down" }), jsonResponse({ access_token: "ghu_refresh_token" }), ]; @@ -95,7 +150,7 @@ describe("GitHub Copilot OAuth device flow", () => { vi.stubGlobal("fetch", fetchMock); const loginPromise = loginGitHubCopilot({ - onAuth: () => {}, + onDeviceCode: () => {}, onPrompt: async () => "", onProgress: () => {}, }); @@ -103,28 +158,28 @@ describe("GitHub Copilot OAuth device flow", () => { await vi.advanceTimersByTimeAsync(0); expect(accessTokenPollTimes).toHaveLength(0); - await vi.advanceTimersByTimeAsync(5999); + await vi.advanceTimersByTimeAsync(4999); expect(accessTokenPollTimes).toHaveLength(0); await vi.advanceTimersByTimeAsync(1); expect(accessTokenPollTimes).toHaveLength(1); - await vi.advanceTimersByTimeAsync(5999); + await vi.advanceTimersByTimeAsync(4999); expect(accessTokenPollTimes).toHaveLength(1); await vi.advanceTimersByTimeAsync(1); expect(accessTokenPollTimes).toHaveLength(2); - await vi.advanceTimersByTimeAsync(13999); + await vi.advanceTimersByTimeAsync(9999); expect(accessTokenPollTimes).toHaveLength(2); await vi.advanceTimersByTimeAsync(1); await loginPromise; expect(accessTokenPollTimes).toEqual([ - startTime.getTime() + 6000, - startTime.getTime() + 12000, - startTime.getTime() + 26000, + startTime.getTime() + 5000, + startTime.getTime() + 10000, + startTime.getTime() + 20000, ]); }); @@ -135,8 +190,8 @@ describe("GitHub Copilot OAuth device flow", () => { const accessTokenPollTimes: number[] = []; const accessTokenResponses = [ - jsonResponse({ error: "slow_down", error_description: "slow down", interval: 10 }), - jsonResponse({ error: "slow_down", error_description: "still too fast", interval: 15 }), + jsonResponse({ error: "slow_down", error_description: "slow down" }), + jsonResponse({ error: "slow_down", error_description: "still too fast" }), jsonResponse({ error: "authorization_pending", error_description: "pending" }), ]; @@ -168,28 +223,28 @@ describe("GitHub Copilot OAuth device flow", () => { vi.stubGlobal("fetch", fetchMock); const loginPromise = loginGitHubCopilot({ - onAuth: () => {}, + onDeviceCode: () => {}, onPrompt: async () => "", }); const rejection = expect(loginPromise).rejects.toThrow( /Device flow timed out after one or more slow_down responses/, ); - await vi.advanceTimersByTimeAsync(6000); - expect(accessTokenPollTimes).toEqual([startTime.getTime() + 6000]); + await vi.advanceTimersByTimeAsync(5000); + expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000]); - await vi.advanceTimersByTimeAsync(14000); - expect(accessTokenPollTimes).toEqual([startTime.getTime() + 6000, startTime.getTime() + 20000]); + await vi.advanceTimersByTimeAsync(10000); + expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 15000]); - await vi.advanceTimersByTimeAsync(4999); - expect(accessTokenPollTimes).toEqual([startTime.getTime() + 6000, startTime.getTime() + 20000]); + await vi.advanceTimersByTimeAsync(9999); + expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 15000]); await vi.advanceTimersByTimeAsync(1); await rejection; expect(accessTokenPollTimes).toEqual([ - startTime.getTime() + 6000, - startTime.getTime() + 20000, + startTime.getTime() + 5000, + startTime.getTime() + 15000, startTime.getTime() + 25000, ]); }); diff --git a/packages/ai/test/oauth-device-code.test.ts b/packages/ai/test/oauth-device-code.test.ts new file mode 100644 index 00000000..40ef4fd8 --- /dev/null +++ b/packages/ai/test/oauth-device-code.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { pollOAuthDeviceCodeFlow } from "../src/utils/oauth/device-code.ts"; + +describe("OAuth device-code polling", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("waits before the first poll and returns the completed value", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-09T00:00:00Z")); + + const pollTimes: number[] = []; + const poll = vi.fn(async () => { + pollTimes.push(Date.now()); + return pollTimes.length === 1 + ? { status: "pending" as const } + : { status: "complete" as const, accessToken: "token" }; + }); + + const resultPromise = pollOAuthDeviceCodeFlow({ + intervalSeconds: 2, + expiresInSeconds: 30, + poll, + }); + + await vi.advanceTimersByTimeAsync(1999); + expect(pollTimes).toEqual([]); + + await vi.advanceTimersByTimeAsync(1); + expect(pollTimes).toEqual([new Date("2026-03-09T00:00:02Z").getTime()]); + + await vi.advanceTimersByTimeAsync(2000); + await expect(resultPromise).resolves.toBe("token"); + expect(pollTimes).toEqual([ + new Date("2026-03-09T00:00:02Z").getTime(), + new Date("2026-03-09T00:00:04Z").getTime(), + ]); + }); + + it("cancels an in-flight wait", async () => { + vi.useFakeTimers(); + const controller = new AbortController(); + + const resultPromise = pollOAuthDeviceCodeFlow({ + intervalSeconds: 5, + expiresInSeconds: 30, + poll: async () => ({ status: "pending" }), + signal: controller.signal, + }); + + controller.abort(); + await expect(resultPromise).rejects.toThrow("Login cancelled"); + }); +}); diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index 80560f14..5a2c365a 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -1,4 +1,4 @@ -import { getOAuthProviders } from "@earendil-works/pi-ai/oauth"; +import { getOAuthProviders, type OAuthDeviceCodeInfo } from "@earendil-works/pi-ai/oauth"; import { Container, type Focusable, getKeybindings, Input, Spacer, Text, type TUI } from "@earendil-works/pi-tui"; import { exec } from "child_process"; import { theme } from "../theme/theme.ts"; @@ -86,7 +86,7 @@ export class LoginDialogComponent extends Container implements Focusable { /** * Called by onAuth callback - show URL and optional instructions */ - showAuth(url: string, instructions?: string): void { + showAuth(url: string, instructions?: string, options: { autoOpenBrowser?: boolean } = {}): void { this.contentContainer.clear(); this.contentContainer.addChild(new Spacer(1)); const linkedUrl = `\x1b]8;;${url}\x07${url}\x1b]8;;\x07`; @@ -101,11 +101,34 @@ export class LoginDialogComponent extends Container implements Focusable { this.contentContainer.addChild(new Text(theme.fg("warning", instructions), 1, 0)); } - // Try to open browser + if (options.autoOpenBrowser ?? true) { + this.openUrl(url); + } + this.tui.requestRender(); + } + + /** + * Called by onDeviceCode callback - show URL and user code. + */ + showDeviceCode(info: OAuthDeviceCodeInfo): void { + this.contentContainer.clear(); + this.contentContainer.addChild(new Spacer(1)); + const linkedUrl = `\x1b]8;;${info.verificationUri}\x07${info.verificationUri}\x1b]8;;\x07`; + this.contentContainer.addChild(new Text(theme.fg("accent", linkedUrl), 1, 0)); + + const clickHint = process.platform === "darwin" ? "Cmd+click to open" : "Ctrl+click to open"; + const hyperlink = `\x1b]8;;${info.verificationUri}\x07${clickHint}\x1b]8;;\x07`; + this.contentContainer.addChild(new Text(theme.fg("dim", hyperlink), 1, 0)); + this.contentContainer.addChild(new Spacer(1)); + this.contentContainer.addChild(new Text(theme.fg("warning", `Enter code: ${info.userCode}`), 1, 0)); + + // Do not open device-code URLs automatically. These flows need to work in headless environments. + this.tui.requestRender(); + } + + private openUrl(url: string): void { const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; exec(`${openCmd} "${url}"`); - - this.tui.requestRender(); } /** diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index aa8db52b..3068c89c 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -4823,13 +4823,15 @@ export class InteractiveMode { manualCodeReject = undefined; } }); - } else if (providerId === "github-copilot") { - // GitHub Copilot polls after onAuth - dialog.showWaiting("Waiting for browser authentication..."); } // For Anthropic: onPrompt is called immediately after }, + onDeviceCode: (info) => { + dialog.showDeviceCode(info); + dialog.showWaiting("Waiting for authentication..."); + }, + onPrompt: async (prompt: { message: string; placeholder?: string }) => { return dialog.showPrompt(prompt.message, prompt.placeholder); }, From baf4028fb97254d359bde48fd1c0a8c27e639a7c Mon Sep 17 00:00:00 2001 From: haoqixu Date: Fri, 22 May 2026 18:33:26 +0800 Subject: [PATCH 002/352] fix(coding-agent): use the right basedir for patterns --- .../src/modes/interactive/components/config-selector.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/interactive/components/config-selector.ts b/packages/coding-agent/src/modes/interactive/components/config-selector.ts index d4f16726..93ef4bea 100644 --- a/packages/coding-agent/src/modes/interactive/components/config-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/config-selector.ts @@ -567,7 +567,7 @@ class ResourceList implements Component, Focusable { private getResourcePattern(item: ResourceItem): string { const scope = item.metadata.scope as "user" | "project"; - const baseDir = this.getTopLevelBaseDir(scope); + const baseDir = item.metadata.baseDir ?? this.getTopLevelBaseDir(scope); return relative(baseDir, item.path); } From b3ed545938952944560d47b10789d025364df691 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 22 May 2026 15:47:57 +0200 Subject: [PATCH 003/352] fix(export-html): escape quotes in exported attributes closes #4832 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/core/export-html/template.js | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ce69a3aa..304bc436 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,7 @@ ### Fixed +- Fixed exported session HTML to escape quote characters in attribute values ([#4832](https://github.com/earendil-works/pi/issues/4832)). - Fixed git package installs to reconcile existing checkouts to the requested ref and update package settings without losing filters ([#4870](https://github.com/earendil-works/pi/issues/4870)). - Published a 0.74.2 rescue release that tells Node 20 users to upgrade Node before updating to newer Pi versions ([#4876](https://github.com/earendil-works/pi/issues/4876)). - Fixed final bash tool cards to avoid rendering duplicate full-output truncation paths ([#4819](https://github.com/earendil-works/pi/issues/4819)). diff --git a/packages/coding-agent/src/core/export-html/template.js b/packages/coding-agent/src/core/export-html/template.js index 07ab3e25..fe5c6d91 100644 --- a/packages/coding-agent/src/core/export-html/template.js +++ b/packages/coding-agent/src/core/export-html/template.js @@ -605,9 +605,12 @@ } function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; + return String(text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); } /** From c841a6c78fbbb12e3ef6014c3044faa15aa7b259 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 22 May 2026 15:50:52 +0200 Subject: [PATCH 004/352] Clean up OAuth device-code callbacks --- AGENTS.md | 1 + packages/ai/CHANGELOG.md | 5 +++ packages/ai/src/cli.ts | 9 ++++ packages/ai/src/utils/oauth/github-copilot.ts | 4 -- packages/ai/src/utils/oauth/types.ts | 4 +- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/custom-provider.md | 42 ++++++++++++++----- .../interactive/components/login-dialog.ts | 8 +++- 8 files changed, 56 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 144d2c60..065f2541 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,7 @@ - No fluff or cheerful filler text (e.g., "Thanks @user" not "Thanks so much @user!") - Technical prose only, be direct - When the user asks a question, answer it first before making edits or running implementation commands. +- When responding to user feedback or an analysis, explicitly say whether you agree or disagree before saying what you changed. ## Code Quality diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 9802972f..768a0525 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,8 +2,13 @@ ## [Unreleased] +### Breaking Changes + +- Changed `OAuthLoginCallbacks` to require `onDeviceCode` and `onSelect`, so OAuth providers can rely on pi supplying device-code and selection UI callbacks. + ### Fixed +- Fixed GitHub Copilot OAuth login to rely on the required device-code callback without a runtime callback availability guard. - Fixed Amazon Bedrock Claude requests to send the model output token cap by default, matching Anthropic requests and avoiding Bedrock's 4096-token default truncation ([#4848](https://github.com/earendil-works/pi/issues/4848)). ## [0.75.4] - 2026-05-20 diff --git a/packages/ai/src/cli.ts b/packages/ai/src/cli.ts index 442ee8ea..21699dbd 100644 --- a/packages/ai/src/cli.ts +++ b/packages/ai/src/cli.ts @@ -50,6 +50,15 @@ async function login(providerId: OAuthProviderId): Promise { onPrompt: async (p) => { return await promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`); }, + onSelect: async (p) => { + console.log(`\n${p.message}`); + for (let i = 0; i < p.options.length; i++) { + console.log(` ${i + 1}. ${p.options[i].label}`); + } + const choice = await promptFn(`Enter number (1-${p.options.length}):`); + const index = parseInt(choice, 10) - 1; + return p.options[index]?.id; + }, onProgress: (msg) => console.log(msg), }); diff --git a/packages/ai/src/utils/oauth/github-copilot.ts b/packages/ai/src/utils/oauth/github-copilot.ts index c4ce3635..d5adb58a 100644 --- a/packages/ai/src/utils/oauth/github-copilot.ts +++ b/packages/ai/src/utils/oauth/github-copilot.ts @@ -319,10 +319,6 @@ export const githubCopilotOAuthProvider: OAuthProviderInterface = { name: "GitHub Copilot", async login(callbacks: OAuthLoginCallbacks): Promise { - if (!callbacks.onDeviceCode) { - throw new Error("GitHub Copilot OAuth requires a device code callback"); - } - return loginGitHubCopilot({ onDeviceCode: callbacks.onDeviceCode, onPrompt: callbacks.onPrompt, diff --git a/packages/ai/src/utils/oauth/types.ts b/packages/ai/src/utils/oauth/types.ts index 3220dcf9..008be405 100644 --- a/packages/ai/src/utils/oauth/types.ts +++ b/packages/ai/src/utils/oauth/types.ts @@ -42,12 +42,12 @@ export type OAuthSelectPrompt = { export interface OAuthLoginCallbacks { onAuth: (info: OAuthAuthInfo) => void; - onDeviceCode?: (info: OAuthDeviceCodeInfo) => void; + onDeviceCode: (info: OAuthDeviceCodeInfo) => void; onPrompt: (prompt: OAuthPrompt) => Promise; onProgress?: (message: string) => void; onManualCodeInput?: () => Promise; /** Show an interactive selector and return the selected option id, or undefined on cancel. */ - onSelect?: (prompt: OAuthSelectPrompt) => Promise; + onSelect: (prompt: OAuthSelectPrompt) => Promise; signal?: AbortSignal; } diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 304bc436..33cf2967 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,6 +13,7 @@ ### Fixed - Fixed exported session HTML to escape quote characters in attribute values ([#4832](https://github.com/earendil-works/pi/issues/4832)). +- Fixed GitHub Copilot device-code login to keep opening the verification URL in browser-capable environments while ignoring browser launch failures for headless use. - Fixed git package installs to reconcile existing checkouts to the requested ref and update package settings without losing filters ([#4870](https://github.com/earendil-works/pi/issues/4870)). - Published a 0.74.2 rescue release that tells Node 20 users to upgrade Node before updating to newer Pi versions ([#4876](https://github.com/earendil-works/pi/issues/4876)). - Fixed final bash tool cards to avoid rendering duplicate full-output truncation paths ([#4819](https://github.com/earendil-works/pi/issues/4819)). diff --git a/packages/coding-agent/docs/custom-provider.md b/packages/coding-agent/docs/custom-provider.md index 09eb6e6e..e7fecc2d 100644 --- a/packages/coding-agent/docs/custom-provider.md +++ b/packages/coding-agent/docs/custom-provider.md @@ -263,17 +263,28 @@ pi.registerProvider("corporate-ai", { name: "Corporate AI (SSO)", async login(callbacks: OAuthLoginCallbacks): Promise { - // Option 1: Browser-based OAuth - callbacks.onAuth({ url: "https://sso.corp.com/authorize?..." }); - - // Option 2: Device code flow - callbacks.onDeviceCode({ - userCode: "ABCD-1234", - verificationUri: "https://sso.corp.com/device" + const method = await callbacks.onSelect({ + message: "Select login method:", + options: [ + { id: "browser", label: "Browser OAuth" }, + { id: "device", label: "Device code" } + ] }); + if (!method) throw new Error("Login cancelled"); - // Option 3: Prompt for token/code - const code = await callbacks.onPrompt({ message: "Enter SSO code:" }); + let code: string; + if (method === "device") { + callbacks.onDeviceCode({ + userCode: "ABCD-1234", + verificationUri: "https://sso.corp.com/device", + intervalSeconds: 5, + expiresInSeconds: 900 + }); + code = await pollDeviceCodeUntilComplete(); + } else { + callbacks.onAuth({ url: "https://sso.corp.com/authorize?..." }); + code = await callbacks.onPrompt({ message: "Enter SSO code:" }); + } // Exchange for tokens (your implementation) const tokens = await exchangeCodeForTokens(code); @@ -322,10 +333,21 @@ interface OAuthLoginCallbacks { onAuth(params: { url: string }): void; // Show device code (for device authorization flow) - onDeviceCode(params: { userCode: string; verificationUri: string }): void; + onDeviceCode(params: { + userCode: string; + verificationUri: string; + intervalSeconds?: number; + expiresInSeconds?: number; + }): void; // Prompt user for input (for manual token entry) onPrompt(params: { message: string }): Promise; + + // Show an interactive selector, e.g. to choose browser OAuth vs device code + onSelect(params: { + message: string; + options: { id: string; label: string }[]; + }): Promise; } ``` diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index 5a2c365a..ee3f45cb 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -122,13 +122,17 @@ export class LoginDialogComponent extends Container implements Focusable { this.contentContainer.addChild(new Spacer(1)); this.contentContainer.addChild(new Text(theme.fg("warning", `Enter code: ${info.userCode}`), 1, 0)); - // Do not open device-code URLs automatically. These flows need to work in headless environments. + this.openUrl(info.verificationUri); this.tui.requestRender(); } private openUrl(url: string): void { const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; - exec(`${openCmd} "${url}"`); + try { + exec(`${openCmd} "${url}"`, () => {}); + } catch { + // Ignore browser launch failures. The URL remains visible for manual opening/copying. + } } /** From 7002c68f8b60bdcc806840615665e896aa7a9e0a Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 22 May 2026 16:49:17 +0200 Subject: [PATCH 005/352] fix(ai): declare Bedrock Smithy HTTP handler dependency closes #4842 --- package-lock.json | 1 + packages/ai/CHANGELOG.md | 1 + packages/ai/package.json | 1 + packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/npm-shrinkwrap.json | 1 + 5 files changed, 5 insertions(+) diff --git a/package-lock.json b/package-lock.json index 3e936140..cab14c6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6031,6 +6031,7 @@ "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.1", + "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 768a0525..7932d9dc 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - Fixed GitHub Copilot OAuth login to rely on the required device-code callback without a runtime callback availability guard. +- Fixed Amazon Bedrock provider loading under strict package managers by declaring its direct `@smithy/node-http-handler` dependency ([#4842](https://github.com/earendil-works/pi/issues/4842)). - Fixed Amazon Bedrock Claude requests to send the model output token cap by default, matching Anthropic requests and avoiding Bedrock's 4096-token default truncation ([#4848](https://github.com/earendil-works/pi/issues/4848)). ## [0.75.4] - 2026-05-20 diff --git a/packages/ai/package.json b/packages/ai/package.json index a10a23df..525c5f6f 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -69,6 +69,7 @@ "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@smithy/node-http-handler": "4.7.3", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.1", "http-proxy-agent": "7.0.2", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 33cf2967..43643b5f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,7 @@ ### Fixed +- Fixed Amazon Bedrock provider loading under strict package managers by inheriting the declared `@smithy/node-http-handler` dependency from `@earendil-works/pi-ai` ([#4842](https://github.com/earendil-works/pi/issues/4842)). - Fixed exported session HTML to escape quote characters in attribute values ([#4832](https://github.com/earendil-works/pi/issues/4832)). - Fixed GitHub Copilot device-code login to keep opening the verification URL in browser-capable environments while ignoring browser launch failures for headless use. - Fixed git package installs to reconcile existing checkouts to the requested ref and update package settings without losing filters ([#4870](https://github.com/earendil-works/pi/issues/4870)). diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 85a22b1a..b9041ce9 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -493,6 +493,7 @@ "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@smithy/node-http-handler": "4.7.3", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.1", "http-proxy-agent": "7.0.2", From d801d88a11fc6a967d2ef98c0e46cd8adef44739 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 22 May 2026 18:30:04 +0200 Subject: [PATCH 006/352] Support adaptive thinking for Anthropic-compatible aliases closes #4790 --- packages/ai/CHANGELOG.md | 1 + packages/ai/scripts/generate-models.ts | 18 +++ packages/ai/src/models.generated.ts | 15 +++ packages/ai/src/providers/anthropic.ts | 62 ++++++----- packages/ai/src/types.ts | 10 ++ ...anthropic-adaptive-thinking-models.test.ts | 37 +++++++ .../anthropic-force-adaptive-thinking.test.ts | 103 ++++++++++++++++++ packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/custom-provider.md | 12 +- packages/coding-agent/docs/models.md | 10 +- .../coding-agent/src/core/model-registry.ts | 3 + 11 files changed, 239 insertions(+), 33 deletions(-) create mode 100644 packages/ai/test/anthropic-adaptive-thinking-models.test.ts create mode 100644 packages/ai/test/anthropic-force-adaptive-thinking.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 7932d9dc..d23a2530 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed custom Anthropic-compatible model aliases for adaptive-thinking Claude models by adding `compat.forceAdaptiveThinking` model metadata and moving built-in adaptive-thinking selection out of provider id substring checks ([#4797](https://github.com/earendil-works/pi-mono/pull/4797) by [@mbazso](https://github.com/mbazso)). - Fixed GitHub Copilot OAuth login to rely on the required device-code callback without a runtime callback availability guard. - Fixed Amazon Bedrock provider loading under strict package managers by declaring its direct `@smithy/node-http-handler` dependency ([#4842](https://github.com/earendil-works/pi/issues/4842)). - Fixed Amazon Bedrock Claude requests to send the model output token cap by default, matching Anthropic requests and avoiding Bedrock's 4096-token default truncation ([#4848](https://github.com/earendil-works/pi/issues/4848)). diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 0c025c68..e2693a3b 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -178,6 +178,21 @@ function isGoogleThinkingApi(model: Model): boolean { return model.api === "google-generative-ai" || model.api === "google-vertex"; } +function isAnthropicAdaptiveThinkingModel(modelId: string): boolean { + return ( + modelId.includes("opus-4-6") || + modelId.includes("opus-4.6") || + modelId.includes("opus-4-7") || + modelId.includes("opus-4.7") || + modelId.includes("sonnet-4-6") || + modelId.includes("sonnet-4.6") + ); +} + +function mergeAnthropicMessagesCompat(model: Model, compat: AnthropicMessagesCompat): void { + model.compat = { ...(model.compat as AnthropicMessagesCompat | undefined), ...compat }; +} + function isGemini3ProModel(modelId: string): boolean { return /gemini-3(?:\.\d+)?-pro/.test(modelId.toLowerCase()); } @@ -216,6 +231,9 @@ function applyThinkingLevelMetadata(model: Model): void { if (model.id.includes("opus-4-7") || model.id.includes("opus-4.7")) { mergeThinkingLevelMap(model, { xhigh: "xhigh" }); } + if (model.api === "anthropic-messages" && isAnthropicAdaptiveThinkingModel(model.id)) { + mergeAnthropicMessagesCompat(model, { forceAdaptiveThinking: true }); + } if (model.api === "openai-completions" && model.id.includes("deepseek-v4")) { mergeThinkingLevelMap(model, DEEPSEEK_V4_THINKING_LEVEL_MAP); } diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 782c4b97..9197fa12 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -1723,6 +1723,7 @@ export const MODELS = { api: "anthropic-messages", provider: "anthropic", baseUrl: "https://api.anthropic.com", + compat: {"forceAdaptiveThinking":true}, reasoning: true, thinkingLevelMap: {"xhigh":"max"}, input: ["text", "image"], @@ -1741,6 +1742,7 @@ export const MODELS = { api: "anthropic-messages", provider: "anthropic", baseUrl: "https://api.anthropic.com", + compat: {"forceAdaptiveThinking":true}, reasoning: true, thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], @@ -1827,6 +1829,7 @@ export const MODELS = { api: "anthropic-messages", provider: "anthropic", baseUrl: "https://api.anthropic.com", + compat: {"forceAdaptiveThinking":true}, reasoning: true, input: ["text", "image"], cost: { @@ -2826,6 +2829,7 @@ export const MODELS = { api: "anthropic-messages", provider: "cloudflare-ai-gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + compat: {"forceAdaptiveThinking":true}, reasoning: true, thinkingLevelMap: {"xhigh":"max"}, input: ["text", "image"], @@ -2844,6 +2848,7 @@ export const MODELS = { api: "anthropic-messages", provider: "cloudflare-ai-gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + compat: {"forceAdaptiveThinking":true}, reasoning: true, thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], @@ -2896,6 +2901,7 @@ export const MODELS = { api: "anthropic-messages", provider: "cloudflare-ai-gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + compat: {"forceAdaptiveThinking":true}, reasoning: true, input: ["text", "image"], cost: { @@ -3924,6 +3930,7 @@ export const MODELS = { provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"forceAdaptiveThinking":true}, reasoning: true, thinkingLevelMap: {"xhigh":"max"}, input: ["text", "image"], @@ -3943,6 +3950,7 @@ export const MODELS = { provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"forceAdaptiveThinking":true}, reasoning: true, thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], @@ -3981,6 +3989,7 @@ export const MODELS = { provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"forceAdaptiveThinking":true}, reasoning: true, input: ["text", "image"], cost: { @@ -7226,6 +7235,7 @@ export const MODELS = { api: "anthropic-messages", provider: "opencode", baseUrl: "https://opencode.ai/zen", + compat: {"forceAdaptiveThinking":true}, reasoning: true, thinkingLevelMap: {"xhigh":"max"}, input: ["text", "image"], @@ -7244,6 +7254,7 @@ export const MODELS = { api: "anthropic-messages", provider: "opencode", baseUrl: "https://opencode.ai/zen", + compat: {"forceAdaptiveThinking":true}, reasoning: true, thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], @@ -7296,6 +7307,7 @@ export const MODELS = { api: "anthropic-messages", provider: "opencode", baseUrl: "https://opencode.ai/zen", + compat: {"forceAdaptiveThinking":true}, reasoning: true, input: ["text", "image"], cost: { @@ -13369,6 +13381,7 @@ export const MODELS = { api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", + compat: {"forceAdaptiveThinking":true}, reasoning: true, thinkingLevelMap: {"xhigh":"max"}, input: ["text", "image"], @@ -13387,6 +13400,7 @@ export const MODELS = { api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", + compat: {"forceAdaptiveThinking":true}, reasoning: true, thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], @@ -13439,6 +13453,7 @@ export const MODELS = { api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", + compat: {"forceAdaptiveThinking":true}, reasoning: true, input: ["text", "image"], cost: { diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index a594a479..ce306056 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -164,7 +164,9 @@ export type AnthropicThinkingDisplay = "summarized" | "omitted"; const FINE_GRAINED_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14"; const INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14"; -function getAnthropicCompat(model: Model<"anthropic-messages">): Required { +function getAnthropicCompat( + model: Model<"anthropic-messages">, +): Required> { // Auto-detect session affinity and cache control support from provider const isFireworks = model.provider === "fireworks"; const isCloudflareAiGatewayAnthropic = @@ -181,29 +183,34 @@ function getAnthropicCompat(model: Model<"anthropic-messages">): Required, sessionId?: string, ): { client: Anthropic; isOAuthToken: boolean } { - // Adaptive thinking models (Opus 4.6, Sonnet 4.6) have interleaved thinking built-in. - // The beta header is deprecated on Opus 4.6 and redundant on Sonnet 4.6, so skip it. - const needsInterleavedBeta = interleavedThinking && !supportsAdaptiveThinking(model.id); + // Adaptive thinking models have interleaved thinking built in, so skip the beta header. + const needsInterleavedBeta = interleavedThinking && model.compat?.forceAdaptiveThinking !== true; const betaFeatures: string[] = []; if (useFineGrainedToolStreamingBeta) { betaFeatures.push(FINE_GRAINED_TOOL_STREAMING_BETA); @@ -939,14 +942,13 @@ function buildParams( ); } - // Configure thinking mode: adaptive (Opus 4.6+ and Sonnet 4.6), - // budget-based (older models), or explicitly disabled. + // Configure thinking mode: adaptive, budget-based, or explicitly disabled. if (model.reasoning) { if (options?.thinkingEnabled) { // Default to "summarized" so Opus 4.7 and Mythos Preview behave like // older Claude 4 models (whose API default is also "summarized"). const display: AnthropicThinkingDisplay = options.thinkingDisplay ?? "summarized"; - if (supportsAdaptiveThinking(model.id)) { + if (model.compat?.forceAdaptiveThinking === true) { // Adaptive thinking: Claude decides when and how much to think. params.thinking = { type: "adaptive", display }; if (options.effort) { diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 6fc703f1..5adc69c6 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -435,6 +435,16 @@ export interface AnthropicMessagesCompat { * Default: true. */ supportsCacheControlOnTools?: boolean; + /** + * Whether to force adaptive thinking (`thinking.type: "adaptive"` plus + * `output_config.effort`) regardless of the model id. Built-in models that + * require adaptive thinking set this in generated metadata. Custom + * Anthropic-compatible providers can set this to `true` for any model whose + * upstream requires the adaptive format. Set to `false` to + * opt out on overridden built-in models. + * Default: false. + */ + forceAdaptiveThinking?: boolean; } /** diff --git a/packages/ai/test/anthropic-adaptive-thinking-models.test.ts b/packages/ai/test/anthropic-adaptive-thinking-models.test.ts new file mode 100644 index 00000000..f118401b --- /dev/null +++ b/packages/ai/test/anthropic-adaptive-thinking-models.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { getModels, getProviders } from "../src/models.ts"; +import type { Api, Model } from "../src/types.ts"; + +const EXPECTED_ADAPTIVE_THINKING_MODELS = [ + "anthropic/claude-opus-4-6", + "anthropic/claude-opus-4-7", + "anthropic/claude-sonnet-4-6", + "cloudflare-ai-gateway/claude-opus-4-6", + "cloudflare-ai-gateway/claude-opus-4-7", + "cloudflare-ai-gateway/claude-sonnet-4-6", + "github-copilot/claude-opus-4.6", + "github-copilot/claude-opus-4.7", + "github-copilot/claude-sonnet-4.6", + "opencode/claude-opus-4-6", + "opencode/claude-opus-4-7", + "opencode/claude-sonnet-4-6", + "vercel-ai-gateway/anthropic/claude-opus-4.6", + "vercel-ai-gateway/anthropic/claude-opus-4.7", + "vercel-ai-gateway/anthropic/claude-sonnet-4.6", +]; + +function getAllModels(): Model[] { + return getProviders().flatMap((provider) => getModels(provider) as Model[]); +} + +describe("Anthropic adaptive thinking model metadata", () => { + it("marks exactly the built-in Anthropic Messages models that use adaptive thinking", () => { + const flaggedModels = getAllModels() + .filter((model): model is Model<"anthropic-messages"> => model.api === "anthropic-messages") + .filter((model) => model.compat?.forceAdaptiveThinking === true) + .map((model) => `${model.provider}/${model.id}`) + .sort(); + + expect(flaggedModels).toEqual([...EXPECTED_ADAPTIVE_THINKING_MODELS].sort()); + }); +}); diff --git a/packages/ai/test/anthropic-force-adaptive-thinking.test.ts b/packages/ai/test/anthropic-force-adaptive-thinking.test.ts new file mode 100644 index 00000000..fd18df96 --- /dev/null +++ b/packages/ai/test/anthropic-force-adaptive-thinking.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { getModel } from "../src/models.ts"; +import { streamSimple } from "../src/stream.ts"; +import type { Context, Model, SimpleStreamOptions } from "../src/types.ts"; + +interface AnthropicThinkingPayload { + thinking?: { type: string; budget_tokens?: number; display?: string }; + output_config?: { effort?: string }; +} + +class PayloadCaptured extends Error { + constructor() { + super("payload captured"); + this.name = "PayloadCaptured"; + } +} + +function makeContext(): Context { + return { + messages: [{ role: "user", content: "Hello", timestamp: Date.now() }], + }; +} + +function makeCustomModel(compat?: Model<"anthropic-messages">["compat"]): Model<"anthropic-messages"> { + return { + // Id intentionally does not match any built-in adaptive substring. This + // mirrors corporate proxy schemes such as `anthropic--claude-opus-latest`. + id: "vendor--claude-opus-latest", + name: "Vendor Proxy Opus Latest", + api: "anthropic-messages", + provider: "vendor-proxy", + baseUrl: "http://127.0.0.1:9", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 32000, + compat, + }; +} + +async function capturePayload( + model: Model<"anthropic-messages">, + options?: SimpleStreamOptions, +): Promise { + let capturedPayload: AnthropicThinkingPayload | undefined; + + const payloadCaptureModel: Model<"anthropic-messages"> = { + ...model, + baseUrl: "http://127.0.0.1:9", + }; + + const s = streamSimple(payloadCaptureModel, makeContext(), { + ...options, + apiKey: "fake-key", + onPayload: (payload) => { + capturedPayload = payload as AnthropicThinkingPayload; + throw new PayloadCaptured(); + }, + }); + + await s.result(); + + if (!capturedPayload) { + throw new Error("Expected payload to be captured before request failure"); + } + + return capturedPayload; +} + +describe("Anthropic forceAdaptiveThinking compat override", () => { + it("sends legacy thinking payload for custom model ids by default", async () => { + const payload = await capturePayload(makeCustomModel(), { reasoning: "medium" }); + + expect(payload.thinking?.type).toBe("enabled"); + expect(payload.output_config).toBeUndefined(); + }); + + it("sends adaptive thinking payload when compat.forceAdaptiveThinking is true", async () => { + const payload = await capturePayload(makeCustomModel({ forceAdaptiveThinking: true }), { reasoning: "medium" }); + + expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(payload.output_config).toEqual({ effort: "medium" }); + }); + + it("allows built-in adaptive models to opt out with compat.forceAdaptiveThinking false", async () => { + const model: Model<"anthropic-messages"> = { + ...getModel("anthropic", "claude-opus-4-7"), + compat: { forceAdaptiveThinking: false }, + }; + const payload = await capturePayload(model, { reasoning: "medium" }); + + expect(payload.thinking?.type).toBe("enabled"); + expect(payload.output_config).toBeUndefined(); + }); + + it("preserves thinking.type=disabled when reasoning is off regardless of override", async () => { + const payload = await capturePayload(makeCustomModel({ forceAdaptiveThinking: true })); + + expect(payload.thinking).toEqual({ type: "disabled" }); + expect(payload.output_config).toBeUndefined(); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 43643b5f..2cf41048 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added `compat.forceAdaptiveThinking` support to custom Anthropic-compatible model configuration docs and validation ([#4797](https://github.com/earendil-works/pi-mono/pull/4797) by [@mbazso](https://github.com/mbazso)). - Added a standard unified patch to edit tool result details for SDK consumers ([#4821](https://github.com/earendil-works/pi/issues/4821)). ### Changed diff --git a/packages/coding-agent/docs/custom-provider.md b/packages/coding-agent/docs/custom-provider.md index e7fecc2d..70ede4c3 100644 --- a/packages/coding-agent/docs/custom-provider.md +++ b/packages/coding-agent/docs/custom-provider.md @@ -230,6 +230,8 @@ models: [{ Use `openrouter` for OpenRouter-style `reasoning: { effort }` controls. Use `together` for Together-style `reasoning: { enabled }` controls; with `supportsReasoningEffort`, it also sends `reasoning_effort`. Use `qwen-chat-template` instead for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking`. Use `cacheControlFormat: "anthropic"` for OpenAI-compatible providers that expose Anthropic-style prompt caching via `cache_control` on the system prompt, last tool definition, and last user/assistant text content. +For Anthropic-compatible providers using `api: "anthropic-messages"`, set `compat.forceAdaptiveThinking: true` on models or providers whose upstream model requires adaptive thinking (`thinking.type: "adaptive"` plus `output_config.effort`). Built-in adaptive Claude models set this automatically. + > Migration note: Mistral moved from `openai-completions` to `mistral-conversations`. > Use `mistral-conversations` for native Mistral models. > If you intentionally route Mistral-compatible/custom endpoints through `openai-completions`, set `compat` flags explicitly as needed. @@ -702,8 +704,9 @@ interface ProviderModelConfig { /** Custom headers for this specific model. */ headers?: Record; - /** OpenAI compatibility settings for openai-completions API. */ + /** Compatibility settings for the selected API. */ compat?: { + // openai-completions supportsStore?: boolean; supportsDeveloperRole?: boolean; supportsReasoningEffort?: boolean; @@ -715,6 +718,13 @@ interface ProviderModelConfig { requiresReasoningContentOnAssistantMessages?: boolean; thinkingFormat?: "openai" | "openrouter" | "deepseek" | "together" | "zai" | "qwen" | "qwen-chat-template"; cacheControlFormat?: "anthropic"; + + // anthropic-messages + supportsEagerToolInputStreaming?: boolean; + supportsLongCacheRetention?: boolean; + sendSessionAffinityHeaders?: boolean; + supportsCacheControlOnTools?: boolean; + forceAdaptiveThinking?: boolean; }; } ``` diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index 12636700..85fccaf3 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -315,10 +315,12 @@ Behavior notes: ## Anthropic Messages Compatibility -For providers or proxies using `api: "anthropic-messages"`, use `compat.supportsEagerToolInputStreaming` to control Anthropic fine-grained tool streaming compatibility. +For providers or proxies using `api: "anthropic-messages"`, use `compat` to control Anthropic-specific request compatibility. By default pi sends per-tool `eager_input_streaming: true`. If a proxy or Anthropic-compatible backend rejects that field, set `supportsEagerToolInputStreaming` to `false`. Pi will omit `tools[].eager_input_streaming` and send the legacy `fine-grained-tool-streaming-2025-05-14` beta header for tool-enabled requests instead. +Some Anthropic models require adaptive thinking (`thinking.type: "adaptive"` plus `output_config.effort`) instead of the legacy budget-based thinking payload. Built-in models set this automatically. For custom providers or aliases that route to those models, set `forceAdaptiveThinking` to `true`. + ```json { "providers": { @@ -328,7 +330,8 @@ By default pi sends per-tool `eager_input_streaming: true`. If a proxy or Anthro "apiKey": "ANTHROPIC_PROXY_KEY", "compat": { "supportsEagerToolInputStreaming": false, - "supportsLongCacheRetention": true + "supportsLongCacheRetention": true, + "forceAdaptiveThinking": true }, "models": [ { @@ -346,6 +349,9 @@ By default pi sends per-tool `eager_input_streaming: true`. If a proxy or Anthro |-------|-------------| | `supportsEagerToolInputStreaming` | Whether the provider accepts per-tool `eager_input_streaming`. Default: `true`. Set to `false` to omit that field and use the legacy fine-grained tool streaming beta header on tool-enabled requests. | | `supportsLongCacheRetention` | Whether the provider accepts Anthropic long cache retention (`cache_control.ttl: "1h"`) when cache retention is `long`. Default: `true`. | +| `sendSessionAffinityHeaders` | Whether to send `x-session-affinity` from the session id when caching is enabled. Default: auto-detected for known providers. | +| `supportsCacheControlOnTools` | Whether the provider accepts Anthropic-style `cache_control` markers on tool definitions. Default: `true`. | +| `forceAdaptiveThinking` | Whether to send adaptive thinking (`thinking.type: "adaptive"` plus `output_config.effort`) for this model. Built-in adaptive models set this automatically. Default: `false`. | ## OpenAI Compatibility diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index 6e3a1031..712874d8 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -128,6 +128,9 @@ const OpenAIResponsesCompatSchema = Type.Object({ const AnthropicMessagesCompatSchema = Type.Object({ supportsEagerToolInputStreaming: Type.Optional(Type.Boolean()), supportsLongCacheRetention: Type.Optional(Type.Boolean()), + sendSessionAffinityHeaders: Type.Optional(Type.Boolean()), + supportsCacheControlOnTools: Type.Optional(Type.Boolean()), + forceAdaptiveThinking: Type.Optional(Type.Boolean()), }); const ProviderCompatSchema = Type.Union([ From 9b62f1f87c3429dc29bf7c33bef082d4be13c8a1 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 22 May 2026 18:40:31 +0200 Subject: [PATCH 007/352] Fix Anthropic eager tool input compat test --- packages/ai/test/anthropic-eager-tool-input-compat.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ai/test/anthropic-eager-tool-input-compat.test.ts b/packages/ai/test/anthropic-eager-tool-input-compat.test.ts index c6df9c3e..0560563a 100644 --- a/packages/ai/test/anthropic-eager-tool-input-compat.test.ts +++ b/packages/ai/test/anthropic-eager-tool-input-compat.test.ts @@ -22,7 +22,7 @@ function createModel(baseUrl: string, compat?: Model<"anthropic-messages">["comp cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 32000, - compat, + compat: { forceAdaptiveThinking: true, ...compat }, }; } From 42379a37bdae3f436bd89553b76cabaea12aa328 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 22 May 2026 22:22:55 +0200 Subject: [PATCH 008/352] fix(coding-agent): add OpenCode session headers closes #4847 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/core/sdk.ts | 10 +++++- .../test/sdk-openrouter-attribution.test.ts | 35 +++++++++++++++++-- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2cf41048..0d66d97d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,6 +13,7 @@ ### Fixed +- Fixed OpenCode Zen/Go requests to send per-session OpenCode routing headers ([#4847](https://github.com/earendil-works/pi/issues/4847)). - Fixed Amazon Bedrock provider loading under strict package managers by inheriting the declared `@smithy/node-http-handler` dependency from `@earendil-works/pi-ai` ([#4842](https://github.com/earendil-works/pi/issues/4842)). - Fixed exported session HTML to escape quote characters in attribute values ([#4832](https://github.com/earendil-works/pi/issues/4832)). - Fixed GitHub Copilot device-code login to keep opening the verification URL in browser-capable environments while ignoring browser launch failures for headless use. diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index f19581e4..b79261ad 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -129,7 +129,15 @@ function getDefaultAgentDir(): string { function getAttributionHeaders( model: Model, settingsManager: SettingsManager, + sessionId?: string, ): Record | undefined { + if ( + sessionId && + (model.provider === "opencode" || model.provider === "opencode-go" || model.baseUrl.includes("opencode.ai")) + ) { + return { "x-opencode-session": sessionId, "x-opencode-client": "pi" }; + } + if (!isInstallTelemetryEnabled(settingsManager)) { return undefined; } @@ -332,7 +340,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} throw new Error(auth.error); } const providerRetrySettings = settingsManager.getProviderRetrySettings(); - const attributionHeaders = getAttributionHeaders(model, settingsManager); + const attributionHeaders = getAttributionHeaders(model, settingsManager, options?.sessionId); return streamSimple(model, context, { ...options, apiKey: auth.apiKey, diff --git a/packages/coding-agent/test/sdk-openrouter-attribution.test.ts b/packages/coding-agent/test/sdk-openrouter-attribution.test.ts index 70bb8709..c7032103 100644 --- a/packages/coding-agent/test/sdk-openrouter-attribution.test.ts +++ b/packages/coding-agent/test/sdk-openrouter-attribution.test.ts @@ -86,6 +86,7 @@ describe("createAgentSession OpenRouter attribution headers", () => { telemetryEnabled?: boolean; providerHeaders?: Record; requestHeaders?: Record; + sessionId?: string; } = {}, ): Promise | undefined> { const settingsManager = SettingsManager.create(cwd, agentDir); @@ -112,6 +113,11 @@ describe("createAgentSession OpenRouter attribution headers", () => { registeredProviders.push(model.provider); } + const sessionManager = SessionManager.inMemory(cwd); + if (options.sessionId) { + sessionManager.newSession({ id: options.sessionId }); + } + const { session } = await createAgentSession({ cwd, agentDir, @@ -119,14 +125,17 @@ describe("createAgentSession OpenRouter attribution headers", () => { authStorage, modelRegistry, settingsManager, - sessionManager: SessionManager.inMemory(cwd), + sessionManager, }); try { await session.agent.streamFn( model, { messages: [] }, - options.requestHeaders ? { headers: options.requestHeaders } : undefined, + { + sessionId: session.sessionId, + ...(options.requestHeaders ? { headers: options.requestHeaders } : {}), + }, ); return capturedOptions?.headers; } finally { @@ -178,4 +187,26 @@ describe("createAgentSession OpenRouter attribution headers", () => { expect(headers?.["X-OpenRouter-Title"]).toBe("request-title"); expect(headers?.["X-OpenRouter-Categories"]).toBe("provider-category"); }); + + it("adds OpenCode session headers", async () => { + const headers = await captureHeaders(createModel("opencode", "https://opencode.ai/zen/v1"), { + sessionId: "opencode-session", + }); + + expect(headers?.["x-opencode-session"]).toBe("opencode-session"); + expect(headers?.["x-opencode-client"]).toBe("pi"); + }); + + it("lets configured OpenCode headers override the defaults", async () => { + const headers = await captureHeaders(createModel("opencode", "https://opencode.ai/zen/v1"), { + sessionId: "opencode-session", + providerHeaders: { + "x-opencode-session": "configured-session", + "x-opencode-client": "configured-client", + }, + }); + + expect(headers?.["x-opencode-session"]).toBe("configured-session"); + expect(headers?.["x-opencode-client"]).toBe("configured-client"); + }); }); From d80bcc367ee78d629b4dd8b001a851ef1884115e Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 22 May 2026 22:59:10 +0200 Subject: [PATCH 009/352] test(ai): avoid hardcoded Fireworks router id --- packages/ai/test/fireworks-models.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/ai/test/fireworks-models.test.ts b/packages/ai/test/fireworks-models.test.ts index 0260a910..6d9bebc6 100644 --- a/packages/ai/test/fireworks-models.test.ts +++ b/packages/ai/test/fireworks-models.test.ts @@ -3,7 +3,7 @@ import type { AddressInfo } from "node:net"; import { Type } from "typebox"; import { afterEach, describe, expect, it } from "vitest"; import { findEnvKeys, getEnvApiKey } from "../src/env-api-keys.ts"; -import { getModel } from "../src/models.ts"; +import { getModel, getModels } from "../src/models.ts"; import { streamAnthropic } from "../src/providers/anthropic.ts"; import type { Context, Model, Tool } from "../src/types.ts"; @@ -38,12 +38,14 @@ describe("Fireworks models", () => { }); it("registers the Fire Pass turbo router model", () => { - const model = getModel("fireworks", "accounts/fireworks/routers/kimi-k2p5-turbo"); + const model = getModels("fireworks").find( + (candidate) => candidate.id.startsWith("accounts/fireworks/routers/") && candidate.id.endsWith("-turbo"), + ); expect(model).toBeDefined(); - expect(model.api).toBe("anthropic-messages"); - expect(model.baseUrl).toBe("https://api.fireworks.ai/inference"); - expect(model.input).toEqual(["text", "image"]); + expect(model?.api).toBe("anthropic-messages"); + expect(model?.baseUrl).toBe("https://api.fireworks.ai/inference"); + expect(model?.input).toEqual(["text", "image"]); }); it("resolves FIREWORKS_API_KEY from the environment", () => { From c85dbb1620f00ecf5e86be7cb438f996e62e1355 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 23 May 2026 00:16:07 +0200 Subject: [PATCH 010/352] fix(coding-agent): reconcile pinned git update refs closes #4869 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/packages.md | 9 +-- packages/coding-agent/docs/usage.md | 4 +- .../coding-agent/src/core/package-manager.ts | 23 ++++--- packages/coding-agent/test/git-update.test.ts | 68 ++++++++++++++++++- .../coding-agent/test/package-manager.test.ts | 20 +++--- 6 files changed, 101 insertions(+), 24 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0d66d97d..83253924 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,6 +13,7 @@ ### Fixed +- Fixed `pi update` to reconcile git-pinned packages to their configured ref ([#4869](https://github.com/earendil-works/pi/issues/4869)). - Fixed OpenCode Zen/Go requests to send per-session OpenCode routing headers ([#4847](https://github.com/earendil-works/pi/issues/4847)). - Fixed Amazon Bedrock provider loading under strict package managers by inheriting the declared `@smithy/node-http-handler` dependency from `@earendil-works/pi-ai` ([#4842](https://github.com/earendil-works/pi/issues/4842)). - Fixed exported session HTML to escape quote characters in attribute values ([#4832](https://github.com/earendil-works/pi/issues/4832)). diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index 1f3c69a6..33527191 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -28,8 +28,8 @@ pi install ./relative/path/to/package pi remove npm:@foo/bar pi list # show installed packages from settings -pi update # update pi and all non-pinned packages -pi update --extensions # update all non-pinned packages only +pi update # update pi, update packages, and reconcile pinned git refs +pi update --extensions # update packages and reconcile pinned git refs only pi update --self # update pi only pi update --self --force # reinstall pi even if current pi update npm:@foo/bar # update one package @@ -85,9 +85,10 @@ ssh://git@github.com/user/repo@v1 - HTTPS and SSH URLs are both supported. - SSH URLs use your configured SSH keys automatically (respects `~/.ssh/config`). - For non-interactive runs (for example CI), you can set `GIT_TERMINAL_PROMPT=0` to disable credential prompts and set `GIT_SSH_COMMAND` (for example `ssh -o BatchMode=yes -o ConnectTimeout=5`) to fail fast. -- Refs are pinned tags or commits and skip package updates (`pi update`, `pi update --extensions`). Use `pi install git:host/user/repo@new-ref` to move an existing package to a new pinned ref. +- Refs are pinned tags or commits. `pi update` and `pi update --extensions` do not move them to newer refs, but they do reconcile an existing clone to the configured ref. +- Use `pi install git:host/user/repo@new-ref` to update settings and move an existing package to a new pinned ref. - Cloned to `~/.pi/agent/git//` (global) or `.pi/git//` (project). -- Runs `npm install` after clone, pull, or pinned ref change if `package.json` exists. +- When reconciliation changes the checkout, pi resets and cleans the clone, then runs `npm install` if `package.json` exists. **SSH examples:** ```bash diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 65081090..ae69afaf 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -129,8 +129,8 @@ pi [options] [@files...] [messages...] pi install [-l] # Install package, -l for project-local pi remove [-l] # Remove package pi uninstall [-l] # Alias for remove -pi update [source|self|pi] # Update pi and packages; skips pinned packages -pi update --extensions # Update packages only +pi update [source|self|pi] # Update pi and packages; reconcile pinned git refs +pi update --extensions # Update packages only; reconcile pinned git refs pi update --self # Update pi only pi update --extension # Update one package pi list # List installed packages diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 994adb56..06933bb2 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -1047,14 +1047,15 @@ export class DefaultPackageManager implements PackageManager { for (const entry of sources) { const parsed = this.parseSource(entry.source); - if (parsed.type === "local" || parsed.pinned) { - continue; - } + // Pinned npm versions are fixed. Pinned git refs are configured checkout targets, + // so include them to reconcile an existing clone when the configured ref changes. if (parsed.type === "npm") { - npmCandidates.push({ ...entry, parsed }); - continue; + if (!parsed.pinned) { + npmCandidates.push({ ...entry, parsed }); + } + } else if (parsed.type === "git") { + gitCandidates.push({ ...entry, parsed }); } - gitCandidates.push({ ...entry, parsed }); } const npmCheckTasks = npmCandidates.map((entry) => async () => ({ @@ -1766,6 +1767,11 @@ export class DefaultPackageManager implements PackageManager { return; } + if (source.ref) { + await this.ensureGitRef(targetDir, ["fetch", "origin", source.ref], "FETCH_HEAD"); + return; + } + const target = await this.getLocalGitUpdateTarget(targetDir); await this.ensureGitRef(targetDir, target.fetchArgs, target.ref); } @@ -1778,7 +1784,8 @@ export class DefaultPackageManager implements PackageManager { cwd: targetDir, timeoutMs: NETWORK_TIMEOUT_MS, }); - const targetHead = await this.runCommandCapture("git", ["rev-parse", ref], { + const commitRef = `${ref}^{commit}`; + const targetHead = await this.runCommandCapture("git", ["rev-parse", commitRef], { cwd: targetDir, timeoutMs: NETWORK_TIMEOUT_MS, }); @@ -1786,7 +1793,7 @@ export class DefaultPackageManager implements PackageManager { return; } - await this.runCommand("git", ["reset", "--hard", ref], { cwd: targetDir }); + await this.runCommand("git", ["reset", "--hard", commitRef], { cwd: targetDir }); // Clean untracked files (extensions should be pristine) await this.runCommand("git", ["clean", "-fdx"], { cwd: targetDir }); diff --git a/packages/coding-agent/test/git-update.test.ts b/packages/coding-agent/test/git-update.test.ts index 0f574ba6..ea8fae4d 100644 --- a/packages/coding-agent/test/git-update.test.ts +++ b/packages/coding-agent/test/git-update.test.ts @@ -282,7 +282,7 @@ describe("DefaultPackageManager git update", () => { }); describe("pinned sources", () => { - it("should not update pinned git sources (with @ref)", async () => { + it("should not move pinned git sources past their configured ref", async () => { // Create remote repo first to get the initial commit mkdirSync(remoteDir, { recursive: true }); initGitRepo(remoteDir); @@ -301,13 +301,77 @@ describe("DefaultPackageManager git update", () => { // Add new commit to remote createCommit(remoteDir, "extension.ts", "// v2", "Second commit"); - // Update should be skipped for pinned sources await packageManager.update(); // Should still be on initial commit expect(getCurrentCommit(installedDir)).toBe(initialCommit); expect(getFileContent(installedDir, "extension.ts")).toBe("// v1"); }); + + it("should checkout the configured pinned git ref during full and targeted updates", async () => { + mkdirSync(remoteDir, { recursive: true }); + initGitRepo(remoteDir); + const v1Commit = createCommit(remoteDir, "extension.ts", "// v1", "Initial commit"); + git(["tag", "v1"], remoteDir); + const v2Commit = createCommit(remoteDir, "extension.ts", "// v2", "Second commit"); + git(["tag", "v2"], remoteDir); + + mkdirSync(join(agentDir, "git", "github.com", "test"), { recursive: true }); + git(["clone", remoteDir, installedDir], tempDir); + git(["checkout", "v1"], installedDir); + expect(getCurrentCommit(installedDir)).toBe(v1Commit); + + const pinnedSource = `${gitSource}@v2`; + settingsManager.setPackages([pinnedSource]); + + await packageManager.update(); + + expect(getCurrentCommit(installedDir)).toBe(v2Commit); + expect(getFileContent(installedDir, "extension.ts")).toBe("// v2"); + + git(["checkout", "v1"], installedDir); + + await packageManager.update(pinnedSource); + + expect(getCurrentCommit(installedDir)).toBe(v2Commit); + expect(getFileContent(installedDir, "extension.ts")).toBe("// v2"); + }); + + it("should not reset an annotated tag checkout that already matches the configured ref", async () => { + mkdirSync(remoteDir, { recursive: true }); + initGitRepo(remoteDir); + const taggedCommit = createCommit(remoteDir, "extension.ts", "// v1", "Initial commit"); + git(["tag", "-a", "v1", "-m", "v1"], remoteDir); + + mkdirSync(join(agentDir, "git", "github.com", "test"), { recursive: true }); + git(["clone", remoteDir, installedDir], tempDir); + git(["checkout", "v1"], installedDir); + expect(getCurrentCommit(installedDir)).toBe(taggedCommit); + + settingsManager.setPackages([`${gitSource}@v1`]); + + const executedCommands: string[] = []; + const managerWithInternals = packageManager as unknown as { + runCommand: (command: string, args: string[], options?: { cwd?: string }) => Promise; + }; + managerWithInternals.runCommand = async (command, args, options) => { + executedCommands.push(`${command} ${args.join(" ")}`); + const result = spawnSync(command, args, { + cwd: options?.cwd, + encoding: "utf-8", + }); + if (result.status !== 0) { + throw new Error(`Command failed: ${command} ${args.join(" ")}\n${result.stderr}`); + } + }; + + await packageManager.update(); + + expect(executedCommands).toContain("git fetch origin v1"); + expect(executedCommands.some((command) => command.startsWith("git reset --hard"))).toBe(false); + expect(executedCommands).not.toContain("git clean -fdx"); + expect(getCurrentCommit(installedDir)).toBe(taggedCommit); + }); }); describe("temporary git sources", () => { diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 30068da1..b464ca7d 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -748,7 +748,7 @@ Content`, if (args[0] === "rev-parse" && args[1] === "HEAD") { return "old-head"; } - if (args[0] === "rev-parse" && args[1] === "FETCH_HEAD") { + if (args[0] === "rev-parse" && args[1] === "FETCH_HEAD^{commit}") { return "new-head"; } throw new Error(`Unexpected runCommandCapture args: ${args.join(" ")}`); @@ -758,7 +758,9 @@ Content`, await packageManager.install(source); expect(runCommandSpy).toHaveBeenCalledWith("git", ["fetch", "origin", "v2"], { cwd: targetDir }); - expect(runCommandSpy).toHaveBeenCalledWith("git", ["reset", "--hard", "FETCH_HEAD"], { cwd: targetDir }); + expect(runCommandSpy).toHaveBeenCalledWith("git", ["reset", "--hard", "FETCH_HEAD^{commit}"], { + cwd: targetDir, + }); expect(runCommandSpy).toHaveBeenCalledWith("git", ["clean", "-fdx"], { cwd: targetDir }); expect(runCommandSpy).toHaveBeenCalledWith("npm", ["install", "--omit=dev"], { cwd: targetDir }); }); @@ -779,7 +781,7 @@ Content`, if (args[0] === "rev-parse" && args[1] === "HEAD") { return "old-head"; } - if (args[0] === "rev-parse" && args[1] === "origin/HEAD") { + if (args[0] === "rev-parse" && args[1] === "origin/HEAD^{commit}") { return "new-head"; } throw new Error(`Unexpected runCommandCapture args: ${args.join(" ")}`); @@ -789,7 +791,9 @@ Content`, await packageManager.install(source); expect(runCommandSpy).toHaveBeenCalledWith("git", fetchArgs, { cwd: targetDir }); - expect(runCommandSpy).toHaveBeenCalledWith("git", ["reset", "--hard", "origin/HEAD"], { cwd: targetDir }); + expect(runCommandSpy).toHaveBeenCalledWith("git", ["reset", "--hard", "origin/HEAD^{commit}"], { + cwd: targetDir, + }); expect(runCommandSpy).toHaveBeenCalledWith("git", ["clean", "-fdx"], { cwd: targetDir }); }); @@ -832,7 +836,7 @@ Content`, if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "@{upstream}") { return "origin/main"; } - if (args[0] === "rev-parse" && args[1] === "@{upstream}") { + if (args[0] === "rev-parse" && (args[1] === "@{upstream}" || args[1] === "@{upstream}^{commit}")) { return "remote-head"; } if (args[0] === "rev-parse" && args[1] === "HEAD") { @@ -868,7 +872,7 @@ Content`, if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "@{upstream}") { return "origin/main"; } - if (args[0] === "rev-parse" && args[1] === "@{upstream}") { + if (args[0] === "rev-parse" && (args[1] === "@{upstream}" || args[1] === "@{upstream}^{commit}")) { return "remote-head"; } if (args[0] === "rev-parse" && args[1] === "HEAD") { @@ -2057,7 +2061,7 @@ export default function(api) { api.registerTool({ name: "test", description: "te expect(packageManager.getInstalledPath("npm:legacy-pkg", "user")).toBe(managedPath); }); - it("should batch npm updates per scope and run git updates in parallel while skipping pinned and current packages", async () => { + it("should batch npm updates per scope and run git updates in parallel while skipping pinned npm and current packages", async () => { const userOldPath = join(agentDir, "npm", "node_modules", "user-old"); const userCurrentPath = join(agentDir, "npm", "node_modules", "user-current"); const userUnknownPath = join(agentDir, "npm", "node_modules", "user-unknown"); @@ -2159,7 +2163,7 @@ export default function(api) { api.registerTool({ name: "test", description: "te ["install", "project-old@latest", "project-missing@latest", "--prefix", join(tempDir, ".pi", "npm")], undefined, ); - expect(updateGitSpy).toHaveBeenCalledTimes(3); + expect(updateGitSpy).toHaveBeenCalledTimes(4); expect(maxConcurrentNpmUpdates).toBeGreaterThan(1); expect(maxConcurrentGitUpdates).toBeGreaterThan(1); }); From e9146a5ff75f4d899bb67b54409f31fbcfa36af5 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 23 May 2026 09:48:51 +0200 Subject: [PATCH 011/352] fix(coding-agent): use async operations in tools --- packages/coding-agent/package.json | 2 +- .../coding-agent/src/cli/file-processor.ts | 5 +- packages/coding-agent/src/core/tools/bash.ts | 121 ++++----- packages/coding-agent/src/core/tools/edit.ts | 152 ++++-------- .../src/core/tools/file-mutation-queue.ts | 46 +++- packages/coding-agent/src/core/tools/find.ts | 12 +- packages/coding-agent/src/core/tools/grep.ts | 6 +- packages/coding-agent/src/core/tools/ls.ts | 16 +- .../coding-agent/src/core/tools/path-utils.ts | 44 ++++ packages/coding-agent/src/core/tools/read.ts | 12 +- packages/coding-agent/src/core/tools/write.ts | 68 +++--- .../src/utils/image-resize-core.ts | 164 +++++++++++++ .../src/utils/image-resize-worker.ts | 42 ++++ .../coding-agent/src/utils/image-resize.ts | 231 ++++++------------ .../test/file-mutation-queue.test.ts | 111 +++++++++ .../test/image-processing.test.ts | 65 +++-- scripts/build-binaries.sh | 7 +- 17 files changed, 706 insertions(+), 398 deletions(-) create mode 100644 packages/coding-agent/src/utils/image-resize-core.ts create mode 100644 packages/coding-agent/src/utils/image-resize-worker.ts diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 0c19d304..55c9b8a6 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -31,7 +31,7 @@ "scripts": { "clean": "shx rm -rf dist", "build": "tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js && npm run copy-assets", - "build:binary": "npm --prefix ../tui run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm run build && bun build --compile ./dist/bun/cli.js --outfile dist/pi && npm run copy-binary-assets", + "build:binary": "npm --prefix ../tui run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm run build && bun build --compile ./dist/bun/cli.js ./dist/utils/image-resize-worker.js --outfile dist/pi && npm run copy-binary-assets", "copy-assets": "shx mkdir -p dist/modes/interactive/theme && shx cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && shx mkdir -p dist/modes/interactive/assets && shx cp src/modes/interactive/assets/*.png dist/modes/interactive/assets/ && shx mkdir -p dist/core/export-html/vendor && shx cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && shx cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/", "copy-binary-assets": "shx cp package.json dist/ && shx cp README.md dist/ && shx cp CHANGELOG.md dist/ && shx mkdir -p dist/theme && shx cp src/modes/interactive/theme/*.json dist/theme/ && shx mkdir -p dist/assets && shx cp src/modes/interactive/assets/*.png dist/assets/ && shx mkdir -p dist/export-html/vendor && shx cp src/core/export-html/template.html dist/export-html/ && shx cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && shx cp -r docs dist/ && shx cp -r examples dist/ && shx cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist/", "test": "vitest --run", diff --git a/packages/coding-agent/src/cli/file-processor.ts b/packages/coding-agent/src/cli/file-processor.ts index fe0e32eb..e1da052a 100644 --- a/packages/coding-agent/src/cli/file-processor.ts +++ b/packages/coding-agent/src/cli/file-processor.ts @@ -50,13 +50,12 @@ export async function processFileArguments(fileArgs: string[], options?: Process if (mimeType) { // Handle image file const content = await readFile(absolutePath); - const base64Content = content.toString("base64"); let attachment: ImageContent; let dimensionNote: string | undefined; if (autoResizeImages) { - const resized = await resizeImage({ type: "image", data: base64Content, mimeType }); + const resized = await resizeImage(content, mimeType); if (!resized) { text += `[Image omitted: could not be resized below the inline image size limit.]\n`; continue; @@ -71,7 +70,7 @@ export async function processFileArguments(fileArgs: string[], options?: Process attachment = { type: "image", mimeType, - data: base64Content, + data: content.toString("base64"), }; } diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index b9e55142..bc1d3cc4 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -1,4 +1,5 @@ -import { existsSync } from "node:fs"; +import { constants } from "node:fs"; +import { access as fsAccess } from "node:fs/promises"; import type { AgentTool } from "@earendil-works/pi-agent-core"; import { Container, Text, truncateToWidth } from "@earendil-works/pi-tui"; import { spawn } from "child_process"; @@ -66,62 +67,70 @@ export function createLocalBashOperations(options?: { shellPath?: string }): Bas return { exec: (command, cwd, { onData, signal, timeout, env }) => { return new Promise((resolve, reject) => { - const { shell, args } = getShellConfig(options?.shellPath); - if (!existsSync(cwd)) { - reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`)); - return; - } - const child = spawn(shell, [...args, command], { - cwd, - detached: process.platform !== "win32", - env: env ?? getShellEnv(), - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - }); - if (child.pid) trackDetachedChildPid(child.pid); - let timedOut = false; - let timeoutHandle: NodeJS.Timeout | undefined; - // Set timeout if provided. - if (timeout !== undefined && timeout > 0) { - timeoutHandle = setTimeout(() => { - timedOut = true; - if (child.pid) killProcessTree(child.pid); - }, timeout * 1000); - } - // Stream stdout and stderr. - child.stdout?.on("data", onData); - child.stderr?.on("data", onData); - // Handle abort signal by killing the entire process tree. - const onAbort = () => { - if (child.pid) killProcessTree(child.pid); - }; - if (signal) { - if (signal.aborted) onAbort(); - else signal.addEventListener("abort", onAbort, { once: true }); - } - // Handle shell spawn errors and wait for the process to terminate without hanging - // on inherited stdio handles held by detached descendants. - waitForChildProcess(child) - .then((code) => { - if (child.pid) untrackDetachedChildPid(child.pid); - if (timeoutHandle) clearTimeout(timeoutHandle); - if (signal) signal.removeEventListener("abort", onAbort); - if (signal?.aborted) { - reject(new Error("aborted")); - return; - } - if (timedOut) { - reject(new Error(`timeout:${timeout}`)); - return; - } - resolve({ exitCode: code }); - }) - .catch((err) => { - if (child.pid) untrackDetachedChildPid(child.pid); - if (timeoutHandle) clearTimeout(timeoutHandle); - if (signal) signal.removeEventListener("abort", onAbort); - reject(err); + void (async () => { + const { shell, args } = getShellConfig(options?.shellPath); + try { + await fsAccess(cwd, constants.F_OK); + } catch { + reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`)); + return; + } + if (signal?.aborted) { + reject(new Error("aborted")); + return; + } + const child = spawn(shell, [...args, command], { + cwd, + detached: process.platform !== "win32", + env: env ?? getShellEnv(), + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, }); + if (child.pid) trackDetachedChildPid(child.pid); + let timedOut = false; + let timeoutHandle: NodeJS.Timeout | undefined; + // Set timeout if provided. + if (timeout !== undefined && timeout > 0) { + timeoutHandle = setTimeout(() => { + timedOut = true; + if (child.pid) killProcessTree(child.pid); + }, timeout * 1000); + } + // Stream stdout and stderr. + child.stdout?.on("data", onData); + child.stderr?.on("data", onData); + // Handle abort signal by killing the entire process tree. + const onAbort = () => { + if (child.pid) killProcessTree(child.pid); + }; + if (signal) { + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + } + // Handle shell spawn errors and wait for the process to terminate without hanging + // on inherited stdio handles held by detached descendants. + waitForChildProcess(child) + .then((code) => { + if (child.pid) untrackDetachedChildPid(child.pid); + if (timeoutHandle) clearTimeout(timeoutHandle); + if (signal) signal.removeEventListener("abort", onAbort); + if (signal?.aborted) { + reject(new Error("aborted")); + return; + } + if (timedOut) { + reject(new Error(`timeout:${timeout}`)); + return; + } + resolve({ exitCode: code }); + }) + .catch((err) => { + if (child.pid) untrackDetachedChildPid(child.pid); + if (timeoutHandle) clearTimeout(timeoutHandle); + if (signal) signal.removeEventListener("abort", onAbort); + reject(err); + }); + })().catch((err: unknown) => reject(err instanceof Error ? err : new Error(String(err)))); }); }, }; diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index 5d915d45..7c6367a6 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -313,113 +313,63 @@ export function createEditToolDefinition( const { path, edits } = validateEditInput(input); const absolutePath = resolveToCwd(path, cwd); - return withFileMutationQueue( - absolutePath, - () => - new Promise<{ - content: Array<{ type: "text"; text: string }>; - details: EditToolDetails | undefined; - }>((resolve, reject) => { - // Check if already aborted. - if (signal?.aborted) { - reject(new Error("Operation aborted")); - return; - } + return withFileMutationQueue(absolutePath, async () => { + let aborted = signal?.aborted ?? false; + const onAbort = () => { + aborted = true; + }; + const throwIfAborted = (): void => { + if (aborted || signal?.aborted) { + throw new Error("Operation aborted"); + } + }; - let aborted = false; + signal?.addEventListener("abort", onAbort, { once: true }); + try { + throwIfAborted(); - // Set up abort handler. - const onAbort = () => { - aborted = true; - reject(new Error("Operation aborted")); - }; + // Check if file exists. + try { + await ops.access(absolutePath); + } catch (error: unknown) { + throwIfAborted(); + const errorMessage = + error instanceof Error && "code" in error ? `Error code: ${error.code}` : String(error); + throw new Error(`Could not edit file: ${path}. ${errorMessage}.`); + } + throwIfAborted(); - if (signal) { - signal.addEventListener("abort", onAbort, { once: true }); - } + // Read the file. + const buffer = await ops.readFile(absolutePath); + throwIfAborted(); - // Perform the edit operation. - void (async () => { - try { - // Check if file exists. - try { - await ops.access(absolutePath); - } catch (error: unknown) { - const errorMessage = - error instanceof Error && "code" in error ? `Error code: ${error.code}` : String(error); - if (signal) { - signal.removeEventListener("abort", onAbort); - } - reject(new Error(`Could not edit file: ${path}. ${errorMessage}.`)); - return; - } + // Strip BOM before matching. The model will not include an invisible BOM in oldText. + const rawContent = buffer.toString("utf-8"); + const { bom, text: content } = stripBom(rawContent); + const originalEnding = detectLineEnding(content); + const normalizedContent = normalizeToLF(content); + const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path); + throwIfAborted(); - // Check if aborted before reading. - if (aborted) { - return; - } + const finalContent = bom + restoreLineEndings(newContent, originalEnding); + await ops.writeFile(absolutePath, finalContent); + throwIfAborted(); - // Read the file. - const buffer = await ops.readFile(absolutePath); - const rawContent = buffer.toString("utf-8"); - - // Check if aborted after reading. - if (aborted) { - return; - } - - // Strip BOM before matching. The model will not include an invisible BOM in oldText. - const { bom, text: content } = stripBom(rawContent); - const originalEnding = detectLineEnding(content); - const normalizedContent = normalizeToLF(content); - const { baseContent, newContent } = applyEditsToNormalizedContent( - normalizedContent, - edits, - path, - ); - - // Check if aborted before writing. - if (aborted) { - return; - } - - const finalContent = bom + restoreLineEndings(newContent, originalEnding); - await ops.writeFile(absolutePath, finalContent); - - // Check if aborted after writing. - if (aborted) { - return; - } - - // Clean up abort handler. - if (signal) { - signal.removeEventListener("abort", onAbort); - } - - const diffResult = generateDiffString(baseContent, newContent); - const patch = generateUnifiedPatch(path, baseContent, newContent); - resolve({ - content: [ - { - type: "text", - text: `Successfully replaced ${edits.length} block(s) in ${path}.`, - }, - ], - details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine }, - }); - } catch (error: unknown) { - // Clean up abort handler. - if (signal) { - signal.removeEventListener("abort", onAbort); - } - - if (!aborted) { - reject(error instanceof Error ? error : new Error(String(error))); - } - } - })(); - }), - ); + const diffResult = generateDiffString(baseContent, newContent); + const patch = generateUnifiedPatch(path, baseContent, newContent); + return { + content: [ + { + type: "text", + text: `Successfully replaced ${edits.length} block(s) in ${path}.`, + }, + ], + details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine }, + }; + } finally { + signal?.removeEventListener("abort", onAbort); + } + }); }, renderCall(args, theme, context) { const component = getEditCallRenderComponent(context.state, context.lastComponent); diff --git a/packages/coding-agent/src/core/tools/file-mutation-queue.ts b/packages/coding-agent/src/core/tools/file-mutation-queue.ts index 22011255..5505a7a2 100644 --- a/packages/coding-agent/src/core/tools/file-mutation-queue.ts +++ b/packages/coding-agent/src/core/tools/file-mutation-queue.ts @@ -1,14 +1,27 @@ -import { realpathSync } from "node:fs"; +import { realpath } from "node:fs/promises"; import { resolve } from "node:path"; const fileMutationQueues = new Map>(); +let registrationQueue = Promise.resolve(); -function getMutationQueueKey(filePath: string): string { +function isMissingPathError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error.code === "ENOENT" || error.code === "ENOTDIR") + ); +} + +async function getMutationQueueKey(filePath: string): Promise { const resolvedPath = resolve(filePath); try { - return realpathSync.native(resolvedPath); - } catch { - return resolvedPath; + return await realpath(resolvedPath); + } catch (error) { + if (isMissingPathError(error)) { + return resolvedPath; + } + throw error; } } @@ -17,16 +30,25 @@ function getMutationQueueKey(filePath: string): string { * Operations for different files still run in parallel. */ export async function withFileMutationQueue(filePath: string, fn: () => Promise): Promise { - const key = getMutationQueueKey(filePath); - const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve(); + const registration = registrationQueue.then(async () => { + const key = await getMutationQueueKey(filePath); + const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve(); - let releaseNext!: () => void; - const nextQueue = new Promise((resolveQueue) => { - releaseNext = resolveQueue; + let releaseNext!: () => void; + const nextQueue = new Promise((resolveQueue) => { + releaseNext = resolveQueue; + }); + const chainedQueue = currentQueue.then(() => nextQueue); + fileMutationQueues.set(key, chainedQueue); + + return { key, currentQueue, chainedQueue, releaseNext }; }); - const chainedQueue = currentQueue.then(() => nextQueue); - fileMutationQueues.set(key, chainedQueue); + registrationQueue = registration.then( + () => undefined, + () => undefined, + ); + const { key, currentQueue, chainedQueue, releaseNext } = await registration; await currentQueue; try { return await fn(); diff --git a/packages/coding-agent/src/core/tools/find.ts b/packages/coding-agent/src/core/tools/find.ts index 3fd2f683..5749b813 100644 --- a/packages/coding-agent/src/core/tools/find.ts +++ b/packages/coding-agent/src/core/tools/find.ts @@ -1,8 +1,9 @@ +import { constants } from "node:fs"; +import { access as fsAccess } from "node:fs/promises"; import { createInterface } from "node:readline"; import type { AgentTool } from "@earendil-works/pi-agent-core"; import { Text } from "@earendil-works/pi-tui"; import { spawn } from "child_process"; -import { existsSync } from "fs"; import path from "path"; import { type Static, Type } from "typebox"; import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; @@ -46,7 +47,14 @@ export interface FindOperations { } const defaultFindOperations: FindOperations = { - exists: existsSync, + exists: async (absolutePath) => { + try { + await fsAccess(absolutePath, constants.F_OK); + return true; + } catch { + return false; + } + }, // This is a placeholder. Actual fd execution happens in execute() when no custom glob is provided. glob: () => [], }; diff --git a/packages/coding-agent/src/core/tools/grep.ts b/packages/coding-agent/src/core/tools/grep.ts index 041b3148..010500df 100644 --- a/packages/coding-agent/src/core/tools/grep.ts +++ b/packages/coding-agent/src/core/tools/grep.ts @@ -1,8 +1,8 @@ +import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises"; import { createInterface } from "node:readline"; import type { AgentTool } from "@earendil-works/pi-agent-core"; import { Text } from "@earendil-works/pi-tui"; import { spawn } from "child_process"; -import { readFileSync, statSync } from "fs"; import path from "path"; import { type Static, Type } from "typebox"; import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; @@ -55,8 +55,8 @@ export interface GrepOperations { } const defaultGrepOperations: GrepOperations = { - isDirectory: (p) => statSync(p).isDirectory(), - readFile: (p) => readFileSync(p, "utf-8"), + isDirectory: async (p) => (await fsStat(p)).isDirectory(), + readFile: (p) => fsReadFile(p, "utf-8"), }; export interface GrepToolOptions { diff --git a/packages/coding-agent/src/core/tools/ls.ts b/packages/coding-agent/src/core/tools/ls.ts index f3d22c1d..46c8cc80 100644 --- a/packages/coding-agent/src/core/tools/ls.ts +++ b/packages/coding-agent/src/core/tools/ls.ts @@ -1,6 +1,7 @@ +import { constants } from "node:fs"; +import { access as fsAccess, readdir as fsReaddir, stat as fsStat } from "node:fs/promises"; import type { AgentTool } from "@earendil-works/pi-agent-core"; import { Text } from "@earendil-works/pi-tui"; -import { existsSync, readdirSync, statSync } from "fs"; import nodePath from "path"; import { type Static, Type } from "typebox"; import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; @@ -38,9 +39,16 @@ export interface LsOperations { } const defaultLsOperations: LsOperations = { - exists: existsSync, - stat: statSync, - readdir: readdirSync, + exists: async (absolutePath) => { + try { + await fsAccess(absolutePath, constants.F_OK); + return true; + } catch { + return false; + } + }, + stat: fsStat, + readdir: fsReaddir, }; export interface LsToolOptions { diff --git a/packages/coding-agent/src/core/tools/path-utils.ts b/packages/coding-agent/src/core/tools/path-utils.ts index 1ed1e2bc..065b9eaa 100644 --- a/packages/coding-agent/src/core/tools/path-utils.ts +++ b/packages/coding-agent/src/core/tools/path-utils.ts @@ -1,4 +1,5 @@ import { accessSync, constants } from "node:fs"; +import { access } from "node:fs/promises"; import { normalizePath, resolvePath } from "../../utils/paths.ts"; const NARROW_NO_BREAK_SPACE = "\u202F"; @@ -27,6 +28,15 @@ function fileExists(filePath: string): boolean { } } +async function fileExistsAsync(filePath: string): Promise { + try { + await access(filePath, constants.F_OK); + return true; + } catch { + return false; + } +} + export function expandPath(filePath: string): string { return normalizePath(filePath, { normalizeUnicodeSpaces: true, stripAtPrefix: true }); } @@ -72,3 +82,37 @@ export function resolveReadPath(filePath: string, cwd: string): string { return resolved; } + +export async function resolveReadPathAsync(filePath: string, cwd: string): Promise { + const resolved = resolveToCwd(filePath, cwd); + + if (await fileExistsAsync(resolved)) { + return resolved; + } + + // Try macOS AM/PM variant (narrow no-break space before AM/PM) + const amPmVariant = tryMacOSScreenshotPath(resolved); + if (amPmVariant !== resolved && (await fileExistsAsync(amPmVariant))) { + return amPmVariant; + } + + // Try NFD variant (macOS stores filenames in NFD form) + const nfdVariant = tryNFDVariant(resolved); + if (nfdVariant !== resolved && (await fileExistsAsync(nfdVariant))) { + return nfdVariant; + } + + // Try curly quote variant (macOS uses U+2019 in screenshot names) + const curlyVariant = tryCurlyQuoteVariant(resolved); + if (curlyVariant !== resolved && (await fileExistsAsync(curlyVariant))) { + return curlyVariant; + } + + // Try combined NFD + curly quote (for French macOS screenshots like "Capture d'écran") + const nfdCurlyVariant = tryCurlyQuoteVariant(nfdVariant); + if (nfdCurlyVariant !== resolved && (await fileExistsAsync(nfdCurlyVariant))) { + return nfdCurlyVariant; + } + + return resolved; +} diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index cd3b41ed..f8d12d11 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -12,7 +12,7 @@ import { formatDimensionNote, resizeImage } from "../../utils/image-resize.ts"; import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.ts"; import { formatPathRelativeToCwdOrAbsolute } from "../../utils/paths.ts"; import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; -import { resolveReadPath } from "./path-utils.ts"; +import { resolveReadPathAsync, resolveToCwd } from "./path-utils.ts"; import { getTextOutput, invalidArgText, replaceTabs, shortenPath, str } from "./render-utils.ts"; import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts"; @@ -124,7 +124,7 @@ function getCompactReadClassification( const rawPath = str(args?.file_path ?? args?.path); if (!rawPath) return undefined; - const absolutePath = resolveReadPath(rawPath, cwd); + const absolutePath = resolveToCwd(rawPath, cwd); const fileName = basename(absolutePath); if (fileName === "SKILL.md") { return { kind: "skill", label: basename(dirname(absolutePath)) || fileName }; @@ -223,7 +223,6 @@ export function createReadToolDefinition( _onUpdate?, ctx?, ) { - const absolutePath = resolveReadPath(path, cwd); return new Promise<{ content: (TextContent | ImageContent)[]; details: ReadToolDetails | undefined }>( (resolve, reject) => { if (signal?.aborted) { @@ -239,6 +238,8 @@ export function createReadToolDefinition( (async () => { try { + const absolutePath = await resolveReadPathAsync(path, cwd); + if (aborted) return; // Check if file exists and is readable. await ops.access(absolutePath); if (aborted) return; @@ -249,10 +250,9 @@ export function createReadToolDefinition( if (mimeType) { // Read image as binary. const buffer = await ops.readFile(absolutePath); - const base64 = buffer.toString("base64"); if (autoResizeImages) { // Resize image if needed before sending it back to the model. - const resized = await resizeImage({ type: "image", data: base64, mimeType }); + const resized = await resizeImage(buffer, mimeType); if (!resized) { let textNote = `Read image file [${mimeType}]\n[Image omitted: could not be resized below the inline image size limit.]`; if (nonVisionImageNote) textNote += `\n${nonVisionImageNote}`; @@ -272,7 +272,7 @@ export function createReadToolDefinition( if (nonVisionImageNote) textNote += `\n${nonVisionImageNote}`; content = [ { type: "text", text: textNote }, - { type: "image", data: base64, mimeType }, + { type: "image", data: buffer.toString("base64"), mimeType }, ]; } } else { diff --git a/packages/coding-agent/src/core/tools/write.ts b/packages/coding-agent/src/core/tools/write.ts index 5c07848e..ba78649b 100644 --- a/packages/coding-agent/src/core/tools/write.ts +++ b/packages/coding-agent/src/core/tools/write.ts @@ -200,44 +200,36 @@ export function createWriteToolDefinition( ) { const absolutePath = resolveToCwd(path, cwd); const dir = dirname(absolutePath); - return withFileMutationQueue( - absolutePath, - () => - new Promise<{ content: Array<{ type: "text"; text: string }>; details: undefined }>( - (resolve, reject) => { - if (signal?.aborted) { - reject(new Error("Operation aborted")); - return; - } - let aborted = false; - const onAbort = () => { - aborted = true; - reject(new Error("Operation aborted")); - }; - signal?.addEventListener("abort", onAbort, { once: true }); - (async () => { - try { - // Create parent directories if needed. - await ops.mkdir(dir); - if (aborted) return; - // Write the file contents. - await ops.writeFile(absolutePath, content); - if (aborted) return; - signal?.removeEventListener("abort", onAbort); - resolve({ - content: [ - { type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }, - ], - details: undefined, - }); - } catch (error: any) { - signal?.removeEventListener("abort", onAbort); - if (!aborted) reject(error); - } - })(); - }, - ), - ); + return withFileMutationQueue(absolutePath, async () => { + let aborted = signal?.aborted ?? false; + const onAbort = () => { + aborted = true; + }; + const throwIfAborted = (): void => { + if (aborted || signal?.aborted) { + throw new Error("Operation aborted"); + } + }; + + signal?.addEventListener("abort", onAbort, { once: true }); + try { + throwIfAborted(); + // Create parent directories if needed. + await ops.mkdir(dir); + throwIfAborted(); + + // Write the file contents. + await ops.writeFile(absolutePath, content); + throwIfAborted(); + + return { + content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }], + details: undefined, + }; + } finally { + signal?.removeEventListener("abort", onAbort); + } + }); }, renderCall(args, theme, context) { const renderArgs = args as { path?: string; file_path?: string; content?: string } | undefined; diff --git a/packages/coding-agent/src/utils/image-resize-core.ts b/packages/coding-agent/src/utils/image-resize-core.ts new file mode 100644 index 00000000..e60820c7 --- /dev/null +++ b/packages/coding-agent/src/utils/image-resize-core.ts @@ -0,0 +1,164 @@ +import { applyExifOrientation } from "./exif-orientation.ts"; +import { loadPhoton } from "./photon.ts"; + +export interface ImageResizeOptions { + maxWidth?: number; // Default: 2000 + maxHeight?: number; // Default: 2000 + maxBytes?: number; // Default: 4.5MB of base64 payload (below Anthropic's 5MB limit) + jpegQuality?: number; // Default: 80 +} + +export interface ResizedImage { + data: string; // base64 + mimeType: string; + originalWidth: number; + originalHeight: number; + width: number; + height: number; + wasResized: boolean; +} + +// 4.5MB of base64 payload. Provides headroom below Anthropic's 5MB limit. +const DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024; + +const DEFAULT_OPTIONS: Required = { + maxWidth: 2000, + maxHeight: 2000, + maxBytes: DEFAULT_MAX_BYTES, + jpegQuality: 80, +}; + +interface EncodedCandidate { + data: string; + encodedSize: number; + mimeType: string; +} + +function encodeCandidate(buffer: Uint8Array, mimeType: string): EncodedCandidate { + const data = Buffer.from(buffer).toString("base64"); + return { + data, + encodedSize: Buffer.byteLength(data, "utf-8"), + mimeType, + }; +} + +/** + * Resize an image to fit within the specified max dimensions and encoded file size. + * Returns null if the image cannot be resized below maxBytes. + * + * Uses Photon (Rust/WASM) for image processing. If Photon is not available, + * returns null. + * + * Strategy for staying under maxBytes: + * 1. First resize to maxWidth/maxHeight + * 2. Try both PNG and JPEG formats, pick the smaller one + * 3. If still too large, try JPEG with decreasing quality + * 4. If still too large, progressively reduce dimensions until 1x1 + */ +export async function resizeImageInProcess( + inputBytes: Uint8Array, + mimeType: string, + options?: ImageResizeOptions, +): Promise { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const inputBase64Size = Math.ceil(inputBytes.byteLength / 3) * 4; + + const photon = await loadPhoton(); + if (!photon) { + return null; + } + + let image: ReturnType | undefined; + try { + const rawImage = photon.PhotonImage.new_from_byteslice(inputBytes); + image = applyExifOrientation(photon, rawImage, inputBytes); + if (image !== rawImage) rawImage.free(); + + const originalWidth = image.get_width(); + const originalHeight = image.get_height(); + const format = mimeType.split("/")[1] ?? "png"; + + // Check if already within all limits (dimensions AND encoded size) + if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && inputBase64Size < opts.maxBytes) { + return { + data: Buffer.from(inputBytes).toString("base64"), + mimeType: mimeType || `image/${format}`, + originalWidth, + originalHeight, + width: originalWidth, + height: originalHeight, + wasResized: false, + }; + } + + // Calculate initial dimensions respecting max limits + let targetWidth = originalWidth; + let targetHeight = originalHeight; + + if (targetWidth > opts.maxWidth) { + targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth); + targetWidth = opts.maxWidth; + } + if (targetHeight > opts.maxHeight) { + targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight); + targetHeight = opts.maxHeight; + } + + function tryEncodings(width: number, height: number, jpegQualities: number[]): EncodedCandidate[] { + const resized = photon!.resize(image!, width, height, photon!.SamplingFilter.Lanczos3); + + try { + const candidates: EncodedCandidate[] = [encodeCandidate(resized.get_bytes(), "image/png")]; + for (const quality of jpegQualities) { + candidates.push(encodeCandidate(resized.get_bytes_jpeg(quality), "image/jpeg")); + } + return candidates; + } finally { + resized.free(); + } + } + + const qualitySteps = Array.from(new Set([opts.jpegQuality, 85, 70, 55, 40])); + let currentWidth = targetWidth; + let currentHeight = targetHeight; + + while (true) { + const candidates = tryEncodings(currentWidth, currentHeight, qualitySteps); + for (const candidate of candidates) { + if (candidate.encodedSize < opts.maxBytes) { + return { + data: candidate.data, + mimeType: candidate.mimeType, + originalWidth, + originalHeight, + width: currentWidth, + height: currentHeight, + wasResized: true, + }; + } + } + + if (currentWidth === 1 && currentHeight === 1) { + break; + } + + const nextWidth = currentWidth === 1 ? 1 : Math.max(1, Math.floor(currentWidth * 0.75)); + const nextHeight = currentHeight === 1 ? 1 : Math.max(1, Math.floor(currentHeight * 0.75)); + if (nextWidth === currentWidth && nextHeight === currentHeight) { + break; + } + + currentWidth = nextWidth; + currentHeight = nextHeight; + } + + return null; + } catch { + return null; + } finally { + if (image) { + image.free(); + } + } +} diff --git a/packages/coding-agent/src/utils/image-resize-worker.ts b/packages/coding-agent/src/utils/image-resize-worker.ts new file mode 100644 index 00000000..ee881b3d --- /dev/null +++ b/packages/coding-agent/src/utils/image-resize-worker.ts @@ -0,0 +1,42 @@ +import { parentPort } from "node:worker_threads"; +import { type ImageResizeOptions, type ResizedImage, resizeImageInProcess } from "./image-resize-core.ts"; + +interface ResizeImageWorkerRequest { + inputBytes: Uint8Array; + mimeType: string; + options?: ImageResizeOptions; +} + +interface ResizeImageWorkerResponse { + result?: ResizedImage | null; + error?: string; +} + +function isResizeImageWorkerRequest(value: unknown): value is ResizeImageWorkerRequest { + if (!value || typeof value !== "object") return false; + const record = value as Record; + return record.inputBytes instanceof Uint8Array && typeof record.mimeType === "string"; +} + +const port = parentPort; +if (!port) { + throw new Error("image resize worker requires parentPort"); +} + +port.once("message", (message: unknown) => { + void (async () => { + try { + if (!isResizeImageWorkerRequest(message)) { + throw new Error("Invalid image resize worker request"); + } + const result = await resizeImageInProcess(message.inputBytes, message.mimeType, message.options); + const response: ResizeImageWorkerResponse = { result }; + port.postMessage(response); + } catch (error) { + const response: ResizeImageWorkerResponse = { + error: error instanceof Error ? error.message : String(error), + }; + port.postMessage(response); + } + })(); +}); diff --git a/packages/coding-agent/src/utils/image-resize.ts b/packages/coding-agent/src/utils/image-resize.ts index 3eb17dae..24a75397 100644 --- a/packages/coding-agent/src/utils/image-resize.ts +++ b/packages/coding-agent/src/utils/image-resize.ts @@ -1,165 +1,96 @@ -import type { ImageContent } from "@earendil-works/pi-ai"; -import { applyExifOrientation } from "./exif-orientation.ts"; -import { loadPhoton } from "./photon.ts"; +import { Worker } from "node:worker_threads"; +import type { ImageResizeOptions, ResizedImage } from "./image-resize-core.ts"; -export interface ImageResizeOptions { - maxWidth?: number; // Default: 2000 - maxHeight?: number; // Default: 2000 - maxBytes?: number; // Default: 4.5MB of base64 payload (below Anthropic's 5MB limit) - jpegQuality?: number; // Default: 80 +export type { ImageResizeOptions, ResizedImage } from "./image-resize-core.ts"; + +interface ResizeImageWorkerResponse { + result?: ResizedImage | null; + error?: string; } -export interface ResizedImage { - data: string; // base64 - mimeType: string; - originalWidth: number; - originalHeight: number; - width: number; - height: number; - wasResized: boolean; +function toTransferableBytes(input: Uint8Array): Uint8Array { + // Transfer detaches the buffer, so transfer a worker-owned copy and leave the + // caller's bytes intact. + return new Uint8Array(input); } -// 4.5MB of base64 payload. Provides headroom below Anthropic's 5MB limit. -const DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024; - -const DEFAULT_OPTIONS: Required = { - maxWidth: 2000, - maxHeight: 2000, - maxBytes: DEFAULT_MAX_BYTES, - jpegQuality: 80, -}; - -interface EncodedCandidate { - data: string; - encodedSize: number; - mimeType: string; +function isResizeImageWorkerResponse(value: unknown): value is ResizeImageWorkerResponse { + return value !== null && typeof value === "object"; } -function encodeCandidate(buffer: Uint8Array, mimeType: string): EncodedCandidate { - const data = Buffer.from(buffer).toString("base64"); - return { - data, - encodedSize: Buffer.byteLength(data, "utf-8"), - mimeType, - }; +function createResizeWorker(): Worker { + const isTypeScriptRuntime = import.meta.url.endsWith(".ts"); + const workerUrl = new URL( + isTypeScriptRuntime ? "./image-resize-worker.ts" : "./image-resize-worker.js", + import.meta.url, + ); + return new Worker(workerUrl); +} + +async function resizeImageInWorker( + inputBytes: Uint8Array, + mimeType: string, + options?: ImageResizeOptions, +): Promise { + const worker = createResizeWorker(); + try { + const inputBytesForWorker = toTransferableBytes(inputBytes); + return await new Promise((resolve, reject) => { + let settled = false; + const settle = (result: ResizedImage | null): void => { + if (settled) return; + settled = true; + resolve(result); + }; + const fail = (error: Error): void => { + if (settled) return; + settled = true; + reject(error); + }; + + worker.once("message", (message: unknown) => { + if (!isResizeImageWorkerResponse(message)) { + fail(new Error("Invalid image resize worker response")); + return; + } + if (message.error) { + fail(new Error(message.error)); + return; + } + settle(message.result ?? null); + }); + worker.once("error", fail); + worker.once("exit", (code) => { + if (!settled) { + fail(new Error(`Image resize worker exited with code ${code}`)); + } + }); + worker.postMessage( + { + inputBytes: inputBytesForWorker, + mimeType, + options, + }, + [inputBytesForWorker.buffer], + ); + }); + } finally { + void worker.terminate().catch(() => undefined); + } } /** * Resize an image to fit within the specified max dimensions and encoded file size. - * Returns null if the image cannot be resized below maxBytes. - * - * Uses Photon (Rust/WASM) for image processing. If Photon is not available, - * returns null. - * - * Strategy for staying under maxBytes: - * 1. First resize to maxWidth/maxHeight - * 2. Try both PNG and JPEG formats, pick the smaller one - * 3. If still too large, try JPEG with decreasing quality - * 4. If still too large, progressively reduce dimensions until 1x1 + * Runs Photon in a worker thread so WASM decoding, resizing, and encoding do not + * block the TUI event loop. Worker failures are propagated instead of retried on + * the main thread. */ -export async function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise { - const opts = { ...DEFAULT_OPTIONS, ...options }; - const inputBuffer = Buffer.from(img.data, "base64"); - const inputBase64Size = Buffer.byteLength(img.data, "utf-8"); - - const photon = await loadPhoton(); - if (!photon) { - return null; - } - - let image: ReturnType | undefined; - try { - const inputBytes = new Uint8Array(inputBuffer); - const rawImage = photon.PhotonImage.new_from_byteslice(inputBytes); - image = applyExifOrientation(photon, rawImage, inputBytes); - if (image !== rawImage) rawImage.free(); - - const originalWidth = image.get_width(); - const originalHeight = image.get_height(); - const format = img.mimeType?.split("/")[1] ?? "png"; - - // Check if already within all limits (dimensions AND encoded size) - if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && inputBase64Size < opts.maxBytes) { - return { - data: img.data, - mimeType: img.mimeType ?? `image/${format}`, - originalWidth, - originalHeight, - width: originalWidth, - height: originalHeight, - wasResized: false, - }; - } - - // Calculate initial dimensions respecting max limits - let targetWidth = originalWidth; - let targetHeight = originalHeight; - - if (targetWidth > opts.maxWidth) { - targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth); - targetWidth = opts.maxWidth; - } - if (targetHeight > opts.maxHeight) { - targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight); - targetHeight = opts.maxHeight; - } - - function tryEncodings(width: number, height: number, jpegQualities: number[]): EncodedCandidate[] { - const resized = photon!.resize(image!, width, height, photon!.SamplingFilter.Lanczos3); - - try { - const candidates: EncodedCandidate[] = [encodeCandidate(resized.get_bytes(), "image/png")]; - for (const quality of jpegQualities) { - candidates.push(encodeCandidate(resized.get_bytes_jpeg(quality), "image/jpeg")); - } - return candidates; - } finally { - resized.free(); - } - } - - const qualitySteps = Array.from(new Set([opts.jpegQuality, 85, 70, 55, 40])); - let currentWidth = targetWidth; - let currentHeight = targetHeight; - - while (true) { - const candidates = tryEncodings(currentWidth, currentHeight, qualitySteps); - for (const candidate of candidates) { - if (candidate.encodedSize < opts.maxBytes) { - return { - data: candidate.data, - mimeType: candidate.mimeType, - originalWidth, - originalHeight, - width: currentWidth, - height: currentHeight, - wasResized: true, - }; - } - } - - if (currentWidth === 1 && currentHeight === 1) { - break; - } - - const nextWidth = currentWidth === 1 ? 1 : Math.max(1, Math.floor(currentWidth * 0.75)); - const nextHeight = currentHeight === 1 ? 1 : Math.max(1, Math.floor(currentHeight * 0.75)); - if (nextWidth === currentWidth && nextHeight === currentHeight) { - break; - } - - currentWidth = nextWidth; - currentHeight = nextHeight; - } - - return null; - } catch { - return null; - } finally { - if (image) { - image.free(); - } - } +export async function resizeImage( + inputBytes: Uint8Array, + mimeType: string, + options?: ImageResizeOptions, +): Promise { + return resizeImageInWorker(inputBytes, mimeType, options); } /** diff --git a/packages/coding-agent/test/file-mutation-queue.test.ts b/packages/coding-agent/test/file-mutation-queue.test.ts index 73d20c6d..8e13a45e 100644 --- a/packages/coding-agent/test/file-mutation-queue.test.ts +++ b/packages/coding-agent/test/file-mutation-queue.test.ts @@ -10,6 +10,18 @@ function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +function createDeferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +} + +async function resolvesWithin(promise: Promise, ms: number): Promise { + return Promise.race([promise.then(() => true), delay(ms).then(() => false)]); +} + const tempDirs: string[] = []; async function createTempDir(): Promise { @@ -160,4 +172,103 @@ describe("built-in edit and write tools", () => { const content = await readFile(filePath, "utf8"); expect(content).toBe("replacement\n"); }); + + it("keeps write queue locked while an aborted write is still in flight", async () => { + const dir = await createTempDir(); + const filePath = join(dir, "abort-write.txt"); + const firstWriteStarted = createDeferred(); + const finishFirstWrite = createDeferred(); + const secondWriteStarted = createDeferred(); + let firstWriteSettled = false; + + const writeTool = createWriteTool(dir, { + operations: { + mkdir: async () => {}, + writeFile: async (path, content) => { + if (content === "first\n") { + firstWriteStarted.resolve(); + await finishFirstWrite.promise; + await writeFile(path, content, "utf8"); + firstWriteSettled = true; + return; + } + + if (content === "second\n") { + expect(firstWriteSettled).toBe(true); + secondWriteStarted.resolve(); + } + await writeFile(path, content, "utf8"); + }, + }, + }); + + const controller = new AbortController(); + const firstWrite = writeTool.execute("call-1", { path: filePath, content: "first\n" }, controller.signal); + await firstWriteStarted.promise; + controller.abort(); + + const secondWrite = writeTool.execute("call-2", { path: filePath, content: "second\n" }); + expect(await resolvesWithin(secondWriteStarted.promise, 20)).toBe(false); + + finishFirstWrite.resolve(); + await expect(firstWrite).rejects.toThrow("Operation aborted"); + await secondWrite; + + const content = await readFile(filePath, "utf8"); + expect(content).toBe("second\n"); + }); + + it("keeps edit queue locked while an aborted edit write is still in flight", async () => { + const dir = await createTempDir(); + const filePath = join(dir, "abort-edit.txt"); + await writeFile(filePath, "alpha\nbeta\n", "utf8"); + const firstWriteStarted = createDeferred(); + const finishFirstWrite = createDeferred(); + const secondWriteStarted = createDeferred(); + let firstWriteSettled = false; + + const editTool = createEditTool(dir, { + operations: { + access, + readFile, + writeFile: async (path, content) => { + if (content === "ALPHA\nbeta\n") { + firstWriteStarted.resolve(); + await finishFirstWrite.promise; + await writeFile(path, content, "utf8"); + firstWriteSettled = true; + return; + } + + if (content === "ALPHA\nBETA\n" || content === "alpha\nBETA\n") { + expect(firstWriteSettled).toBe(true); + secondWriteStarted.resolve(); + } + await writeFile(path, content, "utf8"); + }, + }, + }); + + const controller = new AbortController(); + const firstEdit = editTool.execute( + "call-1", + { path: filePath, edits: [{ oldText: "alpha", newText: "ALPHA" }] }, + controller.signal, + ); + await firstWriteStarted.promise; + controller.abort(); + + const secondEdit = editTool.execute("call-2", { + path: filePath, + edits: [{ oldText: "beta", newText: "BETA" }], + }); + expect(await resolvesWithin(secondWriteStarted.promise, 20)).toBe(false); + + finishFirstWrite.resolve(); + await expect(firstEdit).rejects.toThrow("Operation aborted"); + await secondEdit; + + const content = await readFile(filePath, "utf8"); + expect(content).toBe("ALPHA\nBETA\n"); + }); }); diff --git a/packages/coding-agent/test/image-processing.test.ts b/packages/coding-agent/test/image-processing.test.ts index 40f264a8..13d39dc6 100644 --- a/packages/coding-agent/test/image-processing.test.ts +++ b/packages/coding-agent/test/image-processing.test.ts @@ -22,6 +22,10 @@ const MEDIUM_PNG_100x100 = const LARGE_PNG_200x200 = "iVBORw0KGgoAAAANSUhEUgAAAMgAAADIEAIAAAByquWKAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGYktHRP///////wlY99wAAAAHdElNRQfqAQ4AMzqU03BFAAAEEElEQVR42u3dvWqUURSF4XXMRAyIhSAWQvwnWtjaWusFx9bW1kKD/wELESxEiBjDjJdwysOC57mCXW32+zGcGbtdACpsdi+T8Xz1GABzm91xMl6sHgNgblzcT/Y+rh4DYG6cv0/2H64eA2BOEgI1fHQHaoyzbXIwVo8BMCcJgRqSEKgxft9Lrn5aPQbA3Ph1klw7Wj0GwJwkBGpYWECN8fMiub63egyAORcWUMPCAmqMH3eTG59XjwEwN76/S24+Wj0GwJwkBGpYWECN8e1fcmuzegyAORcWUMPCAmqM09vJ4dfVYwDMjS9vkzuPV48BMCcJgRqeSAZqjA/nyYP91WMAzElCoIYkBGqMk8Pk6HT1GABzm+3L5JIkBApIQqCGj+5AjfHmb/Lk8uoxAOYkIVBDEgI1LCygxmZ7nFyShEABFxZQw8ICaozXf5KnV1aPATDnwgJqWFhADQsLqOFnDUANFxZQw8ICaoxXZ8mzg9VjAMy5sIAaFhZQw3tYQA1/QgHUkIRADUkI1JCEQA1JCNSQhEANFxZQwzcsoIYkBGpIQqCGJARqSEKghiQEalhYQA1/QgHUcGEBNSwsoIYkBGq4sIAaFhZQw8ICaviGBdRwYQE1LCyghiQEariwgBoWFlDDe1hADS+OAjUkIVBDEgI1JCFQQxICNSQhUMOFBdTwDQuoIQmBGpIQqCEJgRqSEKghCYEaFhZQw4ujQA0XFlDDwgJqSEKghgsLqGFhATUsLKCGb1hADRcWUMPCAmpIQqCGCwuoYWEBNbyHBdTw4ihQQxICNSQhUEMSAjUkIVBDEgI1XFhADd+wgBqSEKghCYEakhCoIQmBGpIQqGFhATW8OArUcGEBNSwsoIYkBGq4sIAaFhZQw8ICaviGBdRwYQE1LCyghiQEariwgBoWFlDDe1hADS+OAjUkIVBDEgI1JCFQQxICNSQhUMOFBdTwDQuoIQmBGpIQqCEJgRqSEKghCYEa49lB8ups9RgAc14cBWpIQqCGhQXUkIRADRcWUMPCAmqMp1eS139WjwEw5xsWUEMSAjUsLKCGJARquLCAGhYWUGM8uZy8+bt6DIA5L44CNSQhUMMTyUCNcXSanByuHgNgThICNSQhUGM82E8+nK8eA2DOzxqAGpIQqDHuPE6+vF09BsDcOPyanN5ePQbAnCQEavgdFlBj3Nok3/6tHgNgzoujQA1JCNQYNx8l39+tHgNgbtz4nPy4u3oMgDlJCNSwsIAa4/pe8vNi9RgAc37WANSQhECNce0o+XWyegyAuXH1U/L73uoxAOYkIVDDwgJqjIORnG1XjwEw508ogBqSEKgx9h8m5+9XjwEwN/Y+Jhf3V48BMCcJgRpjPE+2x6vHAJgbSbLbrR4DYO4/GqiSgXN+ksgAAAAldEVYdGRhdGU6Y3JlYXRlADIwMjYtMDEtMTRUMDA6NTE6NTcrMDA6MDDpysx4AAAAJXRFWHRkYXRlOm1vZGlmeQAyMDI2LTAxLTE0VDAwOjUxOjU3KzAwOjAwmJd0xAAAACh0RVh0ZGF0ZTp0aW1lc3RhbXAAMjAyNi0wMS0xNFQwMDo1MTo1NyswMDowMM+CVRsAAAAASUVORK5CYII="; +function imageBytes(base64Data: string): Uint8Array { + return Buffer.from(base64Data, "base64"); +} + describe("convertToPng", () => { it("should return original data for PNG input", async () => { const result = await convertToPng(TINY_PNG, "image/png"); @@ -46,11 +50,28 @@ describe("convertToPng", () => { }); describe("resizeImage", () => { + it("should keep caller input bytes intact", async () => { + const input = new Uint8Array(imageBytes(TINY_PNG)); + const originalByteLength = input.byteLength; + const originalFirstByte = input[0]; + + const result = await resizeImage(input, "image/png", { + maxWidth: 100, + maxHeight: 100, + maxBytes: 1024 * 1024, + }); + + expect(result).not.toBeNull(); + expect(input.byteLength).toBe(originalByteLength); + expect(input[0]).toBe(originalFirstByte); + }); + it("should return original image if within limits", async () => { - const result = await resizeImage( - { type: "image", data: TINY_PNG, mimeType: "image/png" }, - { maxWidth: 100, maxHeight: 100, maxBytes: 1024 * 1024 }, - ); + const result = await resizeImage(imageBytes(TINY_PNG), "image/png", { + maxWidth: 100, + maxHeight: 100, + maxBytes: 1024 * 1024, + }); expect(result).not.toBeNull(); expect(result!.wasResized).toBe(false); @@ -62,10 +83,11 @@ describe("resizeImage", () => { }); it("should resize image exceeding dimension limits", async () => { - const result = await resizeImage( - { type: "image", data: MEDIUM_PNG_100x100, mimeType: "image/png" }, - { maxWidth: 50, maxHeight: 50, maxBytes: 1024 * 1024 }, - ); + const result = await resizeImage(imageBytes(MEDIUM_PNG_100x100), "image/png", { + maxWidth: 50, + maxHeight: 50, + maxBytes: 1024 * 1024, + }); expect(result).not.toBeNull(); expect(result!.wasResized).toBe(true); @@ -80,10 +102,11 @@ describe("resizeImage", () => { const originalSize = originalBuffer.length; // Set maxBytes to less than the original encoded image size - const result = await resizeImage( - { type: "image", data: LARGE_PNG_200x200, mimeType: "image/png" }, - { maxWidth: 2000, maxHeight: 2000, maxBytes: Math.floor(LARGE_PNG_200x200.length * 0.9) }, - ); + const result = await resizeImage(imageBytes(LARGE_PNG_200x200), "image/png", { + maxWidth: 2000, + maxHeight: 2000, + maxBytes: Math.floor(LARGE_PNG_200x200.length * 0.9), + }); // Should have tried to reduce size expect(result).not.toBeNull(); @@ -93,19 +116,21 @@ describe("resizeImage", () => { }); it("should return null when image cannot be resized below maxBytes", async () => { - const result = await resizeImage( - { type: "image", data: LARGE_PNG_200x200, mimeType: "image/png" }, - { maxWidth: 2000, maxHeight: 2000, maxBytes: 1 }, - ); + const result = await resizeImage(imageBytes(LARGE_PNG_200x200), "image/png", { + maxWidth: 2000, + maxHeight: 2000, + maxBytes: 1, + }); expect(result).toBeNull(); }); it("should handle JPEG input", async () => { - const result = await resizeImage( - { type: "image", data: TINY_JPEG, mimeType: "image/jpeg" }, - { maxWidth: 100, maxHeight: 100, maxBytes: 1024 * 1024 }, - ); + const result = await resizeImage(imageBytes(TINY_JPEG), "image/jpeg", { + maxWidth: 100, + maxHeight: 100, + maxBytes: 1024 * 1024, + }); expect(result).not.toBeNull(); expect(result!.wasResized).toBe(false); diff --git a/scripts/build-binaries.sh b/scripts/build-binaries.sh index c1b7b868..dfc34793 100755 --- a/scripts/build-binaries.sh +++ b/scripts/build-binaries.sh @@ -105,10 +105,13 @@ fi for platform in "${PLATFORMS[@]}"; do echo "Building for $platform..." + # Bun compiled executables only embed worker scripts when they are passed as + # explicit build entrypoints. The runtime can still use new URL(...), but the + # worker must be present in the compiled executable. if [[ "$platform" == windows-* ]]; then - bun build --compile --target=bun-$platform ./dist/bun/cli.js --outfile binaries/$platform/pi.exe + bun build --compile --target=bun-$platform ./dist/bun/cli.js ./dist/utils/image-resize-worker.js --outfile binaries/$platform/pi.exe else - bun build --compile --target=bun-$platform ./dist/bun/cli.js --outfile binaries/$platform/pi + bun build --compile --target=bun-$platform ./dist/bun/cli.js ./dist/utils/image-resize-worker.js --outfile binaries/$platform/pi fi done From 2e1f07bac27fbcd81dcaa56032d6f2209b8745b8 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 23 May 2026 09:53:36 +0200 Subject: [PATCH 012/352] fix(coding-agent): avoid invalid footer home abbreviation closes #4878 --- packages/coding-agent/CHANGELOG.md | 1 + .../modes/interactive/components/footer.ts | 21 ++++++++++++++----- .../coding-agent/test/footer-width.test.ts | 13 +++++++++++- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 83253924..ba96b2a8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -22,6 +22,7 @@ - Published a 0.74.2 rescue release that tells Node 20 users to upgrade Node before updating to newer Pi versions ([#4876](https://github.com/earendil-works/pi/issues/4876)). - Fixed final bash tool cards to avoid rendering duplicate full-output truncation paths ([#4819](https://github.com/earendil-works/pi/issues/4819)). - Fixed bash tool truncation line counts to ignore the trailing newline as an extra output line ([#4818](https://github.com/earendil-works/pi/issues/4818)). +- Fixed footer home-directory abbreviation to avoid shortening sibling paths that only share the same prefix ([#4878](https://github.com/earendil-works/pi/issues/4878)). ## [0.75.4] - 2026-05-20 diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts index a48898a5..47ff58ae 100644 --- a/packages/coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -1,3 +1,4 @@ +import { isAbsolute, relative, resolve, sep } from "node:path"; import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import type { AgentSession } from "../../../core/agent-session.ts"; import type { ReadonlyFooterDataProvider } from "../../../core/footer-data-provider.ts"; @@ -26,6 +27,20 @@ function formatTokens(count: number): string { return `${Math.round(count / 1000000)}M`; } +export function formatCwdForFooter(cwd: string, home: string | undefined): string { + if (!home) return cwd; + + const resolvedCwd = resolve(cwd); + const resolvedHome = resolve(home); + const relativeToHome = relative(resolvedHome, resolvedCwd); + const isInsideHome = + relativeToHome === "" || + (relativeToHome !== ".." && !relativeToHome.startsWith(`..${sep}`) && !isAbsolute(relativeToHome)); + + if (!isInsideHome) return cwd; + return relativeToHome === "" ? "~" : `~${sep}${relativeToHome}`; +} + /** * Footer component that shows pwd, token stats, and context usage. * Computes token/context stats from session, gets git branch and extension statuses from provider. @@ -92,11 +107,7 @@ export class FooterComponent implements Component { const contextPercent = contextUsage?.percent !== null ? contextPercentValue.toFixed(1) : "?"; // Replace home directory with ~ - let pwd = this.session.sessionManager.getCwd(); - const home = process.env.HOME || process.env.USERPROFILE; - if (home && pwd.startsWith(home)) { - pwd = `~${pwd.slice(home.length)}`; - } + let pwd = formatCwdForFooter(this.session.sessionManager.getCwd(), process.env.HOME || process.env.USERPROFILE); // Add git branch if available const branch = this.footerData.getGitBranch(); diff --git a/packages/coding-agent/test/footer-width.test.ts b/packages/coding-agent/test/footer-width.test.ts index 2d912647..d5586d7d 100644 --- a/packages/coding-agent/test/footer-width.test.ts +++ b/packages/coding-agent/test/footer-width.test.ts @@ -2,7 +2,7 @@ import { visibleWidth } from "@earendil-works/pi-tui"; import { beforeAll, describe, expect, it } from "vitest"; import type { AgentSession } from "../src/core/agent-session.ts"; import type { ReadonlyFooterDataProvider } from "../src/core/footer-data-provider.ts"; -import { FooterComponent } from "../src/modes/interactive/components/footer.ts"; +import { FooterComponent, formatCwdForFooter } from "../src/modes/interactive/components/footer.ts"; import { initTheme } from "../src/modes/interactive/theme/theme.ts"; type AssistantUsage = { @@ -73,6 +73,17 @@ function createFooterData(providerCount: number): ReadonlyFooterDataProvider { return provider; } +describe("formatCwdForFooter", () => { + it("does not abbreviate sibling paths that share the home prefix", () => { + expect(formatCwdForFooter("/home/user2", "/home/user")).toBe("/home/user2"); + }); + + it("abbreviates the home directory and descendants", () => { + expect(formatCwdForFooter("/home/user", "/home/user")).toBe("~"); + expect(formatCwdForFooter("/home/user/project", "/home/user")).toBe("~/project"); + }); +}); + describe("FooterComponent width handling", () => { beforeAll(() => { initTheme(undefined, false); From ba09f1c9e0dfd3703dc7886e0f564725c2241617 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 23 May 2026 10:30:00 +0200 Subject: [PATCH 013/352] Refine async tool control flow --- packages/coding-agent/src/core/tools/bash.ts | 121 +++++++++---------- packages/coding-agent/src/core/tools/edit.ts | 91 +++++++------- 2 files changed, 97 insertions(+), 115 deletions(-) diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index bc1d3cc4..efe3a5af 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -65,73 +65,62 @@ export interface BashOperations { */ export function createLocalBashOperations(options?: { shellPath?: string }): BashOperations { return { - exec: (command, cwd, { onData, signal, timeout, env }) => { - return new Promise((resolve, reject) => { - void (async () => { - const { shell, args } = getShellConfig(options?.shellPath); - try { - await fsAccess(cwd, constants.F_OK); - } catch { - reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`)); - return; - } - if (signal?.aborted) { - reject(new Error("aborted")); - return; - } - const child = spawn(shell, [...args, command], { - cwd, - detached: process.platform !== "win32", - env: env ?? getShellEnv(), - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - }); - if (child.pid) trackDetachedChildPid(child.pid); - let timedOut = false; - let timeoutHandle: NodeJS.Timeout | undefined; - // Set timeout if provided. - if (timeout !== undefined && timeout > 0) { - timeoutHandle = setTimeout(() => { - timedOut = true; - if (child.pid) killProcessTree(child.pid); - }, timeout * 1000); - } - // Stream stdout and stderr. - child.stdout?.on("data", onData); - child.stderr?.on("data", onData); - // Handle abort signal by killing the entire process tree. - const onAbort = () => { - if (child.pid) killProcessTree(child.pid); - }; - if (signal) { - if (signal.aborted) onAbort(); - else signal.addEventListener("abort", onAbort, { once: true }); - } - // Handle shell spawn errors and wait for the process to terminate without hanging - // on inherited stdio handles held by detached descendants. - waitForChildProcess(child) - .then((code) => { - if (child.pid) untrackDetachedChildPid(child.pid); - if (timeoutHandle) clearTimeout(timeoutHandle); - if (signal) signal.removeEventListener("abort", onAbort); - if (signal?.aborted) { - reject(new Error("aborted")); - return; - } - if (timedOut) { - reject(new Error(`timeout:${timeout}`)); - return; - } - resolve({ exitCode: code }); - }) - .catch((err) => { - if (child.pid) untrackDetachedChildPid(child.pid); - if (timeoutHandle) clearTimeout(timeoutHandle); - if (signal) signal.removeEventListener("abort", onAbort); - reject(err); - }); - })().catch((err: unknown) => reject(err instanceof Error ? err : new Error(String(err)))); + exec: async (command, cwd, { onData, signal, timeout, env }) => { + const { shell, args } = getShellConfig(options?.shellPath); + try { + await fsAccess(cwd, constants.F_OK); + } catch { + throw new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`); + } + if (signal?.aborted) { + throw new Error("aborted"); + } + + const child = spawn(shell, [...args, command], { + cwd, + detached: process.platform !== "win32", + env: env ?? getShellEnv(), + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, }); + if (child.pid) trackDetachedChildPid(child.pid); + let timedOut = false; + let timeoutHandle: NodeJS.Timeout | undefined; + const onAbort = () => { + if (child.pid) killProcessTree(child.pid); + }; + + try { + // Set timeout if provided. + if (timeout !== undefined && timeout > 0) { + timeoutHandle = setTimeout(() => { + timedOut = true; + if (child.pid) killProcessTree(child.pid); + }, timeout * 1000); + } + // Stream stdout and stderr. + child.stdout?.on("data", onData); + child.stderr?.on("data", onData); + // Handle abort signal by killing the entire process tree. + if (signal) { + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + } + // Handle shell spawn errors and wait for the process to terminate without hanging + // on inherited stdio handles held by detached descendants. + const exitCode = await waitForChildProcess(child); + if (signal?.aborted) { + throw new Error("aborted"); + } + if (timedOut) { + throw new Error(`timeout:${timeout}`); + } + return { exitCode }; + } finally { + if (child.pid) untrackDetachedChildPid(child.pid); + if (timeoutHandle) clearTimeout(timeoutHandle); + if (signal) signal.removeEventListener("abort", onAbort); + } }, }; } diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index 7c6367a6..2812b661 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -314,61 +314,54 @@ export function createEditToolDefinition( const absolutePath = resolveToCwd(path, cwd); return withFileMutationQueue(absolutePath, async () => { - let aborted = signal?.aborted ?? false; - const onAbort = () => { - aborted = true; - }; + // Do not reject from an abort event listener here: that would release the + // mutation queue while an in-flight filesystem operation may still finish. + // Checking signal.aborted after each await observes the same aborts while + // keeping the queue locked until the current operation has settled. const throwIfAborted = (): void => { - if (aborted || signal?.aborted) { - throw new Error("Operation aborted"); - } + if (signal?.aborted) throw new Error("Operation aborted"); }; - signal?.addEventListener("abort", onAbort, { once: true }); + throwIfAborted(); + + // Check if file exists. try { + await ops.access(absolutePath); + } catch (error: unknown) { throwIfAborted(); - - // Check if file exists. - try { - await ops.access(absolutePath); - } catch (error: unknown) { - throwIfAborted(); - const errorMessage = - error instanceof Error && "code" in error ? `Error code: ${error.code}` : String(error); - throw new Error(`Could not edit file: ${path}. ${errorMessage}.`); - } - throwIfAborted(); - - // Read the file. - const buffer = await ops.readFile(absolutePath); - throwIfAborted(); - - // Strip BOM before matching. The model will not include an invisible BOM in oldText. - const rawContent = buffer.toString("utf-8"); - const { bom, text: content } = stripBom(rawContent); - const originalEnding = detectLineEnding(content); - const normalizedContent = normalizeToLF(content); - const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path); - throwIfAborted(); - - const finalContent = bom + restoreLineEndings(newContent, originalEnding); - await ops.writeFile(absolutePath, finalContent); - throwIfAborted(); - - const diffResult = generateDiffString(baseContent, newContent); - const patch = generateUnifiedPatch(path, baseContent, newContent); - return { - content: [ - { - type: "text", - text: `Successfully replaced ${edits.length} block(s) in ${path}.`, - }, - ], - details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine }, - }; - } finally { - signal?.removeEventListener("abort", onAbort); + const errorMessage = + error instanceof Error && "code" in error ? `Error code: ${error.code}` : String(error); + throw new Error(`Could not edit file: ${path}. ${errorMessage}.`); } + throwIfAborted(); + + // Read the file. + const buffer = await ops.readFile(absolutePath); + const rawContent = buffer.toString("utf-8"); + throwIfAborted(); + + // Strip BOM before matching. The model will not include an invisible BOM in oldText. + const { bom, text: content } = stripBom(rawContent); + const originalEnding = detectLineEnding(content); + const normalizedContent = normalizeToLF(content); + const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path); + throwIfAborted(); + + const finalContent = bom + restoreLineEndings(newContent, originalEnding); + await ops.writeFile(absolutePath, finalContent); + throwIfAborted(); + + const diffResult = generateDiffString(baseContent, newContent); + const patch = generateUnifiedPatch(path, baseContent, newContent); + return { + content: [ + { + type: "text", + text: `Successfully replaced ${edits.length} block(s) in ${path}.`, + }, + ], + details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine }, + }; }); }, renderCall(args, theme, context) { From 3f89350cef4642ddba6fb87371b48a74e02541db Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 23 May 2026 10:47:35 +0200 Subject: [PATCH 014/352] fix(coding-agent): ship clipboard sidecar in bun binaries closes #4307 --- packages/coding-agent/CHANGELOG.md | 1 + .../src/utils/clipboard-native.ts | 24 ++- .../test/clipboard-native.test.ts | 31 ++++ scripts/build-binaries.sh | 141 ++++++++++++------ scripts/local-release.mjs | 53 ++----- 5 files changed, 158 insertions(+), 92 deletions(-) create mode 100644 packages/coding-agent/test/clipboard-native.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ba96b2a8..74983eff 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -23,6 +23,7 @@ - Fixed final bash tool cards to avoid rendering duplicate full-output truncation paths ([#4819](https://github.com/earendil-works/pi/issues/4819)). - Fixed bash tool truncation line counts to ignore the trailing newline as an extra output line ([#4818](https://github.com/earendil-works/pi/issues/4818)). - Fixed footer home-directory abbreviation to avoid shortening sibling paths that only share the same prefix ([#4878](https://github.com/earendil-works/pi/issues/4878)). +- Fixed macOS Bun release binaries to resolve the native clipboard sidecar so Ctrl+V image paste can load `@mariozechner/clipboard` ([#4307](https://github.com/earendil-works/pi/issues/4307)). ## [0.75.4] - 2026-05-20 diff --git a/packages/coding-agent/src/utils/clipboard-native.ts b/packages/coding-agent/src/utils/clipboard-native.ts index bcdbdfa8..f8aeb547 100644 --- a/packages/coding-agent/src/utils/clipboard-native.ts +++ b/packages/coding-agent/src/utils/clipboard-native.ts @@ -1,4 +1,6 @@ import { createRequire } from "module"; +import { dirname, join } from "path"; +import { pathToFileURL } from "url"; export type ClipboardModule = { setText: (text: string) => Promise; @@ -6,17 +8,25 @@ export type ClipboardModule = { getImageBinary: () => Promise>; }; -const require = createRequire(import.meta.url); -let clipboard: ClipboardModule | null = null; +type ClipboardRequire = (id: string) => unknown; +const moduleRequire = createRequire(import.meta.url); +const executableDirRequire = createRequire(pathToFileURL(join(dirname(process.execPath), "package.json")).href); const hasDisplay = process.platform !== "linux" || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY); -if (!process.env.TERMUX_VERSION && hasDisplay) { - try { - clipboard = require("@mariozechner/clipboard") as ClipboardModule; - } catch { - clipboard = null; +export function loadClipboardNative( + requires: readonly ClipboardRequire[] = [moduleRequire, executableDirRequire], +): ClipboardModule | null { + for (const requireClipboard of requires) { + try { + return requireClipboard("@mariozechner/clipboard") as ClipboardModule; + } catch { + // Try the next resolution root. + } } + return null; } +const clipboard = !process.env.TERMUX_VERSION && hasDisplay ? loadClipboardNative() : null; + export { clipboard }; diff --git a/packages/coding-agent/test/clipboard-native.test.ts b/packages/coding-agent/test/clipboard-native.test.ts new file mode 100644 index 00000000..a9c0c201 --- /dev/null +++ b/packages/coding-agent/test/clipboard-native.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test, vi } from "vitest"; +import { type ClipboardModule, loadClipboardNative } from "../src/utils/clipboard-native.ts"; + +type ClipboardRequire = (id: string) => unknown; + +const fakeClipboard: ClipboardModule = { + setText: async () => {}, + hasImage: () => true, + getImageBinary: async () => [1, 2, 3], +}; + +describe("loadClipboardNative", () => { + test("falls back to the next require root", () => { + const primary = vi.fn(() => { + throw new Error("missing from bundled root"); + }); + const fallback = vi.fn(() => fakeClipboard); + + expect(loadClipboardNative([primary, fallback])).toBe(fakeClipboard); + expect(primary).toHaveBeenCalledWith("@mariozechner/clipboard"); + expect(fallback).toHaveBeenCalledWith("@mariozechner/clipboard"); + }); + + test("returns null when no require root can load clipboard", () => { + const missing = vi.fn(() => { + throw new Error("missing"); + }); + + expect(loadClipboardNative([missing])).toBeNull(); + }); +}); diff --git a/scripts/build-binaries.sh b/scripts/build-binaries.sh index c1b7b868..d4ae44be 100755 --- a/scripts/build-binaries.sh +++ b/scripts/build-binaries.sh @@ -4,11 +4,14 @@ # Mirrors .github/workflows/build-binaries.yml # # Usage: -# ./scripts/build-binaries.sh [--skip-deps] [--platform ] +# ./scripts/build-binaries.sh [--skip-install] [--skip-deps] [--skip-build] [--platform ] [--out ] # # Options: +# --skip-install Skip npm ci # --skip-deps Skip installing cross-platform dependencies +# --skip-build Skip npm run build # --platform Build only for specified platform (darwin-arm64, darwin-x64, linux-x64, linux-arm64, windows-x64, windows-arm64) +# --out Output directory (default: packages/coding-agent/binaries) # # Output: # packages/coding-agent/binaries/ @@ -23,19 +26,34 @@ set -euo pipefail cd "$(dirname "$0")/.." +SKIP_INSTALL=false SKIP_DEPS=false +SKIP_BUILD=false PLATFORM="" +OUTPUT_DIR="" while [[ $# -gt 0 ]]; do case $1 in + --skip-install) + SKIP_INSTALL=true + shift + ;; --skip-deps) SKIP_DEPS=true shift ;; + --skip-build) + SKIP_BUILD=true + shift + ;; --platform) PLATFORM="$2" shift 2 ;; + --out) + OUTPUT_DIR="$2" + shift 2 + ;; *) echo "Unknown option: $1" exit 1 @@ -56,8 +74,19 @@ if [[ -n "$PLATFORM" ]]; then esac fi -echo "==> Installing dependencies..." -npm ci --ignore-scripts +if [[ -z "$OUTPUT_DIR" ]]; then + OUTPUT_DIR="packages/coding-agent/binaries" +fi +if [[ "$OUTPUT_DIR" != /* ]]; then + OUTPUT_DIR="$(pwd)/$OUTPUT_DIR" +fi + +if [[ "$SKIP_INSTALL" == "false" ]]; then + echo "==> Installing dependencies..." + npm ci --ignore-scripts +else + echo "==> Skipping npm ci (--skip-install)" +fi if [[ "$SKIP_DEPS" == "false" ]]; then echo "==> Installing cross-platform native bindings..." @@ -66,35 +95,29 @@ if [[ "$SKIP_DEPS" == "false" ]]; then # Use --force to bypass platform checks (os/cpu restrictions in package.json) # Install all in one command to avoid npm removing packages from previous installs npm install --no-save --package-lock=false --force --ignore-scripts \ - @mariozechner/clipboard-darwin-arm64@0.3.2 \ - @mariozechner/clipboard-darwin-x64@0.3.2 \ - @mariozechner/clipboard-linux-x64-gnu@0.3.2 \ - @mariozechner/clipboard-linux-arm64-gnu@0.3.2 \ - @mariozechner/clipboard-win32-x64-msvc@0.3.2 \ - @mariozechner/clipboard-win32-arm64-msvc@0.3.2 \ - @img/sharp-darwin-arm64@0.34.5 \ - @img/sharp-darwin-x64@0.34.5 \ - @img/sharp-linux-x64@0.34.5 \ - @img/sharp-linux-arm64@0.34.5 \ - @img/sharp-win32-x64@0.34.5 \ - @img/sharp-win32-arm64@0.34.5 \ - @img/sharp-libvips-darwin-arm64@1.2.4 \ - @img/sharp-libvips-darwin-x64@1.2.4 \ - @img/sharp-libvips-linux-x64@1.2.4 \ - @img/sharp-libvips-linux-arm64@1.2.4 + @mariozechner/clipboard-darwin-arm64@0.3.6 \ + @mariozechner/clipboard-darwin-x64@0.3.6 \ + @mariozechner/clipboard-linux-x64-gnu@0.3.6 \ + @mariozechner/clipboard-linux-arm64-gnu@0.3.6 \ + @mariozechner/clipboard-win32-x64-msvc@0.3.6 \ + @mariozechner/clipboard-win32-arm64-msvc@0.3.6 else echo "==> Skipping cross-platform native bindings (--skip-deps)" fi -echo "==> Building all packages..." -npm run build +if [[ "$SKIP_BUILD" == "false" ]]; then + echo "==> Building all packages..." + npm run build +else + echo "==> Skipping package build (--skip-build)" +fi echo "==> Building binaries..." cd packages/coding-agent # Clean previous builds -rm -rf binaries -mkdir -p binaries/{darwin-arm64,darwin-x64,linux-x64,linux-arm64,windows-x64,windows-arm64} +rm -rf "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR"/{darwin-arm64,darwin-x64,linux-x64,linux-arm64,windows-x64,windows-arm64} # Determine which platforms to build if [[ -n "$PLATFORM" ]]; then @@ -106,9 +129,9 @@ fi for platform in "${PLATFORMS[@]}"; do echo "Building for $platform..." if [[ "$platform" == windows-* ]]; then - bun build --compile --target=bun-$platform ./dist/bun/cli.js --outfile binaries/$platform/pi.exe + bun build --compile --target=bun-$platform ./dist/bun/cli.js --outfile "$OUTPUT_DIR/$platform/pi.exe" else - bun build --compile --target=bun-$platform ./dist/bun/cli.js --outfile binaries/$platform/pi + bun build --compile --target=bun-$platform ./dist/bun/cli.js --outfile "$OUTPUT_DIR/$platform/pi" fi done @@ -116,17 +139,41 @@ echo "==> Creating release archives..." # Copy shared files to each platform directory for platform in "${PLATFORMS[@]}"; do - cp package.json binaries/$platform/ - cp README.md binaries/$platform/ - cp CHANGELOG.md binaries/$platform/ - cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm binaries/$platform/ - mkdir -p binaries/$platform/theme - cp dist/modes/interactive/theme/*.json binaries/$platform/theme/ - mkdir -p binaries/$platform/assets - cp dist/modes/interactive/assets/* binaries/$platform/assets/ - cp -r dist/core/export-html binaries/$platform/ - cp -r docs binaries/$platform/ - cp -r examples binaries/$platform/ + cp package.json "$OUTPUT_DIR/$platform/" + cp README.md "$OUTPUT_DIR/$platform/" + cp CHANGELOG.md "$OUTPUT_DIR/$platform/" + cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm "$OUTPUT_DIR/$platform/" + mkdir -p "$OUTPUT_DIR/$platform/theme" + cp dist/modes/interactive/theme/*.json "$OUTPUT_DIR/$platform/theme/" + mkdir -p "$OUTPUT_DIR/$platform/assets" + cp dist/modes/interactive/assets/* "$OUTPUT_DIR/$platform/assets/" + cp -r dist/core/export-html "$OUTPUT_DIR/$platform/" + cp -r docs "$OUTPUT_DIR/$platform/" + cp -r examples "$OUTPUT_DIR/$platform/" + + case "$platform" in + darwin-arm64) + clipboard_native_package="clipboard-darwin-arm64" + ;; + darwin-x64) + clipboard_native_package="clipboard-darwin-x64" + ;; + linux-x64) + clipboard_native_package="clipboard-linux-x64-gnu" + ;; + linux-arm64) + clipboard_native_package="clipboard-linux-arm64-gnu" + ;; + windows-x64) + clipboard_native_package="clipboard-win32-x64-msvc" + ;; + windows-arm64) + clipboard_native_package="clipboard-win32-arm64-msvc" + ;; + esac + mkdir -p "$OUTPUT_DIR/$platform/node_modules/@mariozechner" + cp -r ../../node_modules/@mariozechner/clipboard "$OUTPUT_DIR/$platform/node_modules/@mariozechner/" + cp -r ../../node_modules/@mariozechner/$clipboard_native_package "$OUTPUT_DIR/$platform/node_modules/@mariozechner/" # Copy Windows VT input native helper next to compiled Windows binaries. if [[ "$platform" == windows-* ]]; then @@ -135,47 +182,47 @@ for platform in "${PLATFORMS[@]}"; do else win32_arch_dir="win32-x64" fi - mkdir -p binaries/$platform/native/win32/prebuilds/$win32_arch_dir - cp ../tui/native/win32/prebuilds/$win32_arch_dir/win32-console-mode.node binaries/$platform/native/win32/prebuilds/$win32_arch_dir/ + mkdir -p "$OUTPUT_DIR/$platform/native/win32/prebuilds/$win32_arch_dir" + cp ../tui/native/win32/prebuilds/$win32_arch_dir/win32-console-mode.node "$OUTPUT_DIR/$platform/native/win32/prebuilds/$win32_arch_dir/" fi done # Create archives -cd binaries +cd "$OUTPUT_DIR" for platform in "${PLATFORMS[@]}"; do if [[ "$platform" == windows-* ]]; then # Windows (zip) echo "Creating pi-$platform.zip..." - (cd $platform && zip -r ../pi-$platform.zip .) + (cd "$platform" && zip -r ../pi-$platform.zip .) else # Unix platforms (tar.gz) - use wrapper directory for mise compatibility echo "Creating pi-$platform.tar.gz..." - mv $platform pi && tar -czf pi-$platform.tar.gz pi && mv pi $platform + mv "$platform" pi && tar -czf pi-$platform.tar.gz pi && mv pi "$platform" fi done # Extract archives for easy local testing echo "==> Extracting archives for testing..." for platform in "${PLATFORMS[@]}"; do - rm -rf $platform + rm -rf "$platform" if [[ "$platform" == windows-* ]]; then - mkdir -p $platform && (cd $platform && unzip -q ../pi-$platform.zip) + mkdir -p "$platform" && (cd "$platform" && unzip -q ../pi-$platform.zip) else - tar -xzf pi-$platform.tar.gz && mv pi $platform + tar -xzf pi-$platform.tar.gz && mv pi "$platform" fi done echo "" echo "==> Build complete!" -echo "Archives available in packages/coding-agent/binaries/" +echo "Archives available in $OUTPUT_DIR/" ls -lh *.tar.gz *.zip 2>/dev/null || true echo "" echo "Extracted directories for testing:" for platform in "${PLATFORMS[@]}"; do if [[ "$platform" == windows-* ]]; then - echo " binaries/$platform/pi.exe" + echo " $OUTPUT_DIR/$platform/pi.exe" else - echo " binaries/$platform/pi" + echo " $OUTPUT_DIR/$platform/pi" fi done diff --git a/scripts/local-release.mjs b/scripts/local-release.mjs index 782318bd..3f35e80c 100644 --- a/scripts/local-release.mjs +++ b/scripts/local-release.mjs @@ -2,7 +2,7 @@ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { isAbsolute, join, relative, resolve } from "node:path"; import { spawnSync } from "node:child_process"; const packages = [ @@ -131,48 +131,25 @@ function currentBinaryPlatform() { throw new Error(`Unsupported binary platform: ${process.platform} ${process.arch}`); } -function copyBinaryAssets(targetDirectory) { - const codingAgentDirectory = join(repoRoot, "packages", "coding-agent"); - const distDirectory = join(codingAgentDirectory, "dist"); - cpSync(join(codingAgentDirectory, "package.json"), join(targetDirectory, "package.json")); - cpSync(join(codingAgentDirectory, "README.md"), join(targetDirectory, "README.md")); - cpSync(join(codingAgentDirectory, "CHANGELOG.md"), join(targetDirectory, "CHANGELOG.md")); - cpSync(join(repoRoot, "node_modules", "@silvia-odwyer", "photon-node", "photon_rs_bg.wasm"), join(targetDirectory, "photon_rs_bg.wasm")); - cpSync(join(distDirectory, "modes", "interactive", "theme"), join(targetDirectory, "theme"), { recursive: true }); - cpSync(join(distDirectory, "modes", "interactive", "assets"), join(targetDirectory, "assets"), { recursive: true }); - cpSync(join(distDirectory, "core", "export-html"), join(targetDirectory, "export-html"), { recursive: true }); - cpSync(join(codingAgentDirectory, "docs"), join(targetDirectory, "docs"), { recursive: true }); - cpSync(join(codingAgentDirectory, "examples"), join(targetDirectory, "examples"), { recursive: true }); -} - -function copyWindowsConsoleModeHelper(targetDirectory, platform) { - if (!platform.startsWith("windows-")) return; - const win32Arch = platform === "windows-arm64" ? "win32-arm64" : "win32-x64"; - const relativeNativePath = join("native", "win32", "prebuilds", win32Arch); - mkdirSync(join(targetDirectory, relativeNativePath), { recursive: true }); - cpSync( - join(repoRoot, "packages", "tui", "native", "win32", "prebuilds", win32Arch, "win32-console-mode.node"), - join(targetDirectory, relativeNativePath, "win32-console-mode.node"), - ); -} - function buildBunBinaryRelease(targetDirectory, archiveDirectory) { if (!commandExists("bun")) { throw new Error("Bun is required for the local binary release build."); } const platform = currentBinaryPlatform(); - mkdirSync(targetDirectory, { recursive: true }); - const executableName = platform.startsWith("windows-") ? "pi.exe" : "pi"; - const executablePath = join(targetDirectory, executableName); - const target = `bun-${platform}`; - run("bun", ["build", "--compile", `--target=${target}`, join(repoRoot, "packages", "coding-agent", "dist", "bun", "cli.js"), "--outfile", executablePath]); - copyBinaryAssets(targetDirectory); - copyWindowsConsoleModeHelper(targetDirectory, platform); - if (platform.startsWith("windows-")) { - run("powershell", ["-NoProfile", "-Command", `Compress-Archive -Path '${join(targetDirectory, "*").replaceAll("'", "''")}' -DestinationPath '${join(archiveDirectory, `pi-${platform}.zip`).replaceAll("'", "''")}' -Force`]); - } else { - run("tar", ["-czf", join(archiveDirectory, `pi-${platform}.tar.gz`), "-C", targetDirectory, "."]); - } + const binaryBuildDirectory = join(archiveDirectory, "binary-build"); + run("./scripts/build-binaries.sh", [ + "--skip-install", + "--skip-deps", + "--skip-build", + "--platform", + platform, + "--out", + binaryBuildDirectory, + ]); + rmSync(targetDirectory, { force: true, recursive: true }); + cpSync(join(binaryBuildDirectory, platform), targetDirectory, { recursive: true }); + const archiveName = platform.startsWith("windows-") ? `pi-${platform}.zip` : `pi-${platform}.tar.gz`; + cpSync(join(binaryBuildDirectory, archiveName), join(archiveDirectory, archiveName)); return platform; } From 8100046cb8a54e63239cd922cba2f4f47eb89881 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 23 May 2026 11:36:41 +0200 Subject: [PATCH 015/352] Finish async tool cleanup --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/package.json | 2 +- packages/coding-agent/src/core/tools/find.ts | 13 +----- packages/coding-agent/src/core/tools/ls.ts | 14 ++----- .../coding-agent/src/core/tools/path-utils.ts | 12 +++--- packages/coding-agent/src/core/tools/write.ts | 39 ++++++++---------- .../coding-agent/src/utils/image-resize.ts | 40 +++++++++++++------ scripts/build-binaries.sh | 4 +- 8 files changed, 59 insertions(+), 66 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 74983eff..806bfb5c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -24,6 +24,7 @@ - Fixed bash tool truncation line counts to ignore the trailing newline as an extra output line ([#4818](https://github.com/earendil-works/pi/issues/4818)). - Fixed footer home-directory abbreviation to avoid shortening sibling paths that only share the same prefix ([#4878](https://github.com/earendil-works/pi/issues/4878)). - Fixed macOS Bun release binaries to resolve the native clipboard sidecar so Ctrl+V image paste can load `@mariozechner/clipboard` ([#4307](https://github.com/earendil-works/pi/issues/4307)). +- Fixed coding-agent tools to avoid synchronous filesystem operations during streaming and moved image resizing off the main TUI thread ([#4756](https://github.com/earendil-works/pi-mono/pull/4756) by [@mitsuhiko](https://github.com/mitsuhiko)). ## [0.75.4] - 2026-05-20 diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 55c9b8a6..908cda66 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -31,7 +31,7 @@ "scripts": { "clean": "shx rm -rf dist", "build": "tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js && npm run copy-assets", - "build:binary": "npm --prefix ../tui run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm run build && bun build --compile ./dist/bun/cli.js ./dist/utils/image-resize-worker.js --outfile dist/pi && npm run copy-binary-assets", + "build:binary": "npm --prefix ../tui run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm run build && bun build --compile ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile dist/pi && npm run copy-binary-assets", "copy-assets": "shx mkdir -p dist/modes/interactive/theme && shx cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && shx mkdir -p dist/modes/interactive/assets && shx cp src/modes/interactive/assets/*.png dist/modes/interactive/assets/ && shx mkdir -p dist/core/export-html/vendor && shx cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && shx cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/", "copy-binary-assets": "shx cp package.json dist/ && shx cp README.md dist/ && shx cp CHANGELOG.md dist/ && shx mkdir -p dist/theme && shx cp src/modes/interactive/theme/*.json dist/theme/ && shx mkdir -p dist/assets && shx cp src/modes/interactive/assets/*.png dist/assets/ && shx mkdir -p dist/export-html/vendor && shx cp src/core/export-html/template.html dist/export-html/ && shx cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && shx cp -r docs dist/ && shx cp -r examples dist/ && shx cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist/", "test": "vitest --run", diff --git a/packages/coding-agent/src/core/tools/find.ts b/packages/coding-agent/src/core/tools/find.ts index 5749b813..d833fcb2 100644 --- a/packages/coding-agent/src/core/tools/find.ts +++ b/packages/coding-agent/src/core/tools/find.ts @@ -1,5 +1,3 @@ -import { constants } from "node:fs"; -import { access as fsAccess } from "node:fs/promises"; import { createInterface } from "node:readline"; import type { AgentTool } from "@earendil-works/pi-agent-core"; import { Text } from "@earendil-works/pi-tui"; @@ -9,7 +7,7 @@ import { type Static, Type } from "typebox"; import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; import { ensureTool } from "../../utils/tools-manager.ts"; import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; -import { resolveToCwd } from "./path-utils.ts"; +import { pathExists, resolveToCwd } from "./path-utils.ts"; import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.ts"; import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts"; @@ -47,14 +45,7 @@ export interface FindOperations { } const defaultFindOperations: FindOperations = { - exists: async (absolutePath) => { - try { - await fsAccess(absolutePath, constants.F_OK); - return true; - } catch { - return false; - } - }, + exists: pathExists, // This is a placeholder. Actual fd execution happens in execute() when no custom glob is provided. glob: () => [], }; diff --git a/packages/coding-agent/src/core/tools/ls.ts b/packages/coding-agent/src/core/tools/ls.ts index 46c8cc80..8da367a1 100644 --- a/packages/coding-agent/src/core/tools/ls.ts +++ b/packages/coding-agent/src/core/tools/ls.ts @@ -1,12 +1,11 @@ -import { constants } from "node:fs"; -import { access as fsAccess, readdir as fsReaddir, stat as fsStat } from "node:fs/promises"; +import { readdir as fsReaddir, stat as fsStat } from "node:fs/promises"; import type { AgentTool } from "@earendil-works/pi-agent-core"; import { Text } from "@earendil-works/pi-tui"; import nodePath from "path"; import { type Static, Type } from "typebox"; import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; -import { resolveToCwd } from "./path-utils.ts"; +import { pathExists, resolveToCwd } from "./path-utils.ts"; import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.ts"; import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts"; @@ -39,14 +38,7 @@ export interface LsOperations { } const defaultLsOperations: LsOperations = { - exists: async (absolutePath) => { - try { - await fsAccess(absolutePath, constants.F_OK); - return true; - } catch { - return false; - } - }, + exists: pathExists, stat: fsStat, readdir: fsReaddir, }; diff --git a/packages/coding-agent/src/core/tools/path-utils.ts b/packages/coding-agent/src/core/tools/path-utils.ts index 065b9eaa..1f9ab4cc 100644 --- a/packages/coding-agent/src/core/tools/path-utils.ts +++ b/packages/coding-agent/src/core/tools/path-utils.ts @@ -28,7 +28,7 @@ function fileExists(filePath: string): boolean { } } -async function fileExistsAsync(filePath: string): Promise { +export async function pathExists(filePath: string): Promise { try { await access(filePath, constants.F_OK); return true; @@ -86,31 +86,31 @@ export function resolveReadPath(filePath: string, cwd: string): string { export async function resolveReadPathAsync(filePath: string, cwd: string): Promise { const resolved = resolveToCwd(filePath, cwd); - if (await fileExistsAsync(resolved)) { + if (await pathExists(resolved)) { return resolved; } // Try macOS AM/PM variant (narrow no-break space before AM/PM) const amPmVariant = tryMacOSScreenshotPath(resolved); - if (amPmVariant !== resolved && (await fileExistsAsync(amPmVariant))) { + if (amPmVariant !== resolved && (await pathExists(amPmVariant))) { return amPmVariant; } // Try NFD variant (macOS stores filenames in NFD form) const nfdVariant = tryNFDVariant(resolved); - if (nfdVariant !== resolved && (await fileExistsAsync(nfdVariant))) { + if (nfdVariant !== resolved && (await pathExists(nfdVariant))) { return nfdVariant; } // Try curly quote variant (macOS uses U+2019 in screenshot names) const curlyVariant = tryCurlyQuoteVariant(resolved); - if (curlyVariant !== resolved && (await fileExistsAsync(curlyVariant))) { + if (curlyVariant !== resolved && (await pathExists(curlyVariant))) { return curlyVariant; } // Try combined NFD + curly quote (for French macOS screenshots like "Capture d'écran") const nfdCurlyVariant = tryCurlyQuoteVariant(nfdVariant); - if (nfdCurlyVariant !== resolved && (await fileExistsAsync(nfdCurlyVariant))) { + if (nfdCurlyVariant !== resolved && (await pathExists(nfdCurlyVariant))) { return nfdCurlyVariant; } diff --git a/packages/coding-agent/src/core/tools/write.ts b/packages/coding-agent/src/core/tools/write.ts index ba78649b..3eaa1740 100644 --- a/packages/coding-agent/src/core/tools/write.ts +++ b/packages/coding-agent/src/core/tools/write.ts @@ -201,34 +201,27 @@ export function createWriteToolDefinition( const absolutePath = resolveToCwd(path, cwd); const dir = dirname(absolutePath); return withFileMutationQueue(absolutePath, async () => { - let aborted = signal?.aborted ?? false; - const onAbort = () => { - aborted = true; - }; + // Do not reject from an abort event listener here: that would release the + // mutation queue while an in-flight filesystem operation may still finish. + // Checking signal.aborted after each await observes the same aborts while + // keeping the queue locked until the current operation has settled. const throwIfAborted = (): void => { - if (aborted || signal?.aborted) { - throw new Error("Operation aborted"); - } + if (signal?.aborted) throw new Error("Operation aborted"); }; - signal?.addEventListener("abort", onAbort, { once: true }); - try { - throwIfAborted(); - // Create parent directories if needed. - await ops.mkdir(dir); - throwIfAborted(); + throwIfAborted(); + // Create parent directories if needed. + await ops.mkdir(dir); + throwIfAborted(); - // Write the file contents. - await ops.writeFile(absolutePath, content); - throwIfAborted(); + // Write the file contents. + await ops.writeFile(absolutePath, content); + throwIfAborted(); - return { - content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }], - details: undefined, - }; - } finally { - signal?.removeEventListener("abort", onAbort); - } + return { + content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }], + details: undefined, + }; }); }, renderCall(args, theme, context) { diff --git a/packages/coding-agent/src/utils/image-resize.ts b/packages/coding-agent/src/utils/image-resize.ts index 24a75397..516a1e57 100644 --- a/packages/coding-agent/src/utils/image-resize.ts +++ b/packages/coding-agent/src/utils/image-resize.ts @@ -1,5 +1,5 @@ import { Worker } from "node:worker_threads"; -import type { ImageResizeOptions, ResizedImage } from "./image-resize-core.ts"; +import { type ImageResizeOptions, type ResizedImage, resizeImageInProcess } from "./image-resize-core.ts"; export type { ImageResizeOptions, ResizedImage } from "./image-resize-core.ts"; @@ -18,21 +18,17 @@ function isResizeImageWorkerResponse(value: unknown): value is ResizeImageWorker return value !== null && typeof value === "object"; } -function createResizeWorker(): Worker { - const isTypeScriptRuntime = import.meta.url.endsWith(".ts"); - const workerUrl = new URL( - isTypeScriptRuntime ? "./image-resize-worker.ts" : "./image-resize-worker.js", - import.meta.url, - ); - return new Worker(workerUrl); +function createResizeWorker(workerSpecifier: string | URL): Worker { + return new Worker(workerSpecifier); } async function resizeImageInWorker( + workerSpecifier: string | URL, inputBytes: Uint8Array, mimeType: string, options?: ImageResizeOptions, ): Promise { - const worker = createResizeWorker(); + const worker = createResizeWorker(workerSpecifier); try { const inputBytesForWorker = toTransferableBytes(inputBytes); return await new Promise((resolve, reject) => { @@ -82,15 +78,35 @@ async function resizeImageInWorker( /** * Resize an image to fit within the specified max dimensions and encoded file size. * Runs Photon in a worker thread so WASM decoding, resizing, and encoding do not - * block the TUI event loop. Worker failures are propagated instead of retried on - * the main thread. + * block the TUI event loop. If the worker cannot be loaded (for example in some + * Bun compiled executable layouts), fall back to in-process resizing so image + * reads still work. */ export async function resizeImage( inputBytes: Uint8Array, mimeType: string, options?: ImageResizeOptions, ): Promise { - return resizeImageInWorker(inputBytes, mimeType, options); + const isTypeScriptRuntime = import.meta.url.endsWith(".ts"); + const workerUrl = new URL( + isTypeScriptRuntime ? "./image-resize-worker.ts" : "./image-resize-worker.js", + import.meta.url, + ); + + // Bun compiled executables resolve worker entrypoints by string path, not via + // new URL(..., import.meta.url). Try the string path first under Bun so the + // release binary uses the embedded worker instead of falling back in-process. + if (typeof process.versions.bun === "string") { + try { + return await resizeImageInWorker("./src/utils/image-resize-worker.ts", inputBytes, mimeType, options); + } catch {} + } + + try { + return await resizeImageInWorker(workerUrl, inputBytes, mimeType, options); + } catch { + return resizeImageInProcess(inputBytes, mimeType, options); + } } /** diff --git a/scripts/build-binaries.sh b/scripts/build-binaries.sh index 00cc4818..3fed7647 100755 --- a/scripts/build-binaries.sh +++ b/scripts/build-binaries.sh @@ -132,9 +132,9 @@ for platform in "${PLATFORMS[@]}"; do # explicit build entrypoints. The runtime can still use new URL(...), but the # worker must be present in the compiled executable. if [[ "$platform" == windows-* ]]; then - bun build --compile --target=bun-$platform ./dist/bun/cli.js ./dist/utils/image-resize-worker.js --outfile "$OUTPUT_DIR/$platform/pi.exe" + bun build --compile --target=bun-$platform ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile "$OUTPUT_DIR/$platform/pi.exe" else - bun build --compile --target=bun-$platform ./dist/bun/cli.js ./dist/utils/image-resize-worker.js --outfile "$OUTPUT_DIR/$platform/pi" + bun build --compile --target=bun-$platform ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile "$OUTPUT_DIR/$platform/pi" fi done From 373bd1282e17ed9d3a341e4ebed1f7b354805e44 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 23 May 2026 11:40:54 +0200 Subject: [PATCH 016/352] Collapse read output by default closes #4916 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/core/tools/read.ts | 4 +-- .../test/tool-execution-component.test.ts | 27 +++++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 806bfb5c..6c01b8c1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Changed +- Changed collapsed read tool cards to show only the read line until expanded ([#4916](https://github.com/earendil-works/pi/issues/4916)). - Changed the root development install documentation to use `npm install --ignore-scripts` ([#4868](https://github.com/earendil-works/pi/issues/4868)). ### Fixed diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index f8d12d11..8f39b1ea 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -170,10 +170,10 @@ function formatReadResult( options: ToolRenderResultOptions, theme: Theme, showImages: boolean, - cwd: string, + _cwd: string, isError: boolean, ): string { - if (!options.expanded && !isError && getCompactReadClassification(args, cwd)) { + if (!options.expanded && !isError) { return ""; } diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index 9a3d1639..7a4a813d 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -191,6 +191,7 @@ describe("ToolExecutionComponent parity", () => { process.cwd(), ); component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false); + component.setExpanded(true); const rendered = stripAnsi(component.render(120).join("\n")); expect(rendered).toContain("override call"); expect(rendered).toContain("hello"); @@ -362,12 +363,38 @@ describe("ToolExecutionComponent parity", () => { { content: [{ type: "text", text: "one\ntwo\n" }], details: undefined, isError: false }, false, ); + component.setExpanded(true); const rendered = stripAnsi(component.render(120).join("\n")); expect(rendered).toContain("one"); expect(rendered).toContain("two"); expect(rendered).not.toContain("two\n\n"); }); + test("collapses ordinary read results until expanded", () => { + const component = new ToolExecutionComponent( + "read", + "tool-ordinary-read-collapsed", + { path: "notes.txt" }, + {}, + createReadToolDefinition(process.cwd()), + createFakeTui(), + process.cwd(), + ); + component.updateResult( + { content: [{ type: "text", text: "hidden content" }], details: undefined, isError: false }, + false, + ); + + const collapsed = stripAnsi(component.render(120).join("\n")); + expect(collapsed).toContain("read"); + expect(collapsed).toContain("notes.txt"); + expect(collapsed).not.toContain("hidden content"); + + component.setExpanded(true); + const expanded = stripAnsi(component.render(120).join("\n")); + expect(expanded).toContain("hidden content"); + }); + for (const scenario of [ { title: "SKILL.md", From b9566fc111023491e8ac7169b04bc80f46872d6e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 23 May 2026 11:48:27 +0200 Subject: [PATCH 017/352] Audit unreleased changelog entries --- packages/ai/CHANGELOG.md | 4 ++-- packages/coding-agent/CHANGELOG.md | 14 +++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index d23a2530..d02b245a 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,12 +4,12 @@ ### Breaking Changes -- Changed `OAuthLoginCallbacks` to require `onDeviceCode` and `onSelect`, so OAuth providers can rely on pi supplying device-code and selection UI callbacks. +- Changed `OAuthLoginCallbacks` to require `onDeviceCode` and `onSelect`, so OAuth providers can rely on pi supplying device-code and selection UI callbacks ([#4788](https://github.com/earendil-works/pi-mono/pull/4788) by [@vegarsti](https://github.com/vegarsti)). ### Fixed - Fixed custom Anthropic-compatible model aliases for adaptive-thinking Claude models by adding `compat.forceAdaptiveThinking` model metadata and moving built-in adaptive-thinking selection out of provider id substring checks ([#4797](https://github.com/earendil-works/pi-mono/pull/4797) by [@mbazso](https://github.com/mbazso)). -- Fixed GitHub Copilot OAuth login to rely on the required device-code callback without a runtime callback availability guard. +- Fixed GitHub Copilot OAuth login to rely on the required device-code callback without a runtime callback availability guard ([#4788](https://github.com/earendil-works/pi-mono/pull/4788) by [@vegarsti](https://github.com/vegarsti)). - Fixed Amazon Bedrock provider loading under strict package managers by declaring its direct `@smithy/node-http-handler` dependency ([#4842](https://github.com/earendil-works/pi/issues/4842)). - Fixed Amazon Bedrock Claude requests to send the model output token cap by default, matching Anthropic requests and avoiding Bedrock's 4096-token default truncation ([#4848](https://github.com/earendil-works/pi/issues/4848)). diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6c01b8c1..c38389db 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +### New Features + +- **Cleaner read tool output** - Collapsed `read` tool cards now show only the read line by default, while `Ctrl+O` still expands the full file content. +- **Faster file tools on Windows** - Built-in file tools now use async filesystem operations during streaming, and image resizes run off the main TUI thread in a worker. +- **More reliable package updates** - `pi update` and git package installs now reconcile pinned git refs and keep package settings intact. See [Packages](docs/packages.md). +- **Custom Anthropic-compatible adaptive thinking** - Custom provider model configs can opt into adaptive-thinking Claude behavior with `compat.forceAdaptiveThinking`. See [Custom providers](docs/custom-provider.md) and [Models](docs/models.md). + ### Added - Added `compat.forceAdaptiveThinking` support to custom Anthropic-compatible model configuration docs and validation ([#4797](https://github.com/earendil-works/pi-mono/pull/4797) by [@mbazso](https://github.com/mbazso)). @@ -10,15 +17,20 @@ ### Changed - Changed collapsed read tool cards to show only the read line until expanded ([#4916](https://github.com/earendil-works/pi/issues/4916)). +- Replaced the inherited optional `koffi` dependency for Windows VT input with a tiny vendored native helper, reducing install size while preserving Shift+Tab handling ([#4480](https://github.com/earendil-works/pi/issues/4480)). - Changed the root development install documentation to use `npm install --ignore-scripts` ([#4868](https://github.com/earendil-works/pi/issues/4868)). ### Fixed - Fixed `pi update` to reconcile git-pinned packages to their configured ref ([#4869](https://github.com/earendil-works/pi/issues/4869)). +- Fixed package/resource path handling for Windows and glob/pattern resolution ([#4873](https://github.com/earendil-works/pi-mono/pull/4873) by [@mitsuhiko](https://github.com/mitsuhiko)). +- Fixed config pattern matching to resolve patterns from the correct base directory ([#4898](https://github.com/earendil-works/pi-mono/pull/4898) by [@haoqixu](https://github.com/haoqixu)). +- Fixed theme pickers to list themes by their content name instead of file stem ([#4830](https://github.com/earendil-works/pi-mono/pull/4830) by [@Perlence](https://github.com/Perlence)). - Fixed OpenCode Zen/Go requests to send per-session OpenCode routing headers ([#4847](https://github.com/earendil-works/pi/issues/4847)). - Fixed Amazon Bedrock provider loading under strict package managers by inheriting the declared `@smithy/node-http-handler` dependency from `@earendil-works/pi-ai` ([#4842](https://github.com/earendil-works/pi/issues/4842)). +- Fixed inherited Amazon Bedrock Claude requests to send the model output token cap by default, avoiding Bedrock's 4096-token default truncation ([#4848](https://github.com/earendil-works/pi/issues/4848)). - Fixed exported session HTML to escape quote characters in attribute values ([#4832](https://github.com/earendil-works/pi/issues/4832)). -- Fixed GitHub Copilot device-code login to keep opening the verification URL in browser-capable environments while ignoring browser launch failures for headless use. +- Fixed GitHub Copilot device-code login to keep opening the verification URL in browser-capable environments while ignoring browser launch failures for headless use ([#4788](https://github.com/earendil-works/pi-mono/pull/4788) by [@vegarsti](https://github.com/vegarsti)). - Fixed git package installs to reconcile existing checkouts to the requested ref and update package settings without losing filters ([#4870](https://github.com/earendil-works/pi/issues/4870)). - Published a 0.74.2 rescue release that tells Node 20 users to upgrade Node before updating to newer Pi versions ([#4876](https://github.com/earendil-works/pi/issues/4876)). - Fixed final bash tool cards to avoid rendering duplicate full-output truncation paths ([#4819](https://github.com/earendil-works/pi/issues/4819)). From 98477f2ee790f502f3bf675a522c6559d41811c1 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 23 May 2026 11:54:36 +0200 Subject: [PATCH 018/352] Clarify release smoke test instructions --- AGENTS.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 065f2541..3577f315 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,13 +120,22 @@ Attribution: ```bash npm run release:local -- --out /tmp/pi-local-release --force cd /tmp + + # Node package install smoke tests /tmp/pi-local-release/node/pi --help /tmp/pi-local-release/node/pi --version + /tmp/pi-local-release/node/pi --list-models + /tmp/pi-local-release/node/pi -p "Say exactly: ok" /tmp/pi-local-release/node/pi + + # Bun binary smoke tests /tmp/pi-local-release/bun/pi --help /tmp/pi-local-release/bun/pi --version + /tmp/pi-local-release/bun/pi --list-models + /tmp/pi-local-release/bun/pi -p "Say exactly: ok" + /tmp/pi-local-release/bun/pi ``` - Verify startup, model/account listing, and at least one real prompt with the intended default provider. Failures are release blockers unless the user explicitly accepts the risk. + Verify both Node and Bun startup, model/account listing, interactive startup, and at least one real prompt with the intended default provider. The bare commands `/tmp/pi-local-release/node/pi` and `/tmp/pi-local-release/bun/pi` start interactive mode; run each in tmux, submit a prompt, and wait for the model reply before considering the interactive smoke test passed. Failures are release blockers unless the user explicitly accepts the risk. 3. **Brief the user on the WebAuthn flow before running anything**. Print exactly the following message and then stop and wait for the user to confirm in their next message: From ea2b70ddd9842fd9ec8685219b1d800939bb1193 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 23 May 2026 11:58:31 +0200 Subject: [PATCH 019/352] Release v0.75.5 --- package-lock.json | 24 ++++---- packages/agent/CHANGELOG.md | 2 +- packages/agent/package.json | 4 +- packages/ai/CHANGELOG.md | 2 +- packages/ai/package.json | 2 +- packages/coding-agent/CHANGELOG.md | 2 +- .../package-lock.json | 4 +- .../custom-provider-anthropic/package.json | 2 +- .../custom-provider-gitlab-duo/package.json | 2 +- .../extensions/sandbox/package-lock.json | 4 +- .../examples/extensions/sandbox/package.json | 2 +- .../extensions/with-deps/package-lock.json | 4 +- .../extensions/with-deps/package.json | 2 +- packages/coding-agent/npm-shrinkwrap.json | 24 ++++---- packages/coding-agent/package.json | 8 +-- packages/tui/CHANGELOG.md | 2 +- packages/tui/package.json | 2 +- scripts/check-lockfile-commit.mjs | 58 ++++++++++++++----- 18 files changed, 88 insertions(+), 62 deletions(-) diff --git a/package-lock.json b/package-lock.json index cab14c6d..5796fcf9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5987,10 +5987,10 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.75.4", + "version": "0.75.5", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.75.4", + "@earendil-works/pi-ai": "^0.75.5", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -6024,7 +6024,7 @@ }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.75.4", + "version": "0.75.5", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -6069,12 +6069,12 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "0.75.4", + "version": "0.75.5", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.75.4", - "@earendil-works/pi-ai": "^0.75.4", - "@earendil-works/pi-tui": "^0.75.4", + "@earendil-works/pi-agent-core": "^0.75.5", + "@earendil-works/pi-ai": "^0.75.5", + "@earendil-works/pi-tui": "^0.75.5", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -6113,25 +6113,25 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.75.4", + "version": "0.75.5", "dependencies": { "@anthropic-ai/sdk": "0.52.0" } }, "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": { "name": "pi-extension-custom-provider-gitlab-duo", - "version": "0.75.4" + "version": "0.75.5" }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.5.4", + "version": "1.5.5", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.75.4", + "version": "0.75.5", "dependencies": { "ms": "2.1.3" }, @@ -6167,7 +6167,7 @@ }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.75.4", + "version": "0.75.5", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 4ec02d9a..c215a8a5 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.75.5] - 2026-05-23 ## [0.75.4] - 2026-05-20 diff --git a/packages/agent/package.json b/packages/agent/package.json index f5d823e7..803ca975 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "0.75.4", + "version": "0.75.5", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -29,7 +29,7 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "^0.75.4", + "@earendil-works/pi-ai": "^0.75.5", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index d02b245a..398374ec 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.75.5] - 2026-05-23 ### Breaking Changes diff --git a/packages/ai/package.json b/packages/ai/package.json index 525c5f6f..68946b61 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.75.4", + "version": "0.75.5", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c38389db..5924caa2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.75.5] - 2026-05-23 ### 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 c78ae920..9833a6a3 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-custom-provider", - "version": "0.75.4", + "version": "0.75.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "0.75.4", + "version": "0.75.5", "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 89aa420a..6098f5d4 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-anthropic", "private": true, - "version": "0.75.4", + "version": "0.75.5", "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 2db431cf..49ae54fd 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-gitlab-duo", "private": true, - "version": "0.75.4", + "version": "0.75.5", "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 ef29b150..038a3ee0 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package-lock.json +++ b/packages/coding-agent/examples/extensions/sandbox/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-sandbox", - "version": "1.5.4", + "version": "1.5.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.5.4", + "version": "1.5.5", "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 c33d2d60..9aacc96f 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package.json +++ b/packages/coding-agent/examples/extensions/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-sandbox", "private": true, - "version": "1.5.4", + "version": "1.5.5", "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 526356f4..ba06e6d3 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package-lock.json +++ b/packages/coding-agent/examples/extensions/with-deps/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-with-deps", - "version": "0.75.4", + "version": "0.75.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "0.75.4", + "version": "0.75.5", "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 45f1d3f2..d6554694 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package.json +++ b/packages/coding-agent/examples/extensions/with-deps/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-with-deps", "private": true, - "version": "0.75.4", + "version": "0.75.5", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index b9041ce9..b82cebea 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -1,17 +1,17 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.75.4", + "version": "0.75.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent", - "version": "0.75.4", + "version": "0.75.5", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.75.4", - "@earendil-works/pi-ai": "^0.75.4", - "@earendil-works/pi-tui": "^0.75.4", + "@earendil-works/pi-agent-core": "^0.75.5", + "@earendil-works/pi-ai": "^0.75.5", + "@earendil-works/pi-tui": "^0.75.5", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -473,11 +473,11 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.75.4", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.75.4.tgz", + "version": "0.75.5", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.75.5.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.75.4", + "@earendil-works/pi-ai": "^0.75.5", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -487,8 +487,8 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.75.4", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.75.4.tgz", + "version": "0.75.5", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.75.5.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -510,8 +510,8 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.75.4", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.75.4.tgz", + "version": "0.75.5", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.75.5.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 908cda66..b73e82ef 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.75.4", + "version": "0.75.5", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -39,9 +39,9 @@ "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" }, "dependencies": { - "@earendil-works/pi-agent-core": "^0.75.4", - "@earendil-works/pi-ai": "^0.75.4", - "@earendil-works/pi-tui": "^0.75.4", + "@earendil-works/pi-agent-core": "^0.75.5", + "@earendil-works/pi-ai": "^0.75.5", + "@earendil-works/pi-tui": "^0.75.5", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index b1f8a8dc..f7b66e35 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.75.5] - 2026-05-23 ### Changed diff --git a/packages/tui/package.json b/packages/tui/package.json index c684eae7..4736ada3 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-tui", - "version": "0.75.4", + "version": "0.75.5", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", diff --git a/scripts/check-lockfile-commit.mjs b/scripts/check-lockfile-commit.mjs index abf78947..bd77d609 100644 --- a/scripts/check-lockfile-commit.mjs +++ b/scripts/check-lockfile-commit.mjs @@ -30,30 +30,50 @@ function packageLabel(lockPath, entry) { return entry?.version ? `${name}@${entry.version}` : name; } -function summarizeLockfileChange() { +function getLockfilePackageChanges() { const before = readJsonFromGit("HEAD:package-lock.json"); const after = readJsonFromGit(":package-lock.json"); - if (!before?.packages || !after?.packages) return []; + if (!before?.packages || !after?.packages) return undefined; const changes = []; const paths = new Set([...Object.keys(before.packages), ...Object.keys(after.packages)]); for (const lockPath of [...paths].sort()) { - if (!lockPath.includes("node_modules/")) continue; const oldEntry = before.packages[lockPath]; const newEntry = after.packages[lockPath]; - if (!oldEntry && newEntry) { - changes.push(`added ${packageLabel(lockPath, newEntry)}`); - } else if (oldEntry && !newEntry) { - changes.push(`removed ${packageLabel(lockPath, oldEntry)}`); - } else if (oldEntry?.version !== newEntry?.version) { - changes.push( - `changed ${packageNameFromLockPath(lockPath)} ${oldEntry?.version ?? ""} -> ${newEntry?.version ?? ""}`, - ); + if (JSON.stringify(oldEntry) !== JSON.stringify(newEntry)) { + changes.push({ lockPath, oldEntry, newEntry }); } } return changes; } +function isWorkspacePackagePath(lockPath) { + return lockPath.startsWith("packages/"); +} + +function hasOnlyWorkspacePackageChanges(changes) { + return changes.length > 0 && changes.every((change) => isWorkspacePackagePath(change.lockPath)); +} + +function summarizeLockfileChange(changes) { + const nodeModuleChanges = changes.filter((change) => change.lockPath.includes("node_modules/")); + const summary = []; + for (const { lockPath, oldEntry, newEntry } of nodeModuleChanges) { + if (!oldEntry && newEntry) { + summary.push(`added ${packageLabel(lockPath, newEntry)}`); + } else if (oldEntry && !newEntry) { + summary.push(`removed ${packageLabel(lockPath, oldEntry)}`); + } else if (oldEntry?.version !== newEntry?.version) { + summary.push( + `changed ${packageNameFromLockPath(lockPath)} ${oldEntry?.version ?? ""} -> ${newEntry?.version ?? ""}`, + ); + } else { + summary.push(`changed ${packageLabel(lockPath, newEntry)}`); + } + } + return summary; +} + const stagedFiles = git(["diff", "--cached", "--name-only"]) .split("\n") .map((line) => line.trim()) @@ -68,6 +88,12 @@ if (allowed) { process.exit(0); } +const changes = getLockfilePackageChanges(); +if (changes && hasOnlyWorkspacePackageChanges(changes)) { + console.error("package-lock.json only updates workspace package metadata; allowing commit."); + process.exit(0); +} + console.error("package-lock.json is staged."); console.error(""); console.error("Review lockfile changes before committing:"); @@ -76,15 +102,15 @@ console.error(" - confirm npm age gates were active for resolution"); console.error(" - review any new lifecycle scripts in the dependency tree"); console.error(" - regenerate/check coding-agent shrinkwrap if release deps changed"); -const changes = summarizeLockfileChange(); -if (changes.length > 0) { +const summary = changes ? summarizeLockfileChange(changes) : []; +if (summary.length > 0) { console.error(""); console.error("Detected package version changes:"); - for (const change of changes.slice(0, 40)) { + for (const change of summary.slice(0, 40)) { console.error(` - ${change}`); } - if (changes.length > 40) { - console.error(` ... ${changes.length - 40} more`); + if (summary.length > 40) { + console.error(` ... ${summary.length - 40} more`); } } From 83a227aed3f6f7d2f8a63bdd66df0bdb80cd21e2 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 23 May 2026 12:02:35 +0200 Subject: [PATCH 020/352] Update release instructions and generated models --- AGENTS.md | 10 +- packages/ai/src/models.generated.ts | 291 ++++++++-------------------- 2 files changed, 90 insertions(+), 211 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3577f315..72cdd05c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,7 +137,9 @@ Attribution: ``` Verify both Node and Bun startup, model/account listing, interactive startup, and at least one real prompt with the intended default provider. The bare commands `/tmp/pi-local-release/node/pi` and `/tmp/pi-local-release/bun/pi` start interactive mode; run each in tmux, submit a prompt, and wait for the model reply before considering the interactive smoke test passed. Failures are release blockers unless the user explicitly accepts the risk. -3. **Brief the user on the WebAuthn flow before running anything**. Print exactly the following message and then stop and wait for the user to confirm in their next message: +3. **Verify npm authentication**: run `npm whoami` before starting the release script. If it fails, stop and tell the user to run `npm login` manually first, then retry after they confirm `npm whoami` succeeds. + +4. **Brief the user on the WebAuthn flow before running anything**. Print exactly the following message and then stop and wait for the user to confirm in their next message: ``` Before I run the release script, read this carefully: @@ -150,16 +152,16 @@ Attribution: Reply "ready" once you have read this and are watching the bash output. I will not run the release script until you do. ``` - Do not proceed to step 4 until the user explicitly confirms. + Do not proceed to step 5 until the user explicitly confirms. -4. **Run the release script**: +5. **Run the release script**: ```bash npm run release:patch # fixes + additions npm run release:minor # breaking changes ``` Do not pass a `timeout` to the bash tool for this call. If publish fails partway, stop and report to the user what happened (which package failed, the error output) along with possible solutions. Never rerun the version bump on your own. -5. **After publish succeeds**: +6. **After publish succeeds**: - Add fresh `## [Unreleased]` sections to package changelogs. - Commit with `Add [Unreleased] section for next cycle`. - Push `main` and the release tag. diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 9197fa12..300ed2c1 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -3524,42 +3524,6 @@ export const MODELS = { } satisfies Model<"openai-completions">, }, "fireworks": { - "accounts/fireworks/models/deepseek-v3p1": { - id: "accounts/fireworks/models/deepseek-v3p1", - name: "DeepSeek V3.1", - api: "anthropic-messages", - provider: "fireworks", - baseUrl: "https://api.fireworks.ai/inference", - compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, - reasoning: true, - input: ["text"], - cost: { - input: 0.56, - output: 1.68, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 163840, - maxTokens: 163840, - } satisfies Model<"anthropic-messages">, - "accounts/fireworks/models/deepseek-v3p2": { - id: "accounts/fireworks/models/deepseek-v3p2", - name: "DeepSeek V3.2", - api: "anthropic-messages", - provider: "fireworks", - baseUrl: "https://api.fireworks.ai/inference", - compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, - reasoning: true, - input: ["text"], - cost: { - input: 0.56, - output: 1.68, - cacheRead: 0.28, - cacheWrite: 0, - }, - contextWindow: 160000, - maxTokens: 160000, - } satisfies Model<"anthropic-messages">, "accounts/fireworks/models/deepseek-v4-flash": { id: "accounts/fireworks/models/deepseek-v4-flash", name: "DeepSeek V4 Flash", @@ -3590,84 +3554,12 @@ export const MODELS = { cost: { input: 1.74, output: 3.48, - cacheRead: 0.15, + cacheRead: 0.145, cacheWrite: 0, }, contextWindow: 1000000, maxTokens: 384000, } satisfies Model<"anthropic-messages">, - "accounts/fireworks/models/glm-4p5": { - id: "accounts/fireworks/models/glm-4p5", - name: "GLM 4.5", - api: "anthropic-messages", - provider: "fireworks", - baseUrl: "https://api.fireworks.ai/inference", - compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, - reasoning: true, - input: ["text"], - cost: { - input: 0.55, - output: 2.19, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"anthropic-messages">, - "accounts/fireworks/models/glm-4p5-air": { - id: "accounts/fireworks/models/glm-4p5-air", - name: "GLM 4.5 Air", - api: "anthropic-messages", - provider: "fireworks", - baseUrl: "https://api.fireworks.ai/inference", - compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, - reasoning: true, - input: ["text"], - cost: { - input: 0.22, - output: 0.88, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"anthropic-messages">, - "accounts/fireworks/models/glm-4p7": { - id: "accounts/fireworks/models/glm-4p7", - name: "GLM 4.7", - api: "anthropic-messages", - provider: "fireworks", - baseUrl: "https://api.fireworks.ai/inference", - compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, - reasoning: true, - input: ["text"], - cost: { - input: 0.6, - output: 2.2, - cacheRead: 0.3, - cacheWrite: 0, - }, - contextWindow: 198000, - maxTokens: 198000, - } satisfies Model<"anthropic-messages">, - "accounts/fireworks/models/glm-5": { - id: "accounts/fireworks/models/glm-5", - name: "GLM 5", - api: "anthropic-messages", - provider: "fireworks", - baseUrl: "https://api.fireworks.ai/inference", - compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, - reasoning: true, - input: ["text"], - cost: { - input: 1, - output: 3.2, - cacheRead: 0.5, - cacheWrite: 0, - }, - contextWindow: 202752, - maxTokens: 131072, - } satisfies Model<"anthropic-messages">, "accounts/fireworks/models/glm-5p1": { id: "accounts/fireworks/models/glm-5p1", name: "GLM 5.1", @@ -3698,7 +3590,7 @@ export const MODELS = { cost: { input: 0.15, output: 0.6, - cacheRead: 0, + cacheRead: 0.015, cacheWrite: 0, }, contextWindow: 131072, @@ -3714,50 +3606,14 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.05, - output: 0.2, - cacheRead: 0, + input: 0.07, + output: 0.3, + cacheRead: 0.035, cacheWrite: 0, }, contextWindow: 131072, maxTokens: 32768, } satisfies Model<"anthropic-messages">, - "accounts/fireworks/models/kimi-k2-instruct": { - id: "accounts/fireworks/models/kimi-k2-instruct", - name: "Kimi K2 Instruct", - api: "anthropic-messages", - provider: "fireworks", - baseUrl: "https://api.fireworks.ai/inference", - compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, - reasoning: false, - input: ["text"], - cost: { - input: 1, - output: 3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, - } satisfies Model<"anthropic-messages">, - "accounts/fireworks/models/kimi-k2-thinking": { - id: "accounts/fireworks/models/kimi-k2-thinking", - name: "Kimi K2 Thinking", - api: "anthropic-messages", - provider: "fireworks", - baseUrl: "https://api.fireworks.ai/inference", - compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, - reasoning: true, - input: ["text"], - cost: { - input: 0.6, - output: 2.5, - cacheRead: 0.3, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 256000, - } satisfies Model<"anthropic-messages">, "accounts/fireworks/models/kimi-k2p5": { id: "accounts/fireworks/models/kimi-k2p5", name: "Kimi K2.5", @@ -3794,24 +3650,6 @@ export const MODELS = { contextWindow: 262000, maxTokens: 262000, } satisfies Model<"anthropic-messages">, - "accounts/fireworks/models/minimax-m2p1": { - id: "accounts/fireworks/models/minimax-m2p1", - name: "MiniMax-M2.1", - api: "anthropic-messages", - provider: "fireworks", - baseUrl: "https://api.fireworks.ai/inference", - compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, - reasoning: true, - input: ["text"], - cost: { - input: 0.3, - output: 1.2, - cacheRead: 0.03, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 200000, - } satisfies Model<"anthropic-messages">, "accounts/fireworks/models/minimax-m2p5": { id: "accounts/fireworks/models/minimax-m2p5", name: "MiniMax-M2.5", @@ -3842,7 +3680,7 @@ export const MODELS = { cost: { input: 0.3, output: 1.2, - cacheRead: 0.03, + cacheRead: 0.06, cacheWrite: 0, }, contextWindow: 196608, @@ -3866,9 +3704,27 @@ export const MODELS = { contextWindow: 128000, maxTokens: 8192, } satisfies Model<"anthropic-messages">, - "accounts/fireworks/routers/kimi-k2p5-turbo": { - id: "accounts/fireworks/routers/kimi-k2p5-turbo", - name: "Kimi K2.5 Turbo", + "accounts/fireworks/routers/glm-5p1-fast": { + id: "accounts/fireworks/routers/glm-5p1-fast", + name: "GLM 5.1 Fast", + api: "anthropic-messages", + provider: "fireworks", + baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 2.8, + output: 8.8, + cacheRead: 0.52, + cacheWrite: 0, + }, + contextWindow: 202800, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, + "accounts/fireworks/routers/kimi-k2p6-turbo": { + id: "accounts/fireworks/routers/kimi-k2p6-turbo", + name: "Kimi K2.6 Turbo", api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", @@ -3876,13 +3732,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0, - output: 0, - cacheRead: 0, + input: 2, + output: 8, + cacheRead: 0.3, cacheWrite: 0, }, - contextWindow: 256000, - maxTokens: 256000, + contextWindow: 262000, + maxTokens: 262000, } satisfies Model<"anthropic-messages">, }, "github-copilot": { @@ -4058,6 +3914,25 @@ export const MODELS = { contextWindow: 128000, maxTokens: 64000, } satisfies Model<"openai-completions">, + "gemini-3.5-flash": { + id: "gemini-3.5-flash", + name: "Gemini 3.5 Flash", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, "gpt-4.1": { id: "gpt-4.1", name: "GPT-4.1", @@ -7850,23 +7725,6 @@ export const MODELS = { contextWindow: 262144, maxTokens: 65536, } satisfies Model<"anthropic-messages">, - "qwen3.6-plus-free": { - id: "qwen3.6-plus-free", - name: "Qwen3.6 Plus Free", - api: "anthropic-messages", - provider: "opencode", - baseUrl: "https://opencode.ai/zen", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 65536, - } satisfies Model<"anthropic-messages">, }, "opencode-go": { "deepseek-v4-flash": { @@ -8810,13 +8668,13 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, input: ["text"], cost: { - input: 0.112, - output: 0.224, - cacheRead: 0.022, + input: 0.09999999999999999, + output: 0.19999999999999998, + cacheRead: 0.02, cacheWrite: 0, }, contextWindow: 1048576, - maxTokens: 4096, + maxTokens: 16384, } satisfies Model<"openai-completions">, "deepseek/deepseek-v4-flash:free": { id: "deepseek/deepseek-v4-flash:free", @@ -11757,7 +11615,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 65536, + maxTokens: 262144, } satisfies Model<"openai-completions">, "qwen/qwen3.5-27b": { id: "qwen/qwen3.5-27b", @@ -11887,13 +11745,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.317, + input: 0.3, output: 3.1999999999999997, cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262140, + maxTokens: 262144, } satisfies Model<"openai-completions">, "qwen/qwen3.6-35b-a3b": { id: "qwen/qwen3.6-35b-a3b", @@ -11906,11 +11764,11 @@ export const MODELS = { cost: { input: 0.15, output: 1, - cacheRead: 0.049999999999999996, + cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 262140, } satisfies Model<"openai-completions">, "qwen/qwen3.6-flash": { id: "qwen/qwen3.6-flash", @@ -12760,6 +12618,25 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 500000, } satisfies Model<"openai-completions">, + "Qwen/Qwen3.7-Max": { + id: "Qwen/Qwen3.7-Max", + name: "Qwen3.7 Max", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text"], + cost: { + input: 2.5, + output: 7.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 500000, + } satisfies Model<"openai-completions">, "deepseek-ai/DeepSeek-V3": { id: "deepseek-ai/DeepSeek-V3", name: "DeepSeek V3", @@ -13265,10 +13142,10 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 2.5, - output: 7.5, - cacheRead: 0.5, - cacheWrite: 3.125, + input: 1.25, + output: 3.75, + cacheRead: 0.25, + cacheWrite: 1.5625, }, contextWindow: 991000, maxTokens: 64000, @@ -14164,7 +14041,7 @@ export const MODELS = { } satisfies Model<"anthropic-messages">, "minimax/minimax-m2.7": { id: "minimax/minimax-m2.7", - name: "Minimax M2.7", + name: "MiniMax M2.7", api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", From 89ba72c35dcbcdc0144f4e57a0b76568c50c2cbb Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 23 May 2026 12:07:52 +0200 Subject: [PATCH 021/352] Update release instructions and image models --- AGENTS.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 72cdd05c..33be8f27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,14 +142,16 @@ Attribution: 4. **Brief the user on the WebAuthn flow before running anything**. Print exactly the following message and then stop and wait for the user to confirm in their next message: ``` - Before I run the release script, read this carefully: + Before the release publish step, read this carefully: - `npm publish` uses WebAuthn 2FA. - - A login URL will appear in the live bash output in this TUI. I will NOT see it until the command exits. - - You must watch the bash output, cmd/ctrl-click the URL, log in in the browser, and select the "don't ask again for N minutes" option so publish can continue. - - This may happen more than once during the release. + - The safest flow is for you to run the publish command yourself, because you can see and open the npm authentication URL immediately. + - I will tell you the exact command to run. + - When npm prints an auth URL, cmd/ctrl-click it, log in in the browser, and select the "don't ask again for N minutes" option if available. + - This may happen more than once during publish. + - Do not rerun `npm run release:patch` or `npm run release:minor` after a failed publish; only rerun the publish command I give you. - Reply "ready" once you have read this and are watching the bash output. I will not run the release script until you do. + Reply "ready" once you have read this and are ready to run the command locally. ``` Do not proceed to step 5 until the user explicitly confirms. @@ -159,7 +161,7 @@ Attribution: npm run release:patch # fixes + additions npm run release:minor # breaking changes ``` - Do not pass a `timeout` to the bash tool for this call. If publish fails partway, stop and report to the user what happened (which package failed, the error output) along with possible solutions. Never rerun the version bump on your own. + Do not pass a `timeout` to the bash tool for this call. If publish fails during the WebAuthn/OTP step after version bump, stop and tell the user to run `npm run publish` themselves from the repo root. Never rerun the version bump on your own. After the user reports publish success, continue with the post-publish steps. 6. **After publish succeeds**: - Add fresh `## [Unreleased]` sections to package changelogs. From 30f48feae71c795f4c58941e0a30ec1473b9eb0f Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 23 May 2026 12:08:14 +0200 Subject: [PATCH 022/352] Add [Unreleased] section for next cycle --- packages/agent/CHANGELOG.md | 2 ++ packages/ai/CHANGELOG.md | 2 ++ packages/coding-agent/CHANGELOG.md | 2 ++ packages/tui/CHANGELOG.md | 2 ++ 4 files changed, 8 insertions(+) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index c215a8a5..7915e6f9 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.75.5] - 2026-05-23 ## [0.75.4] - 2026-05-20 diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 398374ec..ed673f1a 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.75.5] - 2026-05-23 ### Breaking Changes diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5924caa2..2bc8dcd9 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.75.5] - 2026-05-23 ### New Features diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index f7b66e35..2c5cebb6 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.75.5] - 2026-05-23 ### Changed From 15f1dea8dfd09756748671c853a9eca3ca3bb4ef Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 23 May 2026 14:15:00 +0200 Subject: [PATCH 023/352] fix(coding-agent): disable managed extension peer resolution closes #4907 --- packages/coding-agent/CHANGELOG.md | 4 ++ .../coding-agent/src/core/package-manager.ts | 18 ++++++-- .../coding-agent/test/package-manager.test.ts | 44 ++++++++++++++++--- 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2bc8dcd9..c7ae064a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed managed npm extension updates to avoid package managers installing or resolving pi host packages as peer dependencies ([#4907](https://github.com/earendil-works/pi/issues/4907)). + ## [0.75.5] - 2026-05-23 ### New Features diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 06933bb2..1d7dc27d 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -1706,13 +1706,25 @@ export class DefaultPackageManager implements PackageManager { private getNpmInstallArgs(specs: string[], installRoot: string): string[] { const packageManagerName = this.getPackageManagerName(); + // Extension packages run inside pi and resolve pi APIs through loader aliases/virtual modules. + // Disable peer dependency resolution for managed installs (npm's --legacy-peer-deps, and + // equivalent bun/pnpm settings) so package managers do not install or solve host-provided + // @earendil-works/pi-* peers. Stale auto-installed pi peers can otherwise block updates. if (packageManagerName === "bun") { - return ["install", ...specs, "--cwd", installRoot]; + return ["install", ...specs, "--cwd", installRoot, "--omit=peer"]; } if (packageManagerName === "pnpm") { - return ["install", ...specs, "--prefix", installRoot, "--config.strict-dep-builds=false"]; + return [ + "install", + ...specs, + "--prefix", + installRoot, + "--config.auto-install-peers=false", + "--config.strict-peer-dependencies=false", + "--config.strict-dep-builds=false", + ]; } - return ["install", ...specs, "--prefix", installRoot]; + return ["install", ...specs, "--prefix", installRoot, "--legacy-peer-deps"]; } private async installNpm(source: NpmSource, scope: SourceScope, temporary: boolean): Promise { diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index b464ca7d..af3bc99d 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -693,7 +693,17 @@ Content`, expect(runCommandSpy).toHaveBeenCalledWith( "mise", - ["exec", "node@20", "--", "npm", "install", "@scope/pkg", "--prefix", join(agentDir, "npm")], + [ + "exec", + "node@20", + "--", + "npm", + "install", + "@scope/pkg", + "--prefix", + join(agentDir, "npm"), + "--legacy-peer-deps", + ], undefined, ); }); @@ -714,7 +724,7 @@ Content`, expect(runCommandSpy).toHaveBeenCalledWith( "mise", - ["exec", "bun@1", "--", "bun", "install", "@scope/pkg", "--cwd", join(agentDir, "npm")], + ["exec", "bun@1", "--", "bun", "install", "@scope/pkg", "--cwd", join(agentDir, "npm"), "--omit=peer"], undefined, ); }); @@ -953,6 +963,8 @@ Content`, "pnpm-pkg", "--prefix", join(agentDir, "npm"), + "--config.auto-install-peers=false", + "--config.strict-peer-dependencies=false", "--config.strict-dep-builds=false", ]); mkdirSync(join(packagePath, "extensions"), { recursive: true }); @@ -2005,7 +2017,7 @@ export default function(api) { api.registerTool({ name: "test", description: "te ); expect(runCommandSpy).toHaveBeenCalledWith( "npm", - ["install", "example@latest", "--prefix", join(tempDir, ".pi", "npm")], + ["install", "example@latest", "--prefix", join(tempDir, ".pi", "npm"), "--legacy-peer-deps"], undefined, ); }); @@ -2044,7 +2056,13 @@ export default function(api) { api.registerTool({ name: "test", description: "te .mockImplementation(async (...callArgs: unknown[]) => { const [command, args] = callArgs as [string, string[]]; expect(command).toBe("npm"); - expect(args).toEqual(["install", "legacy-pkg@latest", "--prefix", join(agentDir, "npm")]); + expect(args).toEqual([ + "install", + "legacy-pkg@latest", + "--prefix", + join(agentDir, "npm"), + "--legacy-peer-deps", + ]); mkdirSync(managedPath, { recursive: true }); writeFileSync( join(managedPath, "package.json"), @@ -2154,13 +2172,27 @@ export default function(api) { api.registerTool({ name: "test", description: "te expect(runCommandSpy).toHaveBeenNthCalledWith( 1, "npm", - ["install", "user-old@latest", "user-unknown@latest", "--prefix", join(agentDir, "npm")], + [ + "install", + "user-old@latest", + "user-unknown@latest", + "--prefix", + join(agentDir, "npm"), + "--legacy-peer-deps", + ], undefined, ); expect(runCommandSpy).toHaveBeenNthCalledWith( 2, "npm", - ["install", "project-old@latest", "project-missing@latest", "--prefix", join(tempDir, ".pi", "npm")], + [ + "install", + "project-old@latest", + "project-missing@latest", + "--prefix", + join(tempDir, ".pi", "npm"), + "--legacy-peer-deps", + ], undefined, ); expect(updateGitSpy).toHaveBeenCalledTimes(4); From 4a98f748bb11a09f5965d29f463ef7ba1851de69 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 23 May 2026 14:18:49 +0200 Subject: [PATCH 024/352] chore(tui): remove unused xterm dependency --- package-lock.json | 8 -------- packages/tui/package.json | 1 - 2 files changed, 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5796fcf9..c34bd21f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2522,13 +2522,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@xterm/xterm": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", - "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "dev": true, - "license": "MIT" - }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -6175,7 +6168,6 @@ }, "devDependencies": { "@xterm/headless": "5.5.0", - "@xterm/xterm": "5.5.0", "chalk": "5.6.2" }, "engines": { diff --git a/packages/tui/package.json b/packages/tui/package.json index 4736ada3..7d1b7f7a 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -41,7 +41,6 @@ }, "devDependencies": { "@xterm/headless": "5.5.0", - "@xterm/xterm": "5.5.0", "chalk": "5.6.2" } } From c5181a266e20e8f5b4f678754a31bf10d8470fe9 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 23 May 2026 15:09:01 +0200 Subject: [PATCH 025/352] fix(tui): detect Apple Terminal Shift+Enter --- packages/coding-agent/docs/terminal-setup.md | 6 ++ packages/tui/CHANGELOG.md | 4 + .../darwin-arm64/darwin-modifiers.node | Bin 0 -> 50200 bytes .../darwin-x64/darwin-modifiers.node | Bin 0 -> 12776 bytes .../tui/native/darwin/src/darwin-modifiers.c | 70 ++++++++++++++++++ packages/tui/package.json | 1 + packages/tui/src/native-modifiers.ts | 60 +++++++++++++++ packages/tui/src/terminal.ts | 19 ++++- packages/tui/test/terminal.test.ts | 41 +++++++++- scripts/build-binaries.sh | 6 +- 10 files changed, 204 insertions(+), 3 deletions(-) create mode 100755 packages/tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node create mode 100755 packages/tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node create mode 100644 packages/tui/native/darwin/src/darwin-modifiers.c create mode 100644 packages/tui/src/native-modifiers.ts diff --git a/packages/coding-agent/docs/terminal-setup.md b/packages/coding-agent/docs/terminal-setup.md index 74b0df2a..5bf9f2b2 100644 --- a/packages/coding-agent/docs/terminal-setup.md +++ b/packages/coding-agent/docs/terminal-setup.md @@ -6,6 +6,12 @@ Pi uses the [Kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-p Work out of the box. +## Apple Terminal + +Pi enables enhanced key reporting when available. If Terminal.app still sends plain Return for `Shift+Enter`, pi uses a local macOS modifier fallback to treat that Return as `Shift+Enter`. + +This fallback only works when pi runs on the same Mac as Terminal.app. It cannot detect the local keyboard over remote SSH. + ## Ghostty Add to your Ghostty config (`~/Library/Application Support/com.mitchellh.ghostty/config` on macOS, `~/.config/ghostty/config` on Linux): diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 2c5cebb6..f132efd1 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `Shift+Enter` in Apple Terminal by detecting local macOS modifier state when Terminal.app sends plain Return. + ## [0.75.5] - 2026-05-23 ### Changed diff --git a/packages/tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node b/packages/tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node new file mode 100755 index 0000000000000000000000000000000000000000..4614842699ca81944f15dff61a999878f841be28 GIT binary patch literal 50200 zcmeI5U2I%e5y$85`YW~Lrr05F6BOg3$_c@Cp%q4ImFqZm95rc@_0pK9llAVkeertN zxpz0Io1(6iwj!wl-YR@l6|jAXh^;s(bts?;a8(h3M1Y1IenPzvrxs?-Ul8)TN5 z`{Aw~S%?RuBK}93oilUh&YAh0d++X({f8S@{#iw&5swO%TUqv&57*HJNwS~KgKN^@!ix^Yx-W9AJTYNlPr`&xq ztQ*-DKGO5^Y)h?sUv}?zhLx}G~e^F{X6vq)|t-nI0O#eImbm2773;=RIic|HeNB$rcD=>#8*PR-pTcFGuP z-evY$5o@Hm(~1lkhm+Qz-Q1qEj2%{Fc)*O>O?|$<`60uy&1AxEZd($;d1c5xtt|dN zM7y|cjhfT8)f_i_Mcgf$wl&2@*pjP=?qZR8PfxiVHhH%2mGK!CS-;Ah8l{2%a1?^t z#rgf(*!4Yuv4tbSv4vMCdo8GqUk}naGaUV_-jJ=Q%+oodlk>jsr0aW@eT8z$zZswp zOX~C+dVs=z^JA_zWch4Iqb;{*-@cQwGr`*N0O#S?*z{Cj>!lf@d*3S8^IY=iHjb6` z%fXuQoUE~4IMU8FZ5x~4-!{fIW~YMcQsEWt;zugTz7k$5<$BA;7LID!d9L>w@5}e! zXoaTdMP}Dg-l_k-JDvK6xz@k?wPjr2Ao~{Uec$!NuB%ej8>&gb=!uUDT4ZN6~E+Nj+!_|57} zs9x)OL=RGUmgCI#PfU+ZOw4Wi{e`(>RWoxNwN>xs_)Pq@-<>!+xAOyDTkWp#4Ig&$ zeyQ!}@2>t<=y=nFleh88iO_FX=Pb$H_K!k%t(FUwaSdOkT!>#2wQB>p5T9G^ci86F zL#T$CZKSpDUM~O99nwSXjlxgaKRvrn&-48{x{1$2WUE&3-escj!jVoszYadf?PDLQ z_Ax$J=X^Q$$-NxECZOjjuJFDshg;?DPR%-y||y}IMqV=WT@aZFvlP*^9A0AE`- z&-0Dm{2I@{%=1KK*i=!=h@=eFmrg`e{K&da+eoPdKjbkrY$f?iIyFM3y*n8*`%J^y zYZ1aU6=o(+2-&knd!bPW82Z9NU{r#*IjV>;bbcMbYHYP$Us! zok&^9I3(100@8p2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?xfB*=9 z00@8p2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?xfB*=900@8p z2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?x zfB*=900@8p2!H?xfB*=900@ANBZsVR3v3^gjKODVPC|I zi+>IKV{vfXEwc|Svx97xu*mkZ zMLA2cubx)yXGL5~$UC5xc-*t_sNHlL%Cv=<*=I7mVau>>BSuO&al@8*NY0~Z-1uYF z6t~;JE}uy)`?&u=H3hh{fWsoe>pk9OtBGE|S|6Y)&;Bg=XO zGG@@^PhL1U^1<%s>!1A1`InA%9NVz_;Pm~Iubo=;mjh*6-*{kV;;}O?^d!%}(>GP} z+Qo~5fjvE?g-6dl`p6S+{PL-;V_SawzrKF7Q2&F;@4ocRT@~lQ@~bPa>aUNS&7VGX z_Cm?@{!{c+$H0HPzxl}@zB)NwHF3Q4*%wj8~?(zN$ D%;!fI literal 0 HcmV?d00001 diff --git a/packages/tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node b/packages/tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node new file mode 100755 index 0000000000000000000000000000000000000000..0c82b391329a43f4d241183f915217b51b8380f5 GIT binary patch literal 12776 zcmeHOZ)jUp6u;S8ZQa(SW0TEoEDWZbNV9B;Q$Lt&%l3lFoTU3By4}4bFYRM#UibcV z77>aygGUHdurLHc{M0W(aZ@)GNw-O>A9Mr7;=oVpDs_SkCL6N&JMT}IWb}*QlzZU3 zbI*C_o_p@^+?N;HlW*_bxceYufu)SG4saJZ*3MWNl4vb>HozF?y@?~ej;Z+)YF3%2 zBLwrAJUBO%*UWpqt?|al8FXxkD@kv2hwinFjWnv98>W>t7EG=2{=lY)R=R@ux~H-Z zFWv*5!HviJmExeH!`2$l0SET&3jrAOQgto&<(D%|T`l!_{;l!06WAcz70lN?wK+D7 zoLi++YAMgvQbBX{=6ZbY#XIf=AiXI+&+ZGV>lrZTd*Z!uzWc@f8MnHAJr|L5PKa?N zoKkb%r!|yy(RhA1nnd$`BK#Y5%4-hn=H+`qhP|;Ls73el^7qVnQRc?*KvpaAK~rBe z-Wo3~#c#}mj*oXF9S_d)vMKw{_vg0;^NGANFwz_2IX4?Fv`-1lc`CX8g~Xnu|7>mZ zocw)y`WyicBH;}keG_9Njtefdb_b+4#ks!|3avvZKYlxOw*35oj}jZcIQQ`CPI`XAU}q1(hY@La z+4eZ@g}^>7$Dkifv7DURKSXI_ERq>EOl2UFQnR|O4@dUu@_=$m(@z+Y-I}iK)8)Z_ zHD~k`e0$`Oq8qAKG9vLN4{sq_XJ^AYjHR&c0E<|L9x19>Vt2`&T|N0>Xzs*EAt>Jo z56`WhPWb*CIw;w{#1F?0#d|Z-=GE4r2QqVR8oIeMaMeb;(G|zbooYb{Gzv&tF)EZ5%qoS zbb^i%evqu>2k-2M^bRTN9Irb%tUg(qyWdICFt&i*Uf8Tr)eO*X9l4^G3f~*pZkFewscv z`ls1BWp0%!n0+Cq4GhSoJd>W8F*lG}GIgy;sbQ^_zS7cB*^b$V-Z%Pj1?=bP2Eq9T_V!EQ2w3~M@{8|x`w zP@}}5V)XqX21)F{`)S-r^x3u?#aIO<0=|LnWo?10N80IJ2@(hh1Ox&C0fB%(Kp-Fx z5C{ka1Ofs9fq+0jARrJB2nYlO0s;Ynz<(Bj5dLLAwBD0jJn34+RUjY`5C{ka1Ofs9 zfq+0jARrJB2nYlO0s?{m0|MQfScdkoGRCO|Ue2B1x&9Np&yw{#d+8(_i=o*y?=;)< zEc(yU96v_>I_r+{-TM+lO3BP&~! zFNpj8?+MmHhw9A5kUi<@Zo?nO)Cr553;N3~`tcT>dIsrt3A8-*MN^-6qb +#include +#include +#include +#include + +#define NAPI_AUTO_LENGTH ((size_t)-1) + +typedef void* napi_env; +typedef void* napi_value; +typedef void* napi_callback_info; +typedef napi_value (*napi_callback)(napi_env, napi_callback_info); +typedef int (*napi_create_function_fn)(napi_env, const char*, size_t, napi_callback, void*, napi_value*); +typedef int (*napi_set_named_property_fn)(napi_env, napi_value, const char*, napi_value); +typedef int (*napi_get_boolean_fn)(napi_env, bool, napi_value*); +typedef int (*napi_get_cb_info_fn)(napi_env, napi_callback_info, size_t*, napi_value*, napi_value*, void**); +typedef int (*napi_get_value_string_utf8_fn)(napi_env, napi_value, char*, size_t, size_t*); + +static void* node_symbol(const char* name) { + return dlsym(RTLD_DEFAULT, name); +} + +static CGEventFlags modifier_mask_for_name(const char* name) { + if (strcmp(name, "shift") == 0) return kCGEventFlagMaskShift; + if (strcmp(name, "command") == 0) return kCGEventFlagMaskCommand; + if (strcmp(name, "control") == 0) return kCGEventFlagMaskControl; + if (strcmp(name, "option") == 0) return kCGEventFlagMaskAlternate; + return 0; +} + +static napi_value is_modifier_pressed(napi_env env, napi_callback_info info) { + napi_get_cb_info_fn napi_get_cb_info = (napi_get_cb_info_fn)node_symbol("napi_get_cb_info"); + napi_get_value_string_utf8_fn napi_get_value_string_utf8 = (napi_get_value_string_utf8_fn)node_symbol("napi_get_value_string_utf8"); + napi_get_boolean_fn napi_get_boolean = (napi_get_boolean_fn)node_symbol("napi_get_boolean"); + + bool pressed = false; + if (napi_get_cb_info && napi_get_value_string_utf8) { + size_t argc = 1; + napi_value args[1] = {0}; + if (napi_get_cb_info(env, info, &argc, args, 0, 0) == 0 && argc >= 1 && args[0]) { + char name[16] = {0}; + size_t copied = 0; + if (napi_get_value_string_utf8(env, args[0], name, sizeof(name), &copied) == 0) { + CGEventFlags mask = modifier_mask_for_name(name); + if (mask != 0) { + CGEventFlags flags = CGEventSourceFlagsState(kCGEventSourceStateCombinedSessionState); + pressed = (flags & mask) != 0; + } + } + } + } + + napi_value result = 0; + if (napi_get_boolean) napi_get_boolean(env, pressed, &result); + return result; +} + +__attribute__((visibility("default"))) napi_value napi_register_module_v1(napi_env env, napi_value exports) { + napi_create_function_fn napi_create_function = (napi_create_function_fn)node_symbol("napi_create_function"); + napi_set_named_property_fn napi_set_named_property = (napi_set_named_property_fn)node_symbol("napi_set_named_property"); + + napi_value fn = 0; + if (napi_create_function && + napi_set_named_property && + napi_create_function(env, "isModifierPressed", NAPI_AUTO_LENGTH, is_modifier_pressed, 0, &fn) == 0) { + napi_set_named_property(env, exports, "isModifierPressed", fn); + } + + return exports; +} diff --git a/packages/tui/package.json b/packages/tui/package.json index 7d1b7f7a..848b16b9 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -13,6 +13,7 @@ "files": [ "dist/**/*", "native/win32/prebuilds/**/*.node", + "native/darwin/prebuilds/**/*.node", "README.md" ], "keywords": [ diff --git a/packages/tui/src/native-modifiers.ts b/packages/tui/src/native-modifiers.ts new file mode 100644 index 00000000..3da0aeba --- /dev/null +++ b/packages/tui/src/native-modifiers.ts @@ -0,0 +1,60 @@ +import { createRequire } from "node:module"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const cjsRequire = createRequire(import.meta.url); + +export type ModifierKey = "shift" | "command" | "control" | "option"; + +type NativeModifiersHelper = { + isModifierPressed: (name: ModifierKey) => boolean; +}; + +let nativeModifiersHelper: NativeModifiersHelper | null | undefined; + +function isNativeModifiersHelper(value: unknown): value is NativeModifiersHelper { + if (typeof value !== "object" || value === null) return false; + const candidate = (value as { isModifierPressed?: unknown }).isModifierPressed; + return typeof candidate === "function"; +} + +function loadNativeModifiersHelper(): NativeModifiersHelper | undefined { + if (nativeModifiersHelper !== undefined) return nativeModifiersHelper ?? undefined; + nativeModifiersHelper = null; + if (process.platform !== "darwin") return undefined; + const arch = process.arch; + if (arch !== "x64" && arch !== "arm64") return undefined; + + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const nativePath = path.join("native", "darwin", "prebuilds", `darwin-${arch}`, "darwin-modifiers.node"); + const candidates = [ + path.join(moduleDir, "..", nativePath), + path.join(moduleDir, nativePath), + path.join(path.dirname(process.execPath), nativePath), + ]; + + for (const modulePath of candidates) { + try { + const helper = cjsRequire(modulePath) as unknown; + if (isNativeModifiersHelper(helper)) { + nativeModifiersHelper = helper; + return helper; + } + } catch { + // Try the next possible packaging location. + } + } + + return undefined; +} + +export function isNativeModifierPressed(key: ModifierKey): boolean { + if (process.env.PI_TUI_DISABLE_NATIVE_MODIFIERS === "1") return false; + const helper = loadNativeModifiersHelper(); + if (!helper) return false; + try { + return helper.isModifierPressed(key) === true; + } catch { + return false; + } +} diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 94c56ff6..714f4d9a 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -3,6 +3,7 @@ import { createRequire } from "node:module"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { setKittyProtocolActive } from "./keys.ts"; +import { isNativeModifierPressed } from "./native-modifiers.ts"; import { StdinBuffer } from "./stdin-buffer.ts"; const cjsRequire = createRequire(import.meta.url); @@ -10,6 +11,16 @@ const cjsRequire = createRequire(import.meta.url); const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000; const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07"; const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07"; +const APPLE_TERMINAL_SHIFT_ENTER_SEQUENCE = "\x1b[13;2u"; + +export function isAppleTerminalSession(): boolean { + return process.platform === "darwin" && process.env.TERM_PROGRAM === "Apple_Terminal"; +} + +export function normalizeAppleTerminalInput(data: string, isAppleTerminal: boolean, isShiftPressed: boolean): string { + if (isAppleTerminal && data === "\r" && isShiftPressed) return APPLE_TERMINAL_SHIFT_ENTER_SEQUENCE; + return data; +} /** * Minimal terminal interface for TUI @@ -159,7 +170,13 @@ export class ProcessTerminal implements Terminal { } if (this.inputHandler) { - this.inputHandler(sequence); + const isAppleTerminal = sequence === "\r" && isAppleTerminalSession(); + const input = normalizeAppleTerminalInput( + sequence, + isAppleTerminal, + isAppleTerminal && isNativeModifierPressed("shift"), + ); + this.inputHandler(input); } }); diff --git a/packages/tui/test/terminal.test.ts b/packages/tui/test/terminal.test.ts index ab2027df..cf980251 100644 --- a/packages/tui/test/terminal.test.ts +++ b/packages/tui/test/terminal.test.ts @@ -1,6 +1,45 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { ProcessTerminal } from "../src/terminal.ts"; +import { isNativeModifierPressed } from "../src/native-modifiers.ts"; +import { normalizeAppleTerminalInput, ProcessTerminal } from "../src/terminal.ts"; + +function withEnv(name: string, value: string | undefined, fn: () => void): void { + const previous = process.env[name]; + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + try { + fn(); + } finally { + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + } +} + +describe("normalizeAppleTerminalInput", () => { + it("rewrites Apple Terminal Return to CSI-u Shift+Enter when Shift is pressed", () => { + assert.equal(normalizeAppleTerminalInput("\r", true, true), "\x1b[13;2u"); + }); + + it("leaves Apple Terminal Return unchanged when Shift is not pressed", () => { + assert.equal(normalizeAppleTerminalInput("\r", true, false), "\r"); + }); + + it("leaves non-Apple Terminal Return unchanged when Shift is pressed", () => { + assert.equal(normalizeAppleTerminalInput("\r", false, true), "\r"); + }); + + it("leaves non-Return input unchanged", () => { + assert.equal(normalizeAppleTerminalInput("\x1b[13;2u", true, true), "\x1b[13;2u"); + assert.equal(normalizeAppleTerminalInput("a", true, true), "a"); + }); + + it("treats native helper failure as Shift not pressed", () => { + withEnv("PI_TUI_DISABLE_NATIVE_MODIFIERS", "1", () => { + assert.equal(isNativeModifierPressed("shift"), false); + assert.equal(normalizeAppleTerminalInput("\r", true, isNativeModifierPressed("shift")), "\r"); + }); + }); +}); describe("ProcessTerminal dimensions", () => { it("falls back to COLUMNS and LINES before default dimensions", () => { diff --git a/scripts/build-binaries.sh b/scripts/build-binaries.sh index 3fed7647..628097a7 100755 --- a/scripts/build-binaries.sh +++ b/scripts/build-binaries.sh @@ -178,7 +178,11 @@ for platform in "${PLATFORMS[@]}"; do cp -r ../../node_modules/@mariozechner/clipboard "$OUTPUT_DIR/$platform/node_modules/@mariozechner/" cp -r ../../node_modules/@mariozechner/$clipboard_native_package "$OUTPUT_DIR/$platform/node_modules/@mariozechner/" - # Copy Windows VT input native helper next to compiled Windows binaries. + # Copy terminal input native helpers next to compiled binaries. + if [[ "$platform" == darwin-* ]]; then + mkdir -p "$OUTPUT_DIR/$platform/native/darwin/prebuilds/$platform" + cp ../tui/native/darwin/prebuilds/$platform/darwin-modifiers.node "$OUTPUT_DIR/$platform/native/darwin/prebuilds/$platform/" + fi if [[ "$platform" == windows-* ]]; then if [[ "$platform" == "windows-arm64" ]]; then win32_arch_dir="win32-arm64" From 30b3ab3532a2c6ca1e2c6a2662479667a7426857 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 23 May 2026 15:32:38 +0200 Subject: [PATCH 026/352] fix(tui): remove native modifier escape hatch --- packages/tui/src/native-modifiers.ts | 1 - packages/tui/test/terminal.test.ts | 20 -------------------- 2 files changed, 21 deletions(-) diff --git a/packages/tui/src/native-modifiers.ts b/packages/tui/src/native-modifiers.ts index 3da0aeba..e2cd631c 100644 --- a/packages/tui/src/native-modifiers.ts +++ b/packages/tui/src/native-modifiers.ts @@ -49,7 +49,6 @@ function loadNativeModifiersHelper(): NativeModifiersHelper | undefined { } export function isNativeModifierPressed(key: ModifierKey): boolean { - if (process.env.PI_TUI_DISABLE_NATIVE_MODIFIERS === "1") return false; const helper = loadNativeModifiersHelper(); if (!helper) return false; try { diff --git a/packages/tui/test/terminal.test.ts b/packages/tui/test/terminal.test.ts index cf980251..a1990135 100644 --- a/packages/tui/test/terminal.test.ts +++ b/packages/tui/test/terminal.test.ts @@ -1,20 +1,7 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { isNativeModifierPressed } from "../src/native-modifiers.ts"; import { normalizeAppleTerminalInput, ProcessTerminal } from "../src/terminal.ts"; -function withEnv(name: string, value: string | undefined, fn: () => void): void { - const previous = process.env[name]; - if (value === undefined) delete process.env[name]; - else process.env[name] = value; - try { - fn(); - } finally { - if (previous === undefined) delete process.env[name]; - else process.env[name] = previous; - } -} - describe("normalizeAppleTerminalInput", () => { it("rewrites Apple Terminal Return to CSI-u Shift+Enter when Shift is pressed", () => { assert.equal(normalizeAppleTerminalInput("\r", true, true), "\x1b[13;2u"); @@ -32,13 +19,6 @@ describe("normalizeAppleTerminalInput", () => { assert.equal(normalizeAppleTerminalInput("\x1b[13;2u", true, true), "\x1b[13;2u"); assert.equal(normalizeAppleTerminalInput("a", true, true), "a"); }); - - it("treats native helper failure as Shift not pressed", () => { - withEnv("PI_TUI_DISABLE_NATIVE_MODIFIERS", "1", () => { - assert.equal(isNativeModifierPressed("shift"), false); - assert.equal(normalizeAppleTerminalInput("\r", true, isNativeModifierPressed("shift")), "\r"); - }); - }); }); describe("ProcessTerminal dimensions", () => { From d0d1d8edca141d1d1e3a6c841cb3c567db1fdb36 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 24 May 2026 11:28:02 +0200 Subject: [PATCH 027/352] fix(rpc): respect stdout backpressure closes #4897 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/sdk.md | 4 +- .../coding-agent/src/core/agent-session.ts | 80 +++++++------ .../coding-agent/src/core/output-guard.ts | 11 +- packages/coding-agent/src/modes/print-mode.ts | 8 +- .../coding-agent/src/modes/rpc/rpc-mode.ts | 110 +++++++++++++----- 6 files changed, 134 insertions(+), 80 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c7ae064a..1a64aecb 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed RPC mode to respect stdout backpressure while streaming events, avoiding `ENOBUFS` crashes when clients drain stdout slowly ([#4897](https://github.com/earendil-works/pi/issues/4897)). - Fixed managed npm extension updates to avoid package managers installing or resolving pi host packages as peer dependencies ([#4907](https://github.com/earendil-works/pi/issues/4907)). ## [0.75.5] - 2026-05-23 diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index 8377185e..a6d8974e 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -81,7 +81,7 @@ interface AgentSession { followUp(text: string): Promise; // Subscribe to events (returns unsubscribe function) - subscribe(listener: (event: AgentSessionEvent) => void): () => void; + subscribe(listener: (event: AgentSessionEvent) => void | Promise): () => void; // Session info sessionFile: string | undefined; @@ -191,7 +191,7 @@ interface PromptOptions { images?: ImageContent[]; streamingBehavior?: "steer" | "followUp"; source?: InputSource; - preflightResult?: (success: boolean) => void; + preflightResult?: (success: boolean) => void | Promise; } ``` diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index cf13594b..2c0b126a 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -147,7 +147,7 @@ export type AgentSessionEvent = | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }; /** Listener function for agent session events */ -export type AgentSessionEventListener = (event: AgentSessionEvent) => void; +export type AgentSessionEventListener = (event: AgentSessionEvent) => void | Promise; // ============================================================================ // Types @@ -202,7 +202,7 @@ export interface PromptOptions { /** Source of input for extension input event handlers. Defaults to "interactive". */ source?: InputSource; /** Internal hook used by RPC mode to observe prompt preflight acceptance or rejection. */ - preflightResult?: (success: boolean) => void; + preflightResult?: (success: boolean) => void | Promise; } /** Result from cycleModel() */ @@ -448,14 +448,18 @@ export class AgentSession { // ========================================================================= /** Emit an event to all listeners */ - private _emit(event: AgentSessionEvent): void { + private async _emit(event: AgentSessionEvent): Promise { for (const l of this._eventListeners) { - l(event); + await l(event); } } - private _emitQueueUpdate(): void { - this._emit({ + private _emitDetached(event: AgentSessionEvent): void { + void this._emit(event); + } + + private async _emitQueueUpdate(): Promise { + await this._emit({ type: "queue_update", steering: [...this._steeringMessages], followUp: [...this._followUpMessages], @@ -477,13 +481,13 @@ export class AgentSession { const steeringIndex = this._steeringMessages.indexOf(messageText); if (steeringIndex !== -1) { this._steeringMessages.splice(steeringIndex, 1); - this._emitQueueUpdate(); + await this._emitQueueUpdate(); } else { // Check follow-up queue const followUpIndex = this._followUpMessages.indexOf(messageText); if (followUpIndex !== -1) { this._followUpMessages.splice(followUpIndex, 1); - this._emitQueueUpdate(); + await this._emitQueueUpdate(); } } } @@ -493,7 +497,9 @@ export class AgentSession { await this._emitExtensionEvent(event); // Notify all listeners - this._emit(event.type === "agent_end" ? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event); + await this._emit( + event.type === "agent_end" ? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event, + ); // Handle session persistence if (event.type === "message_end") { @@ -528,7 +534,7 @@ export class AgentSession { // Reset retry counter immediately on successful assistant response // This prevents accumulation across multiple LLM calls within a turn if (assistantMsg.stopReason !== "error" && this._retryAttempt > 0) { - this._emit({ + await this._emit({ type: "auto_retry_end", success: true, attempt: this._retryAttempt, @@ -938,7 +944,7 @@ export class AgentSession { } if (msg.stopReason === "error" && this._retryAttempt > 0) { - this._emit({ + await this._emit({ type: "auto_retry_end", success: false, attempt: this._retryAttempt, @@ -971,7 +977,7 @@ export class AgentSession { const handled = await this._tryExecuteExtensionCommand(text); if (handled) { // Extension command executed, no prompt to send - preflightResult?.(true); + await preflightResult?.(true); return; } } @@ -986,7 +992,7 @@ export class AgentSession { options?.source ?? "interactive", ); if (inputResult.action === "handled") { - preflightResult?.(true); + await preflightResult?.(true); return; } if (inputResult.action === "transform") { @@ -1014,7 +1020,7 @@ export class AgentSession { } else { await this._queueSteer(expandedText, currentImages); } - preflightResult?.(true); + await preflightResult?.(true); return; } @@ -1099,7 +1105,7 @@ export class AgentSession { this.agent.state.systemPrompt = this._baseSystemPrompt; } } catch (error) { - preflightResult?.(false); + await preflightResult?.(false); throw error; } @@ -1107,7 +1113,7 @@ export class AgentSession { return; } - preflightResult?.(true); + await preflightResult?.(true); await this._runAgentPrompt(messages); } @@ -1217,7 +1223,7 @@ export class AgentSession { */ private async _queueSteer(text: string, images?: ImageContent[]): Promise { this._steeringMessages.push(text); - this._emitQueueUpdate(); + await this._emitQueueUpdate(); const content: (TextContent | ImageContent)[] = [{ type: "text", text }]; if (images) { content.push(...images); @@ -1234,7 +1240,7 @@ export class AgentSession { */ private async _queueFollowUp(text: string, images?: ImageContent[]): Promise { this._followUpMessages.push(text); - this._emitQueueUpdate(); + await this._emitQueueUpdate(); const content: (TextContent | ImageContent)[] = [{ type: "text", text }]; if (images) { content.push(...images); @@ -1303,8 +1309,8 @@ export class AgentSession { message.display, message.details, ); - this._emit({ type: "message_start", message: appMessage }); - this._emit({ type: "message_end", message: appMessage }); + await this._emit({ type: "message_start", message: appMessage }); + await this._emit({ type: "message_end", message: appMessage }); } } @@ -1359,7 +1365,7 @@ export class AgentSession { this._steeringMessages = []; this._followUpMessages = []; this.agent.clearAllQueues(); - this._emitQueueUpdate(); + void this._emitQueueUpdate(); return { steering, followUp }; } @@ -1522,7 +1528,7 @@ export class AgentSession { if (this.supportsThinking() || effectiveLevel !== "off") { this.settingsManager.setDefaultThinkingLevel(effectiveLevel); } - this._emit({ type: "thinking_level_changed", level: effectiveLevel }); + this._emitDetached({ type: "thinking_level_changed", level: effectiveLevel }); void this._extensionRunner.emit({ type: "thinking_level_select", level: effectiveLevel, @@ -1612,7 +1618,7 @@ export class AgentSession { this._disconnectFromAgent(); await this.abort(); this._compactionAbortController = new AbortController(); - this._emit({ type: "compaction_start", reason: "manual" }); + await this._emit({ type: "compaction_start", reason: "manual" }); try { if (!this.model) { @@ -1713,7 +1719,7 @@ export class AgentSession { tokensBefore, details, }; - this._emit({ + await this._emit({ type: "compaction_end", reason: "manual", result: compactionResult, @@ -1724,7 +1730,7 @@ export class AgentSession { } catch (error) { const message = error instanceof Error ? error.message : String(error); const aborted = message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError"); - this._emit({ + await this._emit({ type: "compaction_end", reason: "manual", result: undefined, @@ -1794,7 +1800,7 @@ export class AgentSession { // Case 1: Overflow - LLM returned context overflow error if (sameModel && isContextOverflow(assistantMessage, contextWindow)) { if (this._overflowRecoveryAttempted) { - this._emit({ + await this._emit({ type: "compaction_end", reason: "overflow", result: undefined, @@ -1851,12 +1857,12 @@ export class AgentSession { private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise { const settings = this.settingsManager.getCompactionSettings(); - this._emit({ type: "compaction_start", reason }); + await this._emit({ type: "compaction_start", reason }); this._autoCompactionAbortController = new AbortController(); try { if (!this.model) { - this._emit({ + await this._emit({ type: "compaction_end", reason, result: undefined, @@ -1871,7 +1877,7 @@ export class AgentSession { if (this.agent.streamFn === streamSimple) { const authResult = await this._modelRegistry.getApiKeyAndHeaders(this.model); if (!authResult.ok || !authResult.apiKey) { - this._emit({ + await this._emit({ type: "compaction_end", reason, result: undefined, @@ -1890,7 +1896,7 @@ export class AgentSession { const preparation = prepareCompaction(pathEntries, settings); if (!preparation) { - this._emit({ + await this._emit({ type: "compaction_end", reason, result: undefined, @@ -1913,7 +1919,7 @@ export class AgentSession { })) as SessionBeforeCompactResult | undefined; if (extensionResult?.cancel) { - this._emit({ + await this._emit({ type: "compaction_end", reason, result: undefined, @@ -1959,7 +1965,7 @@ export class AgentSession { } if (this._autoCompactionAbortController.signal.aborted) { - this._emit({ + await this._emit({ type: "compaction_end", reason, result: undefined, @@ -1993,7 +1999,7 @@ export class AgentSession { tokensBefore, details, }; - this._emit({ type: "compaction_end", reason, result, aborted: false, willRetry }); + await this._emit({ type: "compaction_end", reason, result, aborted: false, willRetry }); if (willRetry) { const messages = this.agent.state.messages; @@ -2009,7 +2015,7 @@ export class AgentSession { return this.agent.hasQueuedMessages(); } catch (error) { const errorMessage = error instanceof Error ? error.message : "compaction failed"; - this._emit({ + await this._emit({ type: "compaction_end", reason, result: undefined, @@ -2460,7 +2466,7 @@ export class AgentSession { const delayMs = settings.baseDelayMs * 2 ** (this._retryAttempt - 1); - this._emit({ + await this._emit({ type: "auto_retry_start", attempt: this._retryAttempt, maxAttempts: settings.maxRetries, @@ -2482,7 +2488,7 @@ export class AgentSession { // Aborted during sleep - emit end event so UI can clean up const attempt = this._retryAttempt; this._retryAttempt = 0; - this._emit({ + await this._emit({ type: "auto_retry_end", success: false, attempt, @@ -2636,7 +2642,7 @@ export class AgentSession { */ setSessionName(name: string): void { this.sessionManager.appendSessionInfo(name); - this._emit({ type: "session_info_changed", name: this.sessionManager.getSessionName() }); + this._emitDetached({ type: "session_info_changed", name: this.sessionManager.getSessionName() }); } // ========================================================================= diff --git a/packages/coding-agent/src/core/output-guard.ts b/packages/coding-agent/src/core/output-guard.ts index c0783760..40857f69 100644 --- a/packages/coding-agent/src/core/output-guard.ts +++ b/packages/coding-agent/src/core/output-guard.ts @@ -1,3 +1,5 @@ +import { once } from "node:events"; + interface StdoutTakeoverState { rawStdoutWrite: (chunk: string, callback?: (error?: Error | null) => void) => boolean; rawStderrWrite: (chunk: string, callback?: (error?: Error | null) => void) => boolean; @@ -46,12 +48,11 @@ export function isStdoutTakenOver(): boolean { return stdoutTakeoverState !== undefined; } -export function writeRawStdout(text: string): void { - if (stdoutTakeoverState) { - stdoutTakeoverState.rawStdoutWrite(text); - return; +export async function writeRawStdout(text: string): Promise { + const canContinue = stdoutTakeoverState ? stdoutTakeoverState.rawStdoutWrite(text) : process.stdout.write(text); + if (!canContinue) { + await once(process.stdout, "drain"); } - process.stdout.write(text); } export async function flushRawStdout(): Promise { diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index c9553c55..7a11d0fc 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -100,9 +100,9 @@ export async function runPrintMode(runtimeHost: AgentSessionRuntime, options: Pr }); unsubscribe?.(); - unsubscribe = session.subscribe((event) => { + unsubscribe = session.subscribe(async (event) => { if (mode === "json") { - writeRawStdout(`${JSON.stringify(event)}\n`); + await writeRawStdout(`${JSON.stringify(event)}\n`); } }); }; @@ -111,7 +111,7 @@ export async function runPrintMode(runtimeHost: AgentSessionRuntime, options: Pr if (mode === "json") { const header = session.sessionManager.getHeader(); if (header) { - writeRawStdout(`${JSON.stringify(header)}\n`); + await writeRawStdout(`${JSON.stringify(header)}\n`); } } @@ -137,7 +137,7 @@ export async function runPrintMode(runtimeHost: AgentSessionRuntime, options: Pr } else { for (const content of assistantMsg.content) { if (content.type === "text") { - writeRawStdout(`${content.text}\n`); + await writeRawStdout(`${content.text}\n`); } } } diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 828c9f67..59882a38 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -50,8 +50,14 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise void) | undefined; - const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => { - writeRawStdout(serializeJsonLine(obj)); + const output = async (obj: RpcResponse | RpcExtensionUIRequest | object): Promise => { + await writeRawStdout(serializeJsonLine(obj)); + }; + + const outputDetached = (obj: RpcResponse | RpcExtensionUIRequest | object): void => { + void output(obj).catch((err: unknown) => { + process.stderr.write(`RPC output failed: ${err instanceof Error ? err.message : String(err)}\n`); + }); }; const success = ( @@ -81,28 +87,30 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise void> = []; /** Helper for dialog methods with signal/timeout support */ - function createDialogPromise( + async function createDialogPromise( opts: ExtensionUIDialogOptions | undefined, defaultValue: T, request: Record, parseResponse: (response: RpcExtensionUIResponse) => T, ): Promise { - if (opts?.signal?.aborted) return Promise.resolve(defaultValue); + if (opts?.signal?.aborted) return defaultValue; const id = crypto.randomUUID(); - return new Promise((resolve, reject) => { + let cleanup = () => {}; + const responsePromise = new Promise((resolve, reject) => { let timeoutId: ReturnType | undefined; - const cleanup = () => { - if (timeoutId) clearTimeout(timeoutId); - opts?.signal?.removeEventListener("abort", onAbort); - pendingExtensionRequests.delete(id); - }; - const onAbort = () => { cleanup(); resolve(defaultValue); }; + + cleanup = () => { + if (timeoutId) clearTimeout(timeoutId); + opts?.signal?.removeEventListener("abort", onAbort); + pendingExtensionRequests.delete(id); + }; + opts?.signal?.addEventListener("abort", onAbort, { once: true }); if (opts?.timeout) { @@ -117,10 +125,20 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { + cleanup(); + reject(error); + }, }); - output({ type: "extension_ui_request", id, ...request } as RpcExtensionUIRequest); }); + + try { + await output({ type: "extension_ui_request", id, ...request } as RpcExtensionUIRequest); + } catch (err) { + cleanup(); + throw err; + } + return await responsePromise; } /** @@ -144,7 +162,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { const id = crypto.randomUUID(); - return new Promise((resolve, reject) => { + let cleanup = () => {}; + const responsePromise = new Promise((resolve, reject) => { + cleanup = () => { + pendingExtensionRequests.delete(id); + }; pendingExtensionRequests.set(id, { resolve: (response: RpcExtensionUIResponse) => { + cleanup(); if ("cancelled" in response && response.cancelled) { resolve(undefined); } else if ("value" in response) { @@ -257,10 +280,25 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { + cleanup(); + reject(error); + }, }); - output({ type: "extension_ui_request", id, method: "editor", title, prefill } as RpcExtensionUIRequest); }); + try { + await output({ + type: "extension_ui_request", + id, + method: "editor", + title, + prefill, + } as RpcExtensionUIRequest); + } catch (err) { + cleanup(); + throw err; + } + return await responsePromise; }, addAutocompleteProvider(): void { @@ -338,13 +376,18 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { - output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error }); + outputDetached({ + type: "extension_error", + extensionPath: err.extensionPath, + event: err.event, + error: err.error, + }); }, }); unsubscribe?.(); - unsubscribe = session.subscribe((event) => { - output(event); + unsubscribe = session.subscribe(async (event) => { + await output(event); }); }; @@ -385,16 +428,17 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { + preflightResult: async (didSucceed) => { if (didSucceed) { + await output(success(id, "prompt")); preflightSucceeded = true; - output(success(id, "prompt")); } }, }) - .catch((e) => { + .catch((err: unknown) => { if (!preflightSucceeded) { - output(error(id, "prompt", e.message)); + const message = err instanceof Error ? err.message : String(err); + outputDetached(error(id, "prompt", message)); } }); return undefined; @@ -690,7 +734,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { const detachJsonl = attachJsonlLineReader(process.stdin, (line) => { - void handleInputLine(line); + void handleInputLine(line).catch((err: unknown) => { + process.stderr.write(`RPC command handling failed: ${err instanceof Error ? err.message : String(err)}\n`); + }); }); return () => { detachJsonl(); From 9600ded92253ae0aba564481caafbe5ef31dfc8f Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 24 May 2026 11:50:53 +0200 Subject: [PATCH 028/352] revert: fix rpc stdout backpressure This reverts commit d0d1d8edca141d1d1e3a6c841cb3c567db1fdb36. --- packages/coding-agent/CHANGELOG.md | 1 - packages/coding-agent/docs/sdk.md | 4 +- .../coding-agent/src/core/agent-session.ts | 80 ++++++------- .../coding-agent/src/core/output-guard.ts | 11 +- packages/coding-agent/src/modes/print-mode.ts | 8 +- .../coding-agent/src/modes/rpc/rpc-mode.ts | 108 +++++------------- 6 files changed, 79 insertions(+), 133 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1a64aecb..c7ae064a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,7 +4,6 @@ ### Fixed -- Fixed RPC mode to respect stdout backpressure while streaming events, avoiding `ENOBUFS` crashes when clients drain stdout slowly ([#4897](https://github.com/earendil-works/pi/issues/4897)). - Fixed managed npm extension updates to avoid package managers installing or resolving pi host packages as peer dependencies ([#4907](https://github.com/earendil-works/pi/issues/4907)). ## [0.75.5] - 2026-05-23 diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index a6d8974e..8377185e 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -81,7 +81,7 @@ interface AgentSession { followUp(text: string): Promise; // Subscribe to events (returns unsubscribe function) - subscribe(listener: (event: AgentSessionEvent) => void | Promise): () => void; + subscribe(listener: (event: AgentSessionEvent) => void): () => void; // Session info sessionFile: string | undefined; @@ -191,7 +191,7 @@ interface PromptOptions { images?: ImageContent[]; streamingBehavior?: "steer" | "followUp"; source?: InputSource; - preflightResult?: (success: boolean) => void | Promise; + preflightResult?: (success: boolean) => void; } ``` diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 2c0b126a..cf13594b 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -147,7 +147,7 @@ export type AgentSessionEvent = | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }; /** Listener function for agent session events */ -export type AgentSessionEventListener = (event: AgentSessionEvent) => void | Promise; +export type AgentSessionEventListener = (event: AgentSessionEvent) => void; // ============================================================================ // Types @@ -202,7 +202,7 @@ export interface PromptOptions { /** Source of input for extension input event handlers. Defaults to "interactive". */ source?: InputSource; /** Internal hook used by RPC mode to observe prompt preflight acceptance or rejection. */ - preflightResult?: (success: boolean) => void | Promise; + preflightResult?: (success: boolean) => void; } /** Result from cycleModel() */ @@ -448,18 +448,14 @@ export class AgentSession { // ========================================================================= /** Emit an event to all listeners */ - private async _emit(event: AgentSessionEvent): Promise { + private _emit(event: AgentSessionEvent): void { for (const l of this._eventListeners) { - await l(event); + l(event); } } - private _emitDetached(event: AgentSessionEvent): void { - void this._emit(event); - } - - private async _emitQueueUpdate(): Promise { - await this._emit({ + private _emitQueueUpdate(): void { + this._emit({ type: "queue_update", steering: [...this._steeringMessages], followUp: [...this._followUpMessages], @@ -481,13 +477,13 @@ export class AgentSession { const steeringIndex = this._steeringMessages.indexOf(messageText); if (steeringIndex !== -1) { this._steeringMessages.splice(steeringIndex, 1); - await this._emitQueueUpdate(); + this._emitQueueUpdate(); } else { // Check follow-up queue const followUpIndex = this._followUpMessages.indexOf(messageText); if (followUpIndex !== -1) { this._followUpMessages.splice(followUpIndex, 1); - await this._emitQueueUpdate(); + this._emitQueueUpdate(); } } } @@ -497,9 +493,7 @@ export class AgentSession { await this._emitExtensionEvent(event); // Notify all listeners - await this._emit( - event.type === "agent_end" ? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event, - ); + this._emit(event.type === "agent_end" ? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event); // Handle session persistence if (event.type === "message_end") { @@ -534,7 +528,7 @@ export class AgentSession { // Reset retry counter immediately on successful assistant response // This prevents accumulation across multiple LLM calls within a turn if (assistantMsg.stopReason !== "error" && this._retryAttempt > 0) { - await this._emit({ + this._emit({ type: "auto_retry_end", success: true, attempt: this._retryAttempt, @@ -944,7 +938,7 @@ export class AgentSession { } if (msg.stopReason === "error" && this._retryAttempt > 0) { - await this._emit({ + this._emit({ type: "auto_retry_end", success: false, attempt: this._retryAttempt, @@ -977,7 +971,7 @@ export class AgentSession { const handled = await this._tryExecuteExtensionCommand(text); if (handled) { // Extension command executed, no prompt to send - await preflightResult?.(true); + preflightResult?.(true); return; } } @@ -992,7 +986,7 @@ export class AgentSession { options?.source ?? "interactive", ); if (inputResult.action === "handled") { - await preflightResult?.(true); + preflightResult?.(true); return; } if (inputResult.action === "transform") { @@ -1020,7 +1014,7 @@ export class AgentSession { } else { await this._queueSteer(expandedText, currentImages); } - await preflightResult?.(true); + preflightResult?.(true); return; } @@ -1105,7 +1099,7 @@ export class AgentSession { this.agent.state.systemPrompt = this._baseSystemPrompt; } } catch (error) { - await preflightResult?.(false); + preflightResult?.(false); throw error; } @@ -1113,7 +1107,7 @@ export class AgentSession { return; } - await preflightResult?.(true); + preflightResult?.(true); await this._runAgentPrompt(messages); } @@ -1223,7 +1217,7 @@ export class AgentSession { */ private async _queueSteer(text: string, images?: ImageContent[]): Promise { this._steeringMessages.push(text); - await this._emitQueueUpdate(); + this._emitQueueUpdate(); const content: (TextContent | ImageContent)[] = [{ type: "text", text }]; if (images) { content.push(...images); @@ -1240,7 +1234,7 @@ export class AgentSession { */ private async _queueFollowUp(text: string, images?: ImageContent[]): Promise { this._followUpMessages.push(text); - await this._emitQueueUpdate(); + this._emitQueueUpdate(); const content: (TextContent | ImageContent)[] = [{ type: "text", text }]; if (images) { content.push(...images); @@ -1309,8 +1303,8 @@ export class AgentSession { message.display, message.details, ); - await this._emit({ type: "message_start", message: appMessage }); - await this._emit({ type: "message_end", message: appMessage }); + this._emit({ type: "message_start", message: appMessage }); + this._emit({ type: "message_end", message: appMessage }); } } @@ -1365,7 +1359,7 @@ export class AgentSession { this._steeringMessages = []; this._followUpMessages = []; this.agent.clearAllQueues(); - void this._emitQueueUpdate(); + this._emitQueueUpdate(); return { steering, followUp }; } @@ -1528,7 +1522,7 @@ export class AgentSession { if (this.supportsThinking() || effectiveLevel !== "off") { this.settingsManager.setDefaultThinkingLevel(effectiveLevel); } - this._emitDetached({ type: "thinking_level_changed", level: effectiveLevel }); + this._emit({ type: "thinking_level_changed", level: effectiveLevel }); void this._extensionRunner.emit({ type: "thinking_level_select", level: effectiveLevel, @@ -1618,7 +1612,7 @@ export class AgentSession { this._disconnectFromAgent(); await this.abort(); this._compactionAbortController = new AbortController(); - await this._emit({ type: "compaction_start", reason: "manual" }); + this._emit({ type: "compaction_start", reason: "manual" }); try { if (!this.model) { @@ -1719,7 +1713,7 @@ export class AgentSession { tokensBefore, details, }; - await this._emit({ + this._emit({ type: "compaction_end", reason: "manual", result: compactionResult, @@ -1730,7 +1724,7 @@ export class AgentSession { } catch (error) { const message = error instanceof Error ? error.message : String(error); const aborted = message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError"); - await this._emit({ + this._emit({ type: "compaction_end", reason: "manual", result: undefined, @@ -1800,7 +1794,7 @@ export class AgentSession { // Case 1: Overflow - LLM returned context overflow error if (sameModel && isContextOverflow(assistantMessage, contextWindow)) { if (this._overflowRecoveryAttempted) { - await this._emit({ + this._emit({ type: "compaction_end", reason: "overflow", result: undefined, @@ -1857,12 +1851,12 @@ export class AgentSession { private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise { const settings = this.settingsManager.getCompactionSettings(); - await this._emit({ type: "compaction_start", reason }); + this._emit({ type: "compaction_start", reason }); this._autoCompactionAbortController = new AbortController(); try { if (!this.model) { - await this._emit({ + this._emit({ type: "compaction_end", reason, result: undefined, @@ -1877,7 +1871,7 @@ export class AgentSession { if (this.agent.streamFn === streamSimple) { const authResult = await this._modelRegistry.getApiKeyAndHeaders(this.model); if (!authResult.ok || !authResult.apiKey) { - await this._emit({ + this._emit({ type: "compaction_end", reason, result: undefined, @@ -1896,7 +1890,7 @@ export class AgentSession { const preparation = prepareCompaction(pathEntries, settings); if (!preparation) { - await this._emit({ + this._emit({ type: "compaction_end", reason, result: undefined, @@ -1919,7 +1913,7 @@ export class AgentSession { })) as SessionBeforeCompactResult | undefined; if (extensionResult?.cancel) { - await this._emit({ + this._emit({ type: "compaction_end", reason, result: undefined, @@ -1965,7 +1959,7 @@ export class AgentSession { } if (this._autoCompactionAbortController.signal.aborted) { - await this._emit({ + this._emit({ type: "compaction_end", reason, result: undefined, @@ -1999,7 +1993,7 @@ export class AgentSession { tokensBefore, details, }; - await this._emit({ type: "compaction_end", reason, result, aborted: false, willRetry }); + this._emit({ type: "compaction_end", reason, result, aborted: false, willRetry }); if (willRetry) { const messages = this.agent.state.messages; @@ -2015,7 +2009,7 @@ export class AgentSession { return this.agent.hasQueuedMessages(); } catch (error) { const errorMessage = error instanceof Error ? error.message : "compaction failed"; - await this._emit({ + this._emit({ type: "compaction_end", reason, result: undefined, @@ -2466,7 +2460,7 @@ export class AgentSession { const delayMs = settings.baseDelayMs * 2 ** (this._retryAttempt - 1); - await this._emit({ + this._emit({ type: "auto_retry_start", attempt: this._retryAttempt, maxAttempts: settings.maxRetries, @@ -2488,7 +2482,7 @@ export class AgentSession { // Aborted during sleep - emit end event so UI can clean up const attempt = this._retryAttempt; this._retryAttempt = 0; - await this._emit({ + this._emit({ type: "auto_retry_end", success: false, attempt, @@ -2642,7 +2636,7 @@ export class AgentSession { */ setSessionName(name: string): void { this.sessionManager.appendSessionInfo(name); - this._emitDetached({ type: "session_info_changed", name: this.sessionManager.getSessionName() }); + this._emit({ type: "session_info_changed", name: this.sessionManager.getSessionName() }); } // ========================================================================= diff --git a/packages/coding-agent/src/core/output-guard.ts b/packages/coding-agent/src/core/output-guard.ts index 40857f69..c0783760 100644 --- a/packages/coding-agent/src/core/output-guard.ts +++ b/packages/coding-agent/src/core/output-guard.ts @@ -1,5 +1,3 @@ -import { once } from "node:events"; - interface StdoutTakeoverState { rawStdoutWrite: (chunk: string, callback?: (error?: Error | null) => void) => boolean; rawStderrWrite: (chunk: string, callback?: (error?: Error | null) => void) => boolean; @@ -48,11 +46,12 @@ export function isStdoutTakenOver(): boolean { return stdoutTakeoverState !== undefined; } -export async function writeRawStdout(text: string): Promise { - const canContinue = stdoutTakeoverState ? stdoutTakeoverState.rawStdoutWrite(text) : process.stdout.write(text); - if (!canContinue) { - await once(process.stdout, "drain"); +export function writeRawStdout(text: string): void { + if (stdoutTakeoverState) { + stdoutTakeoverState.rawStdoutWrite(text); + return; } + process.stdout.write(text); } export async function flushRawStdout(): Promise { diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 7a11d0fc..c9553c55 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -100,9 +100,9 @@ export async function runPrintMode(runtimeHost: AgentSessionRuntime, options: Pr }); unsubscribe?.(); - unsubscribe = session.subscribe(async (event) => { + unsubscribe = session.subscribe((event) => { if (mode === "json") { - await writeRawStdout(`${JSON.stringify(event)}\n`); + writeRawStdout(`${JSON.stringify(event)}\n`); } }); }; @@ -111,7 +111,7 @@ export async function runPrintMode(runtimeHost: AgentSessionRuntime, options: Pr if (mode === "json") { const header = session.sessionManager.getHeader(); if (header) { - await writeRawStdout(`${JSON.stringify(header)}\n`); + writeRawStdout(`${JSON.stringify(header)}\n`); } } @@ -137,7 +137,7 @@ export async function runPrintMode(runtimeHost: AgentSessionRuntime, options: Pr } else { for (const content of assistantMsg.content) { if (content.type === "text") { - await writeRawStdout(`${content.text}\n`); + writeRawStdout(`${content.text}\n`); } } } diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 59882a38..828c9f67 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -50,14 +50,8 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise void) | undefined; - const output = async (obj: RpcResponse | RpcExtensionUIRequest | object): Promise => { - await writeRawStdout(serializeJsonLine(obj)); - }; - - const outputDetached = (obj: RpcResponse | RpcExtensionUIRequest | object): void => { - void output(obj).catch((err: unknown) => { - process.stderr.write(`RPC output failed: ${err instanceof Error ? err.message : String(err)}\n`); - }); + const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => { + writeRawStdout(serializeJsonLine(obj)); }; const success = ( @@ -87,30 +81,28 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise void> = []; /** Helper for dialog methods with signal/timeout support */ - async function createDialogPromise( + function createDialogPromise( opts: ExtensionUIDialogOptions | undefined, defaultValue: T, request: Record, parseResponse: (response: RpcExtensionUIResponse) => T, ): Promise { - if (opts?.signal?.aborted) return defaultValue; + if (opts?.signal?.aborted) return Promise.resolve(defaultValue); const id = crypto.randomUUID(); - let cleanup = () => {}; - const responsePromise = new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { let timeoutId: ReturnType | undefined; - const onAbort = () => { - cleanup(); - resolve(defaultValue); - }; - - cleanup = () => { + const cleanup = () => { if (timeoutId) clearTimeout(timeoutId); opts?.signal?.removeEventListener("abort", onAbort); pendingExtensionRequests.delete(id); }; + const onAbort = () => { + cleanup(); + resolve(defaultValue); + }; opts?.signal?.addEventListener("abort", onAbort, { once: true }); if (opts?.timeout) { @@ -125,20 +117,10 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { - cleanup(); - reject(error); - }, + reject, }); + output({ type: "extension_ui_request", id, ...request } as RpcExtensionUIRequest); }); - - try { - await output({ type: "extension_ui_request", id, ...request } as RpcExtensionUIRequest); - } catch (err) { - cleanup(); - throw err; - } - return await responsePromise; } /** @@ -162,7 +144,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { const id = crypto.randomUUID(); - let cleanup = () => {}; - const responsePromise = new Promise((resolve, reject) => { - cleanup = () => { - pendingExtensionRequests.delete(id); - }; + return new Promise((resolve, reject) => { pendingExtensionRequests.set(id, { resolve: (response: RpcExtensionUIResponse) => { - cleanup(); if ("cancelled" in response && response.cancelled) { resolve(undefined); } else if ("value" in response) { @@ -280,25 +257,10 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { - cleanup(); - reject(error); - }, + reject, }); + output({ type: "extension_ui_request", id, method: "editor", title, prefill } as RpcExtensionUIRequest); }); - try { - await output({ - type: "extension_ui_request", - id, - method: "editor", - title, - prefill, - } as RpcExtensionUIRequest); - } catch (err) { - cleanup(); - throw err; - } - return await responsePromise; }, addAutocompleteProvider(): void { @@ -376,18 +338,13 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { - outputDetached({ - type: "extension_error", - extensionPath: err.extensionPath, - event: err.event, - error: err.error, - }); + output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error }); }, }); unsubscribe?.(); - unsubscribe = session.subscribe(async (event) => { - await output(event); + unsubscribe = session.subscribe((event) => { + output(event); }); }; @@ -428,17 +385,16 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { + preflightResult: (didSucceed) => { if (didSucceed) { - await output(success(id, "prompt")); preflightSucceeded = true; + output(success(id, "prompt")); } }, }) - .catch((err: unknown) => { + .catch((e) => { if (!preflightSucceeded) { - const message = err instanceof Error ? err.message : String(err); - outputDetached(error(id, "prompt", message)); + output(error(id, "prompt", e.message)); } }); return undefined; @@ -734,7 +690,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { const detachJsonl = attachJsonlLineReader(process.stdin, (line) => { - void handleInputLine(line).catch((err: unknown) => { - process.stderr.write(`RPC command handling failed: ${err instanceof Error ? err.message : String(err)}\n`); - }); + void handleInputLine(line); }); return () => { detachJsonl(); From ce0e801d8e1f0d22075c736d1aa4ffc979611fdf Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 24 May 2026 22:59:35 +0200 Subject: [PATCH 029/352] fix(coding-agent): retry RPC stdout backpressure closes #4897 --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/output-guard.ts | 72 ++++++++++++++----- .../coding-agent/src/modes/rpc/rpc-mode.ts | 23 +++++- .../rpc-prompt-response-semantics.test.ts | 2 + 4 files changed, 76 insertions(+), 22 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c7ae064a..3dd492c0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed managed npm extension updates to avoid package managers installing or resolving pi host packages as peer dependencies ([#4907](https://github.com/earendil-works/pi/issues/4907)). +- Fixed RPC mode raw stdout writes to retry transient backpressure errors and flush queued protocol output during shutdown ([#4897](https://github.com/earendil-works/pi/issues/4897)). ## [0.75.5] - 2026-05-23 diff --git a/packages/coding-agent/src/core/output-guard.ts b/packages/coding-agent/src/core/output-guard.ts index c0783760..8781a51d 100644 --- a/packages/coding-agent/src/core/output-guard.ts +++ b/packages/coding-agent/src/core/output-guard.ts @@ -6,6 +6,42 @@ interface StdoutTakeoverState { let stdoutTakeoverState: StdoutTakeoverState | undefined; +const RAW_STDOUT_RETRY_DELAY_MS = 10; + +let rawStdoutWriteTail: Promise = Promise.resolve(); + +function getRawStdoutWrite(): StdoutTakeoverState["rawStdoutWrite"] { + if (stdoutTakeoverState) { + return stdoutTakeoverState.rawStdoutWrite; + } + return process.stdout.write.bind(process.stdout) as StdoutTakeoverState["rawStdoutWrite"]; +} + +async function writeRawStdoutChunk(text: string): Promise { + while (true) { + try { + await new Promise((resolve, reject) => { + try { + getRawStdoutWrite()(text, (error) => { + if (error) reject(error); + else resolve(); + }); + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + return; + } catch (error) { + const writeError = error instanceof Error ? error : new Error(String(error)); + const code = (writeError as Error & { code?: unknown }).code; + if (code !== "ENOBUFS" && code !== "EAGAIN" && code !== "EWOULDBLOCK") { + throw writeError; + } + await new Promise((resolve) => setTimeout(resolve, RAW_STDOUT_RETRY_DELAY_MS)); + } + } +} + export function takeOverStdout(): void { if (stdoutTakeoverState) { return; @@ -47,28 +83,26 @@ export function isStdoutTakenOver(): boolean { } export function writeRawStdout(text: string): void { - if (stdoutTakeoverState) { - stdoutTakeoverState.rawStdoutWrite(text); + if (text.length === 0) { return; } - process.stdout.write(text); + rawStdoutWriteTail = rawStdoutWriteTail.then(() => writeRawStdoutChunk(text)); + void rawStdoutWriteTail.catch(() => { + process.exit(1); + }); +} + +export async function waitForRawStdoutBackpressure(): Promise { + while (true) { + const tail = rawStdoutWriteTail; + await tail; + if (tail === rawStdoutWriteTail) { + return; + } + } } export async function flushRawStdout(): Promise { - if (stdoutTakeoverState) { - await new Promise((resolve, reject) => { - stdoutTakeoverState?.rawStdoutWrite("", (err) => { - if (err) reject(err); - else resolve(); - }); - }); - return; - } - - await new Promise((resolve, reject) => { - process.stdout.write("", (err) => { - if (err) reject(err); - else resolve(); - }); - }); + await waitForRawStdoutBackpressure(); + await writeRawStdoutChunk(""); } diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 828c9f67..513af70e 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -19,7 +19,12 @@ import type { ExtensionWidgetOptions, WorkingIndicatorOptions, } from "../../core/extensions/index.ts"; -import { takeOverStdout, writeRawStdout } from "../../core/output-guard.ts"; +import { + flushRawStdout, + takeOverStdout, + waitForRawStdoutBackpressure, + writeRawStdout, +} from "../../core/output-guard.ts"; import { killTrackedDetachedChildren } from "../../utils/shell.ts"; import { type Theme, theme } from "../interactive/theme/theme.ts"; import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.ts"; @@ -49,6 +54,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise void) | undefined; + let unsubscribeBackpressure: (() => void) | undefined; const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => { writeRawStdout(serializeJsonLine(obj)); @@ -343,9 +349,13 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { output(event); }); + unsubscribeBackpressure = session.agent.subscribe(async () => { + await waitForRawStdoutBackpressure(); + }); }; const registerSignalHandlers = (): void => { @@ -357,7 +367,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { killTrackedDetachedChildren(); - void shutdown(signal === "SIGHUP" ? 129 : 143); + void shutdown(signal === "SIGHUP" ? 129 : 143, signal); }; process.on(signal, handler); signalCleanupHandlers.push(() => process.off(signal, handler)); @@ -665,7 +675,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise {}; - async function shutdown(exitCode = 0): Promise { + async function shutdown(exitCode = 0, signal?: NodeJS.Signals): Promise { if (shuttingDown) { process.exit(exitCode); } @@ -674,9 +684,13 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise ({ })); vi.mock("../src/core/output-guard.js", () => ({ + flushRawStdout: vi.fn(async () => {}), takeOverStdout: vi.fn(), + waitForRawStdoutBackpressure: vi.fn(async () => {}), writeRawStdout: (line: string) => { rpcIo.outputLines.push(line); }, From e007fcd0d2a1e924338c719f2a8ac9b561f15305 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 25 May 2026 00:40:07 +0200 Subject: [PATCH 030/352] fix(rpc): reject pending requests on child process exit closes #4764 --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/modes/rpc/rpc-client.ts | 73 +++++++++++++++++-- .../test/rpc-client-process-exit.test.ts | 38 ++++++++++ 3 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 packages/coding-agent/test/rpc-client-process-exit.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3dd492c0..d8b9c62f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed `RpcClient` to reject pending requests and consume stdin pipe errors when the child process exits unexpectedly ([#4764](https://github.com/earendil-works/pi/issues/4764)). - Fixed managed npm extension updates to avoid package managers installing or resolving pi host packages as peer dependencies ([#4907](https://github.com/earendil-works/pi/issues/4907)). - Fixed RPC mode raw stdout writes to retry transient backpressure errors and flush queued protocol output during shutdown ([#4897](https://github.com/earendil-works/pi/issues/4897)). diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index 53b96e64..4c46feff 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -59,6 +59,7 @@ export class RpcClient { new Map(); private requestId = 0; private stderr = ""; + private exitError: Error | null = null; private options: RpcClientOptions; constructor(options: RpcClientOptions = {}) { @@ -73,6 +74,8 @@ export class RpcClient { throw new Error("Client already started"); } + this.exitError = null; + const cliPath = this.options.cliPath ?? "dist/cli.js"; const args = ["--mode", "rpc"]; @@ -86,20 +89,41 @@ export class RpcClient { args.push(...this.options.args); } - this.process = spawn("node", [cliPath, ...args], { + const childProcess = spawn("node", [cliPath, ...args], { cwd: this.options.cwd, env: { ...process.env, ...this.options.env }, stdio: ["pipe", "pipe", "pipe"], }); + this.process = childProcess; // Collect stderr for debugging - this.process.stderr?.on("data", (data) => { + childProcess.stderr?.on("data", (data) => { this.stderr += data.toString(); process.stderr.write(data); }); + childProcess.once("exit", (code, signal) => { + if (this.process !== childProcess) return; + const error = this.createProcessExitError(code, signal); + this.exitError = error; + this.rejectPendingRequests(error); + }); + childProcess.once("error", (error) => { + if (this.process !== childProcess) return; + const processError = new Error(`Agent process error: ${error.message}. Stderr: ${this.stderr}`); + this.exitError = processError; + this.rejectPendingRequests(processError); + }); + childProcess.stdin?.on("error", (error) => { + if (this.process !== childProcess) return; + const stdinError = + this.exitError ?? new Error(`Agent process stdin error: ${error.message}. Stderr: ${this.stderr}`); + this.exitError = stdinError; + this.rejectPendingRequests(stdinError); + }); + // Set up strict JSONL reader for stdout. - this.stopReadingStdout = attachJsonlLineReader(this.process.stdout!, (line) => { + this.stopReadingStdout = attachJsonlLineReader(childProcess.stdout!, (line) => { this.handleLine(line); }); @@ -107,7 +131,9 @@ export class RpcClient { await new Promise((resolve) => setTimeout(resolve, 100)); if (this.process.exitCode !== null) { - throw new Error(`Agent process exited immediately with code ${this.process.exitCode}. Stderr: ${this.stderr}`); + const error = this.exitError ?? this.createProcessExitError(this.process.exitCode, this.process.signalCode); + this.exitError = error; + throw error; } } @@ -474,17 +500,41 @@ export class RpcClient { } } + private createProcessExitError(code: number | null, signal: NodeJS.Signals | null): Error { + return new Error(`Agent process exited (code=${code} signal=${signal}). Stderr: ${this.stderr}`); + } + + private rejectPendingRequests(error: Error): void { + for (const pending of this.pendingRequests.values()) { + pending.reject(error); + } + this.pendingRequests.clear(); + } + private async send(command: RpcCommandBody): Promise { - if (!this.process?.stdin) { + const childProcess = this.process; + const stdin = childProcess?.stdin; + if (!childProcess || !stdin) { throw new Error("Client not started"); } + if (this.exitError) { + throw this.exitError; + } + if (childProcess.exitCode !== null) { + const error = this.createProcessExitError(childProcess.exitCode, childProcess.signalCode); + this.exitError = error; + throw error; + } + if (stdin.destroyed || !stdin.writable) { + const error = new Error(`Agent process stdin is not writable. Stderr: ${this.stderr}`); + this.exitError = error; + throw error; + } const id = `req_${++this.requestId}`; const fullCommand = { ...command, id } as RpcCommand; return new Promise((resolve, reject) => { - this.pendingRequests.set(id, { resolve, reject }); - const timeout = setTimeout(() => { this.pendingRequests.delete(id); reject(new Error(`Timeout waiting for response to ${command.type}. Stderr: ${this.stderr}`)); @@ -501,7 +551,14 @@ export class RpcClient { }, }); - this.process!.stdin!.write(serializeJsonLine(fullCommand)); + try { + stdin.write(serializeJsonLine(fullCommand)); + } catch (error: unknown) { + const writeError = error instanceof Error ? error : new Error(String(error)); + const pending = this.pendingRequests.get(id); + this.pendingRequests.delete(id); + pending?.reject(writeError); + } }); } diff --git a/packages/coding-agent/test/rpc-client-process-exit.test.ts b/packages/coding-agent/test/rpc-client-process-exit.test.ts new file mode 100644 index 00000000..f023849e --- /dev/null +++ b/packages/coding-agent/test/rpc-client-process-exit.test.ts @@ -0,0 +1,38 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { RpcClient } from "../src/modes/rpc/rpc-client.ts"; + +const tempDirs: string[] = []; + +function writeChildScript(contents: string): string { + const dir = mkdtempSync(join(tmpdir(), "pi-rpc-client-exit-")); + tempDirs.push(dir); + const path = join(dir, "child.mjs"); + writeFileSync(path, contents); + return path; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("RpcClient child process failures", () => { + test("rejects an in-flight request when the child process exits", async () => { + const client = new RpcClient({ + cliPath: writeChildScript(` +process.stdin.once("data", () => { + process.exit(43); +}); +process.stdin.resume(); +`), + }); + + await client.start(); + + await expect(client.getCommands()).rejects.toThrow(/Agent process exited \(code=43 signal=null\)/); + }); +}); From 3eb002766fff93e2b374132272b2a892d43c7ef3 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 25 May 2026 00:55:39 +0200 Subject: [PATCH 031/352] fix(tui): enable OSC 8 for Windows Terminal (closes #4923) --- packages/tui/CHANGELOG.md | 1 + packages/tui/src/terminal-image.ts | 6 +++++- packages/tui/test/terminal-image.test.ts | 4 +++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index f132efd1..045039e9 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed `Shift+Enter` in Apple Terminal by detecting local macOS modifier state when Terminal.app sends plain Return. +- Fixed Windows Terminal capability detection to enable OSC 8 hyperlinks, preserving clickable long URLs across wrapped lines ([#4923](https://github.com/earendil-works/pi/issues/4923)). ## [0.75.5] - 2026-05-23 diff --git a/packages/tui/src/terminal-image.ts b/packages/tui/src/terminal-image.ts index d6fe13bd..411f82f3 100644 --- a/packages/tui/src/terminal-image.ts +++ b/packages/tui/src/terminal-image.ts @@ -70,6 +70,10 @@ export function detectCapabilities(): TerminalCapabilities { return { images: "iterm2", trueColor: true, hyperlinks: true }; } + if (process.env.WT_SESSION) { + return { images: null, trueColor: true, hyperlinks: true }; + } + if (termProgram === "vscode") { return { images: null, trueColor: true, hyperlinks: true }; } @@ -82,7 +86,7 @@ export function detectCapabilities(): TerminalCapabilities { // text" on terminals that swallow it, which means the URL disappears from // the rendered output. Default to the legacy `text (url)` behavior unless we // have positively identified a hyperlink-capable terminal above. - return { images: null, trueColor: hasTrueColorHint || !!process.env.WT_SESSION, hyperlinks: false }; + return { images: null, trueColor: hasTrueColorHint, hyperlinks: false }; } export function getCapabilities(): TerminalCapabilities { diff --git a/packages/tui/test/terminal-image.test.ts b/packages/tui/test/terminal-image.test.ts index 70fbf9eb..07313939 100644 --- a/packages/tui/test/terminal-image.test.ts +++ b/packages/tui/test/terminal-image.test.ts @@ -273,10 +273,12 @@ describe("detectCapabilities", () => { }); }); - it("detects truecolor for Windows Terminal outside multiplexers", () => { + it("enables truecolor and hyperlinks for Windows Terminal outside multiplexers", () => { withEnv({ WT_SESSION: "session", TERM: "xterm-256color" }, () => { const caps = detectCapabilities(); assert.strictEqual(caps.trueColor, true); + assert.strictEqual(caps.hyperlinks, true); + assert.strictEqual(caps.images, null); }); }); From fc8a1559017f1e581cfa971aa3cef11a507a4975 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 25 May 2026 12:30:04 +0200 Subject: [PATCH 032/352] fix(ai): honor Codex Responses maxRetries --- packages/ai/src/providers/openai-codex-responses.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 70df0810..7909e925 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -50,7 +50,7 @@ import { buildBaseOptions } from "./simple-options.ts"; const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api"; const JWT_CLAIM_PATH = "https://api.openai.com/auth" as const; -const MAX_RETRIES = 3; +const DEFAULT_MAX_RETRIES = 3; const BASE_DELAY_MS = 1000; const CODEX_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]); const WEBSOCKET_MESSAGE_TOO_BIG_CLOSE_CODE = 1009; @@ -231,8 +231,9 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" // Fetch with retry logic for rate limits and transient errors let response: Response | undefined; let lastError: Error | undefined; + const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES; - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + for (let attempt = 0; attempt <= maxRetries; attempt++) { if (options?.signal?.aborted) { throw new Error("Request was aborted"); } @@ -254,7 +255,7 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" } const errorText = await response.text(); - if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) { + if (attempt < maxRetries && isRetryableError(response.status, errorText)) { let delayMs = BASE_DELAY_MS * 2 ** attempt; const retryAfterMs = response.headers.get("retry-after-ms"); @@ -297,7 +298,7 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" } lastError = error instanceof Error ? error : new Error(String(error)); // Network errors are retryable - if (attempt < MAX_RETRIES && !lastError.message.includes("usage limit")) { + if (attempt < maxRetries && !lastError.message.includes("usage limit")) { const delayMs = BASE_DELAY_MS * 2 ** attempt; await sleep(delayMs, options?.signal); continue; From fa1180b6b72f374f334017a643ed82527ccecffa Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 26 May 2026 08:58:21 +0200 Subject: [PATCH 033/352] fix(ai): detect Poolside context overflow Closes #4943 --- packages/ai/CHANGELOG.md | 4 ++++ packages/ai/src/utils/overflow.ts | 7 +++++-- packages/ai/test/overflow.test.ts | 7 +++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index ed673f1a..da2ea33d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed OpenRouter/Poolside context overflow detection for `maximum allowed input length` errors ([#4943](https://github.com/earendil-works/pi/issues/4943)). + ## [0.75.5] - 2026-05-23 ### Breaking Changes diff --git a/packages/ai/src/utils/overflow.ts b/packages/ai/src/utils/overflow.ts index 8b908e85..e3a9b37a 100644 --- a/packages/ai/src/utils/overflow.ts +++ b/packages/ai/src/utils/overflow.ts @@ -16,6 +16,7 @@ import type { AssistantMessage } from "../types.ts"; * - xAI: "This model's maximum prompt length is 131072 but the request contains 537812 tokens" * - Groq: "Please reduce the length of the messages or completion" * - OpenRouter: "This endpoint's maximum context length is X tokens. However, you requested about Y tokens" + * - OpenRouter/Poolside: "Input length X exceeds the maximum allowed input length of Y tokens." * - Together AI: "The input (X tokens) is longer than the model's context length (Y tokens)." * - llama.cpp: "the request exceeds the available context size, try increasing it" * - LM Studio: "tokens to keep from the initial prompt is greater than the context length" @@ -39,7 +40,8 @@ const OVERFLOW_PATTERNS = [ /input token count.*exceeds the maximum/i, // Google (Gemini) /maximum prompt length is \d+/i, // xAI (Grok) /reduce the length of the messages/i, // Groq - /maximum context length is \d+ tokens/i, // OpenRouter (all backends) + /maximum context length is \d+ tokens/i, // OpenRouter (most backends) + /exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i, // OpenRouter/Poolside /input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i, // Together AI /exceeds the limit of \d+/i, // GitHub Copilot /exceeds the available context size/i, // llama.cpp server @@ -89,7 +91,8 @@ const NON_OVERFLOW_PATTERNS = [ * - Groq: "reduce the length of the messages" * - Cerebras: 400/413 status code (no body) * - Mistral: "Prompt contains X tokens ... too large for model with Y maximum context length" - * - OpenRouter (all backends): "maximum context length is X tokens" + * - OpenRouter (most backends): "maximum context length is X tokens" + * - OpenRouter/Poolside: "Input length X exceeds the maximum allowed input length of Y tokens." * - Together AI: "The input (X tokens) is longer than the model's context length (Y tokens)." * - llama.cpp: "exceeds the available context size" * - LM Studio: "greater than the context length" diff --git a/packages/ai/test/overflow.test.ts b/packages/ai/test/overflow.test.ts index b26948e2..346d2006 100644 --- a/packages/ai/test/overflow.test.ts +++ b/packages/ai/test/overflow.test.ts @@ -49,6 +49,13 @@ describe("isContextOverflow", () => { expect(isContextOverflow(message, 131072)).toBe(true); }); + it("detects OpenRouter Poolside maximum allowed input length errors", () => { + const message = createErrorMessage( + "Provider returned error: Input length 131393 exceeds the maximum allowed input length of 131040 tokens.", + ); + expect(isContextOverflow(message, 131072)).toBe(true); + }); + it("does not treat generic non-overflow Ollama errors as overflow", () => { const message = createErrorMessage("500 `model runner crashed unexpectedly`"); expect(isContextOverflow(message, 32768)).toBe(false); From 71446c6c2baf106b0ffc625d3a656d093e8d161d Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 26 May 2026 09:08:40 +0200 Subject: [PATCH 034/352] fix(ai): correct Codex Spark context window closes #4969 --- packages/ai/CHANGELOG.md | 1 + packages/ai/scripts/generate-models.ts | 3 ++- packages/ai/src/models.generated.ts | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index da2ea33d..ef945ff3 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed `openai-codex/gpt-5.3-codex-spark` generated metadata to use its 128k context window ([#4969](https://github.com/earendil-works/pi/issues/4969)). - Fixed OpenRouter/Poolside context overflow detection for `maximum allowed input length` errors ([#4943](https://github.com/earendil-works/pi/issues/4943)). ## [0.75.5] - 2026-05-23 diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index e2693a3b..a61813c2 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -1512,6 +1512,7 @@ async function generateModels() { // Context window is based on observed server limits (400s above ~272k), not marketing numbers. const CODEX_BASE_URL = "https://chatgpt.com/backend-api"; const CODEX_CONTEXT = 272000; + const CODEX_SPARK_CONTEXT = 128000; const CODEX_MAX_TOKENS = 128000; const codexModels: Model<"openai-codex-responses">[] = [ { @@ -1547,7 +1548,7 @@ async function generateModels() { reasoning: true, input: ["text"], cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - contextWindow: CODEX_CONTEXT, + contextWindow: CODEX_SPARK_CONTEXT, maxTokens: CODEX_MAX_TOKENS, }, { diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 300ed2c1..efd74010 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -6977,7 +6977,7 @@ export const MODELS = { cacheRead: 0.175, cacheWrite: 0, }, - contextWindow: 272000, + contextWindow: 128000, maxTokens: 128000, } satisfies Model<"openai-codex-responses">, "gpt-5.4": { From 7c2775f6f67c38ed491a1ff68240ee4f8ba688da Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 26 May 2026 07:39:20 +0000 Subject: [PATCH 035/352] chore: approve contributor DanielThomas --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index f53db6a6..0e41ae89 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -217,3 +217,5 @@ josephyoung pr mbazso pr AJM10565 pr + +DanielThomas pr From 96f0edd02be13c607aaf98665577e82f975f2199 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 26 May 2026 18:45:56 +0200 Subject: [PATCH 036/352] Count user image tokens in context estimates closes #4983 --- packages/agent/CHANGELOG.md | 4 ++ .../src/harness/compaction/compaction.ts | 44 +++++++++---------- packages/coding-agent/CHANGELOG.md | 1 + .../src/core/compaction/compaction.ts | 44 +++++++++---------- 4 files changed, 49 insertions(+), 44 deletions(-) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 7915e6f9..8996003c 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed context token estimates to count user image attachments consistently with tool result images ([#4983](https://github.com/earendil-works/pi/issues/4983)). + ## [0.75.5] - 2026-05-23 ## [0.75.4] - 2026-05-20 diff --git a/packages/agent/src/harness/compaction/compaction.ts b/packages/agent/src/harness/compaction/compaction.ts index aa7b32c6..a7ae33e9 100644 --- a/packages/agent/src/harness/compaction/compaction.ts +++ b/packages/agent/src/harness/compaction/compaction.ts @@ -198,22 +198,33 @@ export function shouldCompact(contextTokens: number, contextWindow: number, sett return contextTokens > contextWindow - settings.reserveTokens; } +const ESTIMATED_IMAGE_CHARS = 4800; + +function estimateTextAndImageContentChars(content: string | Array<{ type: string; text?: string }>): number { + if (typeof content === "string") { + return content.length; + } + + let chars = 0; + for (const block of content) { + if (block.type === "text" && block.text) { + chars += block.text.length; + } else if (block.type === "image") { + chars += ESTIMATED_IMAGE_CHARS; + } + } + return chars; +} + /** Estimate token count for one message using a conservative character heuristic. */ export function estimateTokens(message: AgentMessage): number { let chars = 0; switch (message.role) { case "user": { - const content = (message as { content: string | Array<{ type: string; text?: string }> }).content; - if (typeof content === "string") { - chars = content.length; - } else if (Array.isArray(content)) { - for (const block of content) { - if (block.type === "text" && block.text) { - chars += block.text.length; - } - } - } + chars = estimateTextAndImageContentChars( + (message as { content: string | Array<{ type: string; text?: string }> }).content, + ); return Math.ceil(chars / 4); } case "assistant": { @@ -231,18 +242,7 @@ export function estimateTokens(message: AgentMessage): number { } case "custom": case "toolResult": { - if (typeof message.content === "string") { - chars = message.content.length; - } else { - for (const block of message.content) { - if (block.type === "text" && block.text) { - chars += block.text.length; - } - if (block.type === "image") { - chars += 4800; - } - } - } + chars = estimateTextAndImageContentChars(message.content); return Math.ceil(chars / 4); } case "bashExecution": { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d8b9c62f..a5a0fd8d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed context token estimates to count user image attachments consistently with tool result images ([#4983](https://github.com/earendil-works/pi/issues/4983)). - Fixed `RpcClient` to reject pending requests and consume stdin pipe errors when the child process exits unexpectedly ([#4764](https://github.com/earendil-works/pi/issues/4764)). - Fixed managed npm extension updates to avoid package managers installing or resolving pi host packages as peer dependencies ([#4907](https://github.com/earendil-works/pi/issues/4907)). - Fixed RPC mode raw stdout writes to retry transient backpressure errors and flush queued protocol output during shutdown ([#4897](https://github.com/earendil-works/pi/issues/4897)). diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index b8339fba..eae19794 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -225,6 +225,24 @@ export function shouldCompact(contextTokens: number, contextWindow: number, sett // Cut point detection // ============================================================================ +const ESTIMATED_IMAGE_CHARS = 4800; + +function estimateTextAndImageContentChars(content: string | Array<{ type: string; text?: string }>): number { + if (typeof content === "string") { + return content.length; + } + + let chars = 0; + for (const block of content) { + if (block.type === "text" && block.text) { + chars += block.text.length; + } else if (block.type === "image") { + chars += ESTIMATED_IMAGE_CHARS; + } + } + return chars; +} + /** * Estimate token count for a message using chars/4 heuristic. * This is conservative (overestimates tokens). @@ -234,16 +252,9 @@ export function estimateTokens(message: AgentMessage): number { switch (message.role) { case "user": { - const content = (message as { content: string | Array<{ type: string; text?: string }> }).content; - if (typeof content === "string") { - chars = content.length; - } else if (Array.isArray(content)) { - for (const block of content) { - if (block.type === "text" && block.text) { - chars += block.text.length; - } - } - } + chars = estimateTextAndImageContentChars( + (message as { content: string | Array<{ type: string; text?: string }> }).content, + ); return Math.ceil(chars / 4); } case "assistant": { @@ -261,18 +272,7 @@ export function estimateTokens(message: AgentMessage): number { } case "custom": case "toolResult": { - if (typeof message.content === "string") { - chars = message.content.length; - } else { - for (const block of message.content) { - if (block.type === "text" && block.text) { - chars += block.text.length; - } - if (block.type === "image") { - chars += 4800; // Estimate images as 4000 chars, or 1200 tokens - } - } - } + chars = estimateTextAndImageContentChars(message.content); return Math.ceil(chars / 4); } case "bashExecution": { From 8fb1e877cd6dfd1a2b4fe01830a77049acffc4c1 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 26 May 2026 23:41:58 +0200 Subject: [PATCH 037/352] fix(ai): disable hidden provider 429 retries (#4991) --- packages/ai/src/providers/anthropic.ts | 2 +- .../src/providers/azure-openai-responses.ts | 2 +- .../ai/src/providers/images/openrouter.ts | 2 +- .../src/providers/openai-codex-responses.ts | 73 +++++++++++----- .../ai/src/providers/openai-completions.ts | 2 +- packages/ai/src/providers/openai-responses.ts | 2 +- packages/ai/test/openai-codex-stream.test.ts | 12 ++- .../ai/test/openai-completions-retry.test.ts | 86 +++++++++++++++++++ packages/coding-agent/docs/settings.md | 4 +- .../coding-agent/src/core/agent-session.ts | 7 ++ 10 files changed, 161 insertions(+), 31 deletions(-) create mode 100644 packages/ai/test/openai-completions-retry.test.ts diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index ce306056..5fb5ffda 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -512,7 +512,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti const requestOptions = { ...(options?.signal ? { signal: options.signal } : {}), ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), - ...(options?.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), + maxRetries: options?.maxRetries ?? 0, }; const response = await client.messages.create({ ...params, stream: true }, requestOptions).asResponse(); await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model); diff --git a/packages/ai/src/providers/azure-openai-responses.ts b/packages/ai/src/providers/azure-openai-responses.ts index 60601a38..770d5930 100644 --- a/packages/ai/src/providers/azure-openai-responses.ts +++ b/packages/ai/src/providers/azure-openai-responses.ts @@ -111,7 +111,7 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses" const requestOptions = { ...(options?.signal ? { signal: options.signal } : {}), ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), - ...(options?.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), + maxRetries: options?.maxRetries ?? 0, }; const { data: openaiStream, response } = await client.responses.create(params, requestOptions).withResponse(); await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model); diff --git a/packages/ai/src/providers/images/openrouter.ts b/packages/ai/src/providers/images/openrouter.ts index 01e81a60..13ba4f34 100644 --- a/packages/ai/src/providers/images/openrouter.ts +++ b/packages/ai/src/providers/images/openrouter.ts @@ -63,7 +63,7 @@ export const generateImagesOpenRouter: ImagesFunction<"openrouter-images", Image const requestOptions = { ...(options?.signal ? { signal: options.signal } : {}), ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), - ...(options?.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), + maxRetries: options?.maxRetries ?? 0, }; const { data: response, response: rawResponse } = await client.chat.completions .create(params as unknown as ChatCompletionCreateParamsNonStreaming, requestOptions) diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 7909e925..b44de436 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -50,8 +50,9 @@ import { buildBaseOptions } from "./simple-options.ts"; const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api"; const JWT_CLAIM_PATH = "https://api.openai.com/auth" as const; -const DEFAULT_MAX_RETRIES = 3; +const DEFAULT_MAX_RETRIES = 0; const BASE_DELAY_MS = 1000; +const DEFAULT_MAX_RETRY_DELAY_MS = 60_000; const CODEX_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]); const WEBSOCKET_MESSAGE_TOO_BIG_CLOSE_CODE = 1009; @@ -100,13 +101,54 @@ interface RequestBody { // Retry Helpers // ============================================================================ +function isTerminalRateLimitError(errorText: string): boolean { + return /GoUsageLimitError|FreeUsageLimitError|Monthly usage limit reached|available balance|insufficient_quota|out of budget|quota exceeded|billing/i.test( + errorText, + ); +} + function isRetryableError(status: number, errorText: string): boolean { + if (status === 429 && isTerminalRateLimitError(errorText)) { + return false; + } if (status === 429 || status === 500 || status === 502 || status === 503 || status === 504) { return true; } return /rate.?limit|overloaded|service.?unavailable|upstream.?connect|connection.?refused/i.test(errorText); } +function getRetryAfterDelayMs(headers: Headers): number | undefined { + const retryAfterMs = headers.get("retry-after-ms"); + if (retryAfterMs !== null) { + const millis = Number(retryAfterMs); + if (Number.isFinite(millis)) { + return Math.max(0, millis); + } + } + + const retryAfter = headers.get("retry-after"); + if (!retryAfter) { + return undefined; + } + + const seconds = Number(retryAfter); + if (Number.isFinite(seconds)) { + return Math.max(0, seconds * 1000); + } + + const date = Date.parse(retryAfter); + if (!Number.isNaN(date)) { + return Math.max(0, date - Date.now()); + } + + return undefined; +} + +function capRetryDelayMs(delayMs: number, options?: StreamOptions): number { + const maxRetryDelayMs = options?.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS; + return maxRetryDelayMs > 0 ? Math.min(delayMs, maxRetryDelayMs) : delayMs; +} + function sleep(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { @@ -256,28 +298,13 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" const errorText = await response.text(); if (attempt < maxRetries && isRetryableError(response.status, errorText)) { - let delayMs = BASE_DELAY_MS * 2 ** attempt; - - const retryAfterMs = response.headers.get("retry-after-ms"); - if (retryAfterMs !== null) { - const millis = Number(retryAfterMs); - if (Number.isFinite(millis)) { - delayMs = Math.max(0, millis); - } - } else { - const retryAfter = response.headers.get("retry-after"); - if (retryAfter) { - const seconds = Number(retryAfter); - if (Number.isFinite(seconds)) { - delayMs = Math.max(0, seconds * 1000); - } else { - const date = Date.parse(retryAfter); - if (!Number.isNaN(date)) { - delayMs = Math.max(0, date - Date.now()); - } - } - } - } + const retryAfterDelayMs = getRetryAfterDelayMs(response.headers); + const delayMs = + retryAfterDelayMs === undefined + ? BASE_DELAY_MS * 2 ** attempt + : response.status === 429 + ? capRetryDelayMs(retryAfterDelayMs, options) + : retryAfterDelayMs; await sleep(delayMs, options?.signal); continue; diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 12b6a508..0d37b955 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -149,7 +149,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA const requestOptions = { ...(options?.signal ? { signal: options.signal } : {}), ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), - ...(options?.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), + maxRetries: options?.maxRetries ?? 0, }; const { data: openaiStream, response } = await client.chat.completions .create(params, requestOptions) diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index e2d1ca80..acf01cf9 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -119,7 +119,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses", OpenAIRes const requestOptions = { ...(options?.signal ? { signal: options.signal } : {}), ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), - ...(options?.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), + maxRetries: options?.maxRetries ?? 0, }; const { data: openaiStream, response } = await client.responses.create(params, requestOptions).withResponse(); await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model); diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index e50111c9..0c404e58 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -1131,7 +1131,11 @@ describe("openai-codex streaming", () => { messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; - const resultPromise = streamOpenAICodexResponses(model, context, { apiKey: token, transport: "sse" }).result(); + const resultPromise = streamOpenAICodexResponses(model, context, { + apiKey: token, + transport: "sse", + maxRetries: 1, + }).result(); await vi.advanceTimersByTimeAsync(0); expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), expectedDelay); @@ -1193,7 +1197,11 @@ describe("openai-codex streaming", () => { messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; - const resultPromise = streamOpenAICodexResponses(model, context, { apiKey: token, transport: "sse" }).result(); + const resultPromise = streamOpenAICodexResponses(model, context, { + apiKey: token, + transport: "sse", + maxRetries: 3, + }).result(); await vi.advanceTimersByTimeAsync(0); expect(setTimeoutSpy).toHaveBeenNthCalledWith(1, expect.any(Function), 1000); diff --git a/packages/ai/test/openai-completions-retry.test.ts b/packages/ai/test/openai-completions-retry.test.ts new file mode 100644 index 00000000..cf631dd1 --- /dev/null +++ b/packages/ai/test/openai-completions-retry.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { streamOpenAICompletions } from "../src/providers/openai-completions.ts"; +import type { Context, Model } from "../src/types.ts"; + +const mockState = vi.hoisted(() => ({ + requestOptions: [] as unknown[], +})); + +vi.mock("openai", () => { + class FakeOpenAI { + chat = { + completions: { + create: (_params: unknown, options: unknown) => { + mockState.requestOptions.push(options); + const stream = { + async *[Symbol.asyncIterator]() { + yield { + id: "chatcmpl-test", + choices: [{ index: 0, delta: { content: "ok" } }], + }; + yield { + id: "chatcmpl-test", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }; + }, + }; + const promise = Promise.resolve(stream) as Promise & { + withResponse: () => Promise<{ + data: typeof stream; + response: { status: number; headers: Headers }; + }>; + }; + promise.withResponse = async () => ({ + data: stream, + response: { status: 200, headers: new Headers() }, + }); + return promise; + }, + }, + }; + } + return { default: FakeOpenAI }; +}); + +const model: Model<"openai-completions"> = { + id: "test-model", + name: "Test Model", + api: "openai-completions", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000, + maxTokens: 100, +}; + +const context: Context = { + systemPrompt: "", + messages: [{ role: "user", content: [{ type: "text", text: "hi" }], timestamp: 0 }], + tools: [], +}; + +async function consume(options?: { maxRetries?: number }) { + const stream = streamOpenAICompletions(model, context, { apiKey: "test", ...options }); + for await (const _event of stream) { + void _event; + } + return stream.result(); +} + +describe("openai-completions provider retries", () => { + beforeEach(() => { + mockState.requestOptions = []; + }); + + it("disables SDK retries by default", async () => { + await consume(); + expect(mockState.requestOptions).toEqual([expect.objectContaining({ maxRetries: 0 })]); + }); + + it("honors explicit provider retry settings", async () => { + await consume({ maxRetries: 2 }); + expect(mockState.requestOptions).toEqual([expect.objectContaining({ maxRetries: 2 })]); + }); +}); diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index befd4da3..447b6489 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -101,11 +101,13 @@ Set `PI_SKIP_VERSION_CHECK=1` to disable the Pi version update check. Use `--off | `retry.maxRetries` | number | `3` | Maximum agent-level retry attempts | | `retry.baseDelayMs` | number | `2000` | Base delay for agent-level exponential backoff (2s, 4s, 8s) | | `retry.provider.timeoutMs` | number | SDK default | Provider/SDK request timeout in milliseconds | -| `retry.provider.maxRetries` | number | SDK default | Provider/SDK retry attempts | +| `retry.provider.maxRetries` | number | `0` | Provider/SDK retry attempts | | `retry.provider.maxRetryDelayMs` | number | `60000` | Max server-requested delay before failing (60s) | When a provider requests a retry delay longer than `retry.provider.maxRetryDelayMs` (e.g., Google's "quota will reset after 5h"), the request fails immediately with an informative error instead of waiting silently. Set to `0` to disable the cap. +Keep `retry.provider.maxRetries` at `0` unless provider-level retries are explicitly needed. Setting it above `0` can make SDK/provider retries handle out-of-usage-limit errors before Pi sees them, which may block the agent until the provider quota resets in some circumstances. + ```json { "retry": { diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index cf13594b..d2835b7b 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -2422,6 +2422,12 @@ export class AgentSession { // Auto-Retry // ========================================================================= + private _isNonRetryableProviderLimitError(errorMessage: string): boolean { + return /GoUsageLimitError|FreeUsageLimitError|Monthly usage limit reached|available balance|insufficient_quota|out of budget|quota exceeded|billing/i.test( + errorMessage, + ); + } + /** * Check if an error is retryable (overloaded, rate limit, server errors). * Context overflow errors are NOT retryable (handled by compaction instead). @@ -2434,6 +2440,7 @@ export class AgentSession { if (isContextOverflow(message, contextWindow)) return false; const err = message.errorMessage; + if (this._isNonRetryableProviderLimitError(err)) return false; // Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504, service unavailable, network/connection errors (including connection lost), WebSocket transport closes/errors, fetch failed, premature stream endings, HTTP/2 closed before response, terminated, retry delay exceeded return /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|stream ended before message_stop|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i.test( err, From 4402100830a67dcce0fe11607a63e5eb4369b737 Mon Sep 17 00:00:00 2001 From: xu0o0 Date: Wed, 27 May 2026 06:28:47 +0800 Subject: [PATCH 038/352] fix(tui): leverage Intl.Segmenter for proper Unicode word boundaries (#5022) --- packages/tui/src/components/editor.ts | 97 ++++++++++++--------------- packages/tui/src/components/input.ts | 4 +- packages/tui/src/utils.ts | 32 +++++---- packages/tui/test/editor.test.ts | 76 +++++++++++++++++++++ 4 files changed, 141 insertions(+), 68 deletions(-) diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 0b30ad0a..fad33c64 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -4,10 +4,11 @@ import { decodePrintableKey, matchesKey } from "../keys.ts"; import { KillRing } from "../kill-ring.ts"; import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.ts"; import { UndoStack } from "../undo-stack.ts"; -import { getSegmenter, isPunctuationChar, isWhitespaceChar, truncateToWidth, visibleWidth } from "../utils.ts"; +import { getGraphemeSegmenter, getWordSegmenter, isWhitespaceChar, truncateToWidth, visibleWidth } from "../utils.ts"; import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list.ts"; -const baseSegmenter = getSegmenter(); +const graphemeSegmenter = getGraphemeSegmenter(); +const wordSegmenter = getWordSegmenter(); /** Regex matching paste markers like `[paste #1 +123 lines]` or `[paste #2 1234 chars]`. */ const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g; @@ -27,7 +28,11 @@ function isPasteMarker(segment: string): boolean { * * Only markers whose numeric ID exists in `validIds` are merged. */ -function segmentWithMarkers(text: string, validIds: Set): Iterable { +function segmentWithMarkers( + text: string, + baseSegmenter: Intl.Segmenter, + validIds: Set, +): Iterable { // Fast path: no paste markers in the text or no valid IDs. if (validIds.size === 0 || !text.includes("[paste #")) { return baseSegmenter.segment(text); @@ -109,7 +114,7 @@ export function wordWrapLine(line: string, maxWidth: number, preSegmented?: Intl } const chunks: TextChunk[] = []; - const segments = preSegmented ?? [...baseSegmenter.segment(line)]; + const segments = preSegmented ?? [...graphemeSegmenter.segment(line)]; let currentWidth = 0; let chunkStart = 0; @@ -301,8 +306,8 @@ export class Editor implements Component, Focusable { } /** Segment text with paste-marker awareness, only merging markers with valid IDs. */ - private segment(text: string): Iterable { - return segmentWithMarkers(text, this.validPasteIds()); + private segment(text: string, mode: "word" | "grapheme"): Iterable { + return segmentWithMarkers(text, mode === "word" ? wordSegmenter : graphemeSegmenter, this.validPasteIds()); } getPaddingX(): number { @@ -482,7 +487,7 @@ export class Editor implements Component, Focusable { if (after.length > 0) { // Cursor is on a character (grapheme) - replace it with highlighted version // Get the first grapheme from 'after' - const afterGraphemes = [...this.segment(after)]; + const afterGraphemes = [...this.segment(after, "grapheme")]; const firstGrapheme = afterGraphemes[0]?.segment || ""; const restAfter = after.slice(firstGrapheme.length); const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`; @@ -855,7 +860,7 @@ export class Editor implements Component, Focusable { } } else { // Line needs wrapping - use word-aware wrapping - const chunks = wordWrapLine(line, contentWidth, [...this.segment(line)]); + const chunks = wordWrapLine(line, contentWidth, [...this.segment(line, "grapheme")]); for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { const chunk = chunks[chunkIndex]; @@ -1213,7 +1218,7 @@ export class Editor implements Component, Focusable { const beforeCursor = line.slice(0, this.state.cursorCol); // Find the last grapheme in the text before cursor - const graphemes = [...this.segment(beforeCursor)]; + const graphemes = [...this.segment(beforeCursor, "grapheme")]; const lastGrapheme = graphemes[graphemes.length - 1]; const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1; @@ -1314,7 +1319,7 @@ export class Editor implements Component, Focusable { // Snap cursor to atomic segment boundary (e.g. paste markers) // so the cursor never lands in the middle of a multi-grapheme unit. // Single-grapheme segments don't need snapping. - const segments = [...this.segment(logicalLine)]; + const segments = [...this.segment(logicalLine, "grapheme")]; for (const seg of segments) { if (seg.index > this.state.cursorCol) break; if (seg.segment.length <= 1) continue; @@ -1585,7 +1590,7 @@ export class Editor implements Component, Focusable { const afterCursor = currentLine.slice(this.state.cursorCol); // Find the first grapheme at cursor - const graphemes = [...this.segment(afterCursor)]; + const graphemes = [...this.segment(afterCursor, "grapheme")]; const firstGrapheme = graphemes[0]; const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1; @@ -1642,7 +1647,7 @@ export class Editor implements Component, Focusable { visualLines.push({ logicalLine: i, startCol: 0, length: line.length }); } else { // Line needs wrapping - use word-aware wrapping - const chunks = wordWrapLine(line, width, [...this.segment(line)]); + const chunks = wordWrapLine(line, width, [...this.segment(line, "grapheme")]); for (const chunk of chunks) { visualLines.push({ logicalLine: i, @@ -1707,7 +1712,7 @@ export class Editor implements Component, Focusable { // Moving right - move by one grapheme (handles emojis, combining characters, etc.) if (this.state.cursorCol < currentLine.length) { const afterCursor = currentLine.slice(this.state.cursorCol); - const graphemes = [...this.segment(afterCursor)]; + const graphemes = [...this.segment(afterCursor, "grapheme")]; const firstGrapheme = graphemes[0]; this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1)); } else if (this.state.cursorLine < this.state.lines.length - 1) { @@ -1725,7 +1730,7 @@ export class Editor implements Component, Focusable { // Moving left - move by one grapheme (handles emojis, combining characters, etc.) if (this.state.cursorCol > 0) { const beforeCursor = currentLine.slice(0, this.state.cursorCol); - const graphemes = [...this.segment(beforeCursor)]; + const graphemes = [...this.segment(beforeCursor, "grapheme")]; const lastGrapheme = graphemes[graphemes.length - 1]; this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1)); } else if (this.state.cursorLine > 0) { @@ -1769,41 +1774,32 @@ export class Editor implements Component, Focusable { } const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); - const graphemes = [...this.segment(textBeforeCursor)]; + const segments = [...this.segment(textBeforeCursor, "word")]; let newCol = this.state.cursorCol; // Skip trailing whitespace while ( - graphemes.length > 0 && - !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "") && - isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") + segments.length > 0 && + !isPasteMarker(segments[segments.length - 1]?.segment || "") && + isWhitespaceChar(segments[segments.length - 1]?.segment || "") ) { - newCol -= graphemes.pop()?.segment.length || 0; + newCol -= segments.pop()?.segment.length || 0; } - if (graphemes.length > 0) { - const lastGrapheme = graphemes[graphemes.length - 1]?.segment || ""; - if (isPasteMarker(lastGrapheme)) { - // Paste marker is a single atomic word - newCol -= graphemes.pop()?.segment.length || 0; - } else if (isPunctuationChar(lastGrapheme)) { - // Skip punctuation run - while ( - graphemes.length > 0 && - isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") && - !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "") - ) { - newCol -= graphemes.pop()?.segment.length || 0; - } + if (segments.length > 0) { + const last = segments[segments.length - 1]!; + if (isPasteMarker(last.segment) || last.isWordLike) { + // Skip one word-like segment (or paste marker) + newCol -= segments.pop()?.segment.length || 0; } else { - // Skip word run + // Skip non-word non-whitespace run (punctuation) while ( - graphemes.length > 0 && - !isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") && - !isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") && - !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "") + segments.length > 0 && + !isPasteMarker(segments[segments.length - 1]?.segment || "") && + !segments[segments.length - 1]?.isWordLike && + !isWhitespaceChar(segments[segments.length - 1]?.segment || "") ) { - newCol -= graphemes.pop()?.segment.length || 0; + newCol -= segments.pop()?.segment.length || 0; } } } @@ -1996,7 +1992,7 @@ export class Editor implements Component, Focusable { } const textAfterCursor = currentLine.slice(this.state.cursorCol); - const segments = this.segment(textAfterCursor); + const segments = this.segment(textAfterCursor, "word"); const iterator = segments[Symbol.iterator](); let next = iterator.next(); let newCol = this.state.cursorCol; @@ -2008,23 +2004,16 @@ export class Editor implements Component, Focusable { } if (!next.done) { - const firstGrapheme = next.value.segment; - if (isPasteMarker(firstGrapheme)) { - // Paste marker is a single atomic word - newCol += firstGrapheme.length; - } else if (isPunctuationChar(firstGrapheme)) { - // Skip punctuation run - while (!next.done && isPunctuationChar(next.value.segment) && !isPasteMarker(next.value.segment)) { - newCol += next.value.segment.length; - next = iterator.next(); - } + if (isPasteMarker(next.value.segment) || next.value.isWordLike) { + // Skip one word-like segment (or paste marker) + newCol += next.value.segment.length; } else { - // Skip word run + // Skip non-word non-whitespace run (punctuation) while ( !next.done && - !isWhitespaceChar(next.value.segment) && - !isPunctuationChar(next.value.segment) && - !isPasteMarker(next.value.segment) + !isPasteMarker(next.value.segment) && + !next.value.isWordLike && + !isWhitespaceChar(next.value.segment) ) { newCol += next.value.segment.length; next = iterator.next(); diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index 71f2363b..2f5a3eb6 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -3,9 +3,9 @@ import { decodeKittyPrintable } from "../keys.ts"; import { KillRing } from "../kill-ring.ts"; import { type Component, CURSOR_MARKER, type Focusable } from "../tui.ts"; import { UndoStack } from "../undo-stack.ts"; -import { getSegmenter, isPunctuationChar, isWhitespaceChar, sliceByColumn, visibleWidth } from "../utils.ts"; +import { getGraphemeSegmenter, isPunctuationChar, isWhitespaceChar, sliceByColumn, visibleWidth } from "../utils.ts"; -const segmenter = getSegmenter(); +const segmenter = getGraphemeSegmenter(); interface InputState { value: string; diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index a6bcc8a5..b3d336a2 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -1,13 +1,21 @@ import { eastAsianWidth } from "get-east-asian-width"; -// Grapheme segmenter (shared instance) -const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); +// segmenters (shared instance) +const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); +const wordSegmenter = new Intl.Segmenter(undefined, { granularity: "word" }); /** * Get the shared grapheme segmenter instance. */ -export function getSegmenter(): Intl.Segmenter { - return segmenter; +export function getGraphemeSegmenter(): Intl.Segmenter { + return graphemeSegmenter; +} + +/** + * Get the shared word segmenter instance. + */ +export function getWordSegmenter(): Intl.Segmenter { + return wordSegmenter; } /** @@ -62,7 +70,7 @@ function truncateFragmentToWidth(text: string, maxWidth: number): { text: string if (!hasAnsi && !hasTabs) { let result = ""; let width = 0; - for (const { segment } of segmenter.segment(text)) { + for (const { segment } of graphemeSegmenter.segment(text)) { const w = graphemeWidth(segment); if (width + w > maxWidth) { break; @@ -109,7 +117,7 @@ function truncateFragmentToWidth(text: string, maxWidth: number): { text: string end++; } - for (const { segment } of segmenter.segment(text.slice(i, end))) { + for (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) { const w = graphemeWidth(segment); if (width + w > maxWidth) { return { text: result, width }; @@ -239,7 +247,7 @@ export function visibleWidth(str: string): number { // Calculate width let width = 0; - for (const { segment } of segmenter.segment(clean)) { + for (const { segment } of graphemeSegmenter.segment(clean)) { width += graphemeWidth(segment); } @@ -790,7 +798,7 @@ function breakLongWord(word: string, width: number, tracker: AnsiCodeTracker): s } // Segment this non-ANSI portion into graphemes const textPortion = word.slice(i, end); - for (const seg of segmenter.segment(textPortion)) { + for (const seg of graphemeSegmenter.segment(textPortion)) { segments.push({ type: "grapheme", value: seg.segment }); } i = end; @@ -912,7 +920,7 @@ export function truncateToWidth( const hasTabs = text.includes("\t"); if (!hasAnsi && !hasTabs) { - for (const { segment } of segmenter.segment(text)) { + for (const { segment } of graphemeSegmenter.segment(text)) { const width = graphemeWidth(segment); if (keepContiguousPrefix && keptWidth + width <= targetWidth) { result += segment; @@ -967,7 +975,7 @@ export function truncateToWidth( end++; } - for (const { segment } of segmenter.segment(text.slice(i, end))) { + for (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) { const width = graphemeWidth(segment); if (keepContiguousPrefix && keptWidth + width <= targetWidth) { if (pendingAnsi) { @@ -1037,7 +1045,7 @@ export function sliceWithWidth( let textEnd = i; while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++; - for (const { segment } of segmenter.segment(line.slice(i, textEnd))) { + for (const { segment } of graphemeSegmenter.segment(line.slice(i, textEnd))) { const w = graphemeWidth(segment); const inRange = currentCol >= startCol && currentCol < endCol; const fits = !strict || currentCol + w <= endCol; @@ -1105,7 +1113,7 @@ export function extractSegments( let textEnd = i; while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++; - for (const { segment } of segmenter.segment(line.slice(i, textEnd))) { + for (const { segment } of graphemeSegmenter.segment(line.slice(i, textEnd))) { const w = graphemeWidth(segment); if (currentCol < beforeEnd) { diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index e7e74ec3..6dc0540c 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -591,6 +591,82 @@ describe("Editor component", () => { editor.handleInput("\x1b[1;5C"); // Ctrl+Right assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 6 }); // after 'foo' }); + + it("stops at fullwidth Chinese punctuation (issue #4972)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // 你好,世界 = 你好(0-2) ,(2-3) 世界(3-5) + editor.setText("你好,世界"); + // Cursor at end (col 5) + + // Move left over 世界 + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 }); // after , + + // Move left over , + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 2 }); // after 你好 + + // Move left over 你好 + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); // start + + // Move right over 你好 + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 2 }); // after 你好 + + // Move right over , + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 }); // after , + + // Move right over 世界 + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 5 }); // end + }); + + it("handles mixed CJK and ASCII word movement", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // "hello你好,world世界" = hello(0-5) 你好(5-7) ,(7-8) world(8-13) 世界(13-15) + editor.setText("hello你好,world世界"); + // Cursor at end (col 15) + + // Move left over 世界 + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 13 }); // after 'world' + + // Move left over world + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 8 }); // after , + + // Move left over , + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 7 }); // after 你好 + + // Move left over 你好 + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 5 }); // after 'hello' + + // Move left over hello + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); // start + + // Forward from start + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 5 }); // after 'hello' + + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 7 }); // after 你好 + + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 8 }); // after , + + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 13 }); // after 'world' + + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 15 }); // end + }); }); describe("Grapheme-aware text wrapping", () => { From 59ec8003db83013890d31849d82dcebd16fb1234 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 27 May 2026 00:59:09 +0200 Subject: [PATCH 039/352] fix(coding-agent): bypass age gates for self-update closes #4929 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/config.ts | 17 +++++- packages/coding-agent/test/config.test.ts | 65 +++++++++++++++-------- 3 files changed, 58 insertions(+), 25 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a5a0fd8d..35412c12 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed self-update commands to bypass npm, pnpm, and Bun minimum release age gates for explicit `pi update` runs ([#4929](https://github.com/earendil-works/pi/issues/4929)). - Fixed context token estimates to count user image attachments consistently with tool result images ([#4983](https://github.com/earendil-works/pi/issues/4983)). - Fixed `RpcClient` to reject pending requests and consume stdin pipe errors when the child process exits unexpectedly ([#4764](https://github.com/earendil-works/pi/issues/4764)). - Fixed managed npm extension updates to avoid package managers installing or resolving pi host packages as peer dependencies ([#4907](https://github.com/earendil-works/pi/issues/4907)). diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index dd091804..01ec8f8e 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -111,7 +111,13 @@ function getSelfUpdateCommandForMethod( return undefined; case "pnpm": return makeSelfUpdateCommand( - makeSelfUpdateCommandStep("pnpm", ["install", "-g", "--ignore-scripts", updatePackageName]), + makeSelfUpdateCommandStep("pnpm", [ + "install", + "-g", + "--ignore-scripts", + "--config.minimumReleaseAge=0", + updatePackageName, + ]), updatePackageName === installedPackageName ? undefined : makeSelfUpdateCommandStep("pnpm", ["remove", "-g", installedPackageName]), @@ -125,7 +131,13 @@ function getSelfUpdateCommandForMethod( ); case "bun": return makeSelfUpdateCommand( - makeSelfUpdateCommandStep("bun", ["install", "-g", "--ignore-scripts", updatePackageName]), + makeSelfUpdateCommandStep("bun", [ + "install", + "-g", + "--ignore-scripts", + "--minimum-release-age=0", + updatePackageName, + ]), updatePackageName === installedPackageName ? undefined : makeSelfUpdateCommandStep("bun", ["uninstall", "-g", installedPackageName]), @@ -139,6 +151,7 @@ function getSelfUpdateCommandForMethod( "install", "-g", "--ignore-scripts", + "--min-release-age=0", updatePackageName, ]); const uninstallStep = diff --git a/packages/coding-agent/test/config.test.ts b/packages/coding-agent/test/config.test.ts index 6b90778a..cbfa044e 100644 --- a/packages/coding-agent/test/config.test.ts +++ b/packages/coding-agent/test/config.test.ts @@ -153,7 +153,7 @@ describe("detectInstallMethod", () => { expect(detectInstallMethod()).toBe("pnpm"); expect(getUpdateInstruction("@earendil-works/pi-coding-agent")).toBe( - "Run: pnpm install -g --ignore-scripts @earendil-works/pi-coding-agent", + "Run: pnpm install -g --ignore-scripts --config.minimumReleaseAge=0 @earendil-works/pi-coding-agent", ); }); @@ -175,8 +175,16 @@ describe("detectInstallMethod", () => { expect(detectInstallMethod()).toBe("npm"); expect(command).toEqual({ command: "npm", - args: ["--prefix", prefix, "install", "-g", "--ignore-scripts", "@earendil-works/pi-coding-agent"], - display: `npm --prefix ${prefix} install -g --ignore-scripts @earendil-works/pi-coding-agent`, + args: [ + "--prefix", + prefix, + "install", + "-g", + "--ignore-scripts", + "--min-release-age=0", + "@earendil-works/pi-coding-agent", + ], + display: `npm --prefix ${prefix} install -g --ignore-scripts --min-release-age=0 @earendil-works/pi-coding-agent`, }); }); @@ -187,8 +195,8 @@ describe("detectInstallMethod", () => { expect(command).toEqual({ command: "npm", - args: ["--prefix", prefix, "install", "-g", "--ignore-scripts", "@new-scope/pi"], - display: `npm --prefix ${prefix} uninstall -g @mariozechner/pi-coding-agent && npm --prefix ${prefix} install -g --ignore-scripts @new-scope/pi`, + args: ["--prefix", prefix, "install", "-g", "--ignore-scripts", "--min-release-age=0", "@new-scope/pi"], + display: `npm --prefix ${prefix} uninstall -g @mariozechner/pi-coding-agent && npm --prefix ${prefix} install -g --ignore-scripts --min-release-age=0 @new-scope/pi`, steps: [ { command: "npm", @@ -197,8 +205,8 @@ describe("detectInstallMethod", () => { }, { command: "npm", - args: ["--prefix", prefix, "install", "-g", "--ignore-scripts", "@new-scope/pi"], - display: `npm --prefix ${prefix} install -g --ignore-scripts @new-scope/pi`, + args: ["--prefix", prefix, "install", "-g", "--ignore-scripts", "--min-release-age=0", "@new-scope/pi"], + display: `npm --prefix ${prefix} install -g --ignore-scripts --min-release-age=0 @new-scope/pi`, }, ], }); @@ -211,8 +219,16 @@ describe("detectInstallMethod", () => { expect(command).toEqual({ command: "npm", - args: ["--prefix", prefix, "install", "-g", "--ignore-scripts", "@earendil-works/pi-coding-agent"], - display: `npm --prefix ${prefix} install -g --ignore-scripts @earendil-works/pi-coding-agent`, + args: [ + "--prefix", + prefix, + "install", + "-g", + "--ignore-scripts", + "--min-release-age=0", + "@earendil-works/pi-coding-agent", + ], + display: `npm --prefix ${prefix} install -g --ignore-scripts --min-release-age=0 @earendil-works/pi-coding-agent`, }); }); @@ -227,6 +243,7 @@ describe("detectInstallMethod", () => { "install", "-g", "--ignore-scripts", + "--min-release-age=0", "@earendil-works/pi-coding-agent", ]); }); @@ -237,7 +254,7 @@ describe("detectInstallMethod", () => { const command = getSelfUpdateCommand("@earendil-works/pi-coding-agent"); expect(command?.display).toBe( - `npm --prefix "${prefix}" install -g --ignore-scripts @earendil-works/pi-coding-agent`, + `npm --prefix "${prefix}" install -g --ignore-scripts --min-release-age=0 @earendil-works/pi-coding-agent`, ); }); @@ -248,7 +265,7 @@ describe("detectInstallMethod", () => { expect(detectInstallMethod()).toBe("npm"); expect(getUpdateInstruction("@earendil-works/pi-coding-agent")).toBe( - "Run: npm install -g --ignore-scripts @earendil-works/pi-coding-agent", + "Run: npm install -g --ignore-scripts --min-release-age=0 @earendil-works/pi-coding-agent", ); }); @@ -260,8 +277,8 @@ describe("detectInstallMethod", () => { expect(detectInstallMethod()).toBe("bun"); expect(command).toEqual({ command: "bun", - args: ["install", "-g", "--ignore-scripts", "@earendil-works/pi-coding-agent"], - display: "bun install -g --ignore-scripts @earendil-works/pi-coding-agent", + args: ["install", "-g", "--ignore-scripts", "--minimum-release-age=0", "@earendil-works/pi-coding-agent"], + display: "bun install -g --ignore-scripts --minimum-release-age=0 @earendil-works/pi-coding-agent", }); }); @@ -273,8 +290,9 @@ describe("detectInstallMethod", () => { expect(detectInstallMethod()).toBe("pnpm"); expect(command).toEqual({ command: "pnpm", - args: ["install", "-g", "--ignore-scripts", "@new-scope/pi"], - display: "pnpm remove -g @mariozechner/pi-coding-agent && pnpm install -g --ignore-scripts @new-scope/pi", + args: ["install", "-g", "--ignore-scripts", "--config.minimumReleaseAge=0", "@new-scope/pi"], + display: + "pnpm remove -g @mariozechner/pi-coding-agent && pnpm install -g --ignore-scripts --config.minimumReleaseAge=0 @new-scope/pi", steps: [ { command: "pnpm", @@ -283,8 +301,8 @@ describe("detectInstallMethod", () => { }, { command: "pnpm", - args: ["install", "-g", "--ignore-scripts", "@new-scope/pi"], - display: "pnpm install -g --ignore-scripts @new-scope/pi", + args: ["install", "-g", "--ignore-scripts", "--config.minimumReleaseAge=0", "@new-scope/pi"], + display: "pnpm install -g --ignore-scripts --config.minimumReleaseAge=0 @new-scope/pi", }, ], }); @@ -328,8 +346,8 @@ describe("detectInstallMethod", () => { expect(detectInstallMethod()).toBe("pnpm"); expect(command).toEqual({ command: "pnpm", - args: ["install", "-g", "--ignore-scripts", packageName], - display: `pnpm install -g --ignore-scripts ${packageName}`, + args: ["install", "-g", "--ignore-scripts", "--config.minimumReleaseAge=0", packageName], + display: `pnpm install -g --ignore-scripts --config.minimumReleaseAge=0 ${packageName}`, }); }); @@ -366,8 +384,9 @@ describe("detectInstallMethod", () => { expect(detectInstallMethod()).toBe("bun"); expect(command).toEqual({ command: "bun", - args: ["install", "-g", "--ignore-scripts", "@new-scope/pi"], - display: "bun uninstall -g @mariozechner/pi-coding-agent && bun install -g --ignore-scripts @new-scope/pi", + args: ["install", "-g", "--ignore-scripts", "--minimum-release-age=0", "@new-scope/pi"], + display: + "bun uninstall -g @mariozechner/pi-coding-agent && bun install -g --ignore-scripts --minimum-release-age=0 @new-scope/pi", steps: [ { command: "bun", @@ -376,8 +395,8 @@ describe("detectInstallMethod", () => { }, { command: "bun", - args: ["install", "-g", "--ignore-scripts", "@new-scope/pi"], - display: "bun install -g --ignore-scripts @new-scope/pi", + args: ["install", "-g", "--ignore-scripts", "--minimum-release-age=0", "@new-scope/pi"], + display: "bun install -g --ignore-scripts --minimum-release-age=0 @new-scope/pi", }, ], }); From 2531fc130d969e89395ef96c4f93b759d73e7113 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 27 May 2026 01:02:04 +0200 Subject: [PATCH 040/352] fix(ui): preserve user ordered-list markers (closes #5013) --- packages/coding-agent/CHANGELOG.md | 1 + .../interactive/components/user-message.ts | 13 +++++++--- packages/tui/CHANGELOG.md | 4 +++ packages/tui/src/components/markdown.ts | 19 +++++++++++++- packages/tui/src/index.ts | 2 +- packages/tui/test/markdown.test.ts | 25 +++++++++++++++++++ 6 files changed, 59 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 35412c12..6c91ca23 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed user message transcript rendering to preserve user-authored ordered-list markers ([#5013](https://github.com/earendil-works/pi/issues/5013)). - Fixed self-update commands to bypass npm, pnpm, and Bun minimum release age gates for explicit `pi update` runs ([#4929](https://github.com/earendil-works/pi/issues/4929)). - Fixed context token estimates to count user image attachments consistently with tool result images ([#4983](https://github.com/earendil-works/pi/issues/4983)). - Fixed `RpcClient` to reject pending requests and consume stdin pipe errors when the child process exits unexpectedly ([#4764](https://github.com/earendil-works/pi/issues/4764)). diff --git a/packages/coding-agent/src/modes/interactive/components/user-message.ts b/packages/coding-agent/src/modes/interactive/components/user-message.ts index 8fda83e9..45d65577 100644 --- a/packages/coding-agent/src/modes/interactive/components/user-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/user-message.ts @@ -15,9 +15,16 @@ export class UserMessageComponent extends Container { super(); this.contentBox = new Box(1, 1, (content: string) => theme.bg("userMessageBg", content)); this.contentBox.addChild( - new Markdown(text, 0, 0, markdownTheme, { - color: (content: string) => theme.fg("userMessageText", content), - }), + new Markdown( + text, + 0, + 0, + markdownTheme, + { + color: (content: string) => theme.fg("userMessageText", content), + }, + { preserveOrderedListMarkers: true }, + ), ); this.addChild(this.contentBox); } diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 045039e9..c4e913d8 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added an opt-in Markdown renderer option to preserve source ordered-list markers for transcript rendering ([#5013](https://github.com/earendil-works/pi/issues/5013)). + ### Fixed - Fixed `Shift+Enter` in Apple Terminal by detecting local macOS modifier state when Terminal.app sends plain Return. diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 2f0ab68d..d5e3f477 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -70,6 +70,11 @@ export interface MarkdownTheme { codeBlockIndent?: string; } +export interface MarkdownOptions { + /** Preserve source ordered-list markers instead of normalizing them from the list start. */ + preserveOrderedListMarkers?: boolean; +} + interface InlineStyleContext { applyText: (text: string) => string; stylePrefix: string; @@ -81,6 +86,7 @@ export class Markdown implements Component { private paddingY: number; // Top/bottom padding private defaultTextStyle?: DefaultTextStyle; private theme: MarkdownTheme; + private options: MarkdownOptions; private defaultStylePrefix?: string; // Cache for rendered output @@ -94,12 +100,14 @@ export class Markdown implements Component { paddingY: number, theme: MarkdownTheme, defaultTextStyle?: DefaultTextStyle, + options?: MarkdownOptions, ) { this.text = text; this.paddingX = paddingX; this.paddingY = paddingY; this.theme = theme; this.defaultTextStyle = defaultTextStyle; + this.options = options ? { ...options } : {}; } setText(text: string): void { @@ -548,6 +556,11 @@ export class Markdown implements Component { return result; } + private getOrderedListMarker(item: Tokens.ListItem): string | undefined { + const match = /^(?: {0,3})(\d{1,9}[.)])[ \t]+/.exec(item.raw); + return match ? `${match[1]} ` : undefined; + } + /** * Render a list with proper nesting support */ @@ -559,7 +572,11 @@ export class Markdown implements Component { for (let i = 0; i < token.items.length; i++) { const item = token.items[i]; - const bullet = token.ordered ? `${startNumber + i}. ` : "- "; + const bullet = token.ordered + ? this.options.preserveOrderedListMarkers + ? (this.getOrderedListMarker(item) ?? `${startNumber + i}. `) + : `${startNumber + i}. ` + : "- "; const taskMarker = item.task ? `[${item.checked ? "x" : " "}] ` : ""; const marker = bullet + taskMarker; const firstPrefix = indent + this.theme.listBullet(marker); diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 9404aa65..a645c8a6 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -15,7 +15,7 @@ export { Editor, type EditorOptions, type EditorTheme } from "./components/edito export { Image, type ImageOptions, type ImageTheme } from "./components/image.ts"; export { Input } from "./components/input.ts"; export { Loader, type LoaderIndicatorOptions } from "./components/loader.ts"; -export { type DefaultTextStyle, Markdown, type MarkdownTheme } from "./components/markdown.ts"; +export { type DefaultTextStyle, Markdown, type MarkdownOptions, type MarkdownTheme } from "./components/markdown.ts"; export { type SelectItem, SelectList, diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 584c583c..fd5345df 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -104,6 +104,31 @@ describe("Markdown component", () => { assert.ok(plainLines.some((line) => line.includes("2. Second"))); }); + it("should normalize ordered list markers by default", () => { + const markdown = new Markdown("1. alpha\n1. beta\n1. gamma", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["1. alpha", "2. beta", "3. gamma"]); + }); + + it("should preserve source ordered list markers when configured", () => { + const markdown = new Markdown( + " 4. forth\n 3. third\n\n10) ten\n7) seven", + 0, + 0, + defaultMarkdownTheme, + undefined, + { + preserveOrderedListMarkers: true, + }, + ); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["4. forth", "3. third", "", "10) ten", "7) seven"]); + }); + it("should render mixed ordered and unordered nested lists", () => { const markdown = new Markdown( `1. Ordered item From 26f1e00f713ddea52a6d9981906b30326a0c0dba Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 27 May 2026 01:11:24 +0200 Subject: [PATCH 041/352] fix(ai): use hyphenated Codex session header closes #4967 --- packages/ai/CHANGELOG.md | 1 + .../ai/src/providers/openai-codex-responses.ts | 4 ++-- packages/ai/test/openai-codex-stream.test.ts | 17 +++++++++++++---- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index ef945ff3..00fe90e7 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed OpenAI Codex Responses cache-affinity headers to send `session-id` instead of proxy-incompatible `session_id` ([#4967](https://github.com/earendil-works/pi/issues/4967)). - Fixed `openai-codex/gpt-5.3-codex-spark` generated metadata to use its 128k context window ([#4969](https://github.com/earendil-works/pi/issues/4969)). - Fixed OpenRouter/Poolside context overflow detection for `maximum allowed input length` errors ([#4943](https://github.com/earendil-works/pi/issues/4943)). diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index b44de436..2cf5b920 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -1376,7 +1376,7 @@ function buildSSEHeaders( headers.set("content-type", "application/json"); if (sessionId) { - headers.set("session_id", sessionId); + headers.set("session-id", sessionId); headers.set("x-client-request-id", sessionId); } @@ -1397,6 +1397,6 @@ function buildWebSocketHeaders( headers.delete("openai-beta"); headers.set("OpenAI-Beta", OPENAI_BETA_RESPONSES_WEBSOCKETS); headers.set("x-client-request-id", requestId); - headers.set("session_id", requestId); + headers.set("session-id", requestId); return headers; } diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index 0c404e58..540f7658 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -311,7 +311,7 @@ describe("openai-codex streaming", () => { expect(result.stopReason).toBe("length"); }); - it("sets session_id/x-client-request-id headers and prompt_cache_key when sessionId is provided", async () => { + it("sets session-id/x-client-request-id headers and prompt_cache_key when sessionId is provided", async () => { const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-")); process.env.PI_CODING_AGENT_DIR = tempDir; @@ -372,7 +372,8 @@ describe("openai-codex streaming", () => { if (url === "https://chatgpt.com/backend-api/codex/responses") { const headers = init?.headers instanceof Headers ? init.headers : undefined; // Verify sessionId is set in headers - expect(headers?.get("session_id")).toBe(sessionId); + expect(headers?.get("session-id")).toBe(sessionId); + expect(headers?.has("session_id")).toBe(false); expect(headers?.get("x-client-request-id")).toBe(sessionId); // Verify sessionId is set in request body as prompt_cache_key @@ -721,7 +722,7 @@ describe("openai-codex streaming", () => { }, ); - it("does not set session_id/x-client-request-id headers when sessionId is not provided", async () => { + it("does not set session-id/x-client-request-id headers when sessionId is not provided", async () => { const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-")); process.env.PI_CODING_AGENT_DIR = tempDir; @@ -781,6 +782,7 @@ describe("openai-codex streaming", () => { if (url === "https://chatgpt.com/backend-api/codex/responses") { const headers = init?.headers instanceof Headers ? init.headers : undefined; // Verify headers are not set when sessionId is not provided + expect(headers?.has("session-id")).toBe(false); expect(headers?.has("session_id")).toBe(false); expect(headers?.has("x-client-request-id")).toBe(false); @@ -819,6 +821,7 @@ describe("openai-codex streaming", () => { it("forwards auto transport from streamSimple options and uses cached websocket context", async () => { const token = mockToken(); const sentBodies: unknown[] = []; + let capturedWebSocketHeaders: Record | undefined; const fetchMock = vi.fn(async () => new Response("unexpected fetch", { status: 500 })); vi.stubGlobal("fetch", fetchMock); @@ -826,7 +829,10 @@ describe("openai-codex streaming", () => { class MockWebSocket { private listeners = new Map void>>(); - constructor(_url: string, _protocols?: string | string[] | { headers?: Record }) { + constructor(_url: string, protocols?: string | string[] | { headers?: Record }) { + if (protocols && typeof protocols === "object" && !Array.isArray(protocols)) { + capturedWebSocketHeaders = protocols.headers; + } queueMicrotask(() => this.dispatch("open", {})); } @@ -917,6 +923,9 @@ describe("openai-codex streaming", () => { }).result(); expect(sentBodies).toHaveLength(1); + expect(capturedWebSocketHeaders?.["session-id"]).toBe("session-auto"); + expect(capturedWebSocketHeaders?.session_id).toBeUndefined(); + expect(capturedWebSocketHeaders?.["x-client-request-id"]).toBe("session-auto"); expect(global.fetch).not.toHaveBeenCalled(); expect(getOpenAICodexWebSocketDebugStats("session-auto")).toMatchObject({ cachedContextRequests: 1, From 493efd422d56f8b281b9a7dd5d52172b62586ae0 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 27 May 2026 12:11:29 +0200 Subject: [PATCH 042/352] fix(codex): timeouts for websockets (#4979) --- packages/ai/CHANGELOG.md | 1 + .../src/providers/openai-codex-responses.ts | 124 +++++--- packages/ai/src/providers/simple-options.ts | 1 + packages/ai/src/types.ts | 6 + packages/ai/test/openai-codex-stream.test.ts | 274 ++++++++++++++++++ packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/settings.md | 4 +- packages/coding-agent/src/core/sdk.ts | 9 +- .../coding-agent/src/core/settings-manager.ts | 26 +- .../test/sdk-stream-options.test.ts | 153 ++++++++++ 10 files changed, 551 insertions(+), 48 deletions(-) create mode 100644 packages/coding-agent/test/sdk-stream-options.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 00fe90e7..26e13406 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -7,6 +7,7 @@ - Fixed OpenAI Codex Responses cache-affinity headers to send `session-id` instead of proxy-incompatible `session_id` ([#4967](https://github.com/earendil-works/pi/issues/4967)). - Fixed `openai-codex/gpt-5.3-codex-spark` generated metadata to use its 128k context window ([#4969](https://github.com/earendil-works/pi/issues/4969)). - Fixed OpenRouter/Poolside context overflow detection for `maximum allowed input length` errors ([#4943](https://github.com/earendil-works/pi/issues/4943)). +- Fixed OpenAI Codex Responses WebSocket streams to apply bounded connect and idle timeouts instead of waiting indefinitely when no events arrive ([#4945](https://github.com/earendil-works/pi/issues/4945)). ## [0.75.5] - 2026-05-23 diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 2cf5b920..69c4ebbb 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -53,6 +53,7 @@ const JWT_CLAIM_PATH = "https://api.openai.com/auth" as const; const DEFAULT_MAX_RETRIES = 0; const BASE_DELAY_MS = 1000; const DEFAULT_MAX_RETRY_DELAY_MS = 60_000; +const DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS = 15_000; const CODEX_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]); const WEBSOCKET_MESSAGE_TOO_BIG_CLOSE_CODE = 1009; @@ -163,6 +164,14 @@ function sleep(ms: number, signal?: AbortSignal): Promise { }); } +function normalizeTimeoutMs(value: number | undefined): number | undefined { + if (value === undefined) return undefined; + if (!Number.isFinite(value) || value < 0) { + throw new Error(`Invalid timeoutMs: ${String(value)}`); + } + return Math.floor(value); +} + // ============================================================================ // Main Stream Function // ============================================================================ @@ -215,6 +224,8 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" websocketRequestId, ); const bodyJson = JSON.stringify(body); + const idleTimeoutMs = normalizeTimeoutMs(options?.timeoutMs); + const websocketConnectTimeoutMs = normalizeTimeoutMs(options?.websocketConnectTimeoutMs); const transport = options?.transport || "auto"; const websocketDisabledForSession = transport !== "sse" && isWebSocketSseFallbackActive(options?.sessionId); if (websocketDisabledForSession) { @@ -234,6 +245,8 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" () => { websocketStarted = true; }, + idleTimeoutMs, + websocketConnectTimeoutMs, options, ); @@ -838,7 +851,12 @@ function scheduleSessionWebSocketExpiry(sessionId: string, entry: CachedWebSocke }, SESSION_WEBSOCKET_CACHE_TTL_MS); } -async function connectWebSocket(url: string, headers: Headers, signal?: AbortSignal): Promise { +async function connectWebSocket( + url: string, + headers: Headers, + signal?: AbortSignal, + connectTimeoutMs = DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS, +): Promise { const WebSocketCtor = await getWebSocketConstructor(); if (!WebSocketCtor) { throw new Error("WebSocket transport is not available in this runtime"); @@ -849,6 +867,7 @@ async function connectWebSocket(url: string, headers: Headers, signal?: AbortSig return new Promise((resolve, reject) => { let settled = false; + let timeout: ReturnType | undefined; let socket: WebSocketLike; try { @@ -858,6 +877,25 @@ async function connectWebSocket(url: string, headers: Headers, signal?: AbortSig return; } + const cleanup = () => { + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + socket.removeEventListener("open", onOpen); + socket.removeEventListener("error", onError); + socket.removeEventListener("close", onClose); + signal?.removeEventListener("abort", onAbort); + }; + const fail = (error: Error, closeReason?: string) => { + if (settled) return; + settled = true; + cleanup(); + if (closeReason) { + closeWebSocketSilently(socket, 1000, closeReason); + } + reject(error); + }; const onOpen: WebSocketListener = () => { if (settled) return; settled = true; @@ -865,38 +903,28 @@ async function connectWebSocket(url: string, headers: Headers, signal?: AbortSig resolve(socket); }; const onError: WebSocketListener = (event) => { - const error = extractWebSocketError(event); - if (settled) return; - settled = true; - cleanup(); - reject(error); + fail(extractWebSocketError(event)); }; const onClose: WebSocketListener = (event) => { - const error = extractWebSocketCloseError(event); - if (settled) return; - settled = true; - cleanup(); - reject(error); + fail(extractWebSocketCloseError(event)); }; const onAbort = () => { - if (settled) return; - settled = true; - cleanup(); - socket.close(1000, "aborted"); - reject(new Error("Request was aborted")); - }; - - const cleanup = () => { - socket.removeEventListener("open", onOpen); - socket.removeEventListener("error", onError); - socket.removeEventListener("close", onClose); - signal?.removeEventListener("abort", onAbort); + fail(new Error("Request was aborted"), "aborted"); }; socket.addEventListener("open", onOpen); socket.addEventListener("error", onError); socket.addEventListener("close", onClose); signal?.addEventListener("abort", onAbort); + + if (connectTimeoutMs > 0) { + timeout = setTimeout(() => { + fail(new Error(`WebSocket connect timeout after ${connectTimeoutMs}ms`), "connect_timeout"); + }, connectTimeoutMs); + } + if (signal?.aborted) { + onAbort(); + } }); } @@ -905,6 +933,7 @@ async function acquireWebSocket( headers: Headers, sessionId: string | undefined, signal?: AbortSignal, + connectTimeoutMs?: number, ): Promise<{ socket: WebSocketLike; entry?: CachedWebSocketConnection; @@ -912,17 +941,11 @@ async function acquireWebSocket( release: (options?: { keep?: boolean }) => void; }> { if (!sessionId) { - const socket = await connectWebSocket(url, headers, signal); + const socket = await connectWebSocket(url, headers, signal, connectTimeoutMs); return { socket, reused: false, - release: ({ keep } = {}) => { - if (keep === false) { - closeWebSocketSilently(socket); - return; - } - closeWebSocketSilently(socket); - }, + release: () => closeWebSocketSilently(socket), }; } @@ -950,7 +973,7 @@ async function acquireWebSocket( }; } if (cached.busy) { - const socket = await connectWebSocket(url, headers, signal); + const socket = await connectWebSocket(url, headers, signal, connectTimeoutMs); return { socket, reused: false, @@ -965,7 +988,7 @@ async function acquireWebSocket( } } - const socket = await connectWebSocket(url, headers, signal); + const socket = await connectWebSocket(url, headers, signal, connectTimeoutMs); const entry: CachedWebSocketConnection = { socket, busy: true }; websocketSessionCache.set(sessionId, entry); return { @@ -1044,7 +1067,11 @@ async function decodeWebSocketData(data: unknown): Promise { return null; } -async function* parseWebSocket(socket: WebSocketLike, signal?: AbortSignal): AsyncGenerator> { +async function* parseWebSocket( + socket: WebSocketLike, + signal?: AbortSignal, + idleTimeoutMs?: number, +): AsyncGenerator> { const queue: Record[] = []; let pending: (() => void) | null = null; let done = false; @@ -1124,8 +1151,23 @@ async function* parseWebSocket(socket: WebSocketLike, signal?: AbortSignal): Asy continue; } if (done) break; - await new Promise((resolve) => { + let timeout: ReturnType | undefined; + await new Promise((resolve, reject) => { pending = resolve; + if (idleTimeoutMs !== undefined && idleTimeoutMs > 0) { + timeout = setTimeout(() => { + const error = new Error(`WebSocket idle timeout after ${idleTimeoutMs}ms`); + failed = error; + done = true; + pending = null; + closeWebSocketSilently(socket, 1000, "idle_timeout"); + reject(error); + }, idleTimeoutMs); + } + }).finally(() => { + if (timeout) { + clearTimeout(timeout); + } }); } @@ -1222,9 +1264,17 @@ async function processWebSocketStream( stream: AssistantMessageEventStream, model: Model<"openai-codex-responses">, onStart: () => void, + idleTimeoutMs: number | undefined, + websocketConnectTimeoutMs: number | undefined, options?: OpenAICodexResponsesOptions, ): Promise { - const { socket, entry, reused, release } = await acquireWebSocket(url, headers, options?.sessionId, options?.signal); + const { socket, entry, reused, release } = await acquireWebSocket( + url, + headers, + options?.sessionId, + options?.signal, + websocketConnectTimeoutMs, + ); let keepConnection = true; const useCachedContext = options?.transport === "websocket-cached" || options?.transport === "auto"; // ChatGPT Codex Responses rejects `store: true` ("Store must be set to false"). @@ -1253,7 +1303,7 @@ async function processWebSocketStream( socket.send(JSON.stringify({ type: "response.create", ...requestBody })); await processResponsesStream( startWebSocketOutputOnFirstEvent( - mapCodexEvents(parseWebSocket(socket, options?.signal)), + mapCodexEvents(parseWebSocket(socket, options?.signal, idleTimeoutMs)), output, stream, onStart, diff --git a/packages/ai/src/providers/simple-options.ts b/packages/ai/src/providers/simple-options.ts index 7f0aeea7..f1709062 100644 --- a/packages/ai/src/providers/simple-options.ts +++ b/packages/ai/src/providers/simple-options.ts @@ -13,6 +13,7 @@ export function buildBaseOptions(_model: Model, options?: SimpleStreamOptio onPayload: options?.onPayload, onResponse: options?.onResponse, timeoutMs: options?.timeoutMs, + websocketConnectTimeoutMs: options?.websocketConnectTimeoutMs, maxRetries: options?.maxRetries, maxRetryDelayMs: options?.maxRetryDelayMs, metadata: options?.metadata, diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 5adc69c6..b27e78b7 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -123,6 +123,12 @@ export interface StreamOptions { * For example, OpenAI and Anthropic SDK clients default to 10 minutes. */ timeoutMs?: number; + /** + * WebSocket connect timeout in milliseconds for providers that support + * WebSocket transports. This covers the connection/open handshake only; + * stream idleness after connection uses timeoutMs. + */ + websocketConnectTimeoutMs?: number; /** * Maximum retry attempts for providers/SDKs that support client-side retries. * For example, OpenAI and Anthropic SDK clients default to 2. diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index 540f7658..6cd21159 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -933,6 +933,280 @@ describe("openai-codex streaming", () => { }); }); + it("falls back to SSE when websocket connect does not open before the connect timeout", async () => { + vi.useFakeTimers(); + const token = mockToken(); + const encoder = new TextEncoder(); + const sse = buildSSEPayload({ status: "completed" }); + + const fetchMock = vi.fn(async (input: string | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url !== "https://chatgpt.com/backend-api/codex/responses") { + throw new Error(`Unexpected URL: ${url}`); + } + + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(sse)); + controller.close(); + }, + }), + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + }); + vi.stubGlobal("fetch", fetchMock); + + class MockWebSocket { + private listeners = new Map void>>(); + + addEventListener(type: string, listener: (event: unknown) => void): void { + let listeners = this.listeners.get(type); + if (!listeners) { + listeners = new Set(); + this.listeners.set(type, listeners); + } + listeners.add(listener); + } + + removeEventListener(type: string, listener: (event: unknown) => void): void { + this.listeners.get(type)?.delete(listener); + } + + send(): void { + throw new Error("send should not be called before websocket open"); + } + + close(): void {} + } + + vi.stubGlobal("WebSocket", MockWebSocket); + + const model: Model<"openai-codex-responses"> = { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + }; + const context: Context = { + systemPrompt: "You are a helpful assistant.", + messages: [{ role: "user", content: "Say hello", timestamp: 1 }], + }; + + const resultPromise = streamOpenAICodexResponses(model, context, { + apiKey: token, + sessionId: "ws-connect-timeout", + transport: "auto", + timeoutMs: 300_000, + websocketConnectTimeoutMs: 50, + }).result(); + + await vi.advanceTimersByTimeAsync(50); + + const result = await resultPromise; + expect(result.content.find((content) => content.type === "text")?.text).toBe("Hello"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(getOpenAICodexWebSocketDebugStats("ws-connect-timeout")).toMatchObject({ + websocketFailures: 1, + sseFallbacks: 1, + websocketFallbackActive: true, + lastWebSocketError: "WebSocket connect timeout after 50ms", + }); + }); + + it("falls back to SSE when a websocket is idle before the first event", async () => { + vi.useFakeTimers(); + const token = mockToken(); + const sentBodies: unknown[] = []; + const encoder = new TextEncoder(); + const sse = buildSSEPayload({ status: "completed" }); + + const fetchMock = vi.fn(async (input: string | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url !== "https://chatgpt.com/backend-api/codex/responses") { + throw new Error(`Unexpected URL: ${url}`); + } + + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(sse)); + controller.close(); + }, + }), + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + }); + vi.stubGlobal("fetch", fetchMock); + + class MockWebSocket { + static OPEN = 1; + readyState = MockWebSocket.OPEN; + private listeners = new Map void>>(); + + constructor(_url: string, _protocols?: string | string[] | { headers?: Record }) { + queueMicrotask(() => this.dispatch("open", {})); + } + + addEventListener(type: string, listener: (event: unknown) => void): void { + let listeners = this.listeners.get(type); + if (!listeners) { + listeners = new Set(); + this.listeners.set(type, listeners); + } + listeners.add(listener); + } + + removeEventListener(type: string, listener: (event: unknown) => void): void { + this.listeners.get(type)?.delete(listener); + } + + send(data: string): void { + sentBodies.push(JSON.parse(data)); + } + + close(): void { + this.readyState = 3; + } + + private dispatch(type: string, event: unknown): void { + for (const listener of this.listeners.get(type) ?? []) { + listener(event); + } + } + } + + vi.stubGlobal("WebSocket", MockWebSocket); + + const model: Model<"openai-codex-responses"> = { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + }; + const context: Context = { + systemPrompt: "You are a helpful assistant.", + messages: [{ role: "user", content: "Say hello", timestamp: 1 }], + }; + + const resultPromise = streamOpenAICodexResponses(model, context, { + apiKey: token, + sessionId: "ws-idle-before-start", + transport: "auto", + timeoutMs: 50, + }).result(); + + await vi.advanceTimersByTimeAsync(0); + expect(sentBodies).toHaveLength(1); + await vi.advanceTimersByTimeAsync(50); + + const result = await resultPromise; + expect(result.content.find((content) => content.type === "text")?.text).toBe("Hello"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(getOpenAICodexWebSocketDebugStats("ws-idle-before-start")).toMatchObject({ + websocketFailures: 1, + sseFallbacks: 1, + websocketFallbackActive: true, + }); + }); + + it("errors when a websocket is idle after the stream started", async () => { + vi.useFakeTimers(); + const token = mockToken(); + + const fetchMock = vi.fn(async () => new Response("unexpected fetch", { status: 500 })); + vi.stubGlobal("fetch", fetchMock); + + class MockWebSocket { + static OPEN = 1; + readyState = MockWebSocket.OPEN; + private listeners = new Map void>>(); + + constructor(_url: string, _protocols?: string | string[] | { headers?: Record }) { + queueMicrotask(() => this.dispatch("open", {})); + } + + addEventListener(type: string, listener: (event: unknown) => void): void { + let listeners = this.listeners.get(type); + if (!listeners) { + listeners = new Set(); + this.listeners.set(type, listeners); + } + listeners.add(listener); + } + + removeEventListener(type: string, listener: (event: unknown) => void): void { + this.listeners.get(type)?.delete(listener); + } + + send(): void { + queueMicrotask(() => { + this.dispatch("message", { + data: JSON.stringify({ + type: "response.output_item.added", + item: { type: "message", id: "msg_1", role: "assistant", status: "in_progress", content: [] }, + }), + }); + }); + } + + close(): void { + this.readyState = 3; + } + + private dispatch(type: string, event: unknown): void { + for (const listener of this.listeners.get(type) ?? []) { + listener(event); + } + } + } + + vi.stubGlobal("WebSocket", MockWebSocket); + + const model: Model<"openai-codex-responses"> = { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + }; + const context: Context = { + systemPrompt: "You are a helpful assistant.", + messages: [{ role: "user", content: "Say hello", timestamp: 1 }], + }; + + const resultPromise = streamOpenAICodexResponses(model, context, { + apiKey: token, + transport: "auto", + timeoutMs: 50, + }).result(); + + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(50); + + const result = await resultPromise; + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toBe("WebSocket idle timeout after 50ms"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("sends only response input deltas in websocket-cached mode", async () => { const token = mockToken(); const sentBodies: unknown[] = []; diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6c91ca23..8dac9c71 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -7,6 +7,7 @@ - Fixed user message transcript rendering to preserve user-authored ordered-list markers ([#5013](https://github.com/earendil-works/pi/issues/5013)). - Fixed self-update commands to bypass npm, pnpm, and Bun minimum release age gates for explicit `pi update` runs ([#4929](https://github.com/earendil-works/pi/issues/4929)). - Fixed context token estimates to count user image attachments consistently with tool result images ([#4983](https://github.com/earendil-works/pi/issues/4983)). +- Fixed `httpIdleTimeoutMs` to apply to OpenAI Codex Responses WebSocket idle waits and added `websocketConnectTimeoutMs` for bounded WebSocket connect waits ([#4945](https://github.com/earendil-works/pi/issues/4945)). - Fixed `RpcClient` to reject pending requests and consume stdin pipe errors when the child process exits unexpectedly ([#4764](https://github.com/earendil-works/pi/issues/4764)). - Fixed managed npm extension updates to avoid package managers installing or resolving pi host packages as peer dependencies ([#4907](https://github.com/earendil-works/pi/issues/4907)). - Fixed RPC mode raw stdout writes to retry transient backpressure errors and flush queued protocol output during shutdown ([#4897](https://github.com/earendil-works/pi/issues/4897)). diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 447b6489..2e039c89 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -129,7 +129,9 @@ Keep `retry.provider.maxRetries` at `0` unless provider-level retries are explic |---------|------|---------|-------------| | `steeringMode` | string | `"one-at-a-time"` | How steering messages are sent: `"all"` or `"one-at-a-time"` | | `followUpMode` | string | `"one-at-a-time"` | How follow-up messages are sent: `"all"` or `"one-at-a-time"` | -| `transport` | string | `"sse"` | Preferred transport for providers that support multiple transports: `"sse"`, `"websocket"`, or `"auto"` | +| `transport` | string | `"auto"` | Preferred transport for providers that support multiple transports: `"sse"`, `"websocket"`, `"websocket-cached"`, or `"auto"` | +| `httpIdleTimeoutMs` | number | `300000` | HTTP header/body idle timeout in milliseconds, also used by providers with explicit stream idle timeouts. Set to `0` to disable. | +| `websocketConnectTimeoutMs` | number | `15000` | WebSocket connect/open handshake timeout in milliseconds for providers that support WebSocket transports. Set to `0` to disable. | ### Terminal & Images diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index b79261ad..569b1a39 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -340,11 +340,18 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} throw new Error(auth.error); } const providerRetrySettings = settingsManager.getProviderRetrySettings(); + const timeoutMs = + options?.timeoutMs ?? + providerRetrySettings.timeoutMs ?? + (model.api === "openai-codex-responses" ? settingsManager.getHttpIdleTimeoutMs() : undefined); + const websocketConnectTimeoutMs = + options?.websocketConnectTimeoutMs ?? settingsManager.getWebSocketConnectTimeoutMs(); const attributionHeaders = getAttributionHeaders(model, settingsManager, options?.sessionId); return streamSimple(model, context, { ...options, apiKey: auth.apiKey, - timeoutMs: options?.timeoutMs ?? providerRetrySettings.timeoutMs, + timeoutMs, + websocketConnectTimeoutMs, maxRetries: options?.maxRetries ?? providerRetrySettings.maxRetries, maxRetryDelayMs: options?.maxRetryDelayMs ?? providerRetrySettings.maxRetryDelayMs, headers: diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 40f97cd3..910b68aa 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -112,6 +112,7 @@ export interface Settings { warnings?: WarningSettings; sessionDir?: string; // Custom session storage directory (same format as --session-dir CLI flag) httpIdleTimeoutMs?: number; // HTTP header/body idle timeout in milliseconds; 0 disables it + websocketConnectTimeoutMs?: number; // WebSocket connect/open handshake timeout in milliseconds; 0 disables it } /** Deep merge settings: project/overrides take precedence, nested objects merge recursively */ @@ -145,6 +146,17 @@ function deepMergeSettings(base: Settings, overrides: Settings): Settings { return result; } +function parseTimeoutSetting(value: unknown, settingName: string): number | undefined { + const timeoutMs = parseHttpIdleTimeoutMs(value); + if (timeoutMs !== undefined) { + return timeoutMs; + } + if (value !== undefined) { + throw new Error(`Invalid ${settingName} setting: ${String(value)}`); + } + return undefined; +} + export type SettingsScope = "global" | "project"; export interface SettingsStorage { @@ -722,15 +734,7 @@ export class SettingsManager { } getHttpIdleTimeoutMs(): number { - const value = this.settings.httpIdleTimeoutMs; - const timeoutMs = parseHttpIdleTimeoutMs(value); - if (timeoutMs !== undefined) { - return timeoutMs; - } - if (value !== undefined) { - throw new Error(`Invalid httpIdleTimeoutMs setting: ${String(value)}`); - } - return DEFAULT_HTTP_IDLE_TIMEOUT_MS; + return parseTimeoutSetting(this.settings.httpIdleTimeoutMs, "httpIdleTimeoutMs") ?? DEFAULT_HTTP_IDLE_TIMEOUT_MS; } setHttpIdleTimeoutMs(timeoutMs: number): void { @@ -750,6 +754,10 @@ export class SettingsManager { }; } + getWebSocketConnectTimeoutMs(): number | undefined { + return parseTimeoutSetting(this.settings.websocketConnectTimeoutMs, "websocketConnectTimeoutMs"); + } + getHideThinkingBlock(): boolean { return this.settings.hideThinkingBlock ?? false; } diff --git a/packages/coding-agent/test/sdk-stream-options.test.ts b/packages/coding-agent/test/sdk-stream-options.test.ts new file mode 100644 index 00000000..e30eff7a --- /dev/null +++ b/packages/coding-agent/test/sdk-stream-options.test.ts @@ -0,0 +1,153 @@ +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + type Api, + type AssistantMessage, + createAssistantMessageEventStream, + type Model, + type SimpleStreamOptions, +} from "@earendil-works/pi-ai"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { ModelRegistry } from "../src/core/model-registry.ts"; +import { createAgentSession } from "../src/core/sdk.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; + +describe("createAgentSession stream options", () => { + let tempDir: string; + let cwd: string; + let agentDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "pi-sdk-stream-options-")); + cwd = join(tempDir, "project"); + agentDir = join(tempDir, "agent"); + mkdirSync(cwd, { recursive: true }); + mkdirSync(agentDir, { recursive: true }); + }); + + afterEach(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + function createModel(api: Api): Model { + return { + id: "capture-model", + name: "Capture Model", + api, + provider: "capture-provider", + baseUrl: "https://capture.invalid/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, + }; + } + + function createDoneStream(api: Api) { + const stream = createAssistantMessageEventStream(); + const message: AssistantMessage = { + role: "assistant", + content: [{ type: "text", text: "ok" }], + api, + provider: "capture-provider", + model: "capture-model", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + stream.end(message); + return stream; + } + + async function captureStreamOptions( + api: Api, + settings: { httpIdleTimeoutMs?: number; websocketConnectTimeoutMs?: number }, + requestOptions: SimpleStreamOptions = {}, + ): Promise { + const model = createModel(api); + const settingsManager = SettingsManager.inMemory(settings); + + const authStorage = AuthStorage.create(join(agentDir, "auth.json")); + authStorage.setRuntimeApiKey(model.provider, "test-api-key"); + const modelRegistry = ModelRegistry.create(authStorage, join(agentDir, "models.json")); + let capturedOptions: SimpleStreamOptions | undefined; + + modelRegistry.registerProvider(model.provider, { + api, + streamSimple: (_model, _context, providerOptions) => { + capturedOptions = providerOptions; + return createDoneStream(api); + }, + }); + + const sessionManager = SessionManager.inMemory(cwd); + const { session } = await createAgentSession({ + cwd, + agentDir, + model, + authStorage, + modelRegistry, + settingsManager, + sessionManager, + }); + + try { + await session.agent.streamFn(model, { messages: [] }, requestOptions); + return capturedOptions; + } finally { + session.dispose(); + modelRegistry.unregisterProvider(model.provider); + } + } + + it("forwards httpIdleTimeoutMs as timeoutMs for OpenAI Codex", async () => { + const options = await captureStreamOptions("openai-codex-responses", { httpIdleTimeoutMs: 1234 }); + + expect(options?.timeoutMs).toBe(1234); + }); + + it("does not default timeoutMs from httpIdleTimeoutMs for other providers", async () => { + const options = await captureStreamOptions("openai-completions", { httpIdleTimeoutMs: 1234 }); + + expect(options?.timeoutMs).toBeUndefined(); + }); + + it("lets request timeoutMs override httpIdleTimeoutMs for OpenAI Codex", async () => { + const options = await captureStreamOptions( + "openai-codex-responses", + { httpIdleTimeoutMs: 1234 }, + { timeoutMs: 0 }, + ); + + expect(options?.timeoutMs).toBe(0); + }); + + it("forwards websocketConnectTimeoutMs from settings", async () => { + const options = await captureStreamOptions("openai-codex-responses", { websocketConnectTimeoutMs: 1234 }); + + expect(options?.websocketConnectTimeoutMs).toBe(1234); + }); + + it("lets request websocketConnectTimeoutMs override settings", async () => { + const options = await captureStreamOptions( + "openai-codex-responses", + { websocketConnectTimeoutMs: 1234 }, + { websocketConnectTimeoutMs: 0 }, + ); + + expect(options?.websocketConnectTimeoutMs).toBe(0); + }); +}); From b62776e4a22761e0efe4b8f5ad7684a15525d609 Mon Sep 17 00:00:00 2001 From: xu0o0 Date: Wed, 27 May 2026 18:12:10 +0800 Subject: [PATCH 043/352] fix(tui): preserve ASCII punctuation word boundaries with Intl.Segmenter (#5067) --- packages/tui/src/components/editor.ts | 30 ++++++++++++++++++++++----- packages/tui/src/utils.ts | 2 +- packages/tui/test/editor.test.ts | 26 +++++++++++++++++++++++ packages/tui/test/input.test.ts | 14 +++++++++++++ 4 files changed, 66 insertions(+), 6 deletions(-) diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index fad33c64..01d9be2c 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -4,7 +4,14 @@ import { decodePrintableKey, matchesKey } from "../keys.ts"; import { KillRing } from "../kill-ring.ts"; import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.ts"; import { UndoStack } from "../undo-stack.ts"; -import { getGraphemeSegmenter, getWordSegmenter, isWhitespaceChar, truncateToWidth, visibleWidth } from "../utils.ts"; +import { + getGraphemeSegmenter, + getWordSegmenter, + isWhitespaceChar, + PUNCTUATION_REGEX, + truncateToWidth, + visibleWidth, +} from "../utils.ts"; import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list.ts"; const graphemeSegmenter = getGraphemeSegmenter(); @@ -1788,9 +1795,19 @@ export class Editor implements Component, Focusable { if (segments.length > 0) { const last = segments[segments.length - 1]!; - if (isPasteMarker(last.segment) || last.isWordLike) { - // Skip one word-like segment (or paste marker) + if (isPasteMarker(last.segment)) { + // Skip one paste marker. newCol -= segments.pop()?.segment.length || 0; + } else if (last.isWordLike) { + // Skip inside one word-like segment, preserving existing ASCII punctuation boundaries. + const segment = last.segment; + const matches = [...segment.matchAll(new RegExp(PUNCTUATION_REGEX, "g"))]; + if (matches.length <= 0) { + newCol -= segment.length; + } else { + const lastMatch = matches[matches.length - 1]!; + newCol -= segment.length - (lastMatch.index + lastMatch[0].length); + } } else { // Skip non-word non-whitespace run (punctuation) while ( @@ -2004,9 +2021,12 @@ export class Editor implements Component, Focusable { } if (!next.done) { - if (isPasteMarker(next.value.segment) || next.value.isWordLike) { - // Skip one word-like segment (or paste marker) + if (isPasteMarker(next.value.segment)) { + // Skip one paste marker. newCol += next.value.segment.length; + } else if (next.value.isWordLike) { + // Skip inside one word-like segment, preserving existing ASCII punctuation boundaries. + newCol += PUNCTUATION_REGEX.exec(next.value.segment)?.index ?? next.value.segment.length; } else { // Skip non-word non-whitespace run (punctuation) while ( diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index b3d336a2..97f93f65 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -757,7 +757,7 @@ function wrapSingleLine(line: string, width: number): string[] { return wrapped.length > 0 ? wrapped.map((line) => line.trimEnd()) : [""]; } -const PUNCTUATION_REGEX = /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/; +export const PUNCTUATION_REGEX = /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/; /** * Check if a character is whitespace. diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 6dc0540c..2d43bc24 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -532,6 +532,15 @@ describe("Editor component", () => { editor.handleInput("\x17"); assert.strictEqual(editor.getText(), "foo bar"); + // ASCII punctuation inside Intl word-like segments preserves old boundaries + editor.setText("foo.bar"); + editor.handleInput("\x17"); + assert.strictEqual(editor.getText(), "foo."); + + editor.setText("foo:bar"); + editor.handleInput("\x17"); + assert.strictEqual(editor.getText(), "foo:"); + // Delete across multiple lines editor.setText("line one\nline two"); editor.handleInput("\x17"); @@ -590,6 +599,23 @@ describe("Editor component", () => { editor.handleInput("\x01"); // Ctrl+A to go to start editor.handleInput("\x1b[1;5C"); // Ctrl+Right assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 6 }); // after 'foo' + + // ASCII punctuation inside Intl word-like segments preserves old boundaries + editor.setText("foo.bar baz"); + editor.handleInput("\x1b[1;5D"); // Ctrl+Left over baz + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 8 }); + editor.handleInput("\x1b[1;5D"); // Ctrl+Left over bar + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 4 }); + editor.handleInput("\x1b[1;5D"); // Ctrl+Left over . + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 }); + + editor.handleInput("\x01"); // Ctrl+A + editor.handleInput("\x1b[1;5C"); // Ctrl+Right over foo + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 }); + editor.handleInput("\x1b[1;5C"); // Ctrl+Right over . + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 4 }); + editor.handleInput("\x1b[1;5C"); // Ctrl+Right over bar + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 7 }); }); it("stops at fullwidth Chinese punctuation (issue #4972)", () => { diff --git a/packages/tui/test/input.test.ts b/packages/tui/test/input.test.ts index e980fb05..355663b0 100644 --- a/packages/tui/test/input.test.ts +++ b/packages/tui/test/input.test.ts @@ -100,6 +100,20 @@ describe("Input component", () => { assert.strictEqual(input.getValue(), "bazfoo bar "); }); + it("Ctrl+W preserves ASCII punctuation boundaries", () => { + const input = new Input(); + + input.setValue("foo.bar"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W - deletes "bar" + assert.strictEqual(input.getValue(), "foo."); + + input.setValue("foo:bar"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W - deletes "bar" + assert.strictEqual(input.getValue(), "foo:"); + }); + it("Ctrl+U saves deleted text to kill ring", () => { const input = new Input(); From 4bbe2959bd93e00d29bdc3cfde71d50e47e80133 Mon Sep 17 00:00:00 2001 From: Sviatoslav Abakumov Date: Wed, 27 May 2026 14:20:44 +0400 Subject: [PATCH 044/352] fix(tui): provide the JetBrains terminal capabilities (#5037) --- packages/tui/src/terminal-image.ts | 5 +++++ packages/tui/test/terminal-image.test.ts | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/packages/tui/src/terminal-image.ts b/packages/tui/src/terminal-image.ts index 411f82f3..22059c49 100644 --- a/packages/tui/src/terminal-image.ts +++ b/packages/tui/src/terminal-image.ts @@ -41,6 +41,7 @@ export function setCellDimensions(dims: CellDimensions): void { export function detectCapabilities(): TerminalCapabilities { const termProgram = process.env.TERM_PROGRAM?.toLowerCase() || ""; + const terminalEmulator = process.env.TERMINAL_EMULATOR?.toLowerCase() || ""; const term = process.env.TERM?.toLowerCase() || ""; const colorTerm = process.env.COLORTERM?.toLowerCase() || ""; const hasTrueColorHint = colorTerm === "truecolor" || colorTerm === "24bit"; @@ -82,6 +83,10 @@ export function detectCapabilities(): TerminalCapabilities { return { images: null, trueColor: true, hyperlinks: true }; } + if (terminalEmulator === "jetbrains-jediterm") { + return { images: null, trueColor: true, hyperlinks: false }; + } + // Unknown terminal: be conservative. OSC 8 is rendered invisibly as "just // text" on terminals that swallow it, which means the URL disappears from // the rendered output. Default to the legacy `text (url)` behavior unless we diff --git a/packages/tui/test/terminal-image.test.ts b/packages/tui/test/terminal-image.test.ts index 07313939..b179c329 100644 --- a/packages/tui/test/terminal-image.test.ts +++ b/packages/tui/test/terminal-image.test.ts @@ -21,6 +21,7 @@ import { const ENV_KEYS = [ "TERM", "TERM_PROGRAM", + "TERMINAL_EMULATOR", "COLORTERM", "TMUX", "KITTY_WINDOW_ID", @@ -282,6 +283,15 @@ describe("detectCapabilities", () => { }); }); + it("enables truecolor without hyperlinks for JetBrains terminal", () => { + withEnv({ TERMINAL_EMULATOR: "JetBrains-JediTerm", TERM: "xterm-256color" }, () => { + const caps = detectCapabilities(); + assert.strictEqual(caps.trueColor, true); + assert.strictEqual(caps.hyperlinks, false); + assert.strictEqual(caps.images, null); + }); + }); + it("does not inherit Windows Terminal truecolor through tmux", () => { withEnv({ WT_SESSION: "session", TMUX: "/tmp/tmux-1000/default,1234,0", TERM: "tmux-256color" }, () => { const caps = detectCapabilities(); From 61babc24cf6d4a5b3e57630c6fe09796304efbf4 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 27 May 2026 18:34:42 +0200 Subject: [PATCH 045/352] feat(rpc): add excludeFromContext flag to bash command (closes #5039) --- packages/coding-agent/CHANGELOG.md | 4 ++++ packages/coding-agent/src/modes/rpc/rpc-mode.ts | 4 +++- packages/coding-agent/src/modes/rpc/rpc-types.ts | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8dac9c71..dddd751c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `excludeFromContext` flag to the `bash` RPC command for parity with the internal `executeBash` API ([#5039](https://github.com/earendil-works/pi/issues/5039)). + ### Fixed - Fixed user message transcript rendering to preserve user-authored ordered-list markers ([#5013](https://github.com/earendil-works/pi/issues/5013)). diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 513af70e..aa09cbb3 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -547,7 +547,9 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise Date: Thu, 28 May 2026 02:50:04 +0800 Subject: [PATCH 046/352] fix(tui): align input word segmentation with editor (#5068) --- packages/tui/src/components/editor.ts | 98 ++--------- packages/tui/src/components/input.ts | 68 +------- packages/tui/src/word-navigation.ts | 117 +++++++++++++ packages/tui/test/input.test.ts | 53 ++++++ packages/tui/test/word-navigation.test.ts | 191 ++++++++++++++++++++++ 5 files changed, 381 insertions(+), 146 deletions(-) create mode 100644 packages/tui/src/word-navigation.ts create mode 100644 packages/tui/test/word-navigation.test.ts diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 01d9be2c..ddbd98ee 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -4,14 +4,8 @@ import { decodePrintableKey, matchesKey } from "../keys.ts"; import { KillRing } from "../kill-ring.ts"; import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.ts"; import { UndoStack } from "../undo-stack.ts"; -import { - getGraphemeSegmenter, - getWordSegmenter, - isWhitespaceChar, - PUNCTUATION_REGEX, - truncateToWidth, - visibleWidth, -} from "../utils.ts"; +import { getGraphemeSegmenter, getWordSegmenter, isWhitespaceChar, truncateToWidth, visibleWidth } from "../utils.ts"; +import { findWordBackward, findWordForward } from "../word-navigation.ts"; import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list.ts"; const graphemeSegmenter = getGraphemeSegmenter(); @@ -1780,48 +1774,12 @@ export class Editor implements Component, Focusable { return; } - const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); - const segments = [...this.segment(textBeforeCursor, "word")]; - let newCol = this.state.cursorCol; - - // Skip trailing whitespace - while ( - segments.length > 0 && - !isPasteMarker(segments[segments.length - 1]?.segment || "") && - isWhitespaceChar(segments[segments.length - 1]?.segment || "") - ) { - newCol -= segments.pop()?.segment.length || 0; - } - - if (segments.length > 0) { - const last = segments[segments.length - 1]!; - if (isPasteMarker(last.segment)) { - // Skip one paste marker. - newCol -= segments.pop()?.segment.length || 0; - } else if (last.isWordLike) { - // Skip inside one word-like segment, preserving existing ASCII punctuation boundaries. - const segment = last.segment; - const matches = [...segment.matchAll(new RegExp(PUNCTUATION_REGEX, "g"))]; - if (matches.length <= 0) { - newCol -= segment.length; - } else { - const lastMatch = matches[matches.length - 1]!; - newCol -= segment.length - (lastMatch.index + lastMatch[0].length); - } - } else { - // Skip non-word non-whitespace run (punctuation) - while ( - segments.length > 0 && - !isPasteMarker(segments[segments.length - 1]?.segment || "") && - !segments[segments.length - 1]?.isWordLike && - !isWhitespaceChar(segments[segments.length - 1]?.segment || "") - ) { - newCol -= segments.pop()?.segment.length || 0; - } - } - } - - this.setCursorCol(newCol); + this.setCursorCol( + findWordBackward(currentLine, this.state.cursorCol, { + segment: (text) => this.segment(text, "word"), + isAtomicSegment: isPasteMarker, + }), + ); } /** @@ -2008,40 +1966,12 @@ export class Editor implements Component, Focusable { return; } - const textAfterCursor = currentLine.slice(this.state.cursorCol); - const segments = this.segment(textAfterCursor, "word"); - const iterator = segments[Symbol.iterator](); - let next = iterator.next(); - let newCol = this.state.cursorCol; - - // Skip leading whitespace - while (!next.done && !isPasteMarker(next.value.segment) && isWhitespaceChar(next.value.segment)) { - newCol += next.value.segment.length; - next = iterator.next(); - } - - if (!next.done) { - if (isPasteMarker(next.value.segment)) { - // Skip one paste marker. - newCol += next.value.segment.length; - } else if (next.value.isWordLike) { - // Skip inside one word-like segment, preserving existing ASCII punctuation boundaries. - newCol += PUNCTUATION_REGEX.exec(next.value.segment)?.index ?? next.value.segment.length; - } else { - // Skip non-word non-whitespace run (punctuation) - while ( - !next.done && - !isPasteMarker(next.value.segment) && - !next.value.isWordLike && - !isWhitespaceChar(next.value.segment) - ) { - newCol += next.value.segment.length; - next = iterator.next(); - } - } - } - - this.setCursorCol(newCol); + this.setCursorCol( + findWordForward(currentLine, this.state.cursorCol, { + segment: (text) => this.segment(text, "word"), + isAtomicSegment: isPasteMarker, + }), + ); } // Slash menu only allowed on the first line of the editor diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index 2f5a3eb6..a054076d 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -3,7 +3,8 @@ import { decodeKittyPrintable } from "../keys.ts"; import { KillRing } from "../kill-ring.ts"; import { type Component, CURSOR_MARKER, type Focusable } from "../tui.ts"; import { UndoStack } from "../undo-stack.ts"; -import { getGraphemeSegmenter, isPunctuationChar, isWhitespaceChar, sliceByColumn, visibleWidth } from "../utils.ts"; +import { getGraphemeSegmenter, isWhitespaceChar, sliceByColumn, visibleWidth } from "../utils.ts"; +import { findWordBackward, findWordForward } from "../word-navigation.ts"; const segmenter = getGraphemeSegmenter(); @@ -347,72 +348,15 @@ export class Input implements Component, Focusable { } private moveWordBackwards(): void { - if (this.cursor === 0) { - return; - } - + if (this.cursor === 0) return; this.lastAction = null; - const textBeforeCursor = this.value.slice(0, this.cursor); - const graphemes = [...segmenter.segment(textBeforeCursor)]; - - // Skip trailing whitespace - while (graphemes.length > 0 && isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) { - this.cursor -= graphemes.pop()?.segment.length || 0; - } - - if (graphemes.length > 0) { - const lastGrapheme = graphemes[graphemes.length - 1]?.segment || ""; - if (isPunctuationChar(lastGrapheme)) { - // Skip punctuation run - while (graphemes.length > 0 && isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) { - this.cursor -= graphemes.pop()?.segment.length || 0; - } - } else { - // Skip word run - while ( - graphemes.length > 0 && - !isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") && - !isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") - ) { - this.cursor -= graphemes.pop()?.segment.length || 0; - } - } - } + this.cursor = findWordBackward(this.value, this.cursor); } private moveWordForwards(): void { - if (this.cursor >= this.value.length) { - return; - } - + if (this.cursor >= this.value.length) return; this.lastAction = null; - const textAfterCursor = this.value.slice(this.cursor); - const segments = segmenter.segment(textAfterCursor); - const iterator = segments[Symbol.iterator](); - let next = iterator.next(); - - // Skip leading whitespace - while (!next.done && isWhitespaceChar(next.value.segment)) { - this.cursor += next.value.segment.length; - next = iterator.next(); - } - - if (!next.done) { - const firstGrapheme = next.value.segment; - if (isPunctuationChar(firstGrapheme)) { - // Skip punctuation run - while (!next.done && isPunctuationChar(next.value.segment)) { - this.cursor += next.value.segment.length; - next = iterator.next(); - } - } else { - // Skip word run - while (!next.done && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) { - this.cursor += next.value.segment.length; - next = iterator.next(); - } - } - } + this.cursor = findWordForward(this.value, this.cursor); } private handlePaste(pastedText: string): void { diff --git a/packages/tui/src/word-navigation.ts b/packages/tui/src/word-navigation.ts new file mode 100644 index 00000000..7c7eced2 --- /dev/null +++ b/packages/tui/src/word-navigation.ts @@ -0,0 +1,117 @@ +import { getWordSegmenter, isWhitespaceChar, PUNCTUATION_REGEX } from "./utils.ts"; + +const wordSegmenter = getWordSegmenter(); + +/** + * Options for word navigation functions. + * When omitted, uses the default Intl.Segmenter word segmentation. + */ +export interface WordNavigationOptions { + /** Custom segmenter returning word segments for the given text. */ + segment?: (text: string) => Iterable; + /** Predicate identifying atomic segments that should be treated as single units (e.g. paste markers). */ + isAtomicSegment?: (segment: string) => boolean; +} + +/** + * Find the cursor position after moving one word backward from `cursor` in `text`. + * Skips trailing whitespace, then stops at the next word/punctuation boundary. + * + * Pure function - does not mutate any state. + */ +export function findWordBackward(text: string, cursor: number, options?: WordNavigationOptions): number { + if (cursor <= 0) return 0; + + const textBeforeCursor = text.slice(0, cursor); + const segmentFn = options?.segment; + const isAtomic = options?.isAtomicSegment; + const segments = segmentFn ? [...segmentFn(textBeforeCursor)] : [...wordSegmenter.segment(textBeforeCursor)]; + let newCursor = cursor; + + // Skip trailing whitespace + while ( + segments.length > 0 && + !isAtomic?.(segments[segments.length - 1]?.segment || "") && + isWhitespaceChar(segments[segments.length - 1]?.segment || "") + ) { + newCursor -= segments.pop()?.segment.length || 0; + } + + if (segments.length === 0) return newCursor; + + const last = segments[segments.length - 1]!; + + if (isAtomic?.(last.segment)) { + // Skip one atomic segment. + newCursor -= last.segment.length; + } else if (last.isWordLike) { + // Skip inside one word-like segment, preserving ASCII punctuation boundaries. + const segment = last.segment; + const matches = [...segment.matchAll(new RegExp(PUNCTUATION_REGEX, "g"))]; + if (matches.length <= 0) { + newCursor -= segment.length; + } else { + const lastMatch = matches[matches.length - 1]!; + newCursor -= segment.length - (lastMatch.index + lastMatch[0].length); + } + } else { + // Skip non-word non-whitespace run (punctuation) + while ( + segments.length > 0 && + !isAtomic?.(segments[segments.length - 1]?.segment || "") && + !segments[segments.length - 1]?.isWordLike && + !isWhitespaceChar(segments[segments.length - 1]?.segment || "") + ) { + newCursor -= segments.pop()?.segment.length || 0; + } + } + + return newCursor; +} + +/** + * Find the cursor position after moving one word forward from `cursor` in `text`. + * Skips leading whitespace, then stops at the next word/punctuation boundary. + * + * Pure function - does not mutate any state. + */ +export function findWordForward(text: string, cursor: number, options?: WordNavigationOptions): number { + if (cursor >= text.length) return text.length; + + const textAfterCursor = text.slice(cursor); + const segmentFn = options?.segment; + const isAtomic = options?.isAtomicSegment; + const segments = segmentFn ? segmentFn(textAfterCursor) : wordSegmenter.segment(textAfterCursor); + const iterator = segments[Symbol.iterator](); + let next = iterator.next(); + let newCursor = cursor; + + // Skip leading whitespace + while (!next.done && !isAtomic?.(next.value.segment) && isWhitespaceChar(next.value.segment)) { + newCursor += next.value.segment.length; + next = iterator.next(); + } + + if (next.done) return newCursor; + + if (isAtomic?.(next.value.segment)) { + // Skip one atomic segment. + newCursor += next.value.segment.length; + } else if (next.value.isWordLike) { + // Skip inside one word-like segment, preserving ASCII punctuation boundaries. + newCursor += PUNCTUATION_REGEX.exec(next.value.segment)?.index ?? next.value.segment.length; + } else { + // Skip non-word non-whitespace run (punctuation) + while ( + !next.done && + !isAtomic?.(next.value.segment) && + !next.value.isWordLike && + !isWhitespaceChar(next.value.segment) + ) { + newCursor += next.value.segment.length; + next = iterator.next(); + } + } + + return newCursor; +} diff --git a/packages/tui/test/input.test.ts b/packages/tui/test/input.test.ts index 355663b0..5b83ec71 100644 --- a/packages/tui/test/input.test.ts +++ b/packages/tui/test/input.test.ts @@ -114,6 +114,26 @@ describe("Input component", () => { assert.strictEqual(input.getValue(), "foo:"); }); + it("Ctrl+W handles Unicode word boundaries", () => { + const input = new Input(); + + // "你好世界。你好,世界" segments as: 你好|世界|。|你好|,|世界 + input.setValue("你好世界。你好,世界"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W - deletes "世界" + assert.strictEqual(input.getValue(), "你好世界。你好,"); + input.handleInput("\x17"); // Ctrl+W - deletes "," + assert.strictEqual(input.getValue(), "你好世界。你好"); + input.handleInput("\x17"); // Ctrl+W - deletes "你好" + assert.strictEqual(input.getValue(), "你好世界。"); + input.handleInput("\x17"); // Ctrl+W - deletes "。" + assert.strictEqual(input.getValue(), "你好世界"); + input.handleInput("\x17"); // Ctrl+W - deletes "世界" + assert.strictEqual(input.getValue(), "你好"); + input.handleInput("\x17"); // Ctrl+W - deletes "你好" + assert.strictEqual(input.getValue(), ""); + }); + it("Ctrl+U saves deleted text to kill ring", () => { const input = new Input(); @@ -327,6 +347,39 @@ describe("Input component", () => { assert.strictEqual(input.getValue(), "hello world test"); }); + it("Alt+D preserves ASCII punctuation boundaries", () => { + const input = new Input(); + + input.setValue("foo.bar baz"); + input.handleInput("\x01"); // Ctrl+A + input.handleInput("\x1bd"); // Alt+D - deletes "foo" + assert.strictEqual(input.getValue(), ".bar baz"); + input.handleInput("\x1bd"); // Alt+D - deletes "." + assert.strictEqual(input.getValue(), "bar baz"); + input.handleInput("\x1bd"); // Alt+D - deletes "bar" + assert.strictEqual(input.getValue(), " baz"); + }); + + it("Alt+D handles Unicode word boundaries", () => { + const input = new Input(); + + // "你好世界。你好,世界" segments as: 你好|世界|。|你好|,|世界 + input.setValue("你好世界。你好,世界"); + input.handleInput("\x01"); // Ctrl+A + input.handleInput("\x1bd"); // Alt+D - deletes "你好" + assert.strictEqual(input.getValue(), "世界。你好,世界"); + input.handleInput("\x1bd"); // Alt+D - deletes "世界" + assert.strictEqual(input.getValue(), "。你好,世界"); + input.handleInput("\x1bd"); // Alt+D - deletes "。" + assert.strictEqual(input.getValue(), "你好,世界"); + input.handleInput("\x1bd"); // Alt+D - deletes "你好" + assert.strictEqual(input.getValue(), ",世界"); + input.handleInput("\x1bd"); // Alt+D - deletes "," + assert.strictEqual(input.getValue(), "世界"); + input.handleInput("\x1bd"); // Alt+D - deletes "世界" + assert.strictEqual(input.getValue(), ""); + }); + it("handles yank in middle of text", () => { const input = new Input(); diff --git a/packages/tui/test/word-navigation.test.ts b/packages/tui/test/word-navigation.test.ts new file mode 100644 index 00000000..2e58e960 --- /dev/null +++ b/packages/tui/test/word-navigation.test.ts @@ -0,0 +1,191 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { findWordBackward, findWordForward } from "../src/word-navigation.ts"; + +describe("findWordBackward", () => { + it("basic words: hello world", () => { + const text = "hello world"; + assert.strictEqual(findWordBackward(text, 11), 6); + assert.strictEqual(findWordBackward(text, 6), 0); + }); + + it("dotted: foo.bar", () => { + const text = "foo.bar"; + assert.strictEqual(findWordBackward(text, 7), 4); + assert.strictEqual(findWordBackward(text, 4), 3); + assert.strictEqual(findWordBackward(text, 3), 0); + }); + + it("colon: foo:bar", () => { + const text = "foo:bar"; + assert.strictEqual(findWordBackward(text, 7), 4); + assert.strictEqual(findWordBackward(text, 4), 3); + assert.strictEqual(findWordBackward(text, 3), 0); + }); + + it("path: path/to/file", () => { + const text = "path/to/file"; + assert.strictEqual(findWordBackward(text, 12), 8); + assert.strictEqual(findWordBackward(text, 8), 7); + // "/to" is one word-like segment with "/" as punctuation boundary + assert.strictEqual(findWordBackward(text, 7), 5); + assert.strictEqual(findWordBackward(text, 5), 4); + assert.strictEqual(findWordBackward(text, 4), 0); + }); + + it("CJK mixed", () => { + const text = "你好世界 test"; + assert.strictEqual(findWordBackward(text, text.length), 5); + // Intl.Segmenter treats each CJK char as a separate word-like segment + assert.strictEqual(findWordBackward(text, 5), 2); + assert.strictEqual(findWordBackward(text, 2), 0); + }); + + it("whitespace at boundaries", () => { + const text = " hello "; + assert.strictEqual(findWordBackward(text, 9), 2); + assert.strictEqual(findWordBackward(text, 2), 0); + }); + + it("punctuation run: foo...bar", () => { + const text = "foo...bar"; + assert.strictEqual(findWordBackward(text, 9), 6); + assert.strictEqual(findWordBackward(text, 6), 3); + assert.strictEqual(findWordBackward(text, 3), 0); + }); + + it("cursor at 0 returns 0", () => { + assert.strictEqual(findWordBackward("hello", 0), 0); + }); +}); + +describe("findWordForward", () => { + it("basic words: hello world", () => { + const text = "hello world"; + assert.strictEqual(findWordForward(text, 0), 5); + assert.strictEqual(findWordForward(text, 5), 11); + }); + + it("dotted: foo.bar", () => { + const text = "foo.bar"; + assert.strictEqual(findWordForward(text, 0), 3); + assert.strictEqual(findWordForward(text, 3), 4); + assert.strictEqual(findWordForward(text, 4), 7); + }); + + it("colon: foo:bar", () => { + const text = "foo:bar"; + assert.strictEqual(findWordForward(text, 0), 3); + assert.strictEqual(findWordForward(text, 3), 4); + assert.strictEqual(findWordForward(text, 4), 7); + }); + + it("path: path/to/file", () => { + const text = "path/to/file"; + assert.strictEqual(findWordForward(text, 0), 4); + assert.strictEqual(findWordForward(text, 4), 5); + assert.strictEqual(findWordForward(text, 5), 7); + assert.strictEqual(findWordForward(text, 7), 8); + assert.strictEqual(findWordForward(text, 8), 12); + }); + + it("CJK mixed", () => { + const text = "你好世界 test"; + const firstEnd = findWordForward(text, 0); + assert.ok(firstEnd > 0); + assert.ok(firstEnd <= 4); + // Walk to end + let pos = 0; + while (pos < text.length) { + const next = findWordForward(text, pos); + if (next === pos) break; + pos = next; + } + assert.strictEqual(pos, text.length); + }); + + it("whitespace at boundaries", () => { + const text = " hello "; + assert.strictEqual(findWordForward(text, 0), 7); + assert.strictEqual(findWordForward(text, 7), 9); + }); + + it("punctuation run: foo...bar", () => { + const text = "foo...bar"; + assert.strictEqual(findWordForward(text, 0), 3); + assert.strictEqual(findWordForward(text, 3), 6); + assert.strictEqual(findWordForward(text, 6), 9); + }); + + it("cursor at end returns end", () => { + assert.strictEqual(findWordForward("hello", 5), 5); + }); +}); + +describe("atomic segments", () => { + const marker = "[paste #1 +5 lines]"; + const text = `hello ${marker} world`; + const isAtomic = (s: string) => s === marker; + + // The functions slice text before calling segment(), so we map each expected + // substring to its pre-split segments. + const segmentMap = new Map([ + [ + text, // full text (not used but for clarity) + [ + { segment: "hello", index: 0, input: text, isWordLike: true }, + { segment: " ", index: 5, input: text, isWordLike: false }, + { segment: marker, index: 6, input: text, isWordLike: true }, + { segment: " ", index: 25, input: text, isWordLike: false }, + { segment: "world", index: 26, input: text, isWordLike: true }, + ], + ], + [ + // backward from end: slice(0, 31) = full text + text.slice(0, text.length), + [ + { segment: "hello", index: 0, input: text, isWordLike: true }, + { segment: " ", index: 5, input: text, isWordLike: false }, + { segment: marker, index: 6, input: text, isWordLike: true }, + { segment: " ", index: 25, input: text, isWordLike: false }, + { segment: "world", index: 26, input: text, isWordLike: true }, + ], + ], + [ + // backward from 26: slice(0, 26) = "hello [paste #1 +5 lines] " + text.slice(0, 26), + [ + { segment: "hello", index: 0, input: text, isWordLike: true }, + { segment: " ", index: 5, input: text, isWordLike: false }, + { segment: marker, index: 6, input: text, isWordLike: true }, + { segment: " ", index: 25, input: text, isWordLike: false }, + ], + ], + [ + // forward from 6: slice(6) = "[paste #1 +5 lines] world" + text.slice(6), + [ + { segment: marker, index: 0, input: text, isWordLike: true }, + { segment: " ", index: 19, input: text, isWordLike: false }, + { segment: "world", index: 20, input: text, isWordLike: true }, + ], + ], + ]); + + const opts = { + segment: (input: string) => segmentMap.get(input) ?? [], + isAtomicSegment: isAtomic, + }; + + it("backward skips word then stops before atomic marker", () => { + assert.strictEqual(findWordBackward(text, text.length, opts), 26); + }); + + it("backward skips whitespace then atomic marker as one unit", () => { + assert.strictEqual(findWordBackward(text, 26, opts), 6); + }); + + it("forward skips atomic marker as one unit", () => { + assert.strictEqual(findWordForward(text, 6, opts), 6 + marker.length); + }); +}); From 91b46c2ba924f50b3365b715e48746d9beeec3db Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 27 May 2026 20:57:00 +0200 Subject: [PATCH 047/352] fix(ai): avoid stale Cerebras test model --- packages/ai/test/context-overflow.test.ts | 13 ++++++++++--- packages/ai/test/tokens.test.ts | 10 ++++++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts index 164ca75d..c5594989 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -14,7 +14,7 @@ import type { ChildProcess } from "child_process"; import { execSync, spawn } from "child_process"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { getModel } from "../src/models.ts"; +import { getModel, getModels } from "../src/models.ts"; import { complete } from "../src/stream.ts"; import type { AssistantMessage, Context, Model, Usage } from "../src/types.ts"; import { isContextOverflow } from "../src/utils/overflow.ts"; @@ -297,8 +297,15 @@ describe("Context overflow error handling", () => { // ============================================================================= describe.skipIf(!process.env.CEREBRAS_API_KEY)("Cerebras", () => { - it("qwen-3-235b - should detect overflow via isContextOverflow", async () => { - const model = getModel("cerebras", "qwen-3-235b-a22b-instruct-2507"); + it("available model - should detect overflow via isContextOverflow", async () => { + const preferredCerebrasModelIds: string[] = ["gpt-oss-120b", "zai-glm-4.7", "llama3.1-8b"]; + const cerebrasModels = getModels("cerebras"); + const model = + cerebrasModels.find((candidate) => preferredCerebrasModelIds.includes(candidate.id)) ?? cerebrasModels[0]; + if (!model) { + throw new Error("No Cerebras models available"); + } + const result = await testContextOverflow(model, process.env.CEREBRAS_API_KEY!); logResult(result); diff --git a/packages/ai/test/tokens.test.ts b/packages/ai/test/tokens.test.ts index f8da26a8..df548163 100644 --- a/packages/ai/test/tokens.test.ts +++ b/packages/ai/test/tokens.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.ts"; +import { getModel, getModels } from "../src/models.ts"; import { stream } from "../src/stream.ts"; import type { Api, Context, Model, StreamOptions } from "../src/types.ts"; @@ -149,9 +149,15 @@ describe("Token Statistics on Abort", () => { }); describe.skipIf(!process.env.CEREBRAS_API_KEY)("Cerebras Provider", () => { - const llm = getModel("cerebras", "qwen-3-235b-a22b-instruct-2507"); + const preferredCerebrasModelIds: string[] = ["gpt-oss-120b", "zai-glm-4.7", "llama3.1-8b"]; + const cerebrasModels = getModels("cerebras"); + const llm = cerebrasModels.find((model) => preferredCerebrasModelIds.includes(model.id)) ?? cerebrasModels[0]; it("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => { + if (!llm) { + throw new Error("No Cerebras models available"); + } + await testTokensOnAbort(llm); }); }); From 7c02a55632d527584ec376e6685625546a1dee5a Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 27 May 2026 21:24:51 +0200 Subject: [PATCH 048/352] fix(ai): timeout Codex SSE header stalls --- packages/ai/CHANGELOG.md | 2 +- .../src/providers/openai-codex-responses.ts | 38 ++++++++-- packages/ai/src/utils/abort-signals.ts | 41 +++++++++++ packages/ai/test/openai-codex-stream.test.ts | 70 ++++++++++++++++++- packages/coding-agent/CHANGELOG.md | 2 +- 5 files changed, 142 insertions(+), 11 deletions(-) create mode 100644 packages/ai/src/utils/abort-signals.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 26e13406..44022b7a 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -7,7 +7,7 @@ - Fixed OpenAI Codex Responses cache-affinity headers to send `session-id` instead of proxy-incompatible `session_id` ([#4967](https://github.com/earendil-works/pi/issues/4967)). - Fixed `openai-codex/gpt-5.3-codex-spark` generated metadata to use its 128k context window ([#4969](https://github.com/earendil-works/pi/issues/4969)). - Fixed OpenRouter/Poolside context overflow detection for `maximum allowed input length` errors ([#4943](https://github.com/earendil-works/pi/issues/4943)). -- Fixed OpenAI Codex Responses WebSocket streams to apply bounded connect and idle timeouts instead of waiting indefinitely when no events arrive ([#4945](https://github.com/earendil-works/pi/issues/4945)). +- Fixed OpenAI Codex Responses WebSocket streams and SSE response-header waits to apply bounded timeouts instead of waiting indefinitely when no events arrive ([#4945](https://github.com/earendil-works/pi/issues/4945)). ## [0.75.5] - 2026-05-23 diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 69c4ebbb..f2c60af1 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -33,6 +33,7 @@ import type { StreamOptions, Usage, } from "../types.ts"; +import { combineAbortSignals } from "../utils/abort-signals.ts"; import { appendAssistantMessageDiagnostic, createAssistantMessageDiagnostic, @@ -53,6 +54,7 @@ const JWT_CLAIM_PATH = "https://api.openai.com/auth" as const; const DEFAULT_MAX_RETRIES = 0; const BASE_DELAY_MS = 1000; const DEFAULT_MAX_RETRY_DELAY_MS = 60_000; +const DEFAULT_SSE_HEADER_TIMEOUT_MS = 10_000; const DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS = 15_000; const CODEX_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]); const WEBSOCKET_MESSAGE_TOO_BIG_CLOSE_CODE = 1009; @@ -172,6 +174,20 @@ function normalizeTimeoutMs(value: number | undefined): number | undefined { return Math.floor(value); } +function createSSEHeaderTimeout(): { signal: AbortSignal; clear: () => void; error: () => Error | undefined } { + const controller = new AbortController(); + let error: Error | undefined; + const timeout = setTimeout(() => { + error = new Error(`Codex SSE response headers timed out after ${DEFAULT_SSE_HEADER_TIMEOUT_MS}ms`); + controller.abort(error); + }, DEFAULT_SSE_HEADER_TIMEOUT_MS); + return { + signal: controller.signal, + clear: () => clearTimeout(timeout), + error: () => error, + }; +} + // ============================================================================ // Main Stream Function // ============================================================================ @@ -294,12 +310,22 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" } try { - response = await fetch(resolveCodexUrl(model.baseUrl), { - method: "POST", - headers: sseHeaders, - body: bodyJson, - signal: options?.signal, - }); + const headerTimeout = createSSEHeaderTimeout(); + const combinedSignal = combineAbortSignals([options?.signal, headerTimeout.signal]); + try { + response = await fetch(resolveCodexUrl(model.baseUrl), { + method: "POST", + headers: sseHeaders, + body: bodyJson, + signal: combinedSignal.signal, + }); + } catch (error) { + const timeoutError = headerTimeout.error(); + throw timeoutError && !options?.signal?.aborted ? timeoutError : error; + } finally { + combinedSignal.cleanup(); + headerTimeout.clear(); + } await options?.onResponse?.( { status: response.status, headers: headersToRecord(response.headers) }, model, diff --git a/packages/ai/src/utils/abort-signals.ts b/packages/ai/src/utils/abort-signals.ts new file mode 100644 index 00000000..8e1a9ade --- /dev/null +++ b/packages/ai/src/utils/abort-signals.ts @@ -0,0 +1,41 @@ +export interface CombinedAbortSignal { + signal?: AbortSignal; + cleanup: () => void; +} + +export function combineAbortSignals(signals: readonly (AbortSignal | undefined)[]): CombinedAbortSignal { + const activeSignals = signals.filter((signal): signal is AbortSignal => signal !== undefined); + if (activeSignals.length === 0) { + return { cleanup: () => {} }; + } + if (activeSignals.length === 1) { + return { signal: activeSignals[0], cleanup: () => {} }; + } + + const controller = new AbortController(); + const listeners: Array<{ signal: AbortSignal; listener: () => void }> = []; + const abort = (signal: AbortSignal) => { + if (!controller.signal.aborted) { + controller.abort(signal.reason); + } + }; + + for (const signal of activeSignals) { + if (signal.aborted) { + abort(signal); + break; + } + const listener = () => abort(signal); + signal.addEventListener("abort", listener, { once: true }); + listeners.push({ signal, listener }); + } + + return { + signal: controller.signal, + cleanup: () => { + for (const { signal, listener } of listeners) { + signal.removeEventListener("abort", listener); + } + }, + }; +} diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index 6cd21159..46621638 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -311,6 +311,65 @@ describe("openai-codex streaming", () => { expect(result.stopReason).toBe("length"); }); + it("aborts SSE fetch when response headers do not arrive", async () => { + vi.useFakeTimers(); + const token = mockToken(); + + const fetchMock = vi.fn((input: string | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (url !== "https://chatgpt.com/backend-api/codex/responses") { + throw new Error(`Unexpected URL: ${url}`); + } + + const signal = init?.signal; + if (!signal) { + throw new Error("Expected SSE fetch to receive an abort signal"); + } + + return new Promise((_, reject) => { + const onAbort = () => { + const reason = signal.reason; + reject(reason instanceof Error ? reason : new Error("SSE fetch aborted")); + }; + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + }); + }); + vi.stubGlobal("fetch", fetchMock); + + const model: Model<"openai-codex-responses"> = { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + }; + const context: Context = { + systemPrompt: "You are a helpful assistant.", + messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], + }; + + const resultPromise = streamOpenAICodexResponses(model, context, { + apiKey: token, + transport: "sse", + }).result(); + await vi.advanceTimersByTimeAsync(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(10_000); + const result = await resultPromise; + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toBe("Codex SSE response headers timed out after 10000ms"); + }); + it("sets session-id/x-client-request-id headers and prompt_cache_key when sessionId is provided", async () => { const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-")); process.env.PI_CODING_AGENT_DIR = tempDir; @@ -1480,19 +1539,24 @@ describe("openai-codex streaming", () => { messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; + const retryTimeoutDelays = () => + setTimeoutSpy.mock.calls + .map((call) => call[1]) + .filter((delay): delay is number => delay === 1000 || delay === 2000 || delay === 4000); + const resultPromise = streamOpenAICodexResponses(model, context, { apiKey: token, transport: "sse", maxRetries: 3, }).result(); await vi.advanceTimersByTimeAsync(0); - expect(setTimeoutSpy).toHaveBeenNthCalledWith(1, expect.any(Function), 1000); + expect(retryTimeoutDelays()).toEqual([1000]); await vi.advanceTimersToNextTimerAsync(); - expect(setTimeoutSpy).toHaveBeenNthCalledWith(2, expect.any(Function), 2000); + expect(retryTimeoutDelays()).toEqual([1000, 2000]); await vi.advanceTimersToNextTimerAsync(); - expect(setTimeoutSpy).toHaveBeenNthCalledWith(3, expect.any(Function), 4000); + expect(retryTimeoutDelays()).toEqual([1000, 2000, 4000]); await vi.advanceTimersToNextTimerAsync(); const result = await resultPromise; diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index dddd751c..884e0ffc 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -11,7 +11,7 @@ - Fixed user message transcript rendering to preserve user-authored ordered-list markers ([#5013](https://github.com/earendil-works/pi/issues/5013)). - Fixed self-update commands to bypass npm, pnpm, and Bun minimum release age gates for explicit `pi update` runs ([#4929](https://github.com/earendil-works/pi/issues/4929)). - Fixed context token estimates to count user image attachments consistently with tool result images ([#4983](https://github.com/earendil-works/pi/issues/4983)). -- Fixed `httpIdleTimeoutMs` to apply to OpenAI Codex Responses WebSocket idle waits and added `websocketConnectTimeoutMs` for bounded WebSocket connect waits ([#4945](https://github.com/earendil-works/pi/issues/4945)). +- Fixed `httpIdleTimeoutMs` to apply to OpenAI Codex Responses WebSocket idle waits, added `websocketConnectTimeoutMs` for bounded WebSocket connect waits, and added a 10s Codex SSE response-header timeout ([#4945](https://github.com/earendil-works/pi/issues/4945)). - Fixed `RpcClient` to reject pending requests and consume stdin pipe errors when the child process exits unexpectedly ([#4764](https://github.com/earendil-works/pi/issues/4764)). - Fixed managed npm extension updates to avoid package managers installing or resolving pi host packages as peer dependencies ([#4907](https://github.com/earendil-works/pi/issues/4907)). - Fixed RPC mode raw stdout writes to retry transient backpressure errors and flush queued protocol output during shutdown ([#4897](https://github.com/earendil-works/pi/issues/4897)). From 52dc08c1f78f0ba17f199a6913f6e3dedf943dcd Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 27 May 2026 21:32:10 +0200 Subject: [PATCH 049/352] feat(session): Explicit session id naming (#5076) --- packages/coding-agent/CHANGELOG.md | 2 + packages/coding-agent/src/cli/args.ts | 4 + .../coding-agent/src/core/session-manager.ts | 56 ++++++-- packages/coding-agent/src/main.ts | 79 +++++++++-- packages/coding-agent/test/args.test.ts | 5 + .../test/session-id-readonly.test.ts | 131 ++++++++++++++++++ .../session-manager/custom-session-id.test.ts | 57 +++++++- 7 files changed, 307 insertions(+), 27 deletions(-) create mode 100644 packages/coding-agent/test/session-id-readonly.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 884e0ffc..9da5b576 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,8 +4,10 @@ ### Added +- Added `--session-id` to let CLI callers use an exact project-local session ID, creating it if missing ([#4874](https://github.com/earendil-works/pi/issues/4874)). - Added `excludeFromContext` flag to the `bash` RPC command for parity with the internal `executeBash` API ([#5039](https://github.com/earendil-works/pi/issues/5039)). + ### Fixed - Fixed user message transcript rendering to preserve user-authored ordered-list markers ([#5013](https://github.com/earendil-works/pi/issues/5013)). diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index e4cb27dd..19053db5 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -23,6 +23,7 @@ export interface Args { mode?: Mode; noSession?: boolean; session?: string; + sessionId?: string; fork?: string; sessionDir?: string; models?: string[]; @@ -95,6 +96,8 @@ export function parseArgs(args: string[]): Args { result.noSession = true; } else if (arg === "--session" && i + 1 < args.length) { result.session = args[++i]; + } else if (arg === "--session-id" && i + 1 < args.length) { + result.sessionId = args[++i]; } else if (arg === "--fork" && i + 1 < args.length) { result.fork = args[++i]; } else if (arg === "--session-dir" && i + 1 < args.length) { @@ -224,6 +227,7 @@ ${chalk.bold("Options:")} --continue, -c Continue previous session --resume, -r Select a session to resume --session Use specific session file or partial UUID + --session-id Use exact project session ID, creating it if missing --fork Fork specific session file or partial UUID into a new session --session-dir Directory for session storage and lookup --no-session Don't save session (ephemeral) diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index e7d12314..22c1f436 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -202,6 +202,14 @@ function createSessionId(): string { return uuidv7(); } +export function assertValidSessionId(id: string): void { + if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(id)) { + throw new Error( + "Session id must be non-empty, contain only alphanumeric characters, '-', '_', and '.', and start and end with an alphanumeric character", + ); + } +} + /** Generate a unique short ID (8 hex chars, collision-checked) */ function generateId(byId: { has(id: string): boolean }): string { for (let i = 0; i < 100; i++) { @@ -721,7 +729,13 @@ export class SessionManager { private labelTimestampsById: Map = new Map(); private leafId: string | null = null; - private constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) { + private constructor( + cwd: string, + sessionDir: string, + sessionFile: string | undefined, + persist: boolean, + newSessionOptions?: NewSessionOptions, + ) { this.cwd = resolvePath(cwd); this.sessionDir = normalizePath(sessionDir); this.persist = persist; @@ -732,7 +746,7 @@ export class SessionManager { if (sessionFile) { this.setSessionFile(sessionFile); } else { - this.newSession(); + this.newSession(newSessionOptions); } } @@ -770,6 +784,9 @@ export class SessionManager { } newSession(options?: NewSessionOptions): string | undefined { + if (options?.id !== undefined) { + assertValidSessionId(options.id); + } this.sessionId = options?.id ?? createSessionId(); const timestamp = new Date().toISOString(); const header: SessionHeader = { @@ -845,14 +862,23 @@ export class SessionManager { const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant"); if (!hasAssistant) { - // Mark as not flushed so when assistant arrives, all entries get written - this.flushed = false; + if (this.flushed) { + appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); + } else { + // Mark as not flushed so when assistant arrives, all entries get written + this.flushed = false; + } return; } if (!this.flushed) { - for (const e of this.fileEntries) { - appendFileSync(this.sessionFile, `${JSON.stringify(e)}\n`); + const fd = openSync(this.sessionFile, "wx"); + try { + for (const e of this.fileEntries) { + writeFileSync(fd, `${JSON.stringify(e)}\n`); + } + } finally { + closeSync(fd); } this.flushed = true; } else { @@ -1308,9 +1334,9 @@ export class SessionManager { * @param cwd Working directory (stored in session header) * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions//). */ - static create(cwd: string, sessionDir?: string): SessionManager { + static create(cwd: string, sessionDir?: string, options?: NewSessionOptions): SessionManager { const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd); - return new SessionManager(cwd, dir, undefined, true); + return new SessionManager(cwd, dir, undefined, true, options); } /** @@ -1356,7 +1382,12 @@ export class SessionManager { * @param targetCwd Target working directory (where the new session will be stored) * @param sessionDir Optional session directory. If omitted, uses default for targetCwd. */ - static forkFrom(sourcePath: string, targetCwd: string, sessionDir?: string): SessionManager { + static forkFrom( + sourcePath: string, + targetCwd: string, + sessionDir?: string, + options?: NewSessionOptions, + ): SessionManager { const resolvedSourcePath = resolvePath(sourcePath); const resolvedTargetCwd = resolvePath(targetCwd); const sourceEntries = loadEntriesFromFile(resolvedSourcePath); @@ -1375,7 +1406,10 @@ export class SessionManager { } // Create new session file with new ID but forked content - const newSessionId = createSessionId(); + if (options?.id !== undefined) { + assertValidSessionId(options.id); + } + const newSessionId = options?.id ?? createSessionId(); const timestamp = new Date().toISOString(); const fileTimestamp = timestamp.replace(/[:.]/g, "-"); const newSessionFile = join(dir, `${fileTimestamp}_${newSessionId}.jsonl`); @@ -1389,7 +1423,7 @@ export class SessionManager { cwd: resolvedTargetCwd, parentSession: resolvedSourcePath, }; - appendFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`); + writeFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`, { flag: "wx" }); // Copy all non-header entries from source for (const entry of sourceEntries) { diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 5395e045..7e69d933 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -37,7 +37,7 @@ import { MissingSessionCwdError, type SessionCwdIssue, } from "./core/session-cwd.ts"; -import { SessionManager } from "./core/session-manager.ts"; +import { assertValidSessionId, SessionManager } from "./core/session-manager.ts"; import { SettingsManager } from "./core/settings-manager.ts"; import { printTimings, resetTimings, time } from "./core/timings.ts"; import { runMigrations, showDeprecationWarnings } from "./migrations.ts"; @@ -145,6 +145,16 @@ type ResolvedSession = * Resolve a session argument to a file path. * If it looks like a path, use as-is. Otherwise try to match as session ID prefix. */ +async function findLocalSessionByExactId( + sessionId: string, + cwd: string, + sessionDir?: string, +): Promise<{ type: "local"; path: string } | undefined> { + const localSessions = await SessionManager.list(cwd, sessionDir); + const localMatch = localSessions.find((s) => s.id === sessionId); + return localMatch ? { type: "local", path: localMatch.path } : undefined; +} + async function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: string): Promise { // If it looks like a file path, resolve it before handing it to the session manager. if (sessionArg.includes("/") || sessionArg.includes("\\") || sessionArg.endsWith(".jsonl")) { @@ -153,19 +163,20 @@ async function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: // Try to match as session ID in current project first const localSessions = await SessionManager.list(cwd, sessionDir); - const localMatches = localSessions.filter((s) => s.id.startsWith(sessionArg)); + const localMatch = + localSessions.find((s) => s.id === sessionArg) ?? localSessions.find((s) => s.id.startsWith(sessionArg)); - if (localMatches.length >= 1) { - return { type: "local", path: localMatches[0].path }; + if (localMatch) { + return { type: "local", path: localMatch.path }; } // Try global search across all projects const allSessions = await SessionManager.listAll(); - const globalMatches = allSessions.filter((s) => s.id.startsWith(sessionArg)); + const globalMatch = + allSessions.find((s) => s.id === sessionArg) ?? allSessions.find((s) => s.id.startsWith(sessionArg)); - if (globalMatches.length >= 1) { - const match = globalMatches[0]; - return { type: "global", path: match.path, cwd: match.cwd }; + if (globalMatch) { + return { type: "global", path: globalMatch.path, cwd: globalMatch.cwd }; } // Not found anywhere @@ -202,9 +213,33 @@ function validateForkFlags(parsed: Args): void { } } -function forkSessionOrExit(sourcePath: string, cwd: string, sessionDir?: string): SessionManager { +function validateSessionIdFlags(parsed: Args): void { + if (parsed.sessionId === undefined) return; + + const conflictingFlags = [ + parsed.session ? "--session" : undefined, + parsed.continue ? "--continue" : undefined, + parsed.resume ? "--resume" : undefined, + parsed.noSession ? "--no-session" : undefined, + ].filter((flag): flag is string => flag !== undefined); + + if (conflictingFlags.length > 0) { + console.error(chalk.red(`Error: --session-id cannot be combined with ${conflictingFlags.join(", ")}`)); + process.exit(1); + } + try { - return SessionManager.forkFrom(sourcePath, cwd, sessionDir); + assertValidSessionId(parsed.sessionId); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.error(chalk.red(`Error: ${message}`)); + process.exit(1); + } +} + +function forkSessionOrExit(sourcePath: string, cwd: string, sessionDir?: string, sessionId?: string): SessionManager { + try { + return SessionManager.forkFrom(sourcePath, cwd, sessionDir, { id: sessionId }); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); console.error(chalk.red(`Error: ${message}`)); @@ -218,18 +253,26 @@ async function createSessionManager( sessionDir: string | undefined, settingsManager: SettingsManager, ): Promise { - if (parsed.noSession) { - return SessionManager.inMemory(); + if (parsed.noSession || parsed.help || parsed.listModels !== undefined) { + return SessionManager.inMemory(cwd); } if (parsed.fork) { + if (parsed.sessionId) { + const existingTarget = await findLocalSessionByExactId(parsed.sessionId, cwd, sessionDir); + if (existingTarget) { + console.error(chalk.red(`Session already exists with id '${parsed.sessionId}'`)); + process.exit(1); + } + } + const resolved = await resolveSessionPath(parsed.fork, cwd, sessionDir); switch (resolved.type) { case "path": case "local": case "global": - return forkSessionOrExit(resolved.path, cwd, sessionDir); + return forkSessionOrExit(resolved.path, cwd, sessionDir, parsed.sessionId); case "not_found": console.error(chalk.red(`No session found matching '${resolved.arg}'`)); @@ -282,7 +325,14 @@ async function createSessionManager( return SessionManager.continueRecent(cwd, sessionDir); } - return SessionManager.create(cwd, sessionDir); + if (parsed.sessionId) { + const existingSession = await findLocalSessionByExactId(parsed.sessionId, cwd, sessionDir); + if (existingSession) { + return SessionManager.open(existingSession.path, sessionDir); + } + } + + return SessionManager.create(cwd, sessionDir, { id: parsed.sessionId }); } function buildSessionOptions( @@ -483,6 +533,7 @@ export async function main(args: string[], options?: MainOptions) { } validateForkFlags(parsed); + validateSessionIdFlags(parsed); // Run migrations (pass cwd for project-local migrations) const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(process.cwd()); diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index 3bf41473..2f6d9ea9 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -130,6 +130,11 @@ describe("parseArgs", () => { expect(result.session).toBe("/path/to/session.jsonl"); }); + test("parses --session-id", () => { + const result = parseArgs(["--session-id", "orchestrated-session"]); + expect(result.sessionId).toBe("orchestrated-session"); + }); + test("parses --fork", () => { const result = parseArgs(["--fork", "1234abcd"]); expect(result.fork).toBe("1234abcd"); diff --git a/packages/coding-agent/test/session-id-readonly.test.ts b/packages/coding-agent/test/session-id-readonly.test.ts new file mode 100644 index 00000000..b6e97ce8 --- /dev/null +++ b/packages/coding-agent/test/session-id-readonly.test.ts @@ -0,0 +1,131 @@ +import { spawn } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { ENV_AGENT_DIR } from "../src/config.ts"; + +const cliPath = resolve(__dirname, "../src/cli.ts"); +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function createTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "pi-session-id-readonly-")); + tempDirs.push(dir); + return dir; +} + +function hasSessionWithId(root: string, sessionId: string): boolean { + if (!existsSync(root)) return false; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (entry.isDirectory() && hasSessionWithId(path, sessionId)) return true; + if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue; + + try { + const firstLine = readFileSync(path, "utf8").split("\n", 1)[0]; + const header = JSON.parse(firstLine) as { type?: string; id?: string }; + if (header.type === "session" && header.id === sessionId) return true; + } catch { + // Ignore malformed session files. + } + } + return false; +} + +interface CliDirs { + agentDir: string; + projectDir: string; + sessionDir: string; +} + +async function runCli( + args: string[] | ((dirs: CliDirs) => string[]), + setup?: (dirs: CliDirs) => void, +): Promise<{ code: number | null; agentDir: string; stderr: string }> { + const tempRoot = createTempDir(); + const dirs: CliDirs = { + agentDir: join(tempRoot, "agent"), + projectDir: join(tempRoot, "project"), + sessionDir: join(tempRoot, "sessions"), + }; + mkdirSync(dirs.agentDir, { recursive: true }); + mkdirSync(dirs.projectDir, { recursive: true }); + setup?.(dirs); + const resolvedArgs = typeof args === "function" ? args(dirs) : args; + + let stderr = ""; + const code = await new Promise((resolvePromise, reject) => { + const child = spawn(process.execPath, [cliPath, ...resolvedArgs], { + cwd: dirs.projectDir, + env: { + ...process.env, + [ENV_AGENT_DIR]: dirs.agentDir, + PI_OFFLINE: "1", + TSX_TSCONFIG_PATH: resolve(__dirname, "../../../tsconfig.json"), + }, + stdio: ["ignore", "ignore", "pipe"], + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", reject); + child.on("close", resolvePromise); + }); + + return { code, agentDir: dirs.agentDir, stderr }; +} + +function writeSession(sessionDir: string, cwd: string, id: string): void { + writeFileSync( + join(sessionDir, `${id}.jsonl`), + `${JSON.stringify({ type: "session", version: 3, id, timestamp: new Date().toISOString(), cwd })}\n`, + ); +} + +describe("--session-id read-only commands", () => { + it("does not reserve a session for --help", async () => { + const result = await runCli(["--session-id", "read-only-help", "--help"]); + + expect(result.code).toBe(0); + expect(hasSessionWithId(join(result.agentDir, "sessions"), "read-only-help")).toBe(false); + }); + + it("does not reserve a session for --list-models", async () => { + const result = await runCli(["--session-id", "read-only-models", "--list-models"]); + + expect(result.code).toBe(0); + expect(hasSessionWithId(join(result.agentDir, "sessions"), "read-only-models")).toBe(false); + }); + + it("rejects an existing fork target session id", async () => { + const result = await runCli( + (dirs) => ["--session-dir", dirs.sessionDir, "--fork", "source-id", "--session-id", "existing-id", "-p", "hi"], + (dirs) => { + mkdirSync(dirs.sessionDir, { recursive: true }); + writeSession(dirs.sessionDir, dirs.projectDir, "source-id"); + writeSession(dirs.sessionDir, dirs.projectDir, "existing-id"); + }, + ); + + expect(result.code).toBe(1); + expect(result.stderr).toContain("Session already exists with id 'existing-id'"); + }); +}); + +describe("--session-id validation", () => { + it("rejects ids invalid under SessionManager rules without stack traces", async () => { + for (const id of ["-bad", "bad id"]) { + const result = await runCli(["--session-id", id, "-p", "hi"]); + + expect(result.code).toBe(1); + expect(result.stderr).toContain("Session id must be non-empty"); + expect(result.stderr).not.toContain("SessionManager.create"); + } + }); +}); diff --git a/packages/coding-agent/test/session-manager/custom-session-id.test.ts b/packages/coding-agent/test/session-manager/custom-session-id.test.ts index f9c6ad0d..ee2da917 100644 --- a/packages/coding-agent/test/session-manager/custom-session-id.test.ts +++ b/packages/coding-agent/test/session-manager/custom-session-id.test.ts @@ -1,6 +1,6 @@ -import { mkdtempSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { describe, expect, it } from "vitest"; import { SessionManager } from "../../src/core/session-manager.ts"; @@ -13,6 +13,23 @@ describe("SessionManager.newSession with custom id", () => { expect(session.getSessionId()).toBe("my-custom-id"); }); + it("allows alphanumeric session ids with interior punctuation", () => { + const session = SessionManager.inMemory(); + session.newSession({ id: "abc-123_def.456" }); + expect(session.getSessionId()).toBe("abc-123_def.456"); + }); + + it("rejects invalid custom session ids", () => { + const invalidIds = ["", "-abc", "abc-", "_abc", "abc_", ".abc", "abc.", "abc/def", "abc\\def", "abc def"]; + + for (const id of invalidIds) { + const session = SessionManager.inMemory(); + expect(() => session.newSession({ id })).toThrow( + "Session id must be non-empty, contain only alphanumeric characters", + ); + } + }); + it("generates a UUIDv7 id when no id is provided", () => { const session = SessionManager.inMemory(); session.newSession(); @@ -46,6 +63,18 @@ describe("SessionManager.newSession with custom id", () => { expect(session.getHeader()!.id).toBe(session.getSessionId()); }); + it("uses the provided id when creating a persisted session", () => { + const tempDir = mkdtempSync(join(tmpdir(), "pi-session-manager-")); + const session = SessionManager.create(tempDir, tempDir, { id: "created-session-id" }); + + expect(session.getSessionId()).toBe("created-session-id"); + expect(session.getHeader()!.id).toBe("created-session-id"); + const sessionFile = session.getSessionFile()!; + expect(sessionFile).toContain("created-session-id"); + expect(basename(sessionFile)).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z_created-session-id\.jsonl$/); + expect(existsSync(sessionFile)).toBe(false); + }); + it("generates a UUIDv7 id when creating a branched session", () => { const session = SessionManager.inMemory(); const firstId = session.appendMessage({ @@ -106,4 +135,28 @@ describe("SessionManager.newSession with custom id", () => { expect(header!.id).toMatch(UUID_V7_RE); expect(header!.parentSession).toBe(sourcePath); }); + + it("uses the provided id when forking from another session file", () => { + const tempDir = mkdtempSync(join(tmpdir(), "pi-session-manager-")); + const sourcePath = join(tempDir, "source.jsonl"); + writeFileSync( + sourcePath, + `${JSON.stringify({ + type: "session", + version: 3, + id: "source-session-id", + timestamp: new Date().toISOString(), + cwd: tempDir, + })}\n`, + ); + + const forked = SessionManager.forkFrom(sourcePath, tempDir, tempDir, { id: "forked-session-id" }); + const header = forked.getHeader(); + expect(header).not.toBeNull(); + expect(header!.id).toBe("forked-session-id"); + expect(header!.parentSession).toBe(sourcePath); + const sessionFile = forked.getSessionFile()!; + expect(sessionFile).toContain("forked-session-id"); + expect(basename(sessionFile)).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z_forked-session-id\.jsonl$/); + }); }); From 41d28a9232778e3da84b8e6274c41a10438851ea Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 27 May 2026 21:40:07 +0200 Subject: [PATCH 050/352] docs(changelog): audit unreleased entries --- packages/ai/CHANGELOG.md | 1 + packages/coding-agent/CHANGELOG.md | 16 +++++++++++++++- packages/tui/CHANGELOG.md | 2 ++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 44022b7a..a30f4624 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -8,6 +8,7 @@ - Fixed `openai-codex/gpt-5.3-codex-spark` generated metadata to use its 128k context window ([#4969](https://github.com/earendil-works/pi/issues/4969)). - Fixed OpenRouter/Poolside context overflow detection for `maximum allowed input length` errors ([#4943](https://github.com/earendil-works/pi/issues/4943)). - Fixed OpenAI Codex Responses WebSocket streams and SSE response-header waits to apply bounded timeouts instead of waiting indefinitely when no events arrive ([#4945](https://github.com/earendil-works/pi/issues/4945)). +- Fixed provider retry controls so OpenAI Codex Responses honors `maxRetries`, SDK retries default to `0`, and quota/billing 429s are not retried behind Pi's retry handling ([#4991](https://github.com/earendil-works/pi-mono/pull/4991) by [@mitsuhiko](https://github.com/mitsuhiko)). ## [0.75.5] - 2026-05-23 diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9da5b576..8272ec6a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,12 +2,18 @@ ## [Unreleased] +### New Features + +- **Explicit session IDs for automation** - `--session-id ` lets scripts create or resume an exact project-local session. See [Sessions](docs/usage.md#sessions). +- **RPC bash output can stay out of model context** - RPC clients can pass `excludeFromContext` to `bash` for commands whose output should not be sent with the next prompt. See [RPC mode](docs/rpc.md#bash). +- **More predictable provider retries and timeouts** - Codex WebSocket/SSE waits are bounded, and `retry.provider.maxRetries` controls provider retries instead of hidden SDK defaults. See [Retry settings](docs/settings.md#retry). +- **Better terminal editing across environments** - Apple Terminal Shift+Enter, Windows/JetBrains capability detection, and Unicode-aware word navigation improve interactive editing. See [Terminal setup](docs/terminal-setup.md) and [Keybindings](docs/keybindings.md). + ### Added - Added `--session-id` to let CLI callers use an exact project-local session ID, creating it if missing ([#4874](https://github.com/earendil-works/pi/issues/4874)). - Added `excludeFromContext` flag to the `bash` RPC command for parity with the internal `executeBash` API ([#5039](https://github.com/earendil-works/pi/issues/5039)). - ### Fixed - Fixed user message transcript rendering to preserve user-authored ordered-list markers ([#5013](https://github.com/earendil-works/pi/issues/5013)). @@ -17,6 +23,14 @@ - Fixed `RpcClient` to reject pending requests and consume stdin pipe errors when the child process exits unexpectedly ([#4764](https://github.com/earendil-works/pi/issues/4764)). - Fixed managed npm extension updates to avoid package managers installing or resolving pi host packages as peer dependencies ([#4907](https://github.com/earendil-works/pi/issues/4907)). - Fixed RPC mode raw stdout writes to retry transient backpressure errors and flush queued protocol output during shutdown ([#4897](https://github.com/earendil-works/pi/issues/4897)). +- Fixed OpenAI Codex Responses cache-affinity headers to send `session-id` instead of proxy-incompatible `session_id` ([#4967](https://github.com/earendil-works/pi/issues/4967)). +- Fixed `openai-codex/gpt-5.3-codex-spark` model metadata to use its 128k context window ([#4969](https://github.com/earendil-works/pi/issues/4969)). +- Fixed OpenRouter/Poolside context overflow detection for `maximum allowed input length` errors ([#4943](https://github.com/earendil-works/pi/issues/4943)). +- Fixed provider retry controls so `retry.provider.maxRetries` is honored, SDK retries default to `0`, and quota/billing 429s are not retried behind Pi's retry handling ([#4991](https://github.com/earendil-works/pi-mono/pull/4991) by [@mitsuhiko](https://github.com/mitsuhiko)). +- Fixed Apple Terminal `Shift+Enter` by detecting local macOS modifier state when Terminal.app sends plain Return. +- Fixed Windows Terminal capability detection to enable OSC 8 hyperlinks, preserving clickable long URLs across wrapped lines ([#4923](https://github.com/earendil-works/pi/issues/4923)). +- Fixed JetBrains terminal capability detection to enable truecolor while disabling unsupported OSC 8 hyperlinks ([#5037](https://github.com/earendil-works/pi-mono/pull/5037) by [@Perlence](https://github.com/Perlence)). +- Fixed editor and input word navigation/deletion to use Unicode word boundaries while preserving ASCII punctuation boundaries ([#5022](https://github.com/earendil-works/pi-mono/pull/5022) by [@haoqixu](https://github.com/haoqixu), [#5067](https://github.com/earendil-works/pi-mono/pull/5067) by [@haoqixu](https://github.com/haoqixu), [#5068](https://github.com/earendil-works/pi-mono/pull/5068) by [@haoqixu](https://github.com/haoqixu)). ## [0.75.5] - 2026-05-23 diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index c4e913d8..150efe8a 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -10,6 +10,8 @@ - Fixed `Shift+Enter` in Apple Terminal by detecting local macOS modifier state when Terminal.app sends plain Return. - Fixed Windows Terminal capability detection to enable OSC 8 hyperlinks, preserving clickable long URLs across wrapped lines ([#4923](https://github.com/earendil-works/pi/issues/4923)). +- Fixed JetBrains terminal capability detection to enable truecolor while disabling unsupported OSC 8 hyperlinks ([#5037](https://github.com/earendil-works/pi-mono/pull/5037) by [@Perlence](https://github.com/Perlence)). +- Fixed editor and input word navigation/deletion to use Unicode word boundaries while preserving ASCII punctuation boundaries ([#5022](https://github.com/earendil-works/pi-mono/pull/5022) by [@haoqixu](https://github.com/haoqixu), [#5067](https://github.com/earendil-works/pi-mono/pull/5067) by [@haoqixu](https://github.com/haoqixu), [#5068](https://github.com/earendil-works/pi-mono/pull/5068) by [@haoqixu](https://github.com/haoqixu)). ## [0.75.5] - 2026-05-23 From 16dc525e7bc92df56dfa5ed6fc9188989c35b4a3 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 27 May 2026 21:56:55 +0200 Subject: [PATCH 051/352] fix(build): resolve internal packages from workspace dist --- packages/agent/tsconfig.build.json | 4 ++++ packages/coding-agent/tsconfig.build.json | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/packages/agent/tsconfig.build.json b/packages/agent/tsconfig.build.json index 695dd9ad..979dbc89 100644 --- a/packages/agent/tsconfig.build.json +++ b/packages/agent/tsconfig.build.json @@ -2,6 +2,10 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist", + "paths": { + "@earendil-works/pi-ai": ["../ai/dist/index.d.ts"], + "@earendil-works/pi-ai/*": ["../ai/dist/*.d.ts", "../ai/dist/providers/*.d.ts"] + }, "rootDir": "./src" }, "include": ["src/**/*.ts"], diff --git a/packages/coding-agent/tsconfig.build.json b/packages/coding-agent/tsconfig.build.json index 3437fd70..15a2436c 100644 --- a/packages/coding-agent/tsconfig.build.json +++ b/packages/coding-agent/tsconfig.build.json @@ -2,6 +2,14 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist", + "paths": { + "@earendil-works/pi-agent-core": ["../agent/dist/index.d.ts"], + "@earendil-works/pi-agent-core/*": ["../agent/dist/*.d.ts"], + "@earendil-works/pi-ai": ["../ai/dist/index.d.ts"], + "@earendil-works/pi-ai/*": ["../ai/dist/*.d.ts", "../ai/dist/providers/*.d.ts"], + "@earendil-works/pi-tui": ["../tui/dist/index.d.ts"], + "@earendil-works/pi-tui/*": ["../tui/dist/*.d.ts", "../tui/dist/components/*.d.ts"] + }, "rootDir": "./src" }, "include": ["src/**/*.ts", "src/**/*.d.ts"], From 706f8720f8cfe75f562b4e488681486cb70bbe9b Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 27 May 2026 22:03:21 +0200 Subject: [PATCH 052/352] Release v0.76.0 --- package-lock.json | 48 ++++++++++++++----- packages/agent/CHANGELOG.md | 2 +- packages/agent/package.json | 4 +- packages/ai/CHANGELOG.md | 2 +- packages/ai/package.json | 2 +- packages/coding-agent/CHANGELOG.md | 2 +- .../package-lock.json | 4 +- .../custom-provider-anthropic/package.json | 2 +- .../custom-provider-gitlab-duo/package.json | 2 +- .../extensions/sandbox/package-lock.json | 4 +- .../examples/extensions/sandbox/package.json | 2 +- .../extensions/with-deps/package-lock.json | 4 +- .../extensions/with-deps/package.json | 2 +- packages/coding-agent/npm-shrinkwrap.json | 24 +++++----- packages/coding-agent/package.json | 8 ++-- packages/tui/CHANGELOG.md | 2 +- packages/tui/package.json | 2 +- 17 files changed, 70 insertions(+), 46 deletions(-) diff --git a/package-lock.json b/package-lock.json index c34bd21f..421dd353 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5980,10 +5980,10 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.75.5", + "version": "0.76.0", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.75.5", + "@earendil-works/pi-ai": "^0.76.0", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -5998,6 +5998,30 @@ "node": ">=22.19.0" } }, + "packages/agent/node_modules/@earendil-works/pi-ai": { + "version": "0.75.5", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.75.5.tgz", + "integrity": "sha512-zf1F5kXk1pqZeFShXOqq9ibUk8QdtRoLCDPAjO+hj44e3EUs9/GFO2qnhTC5+JA2uwVCx+WCNe1PiCjlBYWm5w==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.1", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.1.38" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, "packages/agent/node_modules/@types/node": { "version": "24.12.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", @@ -6017,7 +6041,7 @@ }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.75.5", + "version": "0.76.0", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -6062,12 +6086,12 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "0.75.5", + "version": "0.76.0", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.75.5", - "@earendil-works/pi-ai": "^0.75.5", - "@earendil-works/pi-tui": "^0.75.5", + "@earendil-works/pi-agent-core": "^0.76.0", + "@earendil-works/pi-ai": "^0.76.0", + "@earendil-works/pi-tui": "^0.76.0", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -6106,25 +6130,25 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.75.5", + "version": "0.76.0", "dependencies": { "@anthropic-ai/sdk": "0.52.0" } }, "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": { "name": "pi-extension-custom-provider-gitlab-duo", - "version": "0.75.5" + "version": "0.76.0" }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.5.5", + "version": "1.6.0", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.75.5", + "version": "0.76.0", "dependencies": { "ms": "2.1.3" }, @@ -6160,7 +6184,7 @@ }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.75.5", + "version": "0.76.0", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 8996003c..4571b8d7 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.76.0] - 2026-05-27 ### Fixed diff --git a/packages/agent/package.json b/packages/agent/package.json index 803ca975..3ff40436 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "0.75.5", + "version": "0.76.0", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -29,7 +29,7 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "^0.75.5", + "@earendil-works/pi-ai": "^0.76.0", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index a30f4624..b15784e4 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.76.0] - 2026-05-27 ### Fixed diff --git a/packages/ai/package.json b/packages/ai/package.json index 68946b61..f9f3bfdf 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.75.5", + "version": "0.76.0", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8272ec6a..cd27ca50 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.76.0] - 2026-05-27 ### 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 9833a6a3..85ae7420 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-custom-provider", - "version": "0.75.5", + "version": "0.76.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "0.75.5", + "version": "0.76.0", "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 6098f5d4..8e22c249 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-anthropic", "private": true, - "version": "0.75.5", + "version": "0.76.0", "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 49ae54fd..c7479743 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-gitlab-duo", "private": true, - "version": "0.75.5", + "version": "0.76.0", "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 038a3ee0..9a2c4774 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package-lock.json +++ b/packages/coding-agent/examples/extensions/sandbox/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-sandbox", - "version": "1.5.5", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.5.5", + "version": "1.6.0", "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 9aacc96f..ec1cf105 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package.json +++ b/packages/coding-agent/examples/extensions/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-sandbox", "private": true, - "version": "1.5.5", + "version": "1.6.0", "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 ba06e6d3..e79685e7 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package-lock.json +++ b/packages/coding-agent/examples/extensions/with-deps/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-with-deps", - "version": "0.75.5", + "version": "0.76.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "0.75.5", + "version": "0.76.0", "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 d6554694..2855a465 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package.json +++ b/packages/coding-agent/examples/extensions/with-deps/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-with-deps", "private": true, - "version": "0.75.5", + "version": "0.76.0", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index b82cebea..71ce72ed 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -1,17 +1,17 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.75.5", + "version": "0.76.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent", - "version": "0.75.5", + "version": "0.76.0", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.75.5", - "@earendil-works/pi-ai": "^0.75.5", - "@earendil-works/pi-tui": "^0.75.5", + "@earendil-works/pi-agent-core": "^0.76.0", + "@earendil-works/pi-ai": "^0.76.0", + "@earendil-works/pi-tui": "^0.76.0", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -473,11 +473,11 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.75.5", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.75.5.tgz", + "version": "0.76.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.76.0.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.75.5", + "@earendil-works/pi-ai": "^0.76.0", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -487,8 +487,8 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.75.5", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.75.5.tgz", + "version": "0.76.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.76.0.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -510,8 +510,8 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.75.5", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.75.5.tgz", + "version": "0.76.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.76.0.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 b73e82ef..720b4de3 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.75.5", + "version": "0.76.0", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -39,9 +39,9 @@ "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" }, "dependencies": { - "@earendil-works/pi-agent-core": "^0.75.5", - "@earendil-works/pi-ai": "^0.75.5", - "@earendil-works/pi-tui": "^0.75.5", + "@earendil-works/pi-agent-core": "^0.76.0", + "@earendil-works/pi-ai": "^0.76.0", + "@earendil-works/pi-tui": "^0.76.0", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 150efe8a..1091bfd4 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.76.0] - 2026-05-27 ### Added diff --git a/packages/tui/package.json b/packages/tui/package.json index 848b16b9..d10547de 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-tui", - "version": "0.75.5", + "version": "0.76.0", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", From 39a26c84f889d7557c8900236e982881fa7062c9 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 27 May 2026 22:04:01 +0200 Subject: [PATCH 053/352] Add [Unreleased] section for next cycle --- packages/agent/CHANGELOG.md | 2 + packages/ai/CHANGELOG.md | 2 + packages/ai/src/models.generated.ts | 245 +++++++++++++--------------- packages/coding-agent/CHANGELOG.md | 2 + packages/tui/CHANGELOG.md | 2 + 5 files changed, 122 insertions(+), 131 deletions(-) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 4571b8d7..5e99aada 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.76.0] - 2026-05-27 ### Fixed diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b15784e4..9d9935d5 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.76.0] - 2026-05-27 ### Fixed diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index efd74010..7bfc3c59 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -1922,7 +1922,7 @@ export const MODELS = { cost: { input: 0.1, output: 0.4, - cacheRead: 0.03, + cacheRead: 0.025, cacheWrite: 0, }, contextWindow: 1047576, @@ -2007,7 +2007,7 @@ export const MODELS = { cost: { input: 0.15, output: 0.6, - cacheRead: 0.08, + cacheRead: 0.075, cacheWrite: 0, }, contextWindow: 128000, @@ -2133,7 +2133,7 @@ export const MODELS = { cost: { input: 1.25, output: 10, - cacheRead: 0.13, + cacheRead: 0.125, cacheWrite: 0, }, contextWindow: 400000, @@ -2558,7 +2558,7 @@ export const MODELS = { cost: { input: 1.1, output: 4.4, - cacheRead: 0.28, + cacheRead: 0.275, cacheWrite: 0, }, contextWindow: 200000, @@ -2617,23 +2617,6 @@ export const MODELS = { contextWindow: 32000, maxTokens: 8000, } satisfies Model<"openai-completions">, - "qwen-3-235b-a22b-instruct-2507": { - id: "qwen-3-235b-a22b-instruct-2507", - name: "Qwen 3 235B Instruct", - api: "openai-completions", - provider: "cerebras", - baseUrl: "https://api.cerebras.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.6, - output: 1.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131000, - maxTokens: 32000, - } satisfies Model<"openai-completions">, "zai-glm-4.7": { id: "zai-glm-4.7", name: "Z.AI GLM-4.7", @@ -4850,7 +4833,7 @@ export const MODELS = { cost: { input: 1, output: 3, - cacheRead: 0, + cacheRead: 0.5, cacheWrite: 0, }, contextWindow: 262144, @@ -4867,7 +4850,7 @@ export const MODELS = { cost: { input: 0.15, output: 0.6, - cacheRead: 0, + cacheRead: 0.075, cacheWrite: 0, }, contextWindow: 131072, @@ -4884,7 +4867,7 @@ export const MODELS = { cost: { input: 0.075, output: 0.3, - cacheRead: 0, + cacheRead: 0.0375, cacheWrite: 0, }, contextWindow: 131072, @@ -5170,9 +5153,9 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 1.74, - output: 3.48, - cacheRead: 0.145, + input: 0.435, + output: 0.87, + cacheRead: 0.003625, cacheWrite: 0, }, contextWindow: 1048576, @@ -6265,7 +6248,7 @@ export const MODELS = { cost: { input: 0.1, output: 0.4, - cacheRead: 0.03, + cacheRead: 0.025, cacheWrite: 0, }, contextWindow: 1047576, @@ -6350,7 +6333,7 @@ export const MODELS = { cost: { input: 0.15, output: 0.6, - cacheRead: 0.08, + cacheRead: 0.075, cacheWrite: 0, }, contextWindow: 128000, @@ -6476,7 +6459,7 @@ export const MODELS = { cost: { input: 1.25, output: 10, - cacheRead: 0.13, + cacheRead: 0.125, cacheWrite: 0, }, contextWindow: 400000, @@ -6901,7 +6884,7 @@ export const MODELS = { cost: { input: 1.1, output: 4.4, - cacheRead: 0.28, + cacheRead: 0.275, cacheWrite: 0, }, contextWindow: 200000, @@ -7051,7 +7034,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 200000, - maxTokens: 128000, + maxTokens: 32000, } satisfies Model<"openai-completions">, "claude-haiku-4-5": { id: "claude-haiku-4-5", @@ -7640,6 +7623,23 @@ export const MODELS = { contextWindow: 262144, maxTokens: 65536, } satisfies Model<"openai-completions">, + "mimo-v2.5-free": { + id: "mimo-v2.5-free", + name: "MiMo V2.5 Free", + api: "openai-completions", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "minimax-m2.5": { id: "minimax-m2.5", name: "MiniMax M2.5", @@ -7842,9 +7842,9 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.4, - output: 2, - cacheRead: 0.08, + input: 0.14, + output: 0.28, + cacheRead: 0.0028, cacheWrite: 0, }, contextWindow: 1000000, @@ -7859,9 +7859,9 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 1, - output: 3, - cacheRead: 0.2, + input: 1.74, + output: 3.48, + cacheRead: 0.0145, cacheWrite: 0, }, contextWindow: 1048576, @@ -7937,6 +7937,23 @@ export const MODELS = { contextWindow: 262144, maxTokens: 65536, } satisfies Model<"openai-completions">, + "qwen3.7-max": { + id: "qwen3.7-max", + name: "Qwen3.7 Max", + api: "anthropic-messages", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go", + reasoning: true, + input: ["text"], + cost: { + input: 2.5, + output: 7.5, + cacheRead: 0.5, + cacheWrite: 3.125, + }, + contextWindow: 1000000, + maxTokens: 65536, + } satisfies Model<"anthropic-messages">, }, "openrouter": { "ai21/jamba-large-1.7": { @@ -7956,23 +7973,6 @@ export const MODELS = { contextWindow: 256000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "alibaba/tongyi-deepresearch-30b-a3b": { - id: "alibaba/tongyi-deepresearch-30b-a3b", - name: "Tongyi DeepResearch 30B A3B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.09, - output: 0.44999999999999996, - cacheRead: 0.09, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"openai-completions">, "amazon/nova-2-lite-v1": { id: "amazon/nova-2-lite-v1", name: "Amazon: Nova 2 Lite", @@ -8300,23 +8300,6 @@ export const MODELS = { contextWindow: 262144, maxTokens: 262144, } satisfies Model<"openai-completions">, - "arcee-ai/trinity-large-thinking:free": { - id: "arcee-ai/trinity-large-thinking:free", - name: "Arcee AI: Trinity Large Thinking (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 80000, - } satisfies Model<"openai-completions">, "arcee-ai/trinity-mini": { id: "arcee-ai/trinity-mini", name: "Arcee AI: Trinity Mini", @@ -8368,23 +8351,6 @@ export const MODELS = { contextWindow: 2000000, maxTokens: 30000, } satisfies Model<"openai-completions">, - "baidu/cobuddy:free": { - id: "baidu/cobuddy:free", - name: "Baidu Qianfan: CoBuddy (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 65536, - } satisfies Model<"openai-completions">, "baidu/ernie-4.5-21b-a3b": { id: "baidu/ernie-4.5-21b-a3b", name: "Baidu: ERNIE 4.5 21B A3B", @@ -8530,13 +8496,13 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.32, - output: 0.8899999999999999, + input: 0.2288, + output: 0.9144, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 163840, - maxTokens: 16384, + contextWindow: 131072, + maxTokens: 16000, } satisfies Model<"openai-completions">, "deepseek/deepseek-chat-v3-0324": { id: "deepseek/deepseek-chat-v3-0324", @@ -9803,6 +9769,23 @@ export const MODELS = { contextWindow: 262144, maxTokens: 262142, } satisfies Model<"openai-completions">, + "moonshotai/kimi-k2.6:free": { + id: "moonshotai/kimi-k2.6:free", + name: "MoonshotAI: Kimi K2.6 (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "nex-agi/deepseek-v3.1-nex-n1": { id: "nex-agi/deepseek-v3.1-nex-n1", name: "Nex AGI: DeepSeek V3.1 Nex N1", @@ -10392,11 +10375,11 @@ export const MODELS = { cost: { input: 1.25, output: 10, - cacheRead: 0.125, + cacheRead: 0.13, cacheWrite: 0, }, contextWindow: 128000, - maxTokens: 16384, + maxTokens: 32000, } satisfies Model<"openai-completions">, "openai/gpt-5.1-codex": { id: "openai/gpt-5.1-codex", @@ -10409,7 +10392,7 @@ export const MODELS = { cost: { input: 1.25, output: 10, - cacheRead: 0.125, + cacheRead: 0.13, cacheWrite: 0, }, contextWindow: 400000, @@ -10443,11 +10426,11 @@ export const MODELS = { cost: { input: 0.25, output: 2, - cacheRead: 0.03, + cacheRead: 0.024999999999999998, cacheWrite: 0, }, contextWindow: 400000, - maxTokens: 128000, + maxTokens: 100000, } satisfies Model<"openai-completions">, "openai/gpt-5.2": { id: "openai/gpt-5.2", @@ -10483,7 +10466,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 128000, - maxTokens: 32000, + maxTokens: 16384, } satisfies Model<"openai-completions">, "openai/gpt-5.2-codex": { id: "openai/gpt-5.2-codex", @@ -11019,8 +11002,8 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 8192, + contextWindow: 262144, + maxTokens: 32768, } satisfies Model<"openai-completions">, "poolside/laguna-xs.2:free": { id: "poolside/laguna-xs.2:free", @@ -11036,8 +11019,8 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 8192, + contextWindow: 262144, + maxTokens: 32768, } satisfies Model<"openai-completions">, "prime-intellect/intellect-3": { id: "prime-intellect/intellect-3", @@ -11119,7 +11102,7 @@ export const MODELS = { input: 0.26, output: 0.78, cacheRead: 0, - cacheWrite: 0.325, + cacheWrite: 0, }, contextWindow: 1000000, maxTokens: 32768, @@ -11697,7 +11680,7 @@ export const MODELS = { input: 0.065, output: 0.26, cacheRead: 0, - cacheWrite: 0.08125, + cacheWrite: 0, }, contextWindow: 1000000, maxTokens: 65536, @@ -11714,7 +11697,7 @@ export const MODELS = { input: 0.26, output: 1.56, cacheRead: 0, - cacheWrite: 0.325, + cacheWrite: 0, }, contextWindow: 1000000, maxTokens: 65536, @@ -11731,7 +11714,7 @@ export const MODELS = { input: 0.3, output: 1.7999999999999998, cacheRead: 0, - cacheWrite: 0, + cacheWrite: 0.375, }, contextWindow: 1000000, maxTokens: 65536, @@ -11745,13 +11728,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.3, + input: 0.29, output: 3.1999999999999997, cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 262140, } satisfies Model<"openai-completions">, "qwen/qwen3.6-35b-a3b": { id: "qwen/qwen3.6-35b-a3b", @@ -11762,7 +11745,7 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.15, + input: 0.14, output: 1, cacheRead: 0, cacheWrite: 0, @@ -11830,10 +11813,10 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 2.5, - output: 7.5, - cacheRead: 0, - cacheWrite: 3.125, + input: 1.25, + output: 3.75, + cacheRead: 0.25, + cacheWrite: 1.5625, }, contextWindow: 1000000, maxTokens: 65536, @@ -12102,9 +12085,9 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.39999999999999997, - output: 2, - cacheRead: 0.08, + input: 0.14, + output: 0.28, + cacheRead: 0.0028, cacheWrite: 0, }, contextWindow: 1048576, @@ -12119,13 +12102,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 1, - output: 3, - cacheRead: 0.19999999999999998, + input: 0.435, + output: 0.87, + cacheRead: 0.0036, cacheWrite: 0, }, contextWindow: 1048576, - maxTokens: 16384, + maxTokens: 131072, } satisfies Model<"openai-completions">, "z-ai/glm-4-32b": { id: "z-ai/glm-4-32b", @@ -12170,13 +12153,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.13, - output: 0.85, - cacheRead: 0.024999999999999998, + input: 0.125, + output: 0.84, + cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 98304, + maxTokens: 131070, } satisfies Model<"openai-completions">, "z-ai/glm-4.5-air:free": { id: "z-ai/glm-4.5-air:free", @@ -15284,9 +15267,9 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.39999999999999997, - output: 2, - cacheRead: 0.08, + input: 0.14, + output: 0.28, + cacheRead: 0.0028, cacheWrite: 0, }, contextWindow: 1050000, @@ -15301,9 +15284,9 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 1, - output: 3, - cacheRead: 0.19999999999999998, + input: 0.435, + output: 0.87, + cacheRead: 0.0036, cacheWrite: 0, }, contextWindow: 1050000, diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index cd27ca50..18736cae 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.76.0] - 2026-05-27 ### New Features diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 1091bfd4..201dd2e1 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.76.0] - 2026-05-27 ### Added From 6ab62a06d70338dea01d84a43c84e4339262d676 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 27 May 2026 21:25:52 +0200 Subject: [PATCH 054/352] fix(tui): harden keyboard protocol negotiation --- packages/tui/src/terminal.ts | 239 +++++++++++++---- packages/tui/test/terminal.test.ts | 397 ++++++++++++++++++++++++++++- 2 files changed, 591 insertions(+), 45 deletions(-) diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 714f4d9a..563537d7 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -12,6 +12,31 @@ const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000; const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07"; const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07"; const APPLE_TERMINAL_SHIFT_ENTER_SEQUENCE = "\x1b[13;2u"; +const DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS = 7; +const KITTY_KEYBOARD_PROTOCOL_FALLBACK_TIMEOUT_MS = 150; +const KEYBOARD_PROTOCOL_RESPONSE_FRAGMENT_TIMEOUT_MS = 150; +const KITTY_KEYBOARD_PROTOCOL_QUERY = `\x1b[>${DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS}u\x1b[?u\x1b[c`; + +export type KeyboardProtocolNegotiationSequence = + | { type: "kitty-flags"; flags: number } + | { type: "device-attributes" }; + +export function parseKeyboardProtocolNegotiationSequence( + sequence: string, +): KeyboardProtocolNegotiationSequence | undefined { + const kittyFlags = sequence.match(/^\x1b\[\?(\d+)u$/); + if (kittyFlags) { + return { type: "kitty-flags", flags: Number.parseInt(kittyFlags[1]!, 10) }; + } + if (/^\x1b\[\?[\d;]*c$/.test(sequence)) { + return { type: "device-attributes" }; + } + return undefined; +} + +function isKeyboardProtocolNegotiationSequencePrefix(sequence: string, allowBareEscapePrefix: boolean): boolean { + return (allowBareEscapePrefix && sequence === "\x1b") || sequence === "\x1b[" || /^\x1b\[\?[\d;]*$/.test(sequence); +} export function isAppleTerminalSession(): boolean { return process.platform === "darwin" && process.env.TERM_PROGRAM === "Apple_Terminal"; @@ -78,6 +103,12 @@ export class ProcessTerminal implements Terminal { private resizeHandler?: () => void; private _kittyProtocolActive = false; private _modifyOtherKeysActive = false; + private keyboardProtocolPushed = false; + private keyboardProtocolNegotiationPending = false; + private keyboardProtocolLateResponsePending = false; + private keyboardProtocolNegotiationBuffer = ""; + private keyboardProtocolFallbackTimer?: ReturnType; + private keyboardProtocolBufferFlushTimer?: ReturnType; private stdinBuffer?: StdinBuffer; private stdinDataHandler?: (data: string) => void; private progressInterval?: ReturnType; @@ -147,37 +178,30 @@ export class ProcessTerminal implements Terminal { private setupStdinBuffer(): void { this.stdinBuffer = new StdinBuffer({ timeout: 10 }); - // Kitty protocol response pattern: \x1b[?u - const kittyResponsePattern = /^\x1b\[\?(\d+)u$/; - // Forward individual sequences to the input handler this.stdinBuffer.on("data", (sequence) => { - // Check for Kitty protocol response (only if not already enabled) - if (!this._kittyProtocolActive) { - const match = sequence.match(kittyResponsePattern); - if (match) { - this._kittyProtocolActive = true; - setKittyProtocolActive(true); - - // Enable Kitty keyboard protocol (push flags) - // Flag 1 = disambiguate escape codes - // Flag 2 = report event types (press/repeat/release) - // Flag 4 = report alternate keys (shifted key, base layout key) - // Base layout key enables shortcuts to work with non-Latin keyboard layouts - process.stdout.write("\x1b[>7u"); - return; // Don't forward protocol response to TUI + if (this.keyboardProtocolNegotiationPending) { + const negotiationSequence = this.readKeyboardProtocolNegotiationSequence(sequence, true); + if (negotiationSequence === "pending") { + return; // Wait for the rest of a split negotiation response. + } + if (this.handleKeyboardProtocolNegotiationSequence(negotiationSequence)) { + return; } } - if (this.inputHandler) { - const isAppleTerminal = sequence === "\r" && isAppleTerminalSession(); - const input = normalizeAppleTerminalInput( - sequence, - isAppleTerminal, - isAppleTerminal && isNativeModifierPressed("shift"), - ); - this.inputHandler(input); + if (this.keyboardProtocolLateResponsePending) { + const negotiationSequence = this.readKeyboardProtocolNegotiationSequence(sequence, false); + if (negotiationSequence === "pending") { + this.scheduleKeyboardProtocolNegotiationBufferFlush(); + return; // Wait for the rest of a split late negotiation response. + } + if (this.handleKeyboardProtocolNegotiationSequence(negotiationSequence)) { + return; + } } + + this.forwardInputSequence(sequence); }); // Re-wrap paste content with bracketed paste markers for existing editor handling @@ -194,29 +218,143 @@ export class ProcessTerminal implements Terminal { } /** - * Query terminal for Kitty keyboard protocol support and enable if available. + * Query terminal for Kitty keyboard protocol support and enable it if available. * - * Sends CSI ? u to query current flags. If terminal responds with CSI ? u, - * it supports the protocol and we enable it with CSI > 1 u. + * Kitty's progressive enhancement detection requires requesting the desired + * flags before querying them. The trailing DA query is a sentinel supported by + * terminals that do not know Kitty keyboard protocol. A short timeout remains + * as a backup for terminals, PTYs, and SSH sessions that delay, split, or drop + * the DA response. * - * If no Kitty response arrives shortly after startup, fall back to enabling - * xterm modifyOtherKeys mode 2. This is needed for tmux, which can forward - * modified enter keys as CSI-u when extended-keys is enabled, but may not - * answer the Kitty protocol query. - * - * The response is detected in setupStdinBuffer's data handler, which properly - * handles the case where the response arrives split across multiple stdin events. + * The requested flags are: + * - 1 = disambiguate escape codes + * - 2 = report event types (press/repeat/release) + * - 4 = report alternate keys (shifted key, base layout key) */ private queryAndEnableKittyProtocol(): void { this.setupStdinBuffer(); process.stdin.on("data", this.stdinDataHandler!); - process.stdout.write("\x1b[?u"); - setTimeout(() => { - if (!this._kittyProtocolActive && !this._modifyOtherKeysActive) { - process.stdout.write("\x1b[>4;2m"); - this._modifyOtherKeysActive = true; + this.keyboardProtocolPushed = true; + this.keyboardProtocolNegotiationPending = true; + this.keyboardProtocolLateResponsePending = false; + this.clearKeyboardProtocolNegotiationBuffer(); + process.stdout.write(KITTY_KEYBOARD_PROTOCOL_QUERY); + this.keyboardProtocolFallbackTimer = setTimeout(() => { + this.keyboardProtocolFallbackTimer = undefined; + this.keyboardProtocolNegotiationPending = false; + this.keyboardProtocolLateResponsePending = true; + if (this.keyboardProtocolNegotiationBuffer === "\x1b") { + this.flushKeyboardProtocolNegotiationBufferAsInput(); + } else { + this.scheduleKeyboardProtocolNegotiationBufferFlush(); } - }, 150); + this.enableModifyOtherKeys(); + }, KITTY_KEYBOARD_PROTOCOL_FALLBACK_TIMEOUT_MS); + } + + private handleKeyboardProtocolNegotiationSequence( + negotiationSequence: KeyboardProtocolNegotiationSequence | undefined, + ): boolean { + if (!negotiationSequence) return false; + if (negotiationSequence.type === "kitty-flags") { + if (negotiationSequence.flags !== 0 && !this._kittyProtocolActive) { + this._kittyProtocolActive = true; + setKittyProtocolActive(true); + this.keyboardProtocolNegotiationPending = false; + this.keyboardProtocolLateResponsePending = true; + this.clearKeyboardProtocolNegotiationBuffer(); + this.clearKeyboardProtocolFallbackTimer(); + } + return true; + } + + this.keyboardProtocolNegotiationPending = false; + this.keyboardProtocolLateResponsePending = true; + this.clearKeyboardProtocolNegotiationBuffer(); + this.clearKeyboardProtocolFallbackTimer(); + this.enableModifyOtherKeys(); + return true; + } + + private readKeyboardProtocolNegotiationSequence( + sequence: string, + allowBareEscapePrefix: boolean, + ): KeyboardProtocolNegotiationSequence | "pending" | undefined { + if (this.keyboardProtocolNegotiationBuffer) { + const bufferedSequence = this.keyboardProtocolNegotiationBuffer + sequence; + const negotiationSequence = parseKeyboardProtocolNegotiationSequence(bufferedSequence); + if (negotiationSequence) { + this.clearKeyboardProtocolNegotiationBuffer(); + return negotiationSequence; + } + if (isKeyboardProtocolNegotiationSequencePrefix(bufferedSequence, allowBareEscapePrefix)) { + this.setKeyboardProtocolNegotiationBuffer(bufferedSequence); + return "pending"; + } + this.flushKeyboardProtocolNegotiationBufferAsInput(); + } + + const negotiationSequence = parseKeyboardProtocolNegotiationSequence(sequence); + if (negotiationSequence) return negotiationSequence; + if (isKeyboardProtocolNegotiationSequencePrefix(sequence, allowBareEscapePrefix)) { + this.setKeyboardProtocolNegotiationBuffer(sequence); + return "pending"; + } + return undefined; + } + + private setKeyboardProtocolNegotiationBuffer(sequence: string): void { + this.clearKeyboardProtocolNegotiationBufferFlushTimer(); + this.keyboardProtocolNegotiationBuffer = sequence; + } + + private clearKeyboardProtocolNegotiationBuffer(): void { + this.clearKeyboardProtocolNegotiationBufferFlushTimer(); + this.keyboardProtocolNegotiationBuffer = ""; + } + + private flushKeyboardProtocolNegotiationBufferAsInput(): void { + if (!this.keyboardProtocolNegotiationBuffer) return; + const sequence = this.keyboardProtocolNegotiationBuffer; + this.clearKeyboardProtocolNegotiationBuffer(); + this.forwardInputSequence(sequence); + } + + private scheduleKeyboardProtocolNegotiationBufferFlush(): void { + if (!this.keyboardProtocolNegotiationBuffer || this.keyboardProtocolBufferFlushTimer) return; + this.keyboardProtocolBufferFlushTimer = setTimeout(() => { + this.keyboardProtocolBufferFlushTimer = undefined; + this.flushKeyboardProtocolNegotiationBufferAsInput(); + }, KEYBOARD_PROTOCOL_RESPONSE_FRAGMENT_TIMEOUT_MS); + } + + private clearKeyboardProtocolNegotiationBufferFlushTimer(): void { + if (!this.keyboardProtocolBufferFlushTimer) return; + clearTimeout(this.keyboardProtocolBufferFlushTimer); + this.keyboardProtocolBufferFlushTimer = undefined; + } + + private forwardInputSequence(sequence: string): void { + if (!this.inputHandler) return; + const isAppleTerminal = sequence === "\r" && isAppleTerminalSession(); + const input = normalizeAppleTerminalInput( + sequence, + isAppleTerminal, + isAppleTerminal && isNativeModifierPressed("shift"), + ); + this.inputHandler(input); + } + + private enableModifyOtherKeys(): void { + if (this._kittyProtocolActive || this._modifyOtherKeysActive) return; + process.stdout.write("\x1b[>4;2m"); + this._modifyOtherKeysActive = true; + } + + private clearKeyboardProtocolFallbackTimer(): void { + if (!this.keyboardProtocolFallbackTimer) return; + clearTimeout(this.keyboardProtocolFallbackTimer); + this.keyboardProtocolFallbackTimer = undefined; } /** @@ -256,13 +394,20 @@ export class ProcessTerminal implements Terminal { } async drainInput(maxMs = 1000, idleMs = 50): Promise { - if (this._kittyProtocolActive) { + const shouldDisableKittyProtocol = + this.keyboardProtocolPushed || this._kittyProtocolActive || this.keyboardProtocolNegotiationPending; + this.keyboardProtocolLateResponsePending = false; + this.clearKeyboardProtocolNegotiationBuffer(); + this.clearKeyboardProtocolFallbackTimer(); + if (shouldDisableKittyProtocol) { // Disable Kitty keyboard protocol first so any late key releases // do not generate new Kitty escape sequences. process.stdout.write("\x1b[4;0m"); this._modifyOtherKeysActive = false; @@ -301,12 +446,20 @@ export class ProcessTerminal implements Terminal { // Disable bracketed paste mode process.stdout.write("\x1b[?2004l"); + const shouldDisableKittyProtocol = + this.keyboardProtocolPushed || this._kittyProtocolActive || this.keyboardProtocolNegotiationPending; + this.keyboardProtocolLateResponsePending = false; + this.clearKeyboardProtocolNegotiationBuffer(); + this.clearKeyboardProtocolFallbackTimer(); + // Disable Kitty keyboard protocol if not already done by drainInput() - if (this._kittyProtocolActive) { + if (shouldDisableKittyProtocol) { process.stdout.write("\x1b[4;0m"); this._modifyOtherKeysActive = false; diff --git a/packages/tui/test/terminal.test.ts b/packages/tui/test/terminal.test.ts index a1990135..279efee9 100644 --- a/packages/tui/test/terminal.test.ts +++ b/packages/tui/test/terminal.test.ts @@ -1,6 +1,11 @@ import assert from "node:assert"; -import { describe, it } from "node:test"; -import { normalizeAppleTerminalInput, ProcessTerminal } from "../src/terminal.ts"; +import { describe, it, mock } from "node:test"; +import { setKittyProtocolActive } from "../src/keys.ts"; +import { + normalizeAppleTerminalInput, + ProcessTerminal, + parseKeyboardProtocolNegotiationSequence, +} from "../src/terminal.ts"; describe("normalizeAppleTerminalInput", () => { it("rewrites Apple Terminal Return to CSI-u Shift+Enter when Shift is pressed", () => { @@ -21,6 +26,394 @@ describe("normalizeAppleTerminalInput", () => { }); }); +describe("parseKeyboardProtocolNegotiationSequence", () => { + it("parses Kitty keyboard protocol flag responses", () => { + assert.deepEqual(parseKeyboardProtocolNegotiationSequence("\x1b[?7u"), { type: "kitty-flags", flags: 7 }); + assert.deepEqual(parseKeyboardProtocolNegotiationSequence("\x1b[?1u"), { type: "kitty-flags", flags: 1 }); + assert.deepEqual(parseKeyboardProtocolNegotiationSequence("\x1b[?0u"), { type: "kitty-flags", flags: 0 }); + }); + + it("parses DA responses as the negotiation sentinel", () => { + assert.deepEqual(parseKeyboardProtocolNegotiationSequence("\x1b[?62;4;52c"), { type: "device-attributes" }); + assert.deepEqual(parseKeyboardProtocolNegotiationSequence("\x1b[?1;2c"), { type: "device-attributes" }); + }); + + it("ignores unrelated input", () => { + assert.equal(parseKeyboardProtocolNegotiationSequence("\x1b[13;2u"), undefined); + assert.equal(parseKeyboardProtocolNegotiationSequence("a"), undefined); + }); +}); + +describe("ProcessTerminal Kitty keyboard protocol negotiation", () => { + type NegotiationHarness = { + terminal: ProcessTerminal; + writes: string[]; + send(data: string): void; + getInput(): string | undefined; + cleanup(): void; + }; + + function setupNegotiation(): NegotiationHarness { + const terminal = new ProcessTerminal(); + const writes: string[] = []; + let input: string | undefined; + let dataHandler: ((data: string) => void) | undefined; + let cleaned = false; + const previousWrite = process.stdout.write; + const previousOn = process.stdin.on; + + process.stdout.write = ((chunk: string | Uint8Array) => { + writes.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + process.stdin.on = ((event: string | symbol, listener: (...args: unknown[]) => void) => { + if (event === "data") dataHandler = listener as (data: string) => void; + return process.stdin; + }) as typeof process.stdin.on; + + ( + terminal as unknown as { + inputHandler?: (data: string) => void; + queryAndEnableKittyProtocol(): void; + } + ).inputHandler = (data) => { + input = data; + }; + (terminal as unknown as { queryAndEnableKittyProtocol(): void }).queryAndEnableKittyProtocol(); + + return { + terminal, + writes, + send(data: string): void { + dataHandler?.(data); + }, + getInput(): string | undefined { + return input; + }, + cleanup(): void { + if (cleaned) return; + cleaned = true; + try { + terminal.stop(); + } finally { + process.stdout.write = previousWrite; + process.stdin.on = previousOn; + setKittyProtocolActive(false); + } + }, + }; + } + + function runNegotiation(response: string): { kittyProtocolActive: boolean; writes: string[]; input?: string } { + const harness = setupNegotiation(); + try { + harness.send(response); + return { + kittyProtocolActive: harness.terminal.kittyProtocolActive, + writes: [...harness.writes], + input: harness.getInput(), + }; + } finally { + harness.cleanup(); + } + } + + it("requests flags before querying and uses DA as unsupported sentinel", () => { + const { kittyProtocolActive, writes, input } = runNegotiation("\x1b[?0u\x1b[?62;4;52c"); + + assert.equal(writes[0], "\x1b[>7u\x1b[?u\x1b[c"); + assert.equal(writes.at(-1), "\x1b[>4;2m"); + assert.equal(kittyProtocolActive, false); + assert.equal(input, undefined); + }); + + it("tracks delayed Kitty flags after DA sentinel", () => { + const harness = setupNegotiation(); + try { + harness.send("\x1b[?62;4;52c"); + harness.send("\x1b[?7u"); + + assert.equal(harness.writes[0], "\x1b[>7u\x1b[?u\x1b[c"); + assert.equal(harness.writes.includes("\x1b[>4;2m"), true); + assert.equal(harness.getInput(), undefined); + assert.equal(harness.terminal.kittyProtocolActive, true); + + harness.cleanup(); + assert.equal(harness.writes.filter((write) => write === "\x1b[ write === "\x1b[>4;0m").length, 1); + } finally { + harness.cleanup(); + } + }); + + it("activates Kitty mode for non-zero negotiated flags", () => { + const { kittyProtocolActive, writes, input } = runNegotiation("\x1b[?1u\x1b[?62;4;52c"); + + assert.equal(writes[0], "\x1b[>7u\x1b[?u\x1b[c"); + assert.equal(writes.includes("\x1b[>4;2m"), false); + assert.equal(kittyProtocolActive, true); + assert.equal(input, undefined); + }); + + it("does not fall back after Kitty mode activates", () => { + mock.timers.enable({ apis: ["setTimeout"] }); + const harness = setupNegotiation(); + try { + harness.send("\x1b[?1u"); + mock.timers.tick(150); + + assert.equal(harness.writes.includes("\x1b[>4;2m"), false); + assert.equal(harness.terminal.kittyProtocolActive, true); + } finally { + harness.cleanup(); + mock.timers.reset(); + } + }); + + it("forwards Escape input after Kitty mode activates without DA sentinel", () => { + mock.timers.enable({ apis: ["setTimeout"] }); + const harness = setupNegotiation(); + try { + harness.send("\x1b[?1u"); + mock.timers.tick(150); + harness.send("\x1b"); + mock.timers.tick(10); + + assert.equal(harness.writes.includes("\x1b[>4;2m"), false); + assert.equal(harness.getInput(), "\x1b"); + assert.equal(harness.terminal.kittyProtocolActive, true); + } finally { + harness.cleanup(); + mock.timers.reset(); + } + }); + + it("swallows delayed DA sentinel after Kitty mode activates", () => { + mock.timers.enable({ apis: ["setTimeout"] }); + const harness = setupNegotiation(); + try { + harness.send("\x1b[?1u"); + mock.timers.tick(150); + harness.send("\x1b[?62;4;52c"); + + assert.equal(harness.writes.includes("\x1b[>4;2m"), false); + assert.equal(harness.getInput(), undefined); + assert.equal(harness.terminal.kittyProtocolActive, true); + } finally { + harness.cleanup(); + mock.timers.reset(); + } + }); + + it("pops optimistic Kitty mode on stop while negotiation is pending", () => { + const harness = setupNegotiation(); + harness.cleanup(); + + assert.equal(harness.writes.filter((write) => write === "\x1b[ { + const harness = setupNegotiation(); + try { + await harness.terminal.drainInput(0); + harness.cleanup(); + + assert.equal(harness.writes.filter((write) => write === "\x1b[ { + mock.timers.enable({ apis: ["setTimeout"] }); + const harness = setupNegotiation(); + try { + mock.timers.tick(150); + + assert.equal(harness.writes[0], "\x1b[>7u\x1b[?u\x1b[c"); + assert.equal(harness.writes.includes("\x1b[>4;2m"), true); + assert.equal(harness.terminal.kittyProtocolActive, false); + } finally { + harness.cleanup(); + mock.timers.reset(); + } + }); + + it("replays Escape input buffered during pending negotiation", () => { + mock.timers.enable({ apis: ["setTimeout"] }); + const harness = setupNegotiation(); + try { + harness.send("\x1b"); + mock.timers.tick(10); + + assert.equal(harness.getInput(), undefined); + + mock.timers.tick(140); + + assert.equal(harness.writes.includes("\x1b[>4;2m"), true); + assert.equal(harness.getInput(), "\x1b"); + } finally { + harness.cleanup(); + mock.timers.reset(); + } + }); + + it("replays CSI prefix input buffered during pending negotiation", () => { + mock.timers.enable({ apis: ["setTimeout"] }); + const harness = setupNegotiation(); + try { + harness.send("\x1b["); + mock.timers.tick(10); + + assert.equal(harness.getInput(), undefined); + + mock.timers.tick(140); + + assert.equal(harness.writes.includes("\x1b[>4;2m"), true); + assert.equal(harness.getInput(), undefined); + + mock.timers.tick(150); + + assert.equal(harness.getInput(), "\x1b["); + } finally { + harness.cleanup(); + mock.timers.reset(); + } + }); + + it("forwards Escape input after no-response fallback", () => { + mock.timers.enable({ apis: ["setTimeout"] }); + const harness = setupNegotiation(); + try { + mock.timers.tick(150); + harness.send("\x1b"); + mock.timers.tick(10); + + assert.equal(harness.getInput(), "\x1b"); + } finally { + harness.cleanup(); + mock.timers.reset(); + } + }); + + it("replays CSI prefix input after no-response fallback", () => { + mock.timers.enable({ apis: ["setTimeout"] }); + const harness = setupNegotiation(); + try { + mock.timers.tick(150); + harness.send("\x1b["); + mock.timers.tick(10); + + assert.equal(harness.getInput(), undefined); + + mock.timers.tick(150); + + assert.equal(harness.getInput(), "\x1b["); + } finally { + harness.cleanup(); + mock.timers.reset(); + } + }); + + it("tracks late Kitty confirmation after modifyOtherKeys fallback", () => { + mock.timers.enable({ apis: ["setTimeout"] }); + const harness = setupNegotiation(); + try { + mock.timers.tick(150); + harness.send("\x1b[?7u"); + + assert.equal(harness.writes.includes("\x1b[>4;2m"), true); + assert.equal(harness.terminal.kittyProtocolActive, true); + + harness.cleanup(); + assert.equal(harness.writes.filter((write) => write === "\x1b[ write === "\x1b[>4;0m").length, 1); + } finally { + harness.cleanup(); + mock.timers.reset(); + } + }); + + it("keeps fallback active for delayed DA responses", () => { + mock.timers.enable({ apis: ["setTimeout"] }); + const harness = setupNegotiation(); + try { + mock.timers.tick(150); + harness.send("\x1b[?62;4;52c"); + + assert.equal(harness.writes.filter((write) => write === "\x1b[>4;2m").length, 1); + assert.equal(harness.getInput(), undefined); + assert.equal(harness.terminal.kittyProtocolActive, false); + } finally { + harness.cleanup(); + mock.timers.reset(); + } + }); + + it("swallows and reassembles split DA responses flushed incomplete", () => { + mock.timers.enable({ apis: ["setTimeout"] }); + const harness = setupNegotiation(); + try { + harness.send("\x1b[?62;"); + mock.timers.tick(10); + + assert.equal(harness.getInput(), undefined); + + harness.send("4;52c"); + mock.timers.tick(140); + + assert.equal(harness.writes.includes("\x1b[>4;2m"), true); + assert.equal(harness.getInput(), undefined); + assert.equal(harness.terminal.kittyProtocolActive, false); + } finally { + harness.cleanup(); + mock.timers.reset(); + } + }); + + it("swallows and reassembles split late DA responses after fallback", () => { + mock.timers.enable({ apis: ["setTimeout"] }); + const harness = setupNegotiation(); + try { + mock.timers.tick(150); + harness.send("\x1b[?62;"); + mock.timers.tick(10); + + assert.equal(harness.getInput(), undefined); + + harness.send("4;52c"); + + assert.equal(harness.writes.filter((write) => write === "\x1b[>4;2m").length, 1); + assert.equal(harness.getInput(), undefined); + assert.equal(harness.terminal.kittyProtocolActive, false); + } finally { + harness.cleanup(); + mock.timers.reset(); + } + }); + + it("swallows and reassembles split late Kitty responses after fallback", () => { + mock.timers.enable({ apis: ["setTimeout"] }); + const harness = setupNegotiation(); + try { + mock.timers.tick(150); + harness.send("\x1b[?7"); + mock.timers.tick(10); + + assert.equal(harness.getInput(), undefined); + + harness.send("u"); + + assert.equal(harness.writes.includes("\x1b[>4;2m"), true); + assert.equal(harness.getInput(), undefined); + assert.equal(harness.terminal.kittyProtocolActive, true); + } finally { + harness.cleanup(); + mock.timers.reset(); + } + }); +}); + describe("ProcessTerminal dimensions", () => { it("falls back to COLUMNS and LINES before default dimensions", () => { const previousColumnsDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "columns"); From 1e168a89c5a0917fe8d71017ecb933fb9ceae0bb Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 27 May 2026 22:51:30 +0200 Subject: [PATCH 055/352] docs(coding-agent): fix development AGENTS link Closes #5041 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/development.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 18736cae..7136b79b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -33,6 +33,7 @@ - Fixed Windows Terminal capability detection to enable OSC 8 hyperlinks, preserving clickable long URLs across wrapped lines ([#4923](https://github.com/earendil-works/pi/issues/4923)). - Fixed JetBrains terminal capability detection to enable truecolor while disabling unsupported OSC 8 hyperlinks ([#5037](https://github.com/earendil-works/pi-mono/pull/5037) by [@Perlence](https://github.com/Perlence)). - Fixed editor and input word navigation/deletion to use Unicode word boundaries while preserving ASCII punctuation boundaries ([#5022](https://github.com/earendil-works/pi-mono/pull/5022) by [@haoqixu](https://github.com/haoqixu), [#5067](https://github.com/earendil-works/pi-mono/pull/5067) by [@haoqixu](https://github.com/haoqixu), [#5068](https://github.com/earendil-works/pi-mono/pull/5068) by [@haoqixu](https://github.com/haoqixu)). +- Fixed the development docs `AGENTS.md` link to point at the pi-mono guidelines ([#5041](https://github.com/earendil-works/pi/issues/5041)). ## [0.75.5] - 2026-05-23 diff --git a/packages/coding-agent/docs/development.md b/packages/coding-agent/docs/development.md index 35202a4a..fc57befa 100644 --- a/packages/coding-agent/docs/development.md +++ b/packages/coding-agent/docs/development.md @@ -1,6 +1,6 @@ # Development -See [AGENTS.md](../../../AGENTS.md) for additional guidelines. +See [AGENTS.md](https://github.com/earendil-works/pi-mono/blob/main/AGENTS.md) for additional guidelines. ## Setup From 99aec8e02bd0a89986477f2c88b529d38370b459 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 27 May 2026 23:03:13 +0200 Subject: [PATCH 056/352] test(tui): reduce keyboard negotiation coverage --- packages/tui/test/terminal.test.ts | 329 ++++------------------------- 1 file changed, 44 insertions(+), 285 deletions(-) diff --git a/packages/tui/test/terminal.test.ts b/packages/tui/test/terminal.test.ts index 279efee9..73328142 100644 --- a/packages/tui/test/terminal.test.ts +++ b/packages/tui/test/terminal.test.ts @@ -1,11 +1,7 @@ import assert from "node:assert"; import { describe, it, mock } from "node:test"; import { setKittyProtocolActive } from "../src/keys.ts"; -import { - normalizeAppleTerminalInput, - ProcessTerminal, - parseKeyboardProtocolNegotiationSequence, -} from "../src/terminal.ts"; +import { normalizeAppleTerminalInput, ProcessTerminal } from "../src/terminal.ts"; describe("normalizeAppleTerminalInput", () => { it("rewrites Apple Terminal Return to CSI-u Shift+Enter when Shift is pressed", () => { @@ -26,24 +22,6 @@ describe("normalizeAppleTerminalInput", () => { }); }); -describe("parseKeyboardProtocolNegotiationSequence", () => { - it("parses Kitty keyboard protocol flag responses", () => { - assert.deepEqual(parseKeyboardProtocolNegotiationSequence("\x1b[?7u"), { type: "kitty-flags", flags: 7 }); - assert.deepEqual(parseKeyboardProtocolNegotiationSequence("\x1b[?1u"), { type: "kitty-flags", flags: 1 }); - assert.deepEqual(parseKeyboardProtocolNegotiationSequence("\x1b[?0u"), { type: "kitty-flags", flags: 0 }); - }); - - it("parses DA responses as the negotiation sentinel", () => { - assert.deepEqual(parseKeyboardProtocolNegotiationSequence("\x1b[?62;4;52c"), { type: "device-attributes" }); - assert.deepEqual(parseKeyboardProtocolNegotiationSequence("\x1b[?1;2c"), { type: "device-attributes" }); - }); - - it("ignores unrelated input", () => { - assert.equal(parseKeyboardProtocolNegotiationSequence("\x1b[13;2u"), undefined); - assert.equal(parseKeyboardProtocolNegotiationSequence("a"), undefined); - }); -}); - describe("ProcessTerminal Kitty keyboard protocol negotiation", () => { type NegotiationHarness = { terminal: ProcessTerminal; @@ -104,295 +82,54 @@ describe("ProcessTerminal Kitty keyboard protocol negotiation", () => { }; } - function runNegotiation(response: string): { kittyProtocolActive: boolean; writes: string[]; input?: string } { - const harness = setupNegotiation(); - try { - harness.send(response); - return { - kittyProtocolActive: harness.terminal.kittyProtocolActive, - writes: [...harness.writes], - input: harness.getInput(), - }; - } finally { - harness.cleanup(); - } - } - - it("requests flags before querying and uses DA as unsupported sentinel", () => { - const { kittyProtocolActive, writes, input } = runNegotiation("\x1b[?0u\x1b[?62;4;52c"); - - assert.equal(writes[0], "\x1b[>7u\x1b[?u\x1b[c"); - assert.equal(writes.at(-1), "\x1b[>4;2m"); - assert.equal(kittyProtocolActive, false); - assert.equal(input, undefined); - }); - - it("tracks delayed Kitty flags after DA sentinel", () => { - const harness = setupNegotiation(); - try { - harness.send("\x1b[?62;4;52c"); - harness.send("\x1b[?7u"); - - assert.equal(harness.writes[0], "\x1b[>7u\x1b[?u\x1b[c"); - assert.equal(harness.writes.includes("\x1b[>4;2m"), true); - assert.equal(harness.getInput(), undefined); - assert.equal(harness.terminal.kittyProtocolActive, true); - - harness.cleanup(); - assert.equal(harness.writes.filter((write) => write === "\x1b[ write === "\x1b[>4;0m").length, 1); - } finally { - harness.cleanup(); - } - }); - it("activates Kitty mode for non-zero negotiated flags", () => { - const { kittyProtocolActive, writes, input } = runNegotiation("\x1b[?1u\x1b[?62;4;52c"); - - assert.equal(writes[0], "\x1b[>7u\x1b[?u\x1b[c"); - assert.equal(writes.includes("\x1b[>4;2m"), false); - assert.equal(kittyProtocolActive, true); - assert.equal(input, undefined); - }); - - it("does not fall back after Kitty mode activates", () => { mock.timers.enable({ apis: ["setTimeout"] }); const harness = setupNegotiation(); try { harness.send("\x1b[?1u"); mock.timers.tick(150); - assert.equal(harness.writes.includes("\x1b[>4;2m"), false); - assert.equal(harness.terminal.kittyProtocolActive, true); - } finally { - harness.cleanup(); - mock.timers.reset(); - } - }); - - it("forwards Escape input after Kitty mode activates without DA sentinel", () => { - mock.timers.enable({ apis: ["setTimeout"] }); - const harness = setupNegotiation(); - try { - harness.send("\x1b[?1u"); - mock.timers.tick(150); - harness.send("\x1b"); - mock.timers.tick(10); - - assert.equal(harness.writes.includes("\x1b[>4;2m"), false); - assert.equal(harness.getInput(), "\x1b"); - assert.equal(harness.terminal.kittyProtocolActive, true); - } finally { - harness.cleanup(); - mock.timers.reset(); - } - }); - - it("swallows delayed DA sentinel after Kitty mode activates", () => { - mock.timers.enable({ apis: ["setTimeout"] }); - const harness = setupNegotiation(); - try { - harness.send("\x1b[?1u"); - mock.timers.tick(150); - harness.send("\x1b[?62;4;52c"); - - assert.equal(harness.writes.includes("\x1b[>4;2m"), false); - assert.equal(harness.getInput(), undefined); - assert.equal(harness.terminal.kittyProtocolActive, true); - } finally { - harness.cleanup(); - mock.timers.reset(); - } - }); - - it("pops optimistic Kitty mode on stop while negotiation is pending", () => { - const harness = setupNegotiation(); - harness.cleanup(); - - assert.equal(harness.writes.filter((write) => write === "\x1b[ { - const harness = setupNegotiation(); - try { - await harness.terminal.drainInput(0); - harness.cleanup(); - - assert.equal(harness.writes.filter((write) => write === "\x1b[ { - mock.timers.enable({ apis: ["setTimeout"] }); - const harness = setupNegotiation(); - try { - mock.timers.tick(150); - assert.equal(harness.writes[0], "\x1b[>7u\x1b[?u\x1b[c"); - assert.equal(harness.writes.includes("\x1b[>4;2m"), true); - assert.equal(harness.terminal.kittyProtocolActive, false); - } finally { - harness.cleanup(); - mock.timers.reset(); - } - }); - - it("replays Escape input buffered during pending negotiation", () => { - mock.timers.enable({ apis: ["setTimeout"] }); - const harness = setupNegotiation(); - try { - harness.send("\x1b"); - mock.timers.tick(10); - + assert.equal(harness.writes.includes("\x1b[>4;2m"), false); assert.equal(harness.getInput(), undefined); - - mock.timers.tick(140); - - assert.equal(harness.writes.includes("\x1b[>4;2m"), true); - assert.equal(harness.getInput(), "\x1b"); - } finally { - harness.cleanup(); - mock.timers.reset(); - } - }); - - it("replays CSI prefix input buffered during pending negotiation", () => { - mock.timers.enable({ apis: ["setTimeout"] }); - const harness = setupNegotiation(); - try { - harness.send("\x1b["); - mock.timers.tick(10); - - assert.equal(harness.getInput(), undefined); - - mock.timers.tick(140); - - assert.equal(harness.writes.includes("\x1b[>4;2m"), true); - assert.equal(harness.getInput(), undefined); - - mock.timers.tick(150); - - assert.equal(harness.getInput(), "\x1b["); - } finally { - harness.cleanup(); - mock.timers.reset(); - } - }); - - it("forwards Escape input after no-response fallback", () => { - mock.timers.enable({ apis: ["setTimeout"] }); - const harness = setupNegotiation(); - try { - mock.timers.tick(150); - harness.send("\x1b"); - mock.timers.tick(10); - - assert.equal(harness.getInput(), "\x1b"); - } finally { - harness.cleanup(); - mock.timers.reset(); - } - }); - - it("replays CSI prefix input after no-response fallback", () => { - mock.timers.enable({ apis: ["setTimeout"] }); - const harness = setupNegotiation(); - try { - mock.timers.tick(150); - harness.send("\x1b["); - mock.timers.tick(10); - - assert.equal(harness.getInput(), undefined); - - mock.timers.tick(150); - - assert.equal(harness.getInput(), "\x1b["); - } finally { - harness.cleanup(); - mock.timers.reset(); - } - }); - - it("tracks late Kitty confirmation after modifyOtherKeys fallback", () => { - mock.timers.enable({ apis: ["setTimeout"] }); - const harness = setupNegotiation(); - try { - mock.timers.tick(150); - harness.send("\x1b[?7u"); - - assert.equal(harness.writes.includes("\x1b[>4;2m"), true); assert.equal(harness.terminal.kittyProtocolActive, true); harness.cleanup(); assert.equal(harness.writes.filter((write) => write === "\x1b[ write === "\x1b[>4;0m").length, 1); } finally { harness.cleanup(); mock.timers.reset(); } }); - it("keeps fallback active for delayed DA responses", () => { + it("falls back to modifyOtherKeys for unsupported or silent terminals", () => { + const unsupported = setupNegotiation(); + try { + unsupported.send("\x1b[?62;4;52c"); + + assert.equal(unsupported.writes[0], "\x1b[>7u\x1b[?u\x1b[c"); + assert.equal(unsupported.writes.includes("\x1b[>4;2m"), true); + assert.equal(unsupported.getInput(), undefined); + assert.equal(unsupported.terminal.kittyProtocolActive, false); + } finally { + unsupported.cleanup(); + } + mock.timers.enable({ apis: ["setTimeout"] }); - const harness = setupNegotiation(); + const silent = setupNegotiation(); try { mock.timers.tick(150); - harness.send("\x1b[?62;4;52c"); - assert.equal(harness.writes.filter((write) => write === "\x1b[>4;2m").length, 1); - assert.equal(harness.getInput(), undefined); - assert.equal(harness.terminal.kittyProtocolActive, false); + assert.equal(silent.writes[0], "\x1b[>7u\x1b[?u\x1b[c"); + assert.equal(silent.writes.includes("\x1b[>4;2m"), true); + assert.equal(silent.terminal.kittyProtocolActive, false); } finally { - harness.cleanup(); + silent.cleanup(); mock.timers.reset(); } }); - it("swallows and reassembles split DA responses flushed incomplete", () => { - mock.timers.enable({ apis: ["setTimeout"] }); - const harness = setupNegotiation(); - try { - harness.send("\x1b[?62;"); - mock.timers.tick(10); - - assert.equal(harness.getInput(), undefined); - - harness.send("4;52c"); - mock.timers.tick(140); - - assert.equal(harness.writes.includes("\x1b[>4;2m"), true); - assert.equal(harness.getInput(), undefined); - assert.equal(harness.terminal.kittyProtocolActive, false); - } finally { - harness.cleanup(); - mock.timers.reset(); - } - }); - - it("swallows and reassembles split late DA responses after fallback", () => { - mock.timers.enable({ apis: ["setTimeout"] }); - const harness = setupNegotiation(); - try { - mock.timers.tick(150); - harness.send("\x1b[?62;"); - mock.timers.tick(10); - - assert.equal(harness.getInput(), undefined); - - harness.send("4;52c"); - - assert.equal(harness.writes.filter((write) => write === "\x1b[>4;2m").length, 1); - assert.equal(harness.getInput(), undefined); - assert.equal(harness.terminal.kittyProtocolActive, false); - } finally { - harness.cleanup(); - mock.timers.reset(); - } - }); - - it("swallows and reassembles split late Kitty responses after fallback", () => { + it("tracks late split Kitty confirmation after fallback", () => { mock.timers.enable({ apis: ["setTimeout"] }); const harness = setupNegotiation(); try { @@ -405,8 +142,30 @@ describe("ProcessTerminal Kitty keyboard protocol negotiation", () => { harness.send("u"); assert.equal(harness.writes.includes("\x1b[>4;2m"), true); - assert.equal(harness.getInput(), undefined); assert.equal(harness.terminal.kittyProtocolActive, true); + + harness.cleanup(); + assert.equal(harness.writes.filter((write) => write === "\x1b[ write === "\x1b[>4;0m").length, 1); + } finally { + harness.cleanup(); + mock.timers.reset(); + } + }); + + it("replays buffered CSI-prefix input after fallback", () => { + mock.timers.enable({ apis: ["setTimeout"] }); + const harness = setupNegotiation(); + try { + harness.send("\x1b["); + mock.timers.tick(150); + + assert.equal(harness.writes.includes("\x1b[>4;2m"), true); + assert.equal(harness.getInput(), undefined); + + mock.timers.tick(150); + + assert.equal(harness.getInput(), "\x1b["); } finally { harness.cleanup(); mock.timers.reset(); From b85bf6567894c96cfb7b6b2f0b72d8d64de39791 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 28 May 2026 00:49:27 +0200 Subject: [PATCH 057/352] fix(coding-agent): restore diff code block highlighting closes #5092 --- packages/coding-agent/CHANGELOG.md | 4 +++ .../src/modes/interactive/theme/theme.ts | 10 +++++++ .../test/syntax-highlight.test.ts | 30 ++++++++++++++++++- 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7136b79b..d28d8eed 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed fenced `diff` code blocks and other highlight.js scopes to keep theme-aware syntax colors after the `cli-highlight` replacement ([#5092](https://github.com/earendil-works/pi/issues/5092)). + ## [0.76.0] - 2026-05-27 ### New Features diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index d9bcdf8c..8bdf4816 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -1042,17 +1042,27 @@ function buildCliHighlightTheme(t: Theme): CliHighlightTheme { built_in: (s: string) => t.fg("syntaxType", s), literal: (s: string) => t.fg("syntaxNumber", s), number: (s: string) => t.fg("syntaxNumber", s), + regexp: (s: string) => t.fg("syntaxString", s), string: (s: string) => t.fg("syntaxString", s), comment: (s: string) => t.fg("syntaxComment", s), + doctag: (s: string) => t.fg("syntaxComment", s), + meta: (s: string) => t.fg("muted", s), function: (s: string) => t.fg("syntaxFunction", s), title: (s: string) => t.fg("syntaxFunction", s), class: (s: string) => t.fg("syntaxType", s), type: (s: string) => t.fg("syntaxType", s), + tag: (s: string) => t.fg("syntaxPunctuation", s), + name: (s: string) => t.fg("syntaxKeyword", s), attr: (s: string) => t.fg("syntaxVariable", s), variable: (s: string) => t.fg("syntaxVariable", s), params: (s: string) => t.fg("syntaxVariable", s), operator: (s: string) => t.fg("syntaxOperator", s), punctuation: (s: string) => t.fg("syntaxPunctuation", s), + emphasis: (s: string) => t.italic(s), + strong: (s: string) => t.bold(s), + link: (s: string) => t.underline(s), + addition: (s: string) => t.fg("toolDiffAdded", s), + deletion: (s: string) => t.fg("toolDiffRemoved", s), }; } diff --git a/packages/coding-agent/test/syntax-highlight.test.ts b/packages/coding-agent/test/syntax-highlight.test.ts index 92d8aa39..6f311e49 100644 --- a/packages/coding-agent/test/syntax-highlight.test.ts +++ b/packages/coding-agent/test/syntax-highlight.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { resetCapabilitiesCache, setCapabilities } from "@earendil-works/pi-tui"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { highlightCode, initTheme } from "../src/modes/interactive/theme/theme.ts"; import { highlight, renderHighlightedHtml, supportsLanguage } from "../src/utils/syntax-highlight.ts"; describe("syntax highlight renderer", () => { @@ -46,3 +48,29 @@ describe("syntax highlight renderer", () => { expect(rendered).toContain("[number:1]"); }); }); + +describe("theme syntax highlighting", () => { + beforeEach(() => { + setCapabilities({ images: null, trueColor: true, hyperlinks: false }); + initTheme("dark"); + }); + + afterEach(() => { + resetCapabilitiesCache(); + }); + + it("colors diff additions and deletions in fenced diff blocks", () => { + const lines = highlightCode("-old\n+new\n", "diff"); + + expect(lines[0]).toBe("\x1b[38;2;204;102;102m-old\x1b[39m"); + expect(lines[1]).toBe("\x1b[38;2;181;189;104m+new\x1b[39m"); + }); + + it("keeps cli-highlight default styled scopes mapped to theme styles", () => { + expect(highlightCode("const re = /foo+/gi;", "javascript")[0]).toContain( + "\x1b[38;2;206;145;120m/foo+/gi\x1b[39m", + ); + expect(highlightCode("@decorator", "python")[0]).toBe("\x1b[38;2;128;128;128m@decorator\x1b[39m"); + expect(highlightCode("
", "html")[0]).toContain("\x1b[38;2;86;156;214mdiv\x1b[39m"); + }); +}); From bcea4b2e27e00ab821680c606e24537cde5b05ba Mon Sep 17 00:00:00 2001 From: Danny Thomas Date: Thu, 28 May 2026 15:52:38 +1000 Subject: [PATCH 058/352] feat(coding-agent): expose streamingBehavior on InputEvent Add streamingBehavior to InputEvent so extensions can distinguish idle prompts from mid-stream steers and queued follow-ups. - Add streamingBehavior field to InputEvent type - Thread it through ExtensionRunner.emitInput() and AgentSession.prompt() - Add streaming-aware input gate example with tests - Document in extensions.md --- packages/coding-agent/docs/extensions.md | 6 +- .../extensions/input-transform-streaming.ts | 39 +++++++++ .../coding-agent/src/core/agent-session.ts | 1 + .../src/core/extensions/runner.ts | 15 +++- .../coding-agent/src/core/extensions/types.ts | 2 + .../test/agent-session-concurrent.test.ts | 2 + .../test/extensions-input-event.test.ts | 12 +++ .../input-transform-streaming-example.test.ts | 84 +++++++++++++++++++ 8 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 packages/coding-agent/examples/extensions/input-transform-streaming.ts create mode 100644 packages/coding-agent/test/input-transform-streaming-example.test.ts diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index ce922f2a..a5d55d47 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -819,6 +819,9 @@ pi.on("input", async (event, ctx) => { // event.text - raw input (before skill/template expansion) // event.images - attached images, if any // event.source - "interactive" (typed), "rpc" (API), or "extension" (via sendUserMessage) + // event.streamingBehavior - "steer" | "followUp" | undefined + // undefined when idle, "steer" for mid-stream interrupts, + // "followUp" for messages queued until the agent finishes // Transform: rewrite input before expansion if (event.text.startsWith("?quick ")) @@ -847,7 +850,7 @@ pi.on("input", async (event, ctx) => { - `transform` - modify text/images, then continue to expansion - `handled` - skip agent entirely (first handler to return this wins) -Transforms chain across handlers. See [input-transform.ts](../examples/extensions/input-transform.ts). +Transforms chain across handlers. See [input-transform.ts](../examples/extensions/input-transform.ts) and [input-transform-streaming.ts](../examples/extensions/input-transform-streaming.ts) for `streamingBehavior`-aware routing. ## ExtensionContext @@ -2543,6 +2546,7 @@ All examples in [examples/extensions/](../examples/extensions/). | `confirm-destructive.ts` | Confirm session changes | `on("session_before_switch")`, `on("session_before_fork")` | | `dirty-repo-guard.ts` | Warn on dirty git repo | `on("session_before_*")`, `exec` | | `input-transform.ts` | Transform user input | `on("input")` | +| `input-transform-streaming.ts` | Streaming-aware input transform | `on("input")`, `streamingBehavior` | | `model-status.ts` | React to model changes | `on("model_select")`, `setStatus` | | `provider-payload.ts` | Inspect payloads and provider response headers | `on("before_provider_request")`, `on("after_provider_response")` | | `system-prompt-header.ts` | Display system prompt info | `on("agent_start")`, `getSystemPrompt` | diff --git a/packages/coding-agent/examples/extensions/input-transform-streaming.ts b/packages/coding-agent/examples/extensions/input-transform-streaming.ts new file mode 100644 index 00000000..65d805a5 --- /dev/null +++ b/packages/coding-agent/examples/extensions/input-transform-streaming.ts @@ -0,0 +1,39 @@ +/** + * Streaming-Aware Input Gate + * + * Demonstrates `event.streamingBehavior` to skip expensive pre-processing + * during mid-stream steering, where low latency matters. + * + * This extension prepends `git diff --stat` output when the user mentions + * file changes, giving the model immediate context. During steering the + * exec call is skipped so the correction reaches the model without delay. + * + * Start pi with this extension: + * pi -e ./examples/extensions/input-transform-streaming.ts + */ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +const TRIGGER = /\b(changes?|diff|modified)\b/i; + +export default function (pi: ExtensionAPI) { + pi.on("input", async (event) => { + // During steering, skip the exec call — corrections should be fast + if (event.streamingBehavior === "steer") { + return { action: "continue" }; + } + + if (!TRIGGER.test(event.text)) { + return { action: "continue" }; + } + + const { stdout, code } = await pi.exec("git", ["diff", "--stat"]); + if (code !== 0 || !stdout.trim()) { + return { action: "continue" }; + } + + return { + action: "transform", + text: `${event.text}\n\nCurrent uncommitted changes:\n\`\`\`\n${stdout.trim()}\n\`\`\``, + }; + }); +} diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index d2835b7b..457d77f5 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -984,6 +984,7 @@ export class AgentSession { currentText, currentImages, options?.source ?? "interactive", + options?.streamingBehavior, ); if (inputResult.action === "handled") { preflightResult?.(true); diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 167c52cc..751e28a4 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -1036,7 +1036,12 @@ export class ExtensionRunner { } /** Emit input event. Transforms chain, "handled" short-circuits. */ - async emitInput(text: string, images: ImageContent[] | undefined, source: InputSource): Promise { + async emitInput( + text: string, + images: ImageContent[] | undefined, + source: InputSource, + streamingBehavior?: "steer" | "followUp", + ): Promise { const ctx = this.createContext(); let currentText = text; let currentImages = images; @@ -1044,7 +1049,13 @@ export class ExtensionRunner { for (const ext of this.extensions) { for (const handler of ext.handlers.get("input") ?? []) { try { - const event: InputEvent = { type: "input", text: currentText, images: currentImages, source }; + const event: InputEvent = { + type: "input", + text: currentText, + images: currentImages, + source, + streamingBehavior, + }; const result = (await handler(event, ctx)) as InputEventResult | undefined; if (result?.action === "handled") return result; if (result?.action === "transform") { diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 58b85227..f7afcf13 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -756,6 +756,8 @@ export interface InputEvent { images?: ImageContent[]; /** Where the input came from */ source: InputSource; + /** How the input will be delivered during streaming, or undefined when idle */ + streamingBehavior?: "steer" | "followUp"; } /** Result from input event handler */ diff --git a/packages/coding-agent/test/agent-session-concurrent.test.ts b/packages/coding-agent/test/agent-session-concurrent.test.ts index 81f400ea..dcd2ac9b 100644 --- a/packages/coding-agent/test/agent-session-concurrent.test.ts +++ b/packages/coding-agent/test/agent-session-concurrent.test.ts @@ -444,6 +444,7 @@ describe("AgentSession concurrent prompt guard", () => { text: string, images: unknown, source: "interactive" | "rpc" | "extension", + streamingBehavior?: "steer" | "followUp", ) => Promise<{ action: "continue" }>; emitBeforeAgentStart: ( prompt: string, @@ -588,6 +589,7 @@ describe("AgentSession concurrent prompt guard", () => { text: string, images: unknown, source: "interactive" | "rpc" | "extension", + streamingBehavior?: "steer" | "followUp", ) => Promise<{ action: "continue" }>; emitBeforeAgentStart: ( prompt: string, diff --git a/packages/coding-agent/test/extensions-input-event.test.ts b/packages/coding-agent/test/extensions-input-event.test.ts index 3d5ae5cc..357fb6b8 100644 --- a/packages/coding-agent/test/extensions-input-event.test.ts +++ b/packages/coding-agent/test/extensions-input-event.test.ts @@ -94,6 +94,18 @@ describe("Input Event", () => { } }); + it("passes streamingBehavior correctly", async () => { + const r = await createRunner( + `export default p => p.on("input", async e => { globalThis.testVar = e.streamingBehavior; return { action: "continue" }; });`, + ); + await r.emitInput("x", undefined, "interactive", "steer"); + expect((globalThis as any).testVar).toBe("steer"); + await r.emitInput("x", undefined, "interactive", "followUp"); + expect((globalThis as any).testVar).toBe("followUp"); + await r.emitInput("x", undefined, "interactive"); + expect((globalThis as any).testVar).toBeUndefined(); + }); + it("catches handler errors and continues", async () => { const r = await createRunner(`export default p => p.on("input", async () => { throw new Error("boom"); });`); const errs: string[] = []; diff --git a/packages/coding-agent/test/input-transform-streaming-example.test.ts b/packages/coding-agent/test/input-transform-streaming-example.test.ts new file mode 100644 index 00000000..faf95b6b --- /dev/null +++ b/packages/coding-agent/test/input-transform-streaming-example.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from "vitest"; +import inputTransformStreaming from "../examples/extensions/input-transform-streaming.ts"; +import type { + ExecResult, + ExtensionAPI, + ExtensionContext, + InputEvent, + InputEventResult, +} from "../src/core/extensions/index.ts"; + +type InputHandler = (event: InputEvent, ctx: ExtensionContext) => Promise; + +function setup(execResult: ExecResult) { + let handler: InputHandler | undefined; + + const exec = vi.fn().mockResolvedValue(execResult); + + const api = { + on: (event: string, h: InputHandler) => { + if (event === "input") handler = h; + }, + exec, + } as unknown as ExtensionAPI; + + inputTransformStreaming(api); + + const ctx = {} as ExtensionContext; + + function emit(text: string, streamingBehavior?: "steer" | "followUp") { + return handler!({ type: "input", text, source: "interactive", streamingBehavior }, ctx); + } + + return { emit, exec }; +} + +describe("input-transform-streaming example", () => { + const diffOutput = " src/index.ts | 5 ++---\n 1 file changed, 2 insertions(+), 3 deletions(-)"; + const gitSuccess: ExecResult = { stdout: diffOutput, stderr: "", code: 0, killed: false }; + const gitEmpty: ExecResult = { stdout: "", stderr: "", code: 0, killed: false }; + const gitFail: ExecResult = { stdout: "", stderr: "not a git repo", code: 128, killed: false }; + + it("skips exec during steering", async () => { + const { emit, exec } = setup(gitSuccess); + const result = await emit("what changes did I make?", "steer"); + expect(result).toEqual({ action: "continue" }); + expect(exec).not.toHaveBeenCalled(); + }); + + it("transforms when idle and text matches trigger", async () => { + const { emit, exec } = setup(gitSuccess); + const result = await emit("review my changes"); + expect(exec).toHaveBeenCalledWith("git", ["diff", "--stat"]); + expect(result).toMatchObject({ action: "transform" }); + const text = (result as { text: string }).text; + expect(text).toContain("review my changes"); + expect(text).toContain("src/index.ts"); + }); + + it("transforms when queued as follow-up", async () => { + const { emit, exec } = setup(gitSuccess); + const result = await emit("show me the diff", "followUp"); + expect(exec).toHaveBeenCalled(); + expect(result).toMatchObject({ action: "transform" }); + }); + + it("continues when text does not match trigger", async () => { + const { emit, exec } = setup(gitSuccess); + const result = await emit("explain this function"); + expect(result).toEqual({ action: "continue" }); + expect(exec).not.toHaveBeenCalled(); + }); + + it("continues when git diff is empty", async () => { + const { emit } = setup(gitEmpty); + const result = await emit("any changes?"); + expect(result).toEqual({ action: "continue" }); + }); + + it("continues when git fails", async () => { + const { emit } = setup(gitFail); + const result = await emit("show modified files"); + expect(result).toEqual({ action: "continue" }); + }); +}); From cbe8625528f86dd7f6e5802f9323da0df20512b2 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 28 May 2026 10:13:06 +0200 Subject: [PATCH 059/352] Fix input event streaming behavior semantics --- packages/coding-agent/CHANGELOG.md | 1 + .../examples/extensions/README.md | 1 + .../coding-agent/src/core/agent-session.ts | 2 +- .../test/suite/agent-session-prompt.test.ts | 75 +++++++++++++++++++ 4 files changed, 78 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d28d8eed..4c0f9a33 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed extension input events to report `streamingBehavior` only for prompts actually queued during streaming ([#5107](https://github.com/earendil-works/pi-mono/pull/5107)). - Fixed fenced `diff` code blocks and other highlight.js scopes to keep theme-aware syntax colors after the `cli-highlight` replacement ([#5092](https://github.com/earendil-works/pi/issues/5092)). ## [0.76.0] - 2026-05-27 diff --git a/packages/coding-agent/examples/extensions/README.md b/packages/coding-agent/examples/extensions/README.md index b7521df2..fc2049e6 100644 --- a/packages/coding-agent/examples/extensions/README.md +++ b/packages/coding-agent/examples/extensions/README.md @@ -75,6 +75,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/ | `reload-runtime.ts` | Adds `/reload-runtime` and `reload_runtime` tool showing safe reload flow | | `interactive-shell.ts` | Run interactive commands (vim, htop) with full terminal via `user_bash` hook | | `inline-bash.ts` | Expands `!{command}` patterns in prompts via `input` event transformation | +| `input-transform-streaming.ts` | Skips expensive input preprocessing for mid-stream steering via `streamingBehavior` | ### Git Integration diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 457d77f5..3df2a85a 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -984,7 +984,7 @@ export class AgentSession { currentText, currentImages, options?.source ?? "interactive", - options?.streamingBehavior, + this.isStreaming ? options?.streamingBehavior : undefined, ); if (inputResult.action === "handled") { preflightResult?.(true); diff --git a/packages/coding-agent/test/suite/agent-session-prompt.test.ts b/packages/coding-agent/test/suite/agent-session-prompt.test.ts index dde25f61..94e10a3e 100644 --- a/packages/coding-agent/test/suite/agent-session-prompt.test.ts +++ b/packages/coding-agent/test/suite/agent-session-prompt.test.ts @@ -5,6 +5,7 @@ import type { AgentTool } from "@earendil-works/pi-agent-core"; import { fauxAssistantMessage, fauxToolCall, type Model } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import { afterEach, describe, expect, it } from "vitest"; +import type { InputEvent } from "../../src/core/extensions/index.ts"; import type { PromptTemplate } from "../../src/core/prompt-templates.ts"; import { createSyntheticSourceInfo } from "../../src/core/source-info.ts"; import { createTestResourceLoader } from "../utilities.ts"; @@ -259,6 +260,80 @@ describe("AgentSession prompt characterization", () => { expect(getMessageText(harness.session.messages[0]!)).toBe("from extension"); }); + it("does not report streamingBehavior to input handlers while idle", async () => { + const inputEvents: InputEvent[] = []; + const harness = await createHarness({ + extensionFactories: [ + (pi) => { + pi.on("input", (event) => { + inputEvents.push(event); + }); + }, + ], + }); + harnesses.push(harness); + harness.setResponses([fauxAssistantMessage("ok")]); + + await harness.session.prompt("idle", { streamingBehavior: "followUp" }); + + expect(inputEvents).toHaveLength(1); + expect(inputEvents[0]?.streamingBehavior).toBeUndefined(); + }); + + it("reports streamingBehavior to input handlers while streaming", async () => { + let releaseToolExecution: (() => void) | undefined; + const toolRelease = new Promise((resolve) => { + releaseToolExecution = resolve; + }); + const inputEvents: InputEvent[] = []; + const waitTool: AgentTool = { + name: "wait", + label: "Wait", + description: "Wait for release", + parameters: Type.Object({}), + execute: async () => { + await toolRelease; + return { + content: [{ type: "text", text: "released" }], + details: {}, + }; + }, + }; + const harness = await createHarness({ + tools: [waitTool], + extensionFactories: [ + (pi) => { + pi.on("input", (event) => { + inputEvents.push(event); + }); + }, + ], + }); + harnesses.push(harness); + harness.setResponses([ + fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }), + fauxAssistantMessage("done"), + ]); + + const sawToolStart = new Promise((resolve) => { + const unsubscribe = harness.session.subscribe((event) => { + if (event.type === "tool_execution_start") { + unsubscribe(); + resolve(); + } + }); + }); + + const promptPromise = harness.session.prompt("start"); + await sawToolStart; + await harness.session.prompt("queued", { streamingBehavior: "followUp" }); + + expect(inputEvents.map((event) => event.streamingBehavior)).toEqual([undefined, "followUp"]); + + releaseToolExecution?.(); + await promptPromise; + }); + it("throws when prompted during streaming without a streamingBehavior", async () => { let releaseToolExecution: (() => void) | undefined; const toolRelease = new Promise((resolve) => { From a29a7902e99104f7a62e93c46e33c0697e5e29cd Mon Sep 17 00:00:00 2001 From: Danny Thomas Date: Thu, 28 May 2026 18:15:50 +1000 Subject: [PATCH 060/352] fix(coding-agent): drain follow-ups queued during agent_end When an extension queues a follow-up during `agent_end` it gets stuck on the follow-up queue until after the next user message. Add a hasQueuedMessages() check to _handlePostAgentRun so the existing while/continue loop drains them. - One-line fix in _handlePostAgentRun - Integration test in agent-session-queue - git-merge-and-resolve example extension with tests --- packages/coding-agent/docs/extensions.md | 1 + .../extensions/git-merge-and-resolve.ts | 115 ++++++++++ .../coding-agent/src/core/agent-session.ts | 8 +- .../git-merge-and-resolve-extension.test.ts | 206 ++++++++++++++++++ .../test/suite/agent-session-queue.test.ts | 23 ++ 5 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/examples/extensions/git-merge-and-resolve.ts create mode 100644 packages/coding-agent/test/git-merge-and-resolve-extension.test.ts diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index ce922f2a..c6746dbb 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -2553,6 +2553,7 @@ All examples in [examples/extensions/](../examples/extensions/). | `custom-compaction.ts` | Custom compaction summary | `on("session_before_compact")` | | `trigger-compact.ts` | Trigger compaction manually | `compact()` | | `git-checkpoint.ts` | Git stash on turns | `on("turn_start")`, `on("session_before_fork")`, `exec` | +| `git-merge-and-resolve.ts` | Fetch, merge, and resolve conflicts | `on("agent_end")`, `exec`, `sendUserMessage` | | `auto-commit-on-exit.ts` | Commit on shutdown | `on("session_shutdown")`, `exec` | | **UI Components** ||| | `status-line.ts` | Footer status indicator | `setStatus`, session events | diff --git a/packages/coding-agent/examples/extensions/git-merge-and-resolve.ts b/packages/coding-agent/examples/extensions/git-merge-and-resolve.ts new file mode 100644 index 00000000..61ad1320 --- /dev/null +++ b/packages/coding-agent/examples/extensions/git-merge-and-resolve.ts @@ -0,0 +1,115 @@ +/** + * Merge and Resolve + * + * Keeps the working branch up to date with its upstream tracking ref. + * After each agent turn, fetches and merges. Clean merges complete + * silently. When conflicts arise, the working tree is left dirty and + * the agent receives a follow-up message listing each conflict block + * with file, line range, and ours/theirs sections so it can resolve them. + * Also re-sends unresolved conflicts from a previous incomplete merge. + * + * Start pi with this extension: + * pi -e ./examples/extensions/git-merge-and-resolve.ts + */ +import { createReadStream } from "node:fs"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +interface ConflictBlock { + file: string; + startLine: number; + separatorLine: number; + endLine: number; +} + +/** Parse conflict markers from working tree files with unmerged paths. */ +async function findConflicts(pi: ExtensionAPI, cwd: string): Promise { + const { stdout, code } = await pi.exec("git", ["diff", "--name-only", "--diff-filter=U"]); + if (code !== 0 || !stdout.trim()) return []; + + const blocks: ConflictBlock[] = []; + for (const file of stdout.trim().split("\n")) { + try { + const rl = createInterface({ input: createReadStream(join(cwd, file), "utf-8") }); + let lineNo = 0; + let blockStart: number | undefined; + let separatorLine: number | undefined; + for await (const line of rl) { + lineNo++; + if (line.startsWith("<<<<<<<")) { + blockStart = lineNo; + separatorLine = undefined; + } else if (line.startsWith("=======") && blockStart !== undefined) { + separatorLine = lineNo; + } else if (line.startsWith(">>>>>>>") && blockStart !== undefined && separatorLine !== undefined) { + blocks.push({ file, startLine: blockStart, separatorLine, endLine: lineNo }); + blockStart = undefined; + separatorLine = undefined; + } + } + } catch {} + } + return blocks; +} + +function formatRange(start: number, end: number): string { + if (start > end) return "empty"; + if (start === end) return `${start}`; + return `${start}-${end}`; +} + +function formatConflicts(ref: string, blocks: ConflictBlock[]): string { + const lines = [`Merged ${ref} with conflicts:`, ""]; + for (const b of blocks) { + const ours = formatRange(b.startLine + 1, b.separatorLine - 1); + const theirs = formatRange(b.separatorLine + 1, b.endLine - 1); + lines.push(` ${b.file}:${b.startLine}-${b.endLine} (ours ${ours}, theirs ${theirs})`); + } + lines.push("", "Resolve these conflicts."); + return lines.join("\n"); +} + +export default function (pi: ExtensionAPI) { + pi.on("agent_end", async (_event, ctx) => { + const { code: revParseCode } = await pi.exec("git", ["rev-parse", "--git-dir"]); + if (revParseCode !== 0) return; + + let ref = "MERGE_HEAD"; + + // If not already in a merge, attempt one + const { code: mergeHeadCode } = await pi.exec("git", ["rev-parse", "MERGE_HEAD"]); + if (mergeHeadCode !== 0) { + // Only attempt a new merge if the working tree is clean + const { stdout: status } = await pi.exec("git", ["status", "--porcelain"]); + if (status.trim()) return; + + const { stdout: upstream, code: upstreamCode } = await pi.exec("git", [ + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + "@{u}", + ]); + if (upstreamCode !== 0) return; + + ref = upstream.trim(); + const remote = ref.split("/")[0]; + ctx.ui.notify(`git-merge-and-resolve: fetching ${remote}, merging ${ref}`, "info"); + + const { code: fetchCode, stderr: fetchErr } = await pi.exec("git", ["fetch", remote]); + if (fetchCode !== 0) { + ctx.ui.notify(`git-merge-and-resolve: fetch failed: ${fetchErr.trim()}`, "warning"); + return; + } + + const { code: mergeCode } = await pi.exec("git", ["merge", "--no-ff", ref]); + if (mergeCode === 0) return; + } + + // Either we just merged with conflicts, or we were already in an unfinished merge + const conflicts = await findConflicts(pi, ctx.cwd); + if (conflicts.length === 0) return; + + pi.sendUserMessage(formatConflicts(ref, conflicts), { deliverAs: "followUp" }); + }); +} diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index d2835b7b..37b6279a 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -947,7 +947,13 @@ export class AgentSession { this._retryAttempt = 0; } - return await this._checkCompaction(msg); + if (await this._checkCompaction(msg)) { + return true; + } + + // The agent loop drains both queues before emitting agent_end. Any messages + // here were queued by agent_end extension handlers and need a continuation. + return this.agent.hasQueuedMessages(); } /** diff --git a/packages/coding-agent/test/git-merge-and-resolve-extension.test.ts b/packages/coding-agent/test/git-merge-and-resolve-extension.test.ts new file mode 100644 index 00000000..7c8741c7 --- /dev/null +++ b/packages/coding-agent/test/git-merge-and-resolve-extension.test.ts @@ -0,0 +1,206 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import mergeAndResolve from "../examples/extensions/git-merge-and-resolve.ts"; +import type { ExecResult, ExtensionAPI, ExtensionContext } from "../src/core/extensions/index.ts"; + +type AgentEndHandler = (event: { type: "agent_end" }, ctx: ExtensionContext) => Promise; + +const ok: ExecResult = { stdout: "", stderr: "", code: 0, killed: false }; +const fail: ExecResult = { stdout: "", stderr: "error", code: 1, killed: false }; + +/** Standard exec results for a clean repo tracking origin/main, not in a merge. */ +function withUpstream(results: Map): Map { + results.set("git rev-parse --git-dir", ok); + results.set("git rev-parse MERGE_HEAD", fail); + results.set("git status --porcelain", ok); + results.set("git rev-parse --abbrev-ref --symbolic-full-name @{u}", { ...ok, stdout: "origin/main\n" }); + results.set("git fetch origin", ok); + return results; +} + +function setup(cwd: string, execResults: Map) { + let handler: AgentEndHandler | undefined; + const sendUserMessage = vi.fn(); + + const exec = vi.fn().mockImplementation(async (cmd, args) => { + const key = [cmd, ...args].join(" "); + return execResults.get(key) ?? fail; + }); + + const api = { + on: (event: string, h: AgentEndHandler) => { + if (event === "agent_end") handler = h; + }, + exec, + sendUserMessage, + } as unknown as ExtensionAPI; + + mergeAndResolve(api); + + const ctx = { cwd, ui: { notify: vi.fn() } } as unknown as ExtensionContext; + + async function trigger() { + await handler!({ type: "agent_end" }, ctx); + } + + return { trigger, exec, sendUserMessage }; +} + +describe("git-merge-and-resolve example", () => { + let tempDir: string; + + afterEach(() => { + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + }); + + function createTempDir() { + tempDir = mkdtempSync(join(tmpdir(), "pi-merge-test-")); + return tempDir; + } + + it("skips when not a git repository", async () => { + const cwd = createTempDir(); + const results = new Map(); + results.set("git rev-parse --git-dir", fail); + + const { trigger, exec, sendUserMessage } = setup(cwd, results); + await trigger(); + + expect(exec).toHaveBeenCalledTimes(1); + expect(sendUserMessage).not.toHaveBeenCalled(); + }); + + it("skips when no upstream is configured", async () => { + const cwd = createTempDir(); + const results = new Map(); + results.set("git rev-parse --git-dir", ok); + results.set("git rev-parse --abbrev-ref --symbolic-full-name @{u}", fail); + + const { trigger, sendUserMessage } = setup(cwd, results); + await trigger(); + + expect(sendUserMessage).not.toHaveBeenCalled(); + }); + + it("re-sends conflicts when in an unfinished merge", async () => { + const cwd = createTempDir(); + const conflictContent = ["<<<<<<< HEAD", "ours", "=======", "theirs", ">>>>>>> origin/main"].join("\n"); + writeFileSync(join(cwd, "file.ts"), conflictContent); + + const results = new Map(); + results.set("git rev-parse --git-dir", ok); + results.set("git rev-parse MERGE_HEAD", ok); + results.set("git diff --name-only --diff-filter=U", { ...ok, stdout: "file.ts\n" }); + + const { trigger, exec, sendUserMessage } = setup(cwd, results); + await trigger(); + + // Should not attempt a new fetch/merge + expect(exec).not.toHaveBeenCalledWith("git", ["fetch", "origin"]); + expect(sendUserMessage).toHaveBeenCalledTimes(1); + const message = sendUserMessage.mock.calls[0][0] as string; + expect(message).toContain("file.ts:1-5"); + }); + + it("skips when working tree is dirty and not in a merge", async () => { + const cwd = createTempDir(); + const results = new Map(); + results.set("git rev-parse --git-dir", ok); + results.set("git rev-parse MERGE_HEAD", fail); + results.set("git status --porcelain", { ...ok, stdout: " M src/index.ts\n" }); + + const { trigger, exec, sendUserMessage } = setup(cwd, results); + await trigger(); + + expect(exec).not.toHaveBeenCalledWith("git", ["fetch", "origin"]); + expect(sendUserMessage).not.toHaveBeenCalled(); + }); + + it("skips when fetch fails", async () => { + const cwd = createTempDir(); + const results = withUpstream(new Map()); + results.set("git fetch origin", fail); + + const { trigger, sendUserMessage } = setup(cwd, results); + await trigger(); + + expect(sendUserMessage).not.toHaveBeenCalled(); + }); + + it("skips when merge is clean", async () => { + const cwd = createTempDir(); + const results = withUpstream(new Map()); + results.set("git merge --no-ff origin/main", ok); + + const { trigger, sendUserMessage } = setup(cwd, results); + await trigger(); + + expect(sendUserMessage).not.toHaveBeenCalled(); + }); + + it("sends conflict report as a follow-up", async () => { + const cwd = createTempDir(); + const conflictContent = [ + "line 1", + "<<<<<<< HEAD", + "our change", + "=======", + "their change", + ">>>>>>> origin/main", + "line 7", + "<<<<<<< HEAD", + "second conflict", + "=======", + "their second", + ">>>>>>> origin/main", + ].join("\n"); + + mkdirSync(join(cwd, "src"), { recursive: true }); + writeFileSync(join(cwd, "src/index.ts"), conflictContent); + + const results = withUpstream(new Map()); + results.set("git merge --no-ff origin/main", { ...fail, code: 1 }); + results.set("git diff --name-only --diff-filter=U", { ...ok, stdout: "src/index.ts\n" }); + + const { trigger, sendUserMessage } = setup(cwd, results); + await trigger(); + + expect(sendUserMessage).toHaveBeenCalledTimes(1); + const [message, options] = sendUserMessage.mock.calls[0]; + expect(message).toContain("src/index.ts:2-6 (ours 3, theirs 5)"); + expect(message).toContain("src/index.ts:8-12 (ours 9, theirs 11)"); + expect(options).toEqual({ deliverAs: "followUp" }); + }); + + it("handles empty ours or theirs sections", async () => { + const cwd = createTempDir(); + const conflictContent = ["<<<<<<< HEAD", "=======", "only theirs", ">>>>>>> origin/main"].join("\n"); + + writeFileSync(join(cwd, "empty-ours.ts"), conflictContent); + + const results = withUpstream(new Map()); + results.set("git merge --no-ff origin/main", { ...fail, code: 1 }); + results.set("git diff --name-only --diff-filter=U", { ...ok, stdout: "empty-ours.ts\n" }); + + const { trigger, sendUserMessage } = setup(cwd, results); + await trigger(); + + expect(sendUserMessage).toHaveBeenCalledTimes(1); + const message = sendUserMessage.mock.calls[0][0] as string; + expect(message).toContain("empty-ours.ts:1-4 (ours empty, theirs 3)"); + }); + + it("skips message when merge fails but no conflict markers found", async () => { + const cwd = createTempDir(); + const results = withUpstream(new Map()); + results.set("git merge --no-ff origin/main", { ...fail, code: 1 }); + results.set("git diff --name-only --diff-filter=U", { ...ok, stdout: "" }); + + const { trigger, sendUserMessage } = setup(cwd, results); + await trigger(); + + expect(sendUserMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/coding-agent/test/suite/agent-session-queue.test.ts b/packages/coding-agent/test/suite/agent-session-queue.test.ts index 3db7c14b..a1c3fd8a 100644 --- a/packages/coding-agent/test/suite/agent-session-queue.test.ts +++ b/packages/coding-agent/test/suite/agent-session-queue.test.ts @@ -419,4 +419,27 @@ describe("AgentSession queue characterization", () => { 'Extension command "/testcmd" cannot be queued. Use prompt() or execute the command when not streaming.', ); }); + + it("delivers follow-ups queued during agent_end", async () => { + let sent = false; + const harness = await createHarness({ + extensionFactories: [ + (pi: ExtensionAPI) => { + pi.on("agent_end", async () => { + if (sent) return; + sent = true; + pi.sendUserMessage("conflict report", { deliverAs: "followUp" }); + }); + }, + ], + }); + harnesses.push(harness); + + harness.setResponses([fauxAssistantMessage("reply"), fauxAssistantMessage("follow-up reply")]); + + await harness.session.prompt("hello"); + await harness.session.agent.waitForIdle(); + + expect(getUserTexts(harness)).toEqual(["hello", "conflict report"]); + }); }); From e43f2c3d0a618bc0128b8528195acc978eb792eb Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 28 May 2026 10:31:03 +0200 Subject: [PATCH 061/352] Clarify PR review worktree rules --- .pi/prompts/pr.md | 2 +- AGENTS.md | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.pi/prompts/pr.md b/.pi/prompts/pr.md index b1b2617e..19ce8c51 100644 --- a/.pi/prompts/pr.md +++ b/.pi/prompts/pr.md @@ -8,7 +8,7 @@ For each PR URL, do the following in order: 1. Add the `inprogress` label to the PR via GitHub CLI before analysis starts. If adding the label fails, report that explicitly and continue. 2. Read the PR page in full. Include description, all comments, all commits, and all changed files. 3. Identify any linked issues referenced in the PR body, comments, commit messages, or cross links. Read each issue in full, including all comments. -4. Analyze the PR diff. Read all relevant code files in full with no truncation and compare against the diff. Do not fetch PR file blobs unless a file is missing on main or the diff context is insufficient. Include related code paths that are not in the diff but are required to validate behavior. +4. Analyze the PR diff without checking out or switching to the PR branch. Use `gh pr diff`, `gh pr view`, `gh api`, and local main-branch files; if PR file contents are needed, use fetched refs with `git show :` or temporary files. Read all relevant code files in full with no truncation and compare against the diff. Do not fetch PR file blobs unless a file is missing on main or the diff context is insufficient. Include related code paths that are not in the diff but are required to validate behavior. 5. Do not check for a changelog entry. Per CONTRIBUTING.md, contributor PRs must not edit `CHANGELOG.md` — the maintainer adds the entry when merging. 6. Check if packages/coding-agent/README.md, packages/coding-agent/docs/*.md, packages/coding-agent/examples/**/*.md require modification. This is usually the case when existing features have been changed, or new features have been added. 7. Provide a structured review with these sections: diff --git a/AGENTS.md b/AGENTS.md index 33be8f27..933835e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,12 @@ If rebase conflicts occur: See `CONTRIBUTING.md` for the contributor gate (auto-close workflows, `lgtm`/`lgtmi`, quality bar). +When reviewing PRs: + +- Do not run `gh pr checkout`, `git switch`, or otherwise move the worktree to the PR branch unless the user explicitly asks. +- Use `gh pr view`, `gh pr diff`, `gh api`, and local `git show`/`git diff` against fetched refs to inspect PR metadata, commits, and patches without changing branches. +- If you need PR file contents, fetch/read them into temporary files or use `git show :` without switching branches. + When creating issues: - Add `pkg:*` labels for affected packages (`pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:tui`); use all that apply. From 17d39ccfd4c57c3e640fd5d2bf1aa854ed7c7574 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 28 May 2026 10:48:11 +0200 Subject: [PATCH 062/352] Add changelog entry for PR 5115 --- packages/coding-agent/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4c0f9a33..c8a57b87 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed follow-up messages queued by `agent_end` extension handlers to drain before the agent becomes idle ([#5115](https://github.com/earendil-works/pi-mono/pull/5115) by [@DanielThomas](https://github.com/DanielThomas)). - Fixed extension input events to report `streamingBehavior` only for prompts actually queued during streaming ([#5107](https://github.com/earendil-works/pi-mono/pull/5107)). - Fixed fenced `diff` code blocks and other highlight.js scopes to keep theme-aware syntax colors after the `cli-highlight` replacement ([#5092](https://github.com/earendil-works/pi/issues/5092)). From edd26444afe9bcbb513ae23a136bd2dfcb5b8342 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 28 May 2026 11:03:25 +0200 Subject: [PATCH 063/352] Expose tool prompt guidelines to extensions closes #4879 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/extensions.md | 5 +++-- packages/coding-agent/src/core/agent-session.ts | 3 ++- packages/coding-agent/src/core/extensions/types.ts | 6 +++--- .../coding-agent/test/agent-session-dynamic-tools.test.ts | 3 +++ 5 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c8a57b87..0c4aba0b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed `pi.getAllTools()` to expose each tool's `promptGuidelines` for extensions that need per-tool guideline attribution ([#4879](https://github.com/earendil-works/pi/issues/4879)). - Fixed follow-up messages queued by `agent_end` extension handlers to drain before the agent becomes idle ([#5115](https://github.com/earendil-works/pi-mono/pull/5115) by [@DanielThomas](https://github.com/DanielThomas)). - Fixed extension input events to report `streamingBehavior` only for prompts actually queued during streaming ([#5107](https://github.com/earendil-works/pi-mono/pull/5107)). - Fixed fenced `diff` code blocks and other highlight.js scopes to keep theme-aware syntax colors after the `cli-highlight` replacement ([#5092](https://github.com/earendil-works/pi/issues/5092)). diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 4d243d9a..db856fc8 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -1493,7 +1493,8 @@ const all = pi.getAllTools(); // [{ // name: "read", // description: "Read file contents...", -// parameters: ..., +// parameters: ..., +// promptGuidelines: ["Use read to examine files instead of cat or sed."], // sourceInfo: { path: "", source: "builtin", scope: "temporary", origin: "top-level" } // }, ...] const names = all.map(t => t.name); @@ -1502,7 +1503,7 @@ const extensionTools = all.filter((t) => t.sourceInfo.source !== "builtin" && t. pi.setActiveTools(["read", "bash"]); // Switch to read-only ``` -`pi.getAllTools()` returns `name`, `description`, `parameters`, and `sourceInfo`. +`pi.getAllTools()` returns `name`, `description`, `parameters`, `promptGuidelines`, and `sourceInfo`. Typical `sourceInfo.source` values: - `builtin` for built-in tools diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 59a39ef3..212de19e 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -759,13 +759,14 @@ export class AgentSession { } /** - * Get all configured tools with name, description, parameter schema, and source metadata. + * Get all configured tools with name, description, parameter schema, prompt guidelines, and source metadata. */ getAllTools(): ToolInfo[] { return Array.from(this._toolDefinitions.values()).map(({ definition, sourceInfo }) => ({ name: definition.name, description: definition.description, parameters: definition.parameters, + promptGuidelines: definition.promptGuidelines, sourceInfo, })); } diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index f7afcf13..0d5bb7af 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -1213,7 +1213,7 @@ export interface ExtensionAPI { /** Get the list of currently active tool names. */ getActiveTools(): string[]; - /** Get all configured tools with parameter schema and source metadata. */ + /** Get all configured tools with parameter schema, prompt guidelines, and source metadata. */ getAllTools(): ToolInfo[]; /** Set the active tools by name. */ @@ -1424,8 +1424,8 @@ export type GetSessionNameHandler = () => string | undefined; export type GetActiveToolsHandler = () => string[]; -/** Tool info with name, description, parameter schema, and source metadata */ -export type ToolInfo = Pick & { +/** Tool info with name, description, parameter schema, prompt guidelines, and source metadata. */ +export type ToolInfo = Pick & { sourceInfo: SourceInfo; }; diff --git a/packages/coding-agent/test/agent-session-dynamic-tools.test.ts b/packages/coding-agent/test/agent-session-dynamic-tools.test.ts index e58ea22f..cf64a17f 100644 --- a/packages/coding-agent/test/agent-session-dynamic-tools.test.ts +++ b/packages/coding-agent/test/agent-session-dynamic-tools.test.ts @@ -72,6 +72,9 @@ describe("AgentSession dynamic tool registration", () => { const readTool = allTools.find((tool) => tool.name === "read"); expect(allTools.map((tool) => tool.name)).toContain("dynamic_tool"); + expect(dynamicTool?.promptGuidelines).toEqual([ + "Use dynamic_tool when the user asks for dynamic behavior tests.", + ]); expect(dynamicTool?.sourceInfo).toMatchObject({ path: "", source: "inline", From 9d5fb70b7e022bb6f7c63cddf5e647d44d05de69 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Thu, 28 May 2026 11:09:33 +0200 Subject: [PATCH 064/352] feat(ai): add Codex device code login (#4911) --- packages/ai/CHANGELOG.md | 4 + packages/ai/src/utils/oauth/device-code.ts | 29 +- packages/ai/src/utils/oauth/github-copilot.ts | 10 +- packages/ai/src/utils/oauth/index.ts | 9 +- packages/ai/src/utils/oauth/openai-codex.ts | 351 ++++++++++----- packages/ai/test/github-copilot-oauth.test.ts | 30 +- packages/ai/test/oauth-device-code.test.ts | 14 +- packages/ai/test/openai-codex-oauth.test.ts | 421 +++++++++++++++++- packages/coding-agent/CHANGELOG.md | 1 + .../interactive/components/login-dialog.ts | 6 +- 10 files changed, 723 insertions(+), 152 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 9d9935d5..ba60d6c4 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added OpenAI Codex subscription device-code login as a selectable headless alternative while keeping browser login as the default. + ## [0.76.0] - 2026-05-27 ### Fixed diff --git a/packages/ai/src/utils/oauth/device-code.ts b/packages/ai/src/utils/oauth/device-code.ts index 95dba2e7..1121136f 100644 --- a/packages/ai/src/utils/oauth/device-code.ts +++ b/packages/ai/src/utils/oauth/device-code.ts @@ -8,16 +8,17 @@ const DEFAULT_POLL_INTERVAL_SECONDS = 5; // RFC 8628 section 3.5: `slow_down` means the polling interval must increase by 5 seconds. const SLOW_DOWN_INTERVAL_INCREMENT_MS = 5000; -export type OAuthDeviceCodePollResult = +type OAuthDeviceCodeIncompletePollResult = | { status: "pending" } | { status: "slow_down" } - | { status: "complete"; accessToken: string } | { status: "failed"; message: string }; -export type OAuthDeviceCodePollOptions = { +export type OAuthDeviceCodePollResult = OAuthDeviceCodeIncompletePollResult | { status: "complete"; value: T }; + +export type OAuthDeviceCodePollOptions = { intervalSeconds?: number; expiresInSeconds?: number; - poll: () => Promise; + poll: () => Promise>; signal?: AbortSignal; }; @@ -41,7 +42,7 @@ function abortableSleep(ms: number, signal: AbortSignal | undefined, cancelMessa }); } -export async function pollOAuthDeviceCodeFlow(options: OAuthDeviceCodePollOptions): Promise { +export async function pollOAuthDeviceCodeFlow(options: OAuthDeviceCodePollOptions): Promise { const deadline = typeof options.expiresInSeconds === "number" ? Date.now() + options.expiresInSeconds * 1000 @@ -57,23 +58,25 @@ export async function pollOAuthDeviceCodeFlow(options: OAuthDeviceCodePollOption throw new Error(CANCEL_MESSAGE); } - const remainingMs = deadline - Date.now(); - await abortableSleep(Math.min(intervalMs, remainingMs), options.signal, CANCEL_MESSAGE); - const result = await options.poll(); if (result.status === "complete") { - return result.accessToken; + return result.value; } - if (result.status === "pending") { - continue; + if (result.status === "failed") { + throw new Error(result.message); } if (result.status === "slow_down") { slowDownResponses += 1; // RFC 8628 section 3.5: apply this increase to this and all subsequent requests. intervalMs = Math.max(MINIMUM_INTERVAL_MS, intervalMs + SLOW_DOWN_INTERVAL_INCREMENT_MS); - continue; } - throw new Error(result.message); + + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + break; + } + + await abortableSleep(Math.min(intervalMs, remainingMs), options.signal, CANCEL_MESSAGE); } throw new Error(slowDownResponses > 0 ? SLOW_DOWN_TIMEOUT_MESSAGE : TIMEOUT_MESSAGE); diff --git a/packages/ai/src/utils/oauth/github-copilot.ts b/packages/ai/src/utils/oauth/github-copilot.ts index d5adb58a..ba371b8f 100644 --- a/packages/ai/src/utils/oauth/github-copilot.ts +++ b/packages/ai/src/utils/oauth/github-copilot.ts @@ -141,9 +141,13 @@ async function startDeviceFlow(domain: string): Promise { }; } -async function pollForGitHubAccessToken(domain: string, device: DeviceCodeResponse, signal?: AbortSignal) { +async function pollForGitHubAccessToken( + domain: string, + device: DeviceCodeResponse, + signal?: AbortSignal, +): Promise { const urls = getUrls(domain); - return pollOAuthDeviceCodeFlow({ + return pollOAuthDeviceCodeFlow({ intervalSeconds: device.interval, expiresInSeconds: device.expires_in, signal, @@ -163,7 +167,7 @@ async function pollForGitHubAccessToken(domain: string, device: DeviceCodeRespon }); if (raw && typeof raw === "object" && typeof (raw as DeviceTokenSuccessResponse).access_token === "string") { - return { status: "complete", accessToken: (raw as DeviceTokenSuccessResponse).access_token }; + return { status: "complete", value: (raw as DeviceTokenSuccessResponse).access_token }; } if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") { diff --git a/packages/ai/src/utils/oauth/index.ts b/packages/ai/src/utils/oauth/index.ts index 55910322..a57badda 100644 --- a/packages/ai/src/utils/oauth/index.ts +++ b/packages/ai/src/utils/oauth/index.ts @@ -19,7 +19,14 @@ export { refreshGitHubCopilotToken, } from "./github-copilot.ts"; // OpenAI Codex (ChatGPT OAuth) -export { loginOpenAICodex, openaiCodexOAuthProvider, refreshOpenAICodexToken } from "./openai-codex.ts"; +export { + loginOpenAICodex, + loginOpenAICodexDeviceCode, + OPENAI_CODEX_BROWSER_LOGIN_METHOD, + OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD, + openaiCodexOAuthProvider, + refreshOpenAICodexToken, +} from "./openai-codex.ts"; export * from "./types.ts"; diff --git a/packages/ai/src/utils/oauth/openai-codex.ts b/packages/ai/src/utils/oauth/openai-codex.ts index 7dd1fbb8..51ea832b 100644 --- a/packages/ai/src/utils/oauth/openai-codex.ts +++ b/packages/ai/src/utils/oauth/openai-codex.ts @@ -17,21 +17,46 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version }); } +import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts"; import { generatePKCE } from "./pkce.ts"; -import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types.ts"; +import type { + OAuthCredentials, + OAuthDeviceCodeInfo, + OAuthLoginCallbacks, + OAuthPrompt, + OAuthProviderInterface, +} from "./types.ts"; const CALLBACK_HOST = process.env.PI_OAUTH_CALLBACK_HOST || "127.0.0.1"; const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; -const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"; -const TOKEN_URL = "https://auth.openai.com/oauth/token"; +const AUTH_BASE_URL = "https://auth.openai.com"; +const AUTHORIZE_URL = `${AUTH_BASE_URL}/oauth/authorize`; +const TOKEN_URL = `${AUTH_BASE_URL}/oauth/token`; const REDIRECT_URI = "http://localhost:1455/auth/callback"; +const DEVICE_USER_CODE_URL = `${AUTH_BASE_URL}/api/accounts/deviceauth/usercode`; +const DEVICE_TOKEN_URL = `${AUTH_BASE_URL}/api/accounts/deviceauth/token`; +const DEVICE_VERIFICATION_URI = `${AUTH_BASE_URL}/codex/device`; +const DEVICE_REDIRECT_URI = `${AUTH_BASE_URL}/deviceauth/callback`; +const DEVICE_CODE_TIMEOUT_SECONDS = 15 * 60; +export const OPENAI_CODEX_BROWSER_LOGIN_METHOD = "browser"; +export const OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD = "device_code"; const SCOPE = "openid profile email offline_access"; const JWT_CLAIM_PATH = "https://api.openai.com/auth"; -type TokenSuccess = { type: "success"; access: string; refresh: string; expires: number }; -type TokenFailure = { type: "failed"; message: string; status?: number }; -type TokenResult = TokenSuccess | TokenFailure; +type OAuthToken = { access: string; refresh: string; expires: number }; +type TokenOperation = "exchange" | "refresh"; + +type DeviceAuthInfo = { + deviceAuthId: string; + userCode: string; + intervalSeconds: number; +}; + +type DeviceTokenSuccess = { + authorizationCode: string; + codeVerifier: string; +}; type JwtPayload = { [JWT_CLAIM_PATH]?: { @@ -89,12 +114,47 @@ function decodeJwt(token: string): JwtPayload | null { } } +async function fetchWithLoginCancellation(input: string, init: RequestInit): Promise { + try { + return await fetch(input, init); + } catch (error) { + if (init.signal?.aborted) { + throw new Error("Login cancelled"); + } + throw error; + } +} + +async function readTokenResponse(response: Response, operation: TokenOperation): Promise { + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`OpenAI Codex token ${operation} failed (${response.status}): ${text || response.statusText}`); + } + + const rawJson = await response.json(); + const json = rawJson as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + } | null; + if (!json?.access_token || !json.refresh_token || typeof json.expires_in !== "number") { + throw new Error(`OpenAI Codex token ${operation} response missing fields: ${JSON.stringify(json)}`); + } + + return { + access: json.access_token, + refresh: json.refresh_token, + expires: Date.now() + json.expires_in * 1000, + }; +} + async function exchangeAuthorizationCode( code: string, verifier: string, redirectUri: string = REDIRECT_URI, -): Promise { - const response = await fetch(TOKEN_URL, { + signal?: AbortSignal, +): Promise { + const response = await fetchWithLoginCancellation(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ @@ -104,41 +164,16 @@ async function exchangeAuthorizationCode( code_verifier: verifier, redirect_uri: redirectUri, }), + signal, }); - if (!response.ok) { - const text = await response.text().catch(() => ""); - return { - type: "failed", - status: response.status, - message: `OpenAI Codex token exchange failed (${response.status}): ${text || response.statusText}`, - }; - } - - const json = (await response.json()) as { - access_token?: string; - refresh_token?: string; - expires_in?: number; - }; - - if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") { - return { - type: "failed", - message: `OpenAI Codex token exchange response missing fields: ${JSON.stringify(json)}`, - }; - } - - return { - type: "success", - access: json.access_token, - refresh: json.refresh_token, - expires: Date.now() + json.expires_in * 1000, - }; + return readTokenResponse(response, "exchange"); } -async function refreshAccessToken(refreshToken: string): Promise { +async function refreshAccessToken(refreshToken: string): Promise { + let response: Response; try { - const response = await fetch(TOKEN_URL, { + response = await fetch(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ @@ -147,41 +182,113 @@ async function refreshAccessToken(refreshToken: string): Promise { client_id: CLIENT_ID, }), }); - - if (!response.ok) { - const text = await response.text().catch(() => ""); - return { - type: "failed", - status: response.status, - message: `OpenAI Codex token refresh failed (${response.status}): ${text || response.statusText}`, - }; - } - - const json = (await response.json()) as { - access_token?: string; - refresh_token?: string; - expires_in?: number; - }; - - if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") { - return { - type: "failed", - message: `OpenAI Codex token refresh response missing fields: ${JSON.stringify(json)}`, - }; - } - - return { - type: "success", - access: json.access_token, - refresh: json.refresh_token, - expires: Date.now() + json.expires_in * 1000, - }; } catch (error) { - return { - type: "failed", - message: `OpenAI Codex token refresh error: ${error instanceof Error ? error.message : String(error)}`, - }; + throw new Error(`OpenAI Codex token refresh error: ${error instanceof Error ? error.message : String(error)}`); } + + return readTokenResponse(response, "refresh"); +} + +async function startOpenAICodexDeviceAuth(signal?: AbortSignal): Promise { + const response = await fetchWithLoginCancellation(DEVICE_USER_CODE_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ client_id: CLIENT_ID }), + signal, + }); + + if (!response.ok) { + if (response.status === 404) { + throw new Error( + "OpenAI Codex device code login is not enabled for this server. Use browser login or verify the server URL.", + ); + } + const responseBody = await response.text().catch(() => ""); + throw new Error( + `OpenAI Codex device code request failed with status ${response.status}${responseBody ? `: ${responseBody}` : ""}`, + ); + } + + const rawJson = await response.json(); + const json = rawJson as { + device_auth_id?: string; + user_code?: string; + interval?: number | string; + } | null; + const intervalSeconds = typeof json?.interval === "string" ? Number(json.interval.trim()) : json?.interval; + if ( + !json?.device_auth_id || + !json.user_code || + typeof intervalSeconds !== "number" || + !Number.isFinite(intervalSeconds) || + intervalSeconds < 0 + ) { + throw new Error(`Invalid OpenAI Codex device code response: ${JSON.stringify(json)}`); + } + + return { + deviceAuthId: json.device_auth_id, + userCode: json.user_code, + intervalSeconds, + }; +} + +async function pollOpenAICodexDeviceAuth(device: DeviceAuthInfo, signal?: AbortSignal): Promise { + return pollOAuthDeviceCodeFlow({ + intervalSeconds: device.intervalSeconds, + expiresInSeconds: DEVICE_CODE_TIMEOUT_SECONDS, + signal, + poll: async () => { + const response = await fetchWithLoginCancellation(DEVICE_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + device_auth_id: device.deviceAuthId, + user_code: device.userCode, + }), + signal, + }); + + if (response.ok) { + const rawJson = await response.json(); + const json = rawJson as { authorization_code?: string; code_verifier?: string } | null; + if (!json?.authorization_code || !json.code_verifier) { + return { + status: "failed", + message: `Invalid OpenAI Codex device auth token response: ${JSON.stringify(json)}`, + }; + } + return { + status: "complete", + value: { authorizationCode: json.authorization_code, codeVerifier: json.code_verifier }, + }; + } + + if (response.status === 403 || response.status === 404) { + return { status: "pending" }; + } + + const responseBody = await response.text().catch(() => ""); + let errorCode: unknown; + try { + const json = JSON.parse(responseBody) as { error?: string | { code?: string } } | null; + const error = json?.error; + errorCode = typeof error === "object" ? error?.code : error; + } catch {} + + if (errorCode === "deviceauth_authorization_pending") { + return { status: "pending" }; + } + if (errorCode === "slow_down") { + return { status: "slow_down" }; + } + + return { + status: "failed", + message: `OpenAI Codex device auth failed with status ${response.status}${responseBody ? `: ${responseBody}` : ""}`, + }; + }, + }); } async function createAuthorizationFlow( @@ -294,6 +401,52 @@ function getAccountId(accessToken: string): string | null { return typeof accountId === "string" && accountId.length > 0 ? accountId : null; } +function credentialsFromToken(token: OAuthToken): OAuthCredentials { + const accountId = getAccountId(token.access); + if (!accountId) { + throw new Error("Failed to extract accountId from token"); + } + + return { + access: token.access, + refresh: token.refresh, + expires: token.expires, + accountId, + }; +} + +async function exchangeAuthorizationCodeForCredentials( + code: string, + verifier: string, + redirectUri: string, + signal?: AbortSignal, +): Promise { + return credentialsFromToken(await exchangeAuthorizationCode(code, verifier, redirectUri, signal)); +} + +/** + * Login with OpenAI Codex OAuth using the Codex device-code flow. + */ +export async function loginOpenAICodexDeviceCode(options: { + onDeviceCode: (info: OAuthDeviceCodeInfo) => void; + signal?: AbortSignal; +}): Promise { + const device = await startOpenAICodexDeviceAuth(options.signal); + options.onDeviceCode({ + userCode: device.userCode, + verificationUri: DEVICE_VERIFICATION_URI, + intervalSeconds: device.intervalSeconds, + expiresInSeconds: DEVICE_CODE_TIMEOUT_SECONDS, + }); + const code = await pollOpenAICodexDeviceAuth(device, options.signal); + return exchangeAuthorizationCodeForCredentials( + code.authorizationCode, + code.codeVerifier, + DEVICE_REDIRECT_URI, + options.signal, + ); +} + /** * Login with OpenAI Codex OAuth * @@ -391,22 +544,7 @@ export async function loginOpenAICodex(options: { throw new Error("Missing authorization code"); } - const tokenResult = await exchangeAuthorizationCode(code, verifier); - if (tokenResult.type !== "success") { - throw new Error(tokenResult.message); - } - - const accountId = getAccountId(tokenResult.access); - if (!accountId) { - throw new Error("Failed to extract accountId from token"); - } - - return { - access: tokenResult.access, - refresh: tokenResult.refresh, - expires: tokenResult.expires, - accountId, - }; + return exchangeAuthorizationCodeForCredentials(code, verifier, REDIRECT_URI); } finally { server.close(); } @@ -416,22 +554,7 @@ export async function loginOpenAICodex(options: { * Refresh OpenAI Codex OAuth token */ export async function refreshOpenAICodexToken(refreshToken: string): Promise { - const result = await refreshAccessToken(refreshToken); - if (result.type !== "success") { - throw new Error(result.message); - } - - const accountId = getAccountId(result.access); - if (!accountId) { - throw new Error("Failed to extract accountId from token"); - } - - return { - access: result.access, - refresh: result.refresh, - expires: result.expires, - accountId, - }; + return credentialsFromToken(await refreshAccessToken(refreshToken)); } export const openaiCodexOAuthProvider: OAuthProviderInterface = { @@ -440,6 +563,28 @@ export const openaiCodexOAuthProvider: OAuthProviderInterface = { usesCallbackServer: true, async login(callbacks: OAuthLoginCallbacks): Promise { + const loginMethod = await callbacks.onSelect({ + message: "Select OpenAI Codex login method:", + options: [ + { id: OPENAI_CODEX_BROWSER_LOGIN_METHOD, label: "Browser login (default)" }, + { id: OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD, label: "Device code login (headless)" }, + ], + }); + if (!loginMethod) { + throw new Error("Login cancelled"); + } + + if (loginMethod === OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD) { + return loginOpenAICodexDeviceCode({ + onDeviceCode: callbacks.onDeviceCode, + signal: callbacks.signal, + }); + } + + if (loginMethod !== OPENAI_CODEX_BROWSER_LOGIN_METHOD) { + throw new Error(`Unknown OpenAI Codex login method: ${loginMethod}`); + } + return loginOpenAICodex({ onAuth: callbacks.onAuth, onPrompt: callbacks.onPrompt, diff --git a/packages/ai/test/github-copilot-oauth.test.ts b/packages/ai/test/github-copilot-oauth.test.ts index 1fb92b1e..324883ec 100644 --- a/packages/ai/test/github-copilot-oauth.test.ts +++ b/packages/ai/test/github-copilot-oauth.test.ts @@ -84,7 +84,7 @@ describe("GitHub Copilot OAuth device flow", () => { await loginPromise; }); - it("waits before the first poll and increases the interval after slow_down", async () => { + it("polls immediately and increases the interval after slow_down", async () => { vi.useFakeTimers(); const startTime = new Date("2026-03-09T00:00:00Z"); vi.setSystemTime(startTime); @@ -156,12 +156,6 @@ describe("GitHub Copilot OAuth device flow", () => { }); await vi.advanceTimersByTimeAsync(0); - expect(accessTokenPollTimes).toHaveLength(0); - - await vi.advanceTimersByTimeAsync(4999); - expect(accessTokenPollTimes).toHaveLength(0); - - await vi.advanceTimersByTimeAsync(1); expect(accessTokenPollTimes).toHaveLength(1); await vi.advanceTimersByTimeAsync(4999); @@ -177,13 +171,13 @@ describe("GitHub Copilot OAuth device flow", () => { await loginPromise; expect(accessTokenPollTimes).toEqual([ + startTime.getTime(), startTime.getTime() + 5000, - startTime.getTime() + 10000, - startTime.getTime() + 20000, + startTime.getTime() + 15000, ]); }); - it("uses the remaining lifetime for a final poll before timing out after repeated slow_down responses", async () => { + it("times out after repeated slow_down responses", async () => { vi.useFakeTimers(); const startTime = new Date("2026-03-09T00:00:00Z"); vi.setSystemTime(startTime); @@ -231,21 +225,17 @@ describe("GitHub Copilot OAuth device flow", () => { ); await vi.advanceTimersByTimeAsync(5000); - expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000]); + expect(accessTokenPollTimes).toEqual([startTime.getTime()]); - await vi.advanceTimersByTimeAsync(10000); - expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 15000]); + await vi.advanceTimersByTimeAsync(5000); + expect(accessTokenPollTimes).toEqual([startTime.getTime(), startTime.getTime() + 10000]); - await vi.advanceTimersByTimeAsync(9999); - expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 15000]); + await vi.advanceTimersByTimeAsync(14999); + expect(accessTokenPollTimes).toEqual([startTime.getTime(), startTime.getTime() + 10000]); await vi.advanceTimersByTimeAsync(1); await rejection; - expect(accessTokenPollTimes).toEqual([ - startTime.getTime() + 5000, - startTime.getTime() + 15000, - startTime.getTime() + 25000, - ]); + expect(accessTokenPollTimes).toEqual([startTime.getTime(), startTime.getTime() + 10000]); }); }); diff --git a/packages/ai/test/oauth-device-code.test.ts b/packages/ai/test/oauth-device-code.test.ts index 40ef4fd8..f32decb6 100644 --- a/packages/ai/test/oauth-device-code.test.ts +++ b/packages/ai/test/oauth-device-code.test.ts @@ -6,7 +6,7 @@ describe("OAuth device-code polling", () => { vi.useRealTimers(); }); - it("waits before the first poll and returns the completed value", async () => { + it("polls immediately and returns the completed value", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-09T00:00:00Z")); @@ -15,7 +15,7 @@ describe("OAuth device-code polling", () => { pollTimes.push(Date.now()); return pollTimes.length === 1 ? { status: "pending" as const } - : { status: "complete" as const, accessToken: "token" }; + : { status: "complete" as const, value: "token" }; }); const resultPromise = pollOAuthDeviceCodeFlow({ @@ -24,17 +24,17 @@ describe("OAuth device-code polling", () => { poll, }); + await vi.advanceTimersByTimeAsync(0); + expect(pollTimes).toEqual([new Date("2026-03-09T00:00:00Z").getTime()]); + await vi.advanceTimersByTimeAsync(1999); - expect(pollTimes).toEqual([]); + expect(pollTimes).toEqual([new Date("2026-03-09T00:00:00Z").getTime()]); await vi.advanceTimersByTimeAsync(1); - expect(pollTimes).toEqual([new Date("2026-03-09T00:00:02Z").getTime()]); - - await vi.advanceTimersByTimeAsync(2000); await expect(resultPromise).resolves.toBe("token"); expect(pollTimes).toEqual([ + new Date("2026-03-09T00:00:00Z").getTime(), new Date("2026-03-09T00:00:02Z").getTime(), - new Date("2026-03-09T00:00:04Z").getTime(), ]); }); diff --git a/packages/ai/test/openai-codex-oauth.test.ts b/packages/ai/test/openai-codex-oauth.test.ts index 0157f6f6..820fbe6b 100644 --- a/packages/ai/test/openai-codex-oauth.test.ts +++ b/packages/ai/test/openai-codex-oauth.test.ts @@ -1,10 +1,429 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { refreshOpenAICodexToken } from "../src/utils/oauth/openai-codex.ts"; +import { + loginOpenAICodexDeviceCode, + openaiCodexOAuthProvider, + refreshOpenAICodexToken, +} from "../src/utils/oauth/openai-codex.ts"; + +function jsonResponse(body: unknown, status: number = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function getUrl(input: unknown): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.toString(); + if (input instanceof Request) return input.url; + throw new Error(`Unsupported fetch input: ${String(input)}`); +} + +function createAccessToken(accountId: string): string { + const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64"); + const payload = Buffer.from( + JSON.stringify({ + "https://api.openai.com/auth": { + chatgpt_account_id: accountId, + }, + }), + ).toString("base64"); + return `${header}.${payload}.signature`; +} + +function deviceAuthPendingResponse(): Response { + return jsonResponse( + { + error: { + message: "Device authorization is pending. Please try again.", + type: "invalid_request_error", + param: null, + code: "deviceauth_authorization_pending", + }, + }, + 403, + ); +} describe("OpenAI Codex OAuth", () => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("logs in with the OpenAI Codex device code flow", async () => { + vi.useFakeTimers(); + const startTime = new Date("2026-05-20T00:00:00Z"); + vi.setSystemTime(startTime); + + const accessToken = createAccessToken("account-123"); + const deviceInfos: Array<{ + userCode: string; + verificationUri: string; + instructions?: string; + intervalSeconds?: number; + expiresInSeconds?: number; + }> = []; + const pollTimes: number[] = []; + const pollResponses = [ + deviceAuthPendingResponse(), + jsonResponse({ + authorization_code: "oauth-code", + code_challenge: "device-code-challenge", + code_verifier: "device-code-verifier", + }), + ]; + + const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise => { + const url = getUrl(input); + + if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") { + expect(init?.method).toBe("POST"); + expect(init?.headers).toMatchObject({ "Content-Type": "application/json" }); + expect(JSON.parse(String(init?.body))).toEqual({ client_id: "app_EMoamEEZ73f0CkXaXp7hrann" }); + return jsonResponse({ + device_auth_id: "device-auth-id", + user_code: "ABCD-1234", + interval: "5", + }); + } + + if (url === "https://auth.openai.com/api/accounts/deviceauth/token") { + pollTimes.push(Date.now()); + expect(init?.method).toBe("POST"); + expect(init?.headers).toMatchObject({ "Content-Type": "application/json" }); + expect(JSON.parse(String(init?.body))).toEqual({ + device_auth_id: "device-auth-id", + user_code: "ABCD-1234", + }); + const response = pollResponses.shift(); + if (!response) { + throw new Error("Unexpected extra device auth poll"); + } + return response; + } + + if (url === "https://auth.openai.com/oauth/token") { + expect(init?.method).toBe("POST"); + expect(init?.headers).toMatchObject({ "Content-Type": "application/x-www-form-urlencoded" }); + const params = new URLSearchParams(String(init?.body)); + expect(params.get("grant_type")).toBe("authorization_code"); + expect(params.get("client_id")).toBe("app_EMoamEEZ73f0CkXaXp7hrann"); + expect(params.get("code")).toBe("oauth-code"); + expect(params.get("redirect_uri")).toBe("https://auth.openai.com/deviceauth/callback"); + expect(params.get("code_verifier")).toBe("device-code-verifier"); + return jsonResponse({ + access_token: accessToken, + refresh_token: "refresh-token", + expires_in: 3600, + }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + vi.stubGlobal("fetch", fetchMock); + + const credentialsPromise = loginOpenAICodexDeviceCode({ + onDeviceCode: (info) => deviceInfos.push(info), + }); + + for (let i = 0; i < 5 && pollTimes.length === 0; i++) { + await vi.advanceTimersByTimeAsync(0); + } + expect(deviceInfos).toEqual([ + { + userCode: "ABCD-1234", + verificationUri: "https://auth.openai.com/codex/device", + intervalSeconds: 5, + expiresInSeconds: 900, + }, + ]); + expect(pollTimes).toEqual([startTime.getTime()]); + + await vi.advanceTimersByTimeAsync(4999); + expect(pollTimes).toEqual([startTime.getTime()]); + + await vi.advanceTimersByTimeAsync(1); + await expect(credentialsPromise).resolves.toMatchObject({ + access: accessToken, + refresh: "refresh-token", + expires: startTime.getTime() + 5000 + 3600 * 1000, + accountId: "account-123", + }); + expect(pollTimes).toEqual([startTime.getTime(), startTime.getTime() + 5000]); + }); + + it("offers browser login first and uses the selected OpenAI Codex device code flow", async () => { + const accessToken = createAccessToken("account-456"); + const selectPrompts: Array<{ + message: string; + options: Array<{ id: string; label: string }>; + }> = []; + const deviceInfos: Array<{ + userCode: string; + verificationUri: string; + intervalSeconds?: number; + expiresInSeconds?: number; + }> = []; + + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown, init?: RequestInit): Promise => { + const url = getUrl(input); + if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") { + expect(JSON.parse(String(init?.body))).toEqual({ client_id: "app_EMoamEEZ73f0CkXaXp7hrann" }); + return jsonResponse({ + device_auth_id: "device-auth-id", + user_code: "WXYZ-7890", + interval: "5", + }); + } + if (url === "https://auth.openai.com/api/accounts/deviceauth/token") { + return jsonResponse({ + authorization_code: "oauth-code", + code_challenge: "device-code-challenge", + code_verifier: "device-code-verifier", + }); + } + if (url === "https://auth.openai.com/oauth/token") { + return jsonResponse({ + access_token: accessToken, + refresh_token: "refresh-token", + expires_in: 3600, + }); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }), + ); + + await expect( + openaiCodexOAuthProvider.login({ + onAuth: () => { + throw new Error("Browser login should not start"); + }, + onDeviceCode: (info) => deviceInfos.push(info), + onPrompt: async () => { + throw new Error("Prompt should not be used"); + }, + onSelect: async (prompt) => { + selectPrompts.push(prompt); + return "device_code"; + }, + }), + ).resolves.toMatchObject({ + access: accessToken, + refresh: "refresh-token", + accountId: "account-456", + }); + + expect(selectPrompts).toEqual([ + { + message: "Select OpenAI Codex login method:", + options: [ + { id: "browser", label: "Browser login (default)" }, + { id: "device_code", label: "Device code login (headless)" }, + ], + }, + ]); + expect(deviceInfos).toEqual([ + { + userCode: "WXYZ-7890", + verificationUri: "https://auth.openai.com/codex/device", + intervalSeconds: 5, + expiresInSeconds: 900, + }, + ]); + }); + + it("cancels when OpenAI Codex login method selection is cancelled", async () => { + await expect( + openaiCodexOAuthProvider.login({ + onAuth: () => {}, + onDeviceCode: () => {}, + onPrompt: async () => "", + onSelect: async () => undefined, + }), + ).rejects.toThrow("Login cancelled"); + }); + + it("cancels the OpenAI Codex device code flow while waiting", async () => { + vi.useFakeTimers(); + const controller = new AbortController(); + const pollTimes: number[] = []; + + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown, init?: RequestInit): Promise => { + const url = getUrl(input); + if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") { + expect(JSON.parse(String(init?.body))).toEqual({ client_id: "app_EMoamEEZ73f0CkXaXp7hrann" }); + return jsonResponse({ + device_auth_id: "device-auth-id", + user_code: "ABCD-1234", + interval: "5", + }); + } + if (url === "https://auth.openai.com/api/accounts/deviceauth/token") { + pollTimes.push(Date.now()); + return deviceAuthPendingResponse(); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }), + ); + + const credentialsPromise = loginOpenAICodexDeviceCode({ + onDeviceCode: () => {}, + signal: controller.signal, + }); + const rejectionPromise = credentialsPromise.then( + () => new Error("Expected login to fail"), + (error: unknown) => error, + ); + + for (let i = 0; i < 5 && pollTimes.length === 0; i++) { + await vi.advanceTimersByTimeAsync(0); + } + expect(pollTimes).toHaveLength(1); + + controller.abort(); + const rejection = await rejectionPromise; + expect(rejection).toBeInstanceOf(Error); + expect((rejection as Error).message).toBe("Login cancelled"); + }); + + it("times out the OpenAI Codex device code flow after 15 minutes", async () => { + vi.useFakeTimers(); + const pollTimes: number[] = []; + + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown, init?: RequestInit): Promise => { + const url = getUrl(input); + if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") { + expect(JSON.parse(String(init?.body))).toEqual({ client_id: "app_EMoamEEZ73f0CkXaXp7hrann" }); + return jsonResponse({ + device_auth_id: "device-auth-id", + user_code: "ABCD-1234", + interval: "60", + }); + } + if (url === "https://auth.openai.com/api/accounts/deviceauth/token") { + pollTimes.push(Date.now()); + return deviceAuthPendingResponse(); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }), + ); + + const credentialsPromise = loginOpenAICodexDeviceCode({ + onDeviceCode: () => {}, + }); + const rejectionPromise = credentialsPromise.then( + () => new Error("Expected login to fail"), + (error: unknown) => error, + ); + + for (let i = 0; i < 5 && pollTimes.length === 0; i++) { + await vi.advanceTimersByTimeAsync(0); + } + expect(pollTimes).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(15 * 60 * 1000); + const rejection = await rejectionPromise; + expect(rejection).toBeInstanceOf(Error); + expect((rejection as Error).message).toBe("Device flow timed out"); + }); + + it("treats OpenAI Codex device auth 403 and 404 responses as pending", async () => { + vi.useFakeTimers(); + const accessToken = createAccessToken("account-403-404"); + const pollTimes: number[] = []; + const pollResponses = [ + jsonResponse({ error: "access_denied", error_description: "denied" }, 403), + new Response("not ready", { status: 404, headers: { "Content-Type": "text/plain" } }), + jsonResponse({ + authorization_code: "oauth-code", + code_challenge: "device-code-challenge", + code_verifier: "device-code-verifier", + }), + ]; + + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown): Promise => { + const url = getUrl(input); + if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") { + return jsonResponse({ + device_auth_id: "device-auth-id", + user_code: "ABCD-1234", + interval: "1", + }); + } + if (url === "https://auth.openai.com/api/accounts/deviceauth/token") { + pollTimes.push(Date.now()); + const response = pollResponses.shift(); + if (!response) { + throw new Error("Unexpected extra device auth poll"); + } + return response; + } + if (url === "https://auth.openai.com/oauth/token") { + return jsonResponse({ + access_token: accessToken, + refresh_token: "refresh-token", + expires_in: 3600, + }); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }), + ); + + const credentialsPromise = loginOpenAICodexDeviceCode({ + onDeviceCode: () => {}, + }); + + for (let i = 0; i < 5 && pollTimes.length === 0; i++) { + await vi.advanceTimersByTimeAsync(0); + } + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(1000); + + await expect(credentialsPromise).resolves.toMatchObject({ + access: accessToken, + refresh: "refresh-token", + accountId: "account-403-404", + }); + expect(pollTimes).toHaveLength(3); + }); + + it("includes the response body in OpenAI Codex device auth poll failures", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown): Promise => { + const url = getUrl(input); + if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") { + return jsonResponse({ + device_auth_id: "device-auth-id", + user_code: "ABCD-1234", + interval: "5", + }); + } + if (url === "https://auth.openai.com/api/accounts/deviceauth/token") { + return jsonResponse({ error: "server_error", error_description: "try again later" }, 500); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }), + ); + + await expect( + loginOpenAICodexDeviceCode({ + onDeviceCode: () => {}, + }), + ).rejects.toThrow( + 'OpenAI Codex device auth failed with status 500: {"error":"server_error","error_description":"try again later"}', + ); }); it("does not write token refresh failures to stderr", async () => { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0c4aba0b..35521fef 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -55,6 +55,7 @@ - Added `compat.forceAdaptiveThinking` support to custom Anthropic-compatible model configuration docs and validation ([#4797](https://github.com/earendil-works/pi-mono/pull/4797) by [@mbazso](https://github.com/mbazso)). - Added a standard unified patch to edit tool result details for SDK consumers ([#4821](https://github.com/earendil-works/pi/issues/4821)). +- Added a Codex subscription login method selector with device-code auth for headless environments. ### Changed diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index ee3f45cb..3ae9c92d 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -86,7 +86,7 @@ export class LoginDialogComponent extends Container implements Focusable { /** * Called by onAuth callback - show URL and optional instructions */ - showAuth(url: string, instructions?: string, options: { autoOpenBrowser?: boolean } = {}): void { + showAuth(url: string, instructions?: string): void { this.contentContainer.clear(); this.contentContainer.addChild(new Spacer(1)); const linkedUrl = `\x1b]8;;${url}\x07${url}\x1b]8;;\x07`; @@ -101,9 +101,7 @@ export class LoginDialogComponent extends Container implements Focusable { this.contentContainer.addChild(new Text(theme.fg("warning", instructions), 1, 0)); } - if (options.autoOpenBrowser ?? true) { - this.openUrl(url); - } + this.openUrl(url); this.tui.requestRender(); } From 5b31ffd7446195dd30356ccf5df2c8d79e3d84c0 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 28 May 2026 11:26:52 +0200 Subject: [PATCH 065/352] Abort session work during dispose --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/core/agent-session.ts | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 35521fef..10abf3c4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed session disposal to abort in-flight agent, compaction, branch summary, retry, and bash work ([#5029](https://github.com/earendil-works/pi/pull/5029) by [@TerminallyChilI](https://github.com/TerminallyChilI)). - Fixed `pi.getAllTools()` to expose each tool's `promptGuidelines` for extensions that need per-tool guideline attribution ([#4879](https://github.com/earendil-works/pi/issues/4879)). - Fixed follow-up messages queued by `agent_end` extension handlers to drain before the agent becomes idle ([#5115](https://github.com/earendil-works/pi-mono/pull/5115) by [@DanielThomas](https://github.com/DanielThomas)). - Fixed extension input events to report `streamingBehavior` only for prompts actually queued during streaming ([#5107](https://github.com/earendil-works/pi-mono/pull/5107)). diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 212de19e..4ac9d18c 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -708,6 +708,16 @@ export class AgentSession { * Call this when completely done with the session. */ dispose(): void { + try { + this.abortRetry(); + this.abortCompaction(); + this.abortBranchSummary(); + this.abortBash(); + this.agent.abort(); + } catch { + // Dispose must succeed even if an abort hook throws. + } + this._extensionRunner.invalidate( "This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().", ); From 3e9f7174456b5789a1c16398782c683d48321741 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 28 May 2026 11:57:10 +0200 Subject: [PATCH 066/352] fix(coding-agent): make config env references explicit closes #5095 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/custom-provider.md | 20 +- packages/coding-agent/docs/extensions.md | 6 +- packages/coding-agent/docs/models.md | 31 ++- packages/coding-agent/docs/providers.md | 18 +- .../custom-provider-anthropic/index.ts | 2 +- .../custom-provider-gitlab-duo/index.ts | 2 +- .../coding-agent/src/core/extensions/types.ts | 4 +- .../coding-agent/src/core/model-registry.ts | 103 +++++++-- .../src/core/resolve-config-value.ts | 166 +++++++++++++- packages/coding-agent/src/migrations.ts | 134 ++++++++++- .../coding-agent/src/utils/deprecation.ts | 14 ++ packages/coding-agent/src/utils/json.ts | 6 + .../coding-agent/test/auth-storage.test.ts | 140 +++++++++++- .../test/config-value-migration.test.ts | 138 +++++++++++ .../test/extensions-runner.test.ts | 2 +- .../coding-agent/test/model-registry.test.ts | 214 +++++++++++++++++- 17 files changed, 930 insertions(+), 71 deletions(-) create mode 100644 packages/coding-agent/src/utils/deprecation.ts create mode 100644 packages/coding-agent/src/utils/json.ts create mode 100644 packages/coding-agent/test/config-value-migration.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 10abf3c4..1225fdb6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed API key and header config resolution to treat plain strings as literals, support `$ENV_VAR` / `${ENV_VAR}` interpolation and `$!` bang escaping, and require explicit env syntax for config files, avoiding Windows case-insensitive env matches corrupting literal keys ([#5095](https://github.com/earendil-works/pi/issues/5095)). - Fixed session disposal to abort in-flight agent, compaction, branch summary, retry, and bash work ([#5029](https://github.com/earendil-works/pi/pull/5029) by [@TerminallyChilI](https://github.com/TerminallyChilI)). - Fixed `pi.getAllTools()` to expose each tool's `promptGuidelines` for extensions that need per-tool guideline attribution ([#4879](https://github.com/earendil-works/pi/issues/4879)). - Fixed follow-up messages queued by `agent_end` extension handlers to drain before the agent becomes idle ([#5115](https://github.com/earendil-works/pi-mono/pull/5115) by [@DanielThomas](https://github.com/DanielThomas)). diff --git a/packages/coding-agent/docs/custom-provider.md b/packages/coding-agent/docs/custom-provider.md index 70ede4c3..50cb1ef0 100644 --- a/packages/coding-agent/docs/custom-provider.md +++ b/packages/coding-agent/docs/custom-provider.md @@ -43,7 +43,7 @@ export default function (pi: ExtensionAPI) { pi.registerProvider("my-provider", { name: "My Provider", baseUrl: "https://api.example.com", - apiKey: "MY_API_KEY", + apiKey: "$MY_API_KEY", api: "openai-completions", models: [ { @@ -83,7 +83,7 @@ pi.registerProvider("openai", { pi.registerProvider("google", { baseUrl: "https://ai-gateway.corp.com/google", headers: { - "X-Corp-Auth": "CORP_AUTH_TOKEN" // env var or literal + "X-Corp-Auth": "$CORP_AUTH_TOKEN" // env var or literal } }); ``` @@ -112,7 +112,7 @@ export default async function (pi: ExtensionAPI) { pi.registerProvider("local-openai", { baseUrl: "http://localhost:1234/v1", - apiKey: "LOCAL_OPENAI_API_KEY", + apiKey: "$LOCAL_OPENAI_API_KEY", api: "openai-completions", models: payload.data.map((model) => ({ id: model.id, @@ -132,7 +132,7 @@ This registers the fetched models before startup finishes. ```typescript pi.registerProvider("my-llm", { baseUrl: "https://api.my-llm.com/v1", - apiKey: "MY_LLM_API_KEY", // env var name or literal value + apiKey: "$MY_LLM_API_KEY", // env var reference api: "openai-completions", // which streaming API to use models: [ { @@ -155,6 +155,8 @@ pi.registerProvider("my-llm", { When `models` is provided, it **replaces** all existing models for that provider. +`apiKey` and custom header values use the same config value syntax as `models.json`: `!command` at the start executes a command for the whole value, `$ENV_VAR` and `${ENV_VAR}` interpolate environment variables, `$$` emits a literal `$`, and `$!` emits a literal `!`. + ## Unregister Provider Use `pi.unregisterProvider(name)` to remove a provider that was previously registered via `pi.registerProvider(name, ...)`: @@ -163,7 +165,7 @@ Use `pi.unregisterProvider(name)` to remove a provider that was previously regis // Register pi.registerProvider("my-llm", { baseUrl: "https://api.my-llm.com/v1", - apiKey: "MY_LLM_API_KEY", + apiKey: "$MY_LLM_API_KEY", api: "openai-completions", models: [ { @@ -243,7 +245,7 @@ If your provider expects `Authorization: Bearer ` but doesn't use a standar ```typescript pi.registerProvider("custom-api", { baseUrl: "https://api.example.com", - apiKey: "MY_API_KEY", + apiKey: "$MY_API_KEY", authHeader: true, // adds Authorization: Bearer header api: "openai-completions", models: [...] @@ -592,7 +594,7 @@ Register your stream function: ```typescript pi.registerProvider("my-provider", { baseUrl: "https://api.example.com", - apiKey: "MY_API_KEY", + apiKey: "$MY_API_KEY", api: "my-custom-api", models: [...], streamSimple: streamMyProvider @@ -629,7 +631,7 @@ interface ProviderConfig { /** API endpoint URL. Required when defining models. */ baseUrl?: string; - /** API key or environment variable name. Required when defining models (unless oauth). */ + /** API key literal, env interpolation ($ENV_VAR or ${ENV_VAR}), or !command. Required when defining models (unless oauth). */ apiKey?: string; /** API type for streaming. Required at provider or model level when defining models. */ @@ -642,7 +644,7 @@ interface ProviderConfig { options?: SimpleStreamOptions ) => AssistantMessageEventStream; - /** Custom headers to include in requests. Values can be env var names. */ + /** Custom headers to include in requests. Values use the same resolution syntax as apiKey. */ headers?: Record; /** If true, adds Authorization: Bearer header with the resolved API key. */ diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index db856fc8..40781092 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -199,7 +199,7 @@ export default async function (pi: ExtensionAPI) { pi.registerProvider("local-openai", { baseUrl: "http://localhost:1234/v1", - apiKey: "LOCAL_OPENAI_API_KEY", + apiKey: "$LOCAL_OPENAI_API_KEY", api: "openai-completions", models: payload.data.map((model) => ({ id: model.id, @@ -1555,7 +1555,7 @@ If you need to discover models from a remote endpoint, prefer an async extension pi.registerProvider("my-proxy", { name: "My Proxy", baseUrl: "https://proxy.example.com", - apiKey: "PROXY_API_KEY", // env var name or literal + apiKey: "$PROXY_API_KEY", // env var reference api: "anthropic-messages", models: [ { @@ -1602,7 +1602,7 @@ pi.registerProvider("corporate-ai", { **Config options:** - `name` - Display name for the provider in UI such as `/login`. - `baseUrl` - API endpoint URL. Required when defining models. -- `apiKey` - API key or environment variable name. Required when defining models (unless `oauth` provided). +- `apiKey` - API key literal, environment interpolation (`$ENV_VAR` or `${ENV_VAR}`), or leading `!command`. Required when defining models (unless `oauth` provided). `$$` escapes `$`, and `$!` escapes a literal `!` without triggering command execution. - `api` - API type: `"anthropic-messages"`, `"openai-completions"`, `"openai-responses"`, etc. - `headers` - Custom headers to include in requests. - `authHeader` - If true, adds `Authorization: Bearer` header automatically. diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index 85fccaf3..c97a6cf3 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -101,7 +101,7 @@ Use `google-generative-ai` with a `baseUrl` to add models from Google AI Studio, "my-google": { "baseUrl": "https://generativelanguage.googleapis.com/v1beta", "api": "google-generative-ai", - "apiKey": "GEMINI_API_KEY", + "apiKey": "$GEMINI_API_KEY", "models": [ { "id": "gemma-4-31b-it", @@ -143,22 +143,31 @@ Set `api` at provider level (default for all models) or model level (override pe ### Value Resolution -The `apiKey` and `headers` fields support three formats: +The `apiKey` and `headers` fields support command execution, environment interpolation, and literals: -- **Shell command:** `"!command"` executes and uses stdout +- **Shell command:** `"!command"` at the start executes the whole value as a command and uses stdout ```json "apiKey": "!security find-generic-password -ws 'anthropic'" "apiKey": "!op read 'op://vault/item/credential'" ``` -- **Environment variable:** Uses the value of the named variable +- **Environment interpolation:** `"$ENV_VAR"` or `"${ENV_VAR}"` uses the value of the named variable. Interpolation works inside larger literals. ```json - "apiKey": "MY_API_KEY" + "apiKey": "$MY_API_KEY" + "apiKey": "${KEY_PREFIX}_${KEY_SUFFIX}" + ``` + `$FOO_BAR` is the variable `FOO_BAR`; use `${FOO}_BAR` when `BAR` is literal text. Missing environment variables make the value unresolved. +- **Escapes:** `"$$"` emits a literal `"$"`; `"$!"` emits a literal `"!"` without triggering command execution. + ```json + "apiKey": "$$literal-dollar-prefix" + "apiKey": "$!literal-bang-prefix" ``` - **Literal value:** Used directly ```json "apiKey": "sk-..." ``` +Legacy uppercase env-var-like values such as `MY_API_KEY` are migrated to `$MY_API_KEY` on startup. + For `models.json`, shell commands are resolved at request time. pi intentionally does not apply built-in TTL, stale reuse, or recovery logic for arbitrary commands. Different commands need different caching and failure strategies, and pi cannot infer the right one. If your command is slow, expensive, rate-limited, or should keep using a previous value on transient failures, wrap it in your own script or command that implements the caching or TTL behavior you want. @@ -172,10 +181,10 @@ If your command is slow, expensive, rate-limited, or should keep using a previou "providers": { "custom-proxy": { "baseUrl": "https://proxy.example.com/v1", - "apiKey": "MY_API_KEY", + "apiKey": "$MY_API_KEY", "api": "anthropic-messages", "headers": { - "x-portkey-api-key": "PORTKEY_API_KEY", + "x-portkey-api-key": "$PORTKEY_API_KEY", "x-secret": "!op read 'op://vault/item/secret'" }, "models": [...] @@ -268,7 +277,7 @@ To merge custom models into a built-in provider, include the `models` array: "providers": { "anthropic": { "baseUrl": "https://my-proxy.example.com/v1", - "apiKey": "ANTHROPIC_API_KEY", + "apiKey": "$ANTHROPIC_API_KEY", "api": "anthropic-messages", "models": [...] } @@ -327,7 +336,7 @@ Some Anthropic models require adaptive thinking (`thinking.type: "adaptive"` plu "anthropic-proxy": { "baseUrl": "https://proxy.example.com", "api": "anthropic-messages", - "apiKey": "ANTHROPIC_PROXY_KEY", + "apiKey": "$ANTHROPIC_PROXY_KEY", "compat": { "supportsEagerToolInputStreaming": false, "supportsLongCacheRetention": true, @@ -405,7 +414,7 @@ Example: "providers": { "openrouter": { "baseUrl": "https://openrouter.ai/api/v1", - "apiKey": "OPENROUTER_API_KEY", + "apiKey": "$OPENROUTER_API_KEY", "api": "openai-completions", "models": [ { @@ -455,7 +464,7 @@ Vercel AI Gateway example: "providers": { "vercel-ai-gateway": { "baseUrl": "https://ai-gateway.vercel.sh/v1", - "apiKey": "AI_GATEWAY_API_KEY", + "apiKey": "$AI_GATEWAY_API_KEY", "api": "openai-completions", "models": [ { diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index 7e80f931..07b6ba08 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -101,23 +101,31 @@ The file is created with `0600` permissions (user read/write only). Auth file cr ### Key Resolution -The `key` field supports three formats: +The `key` field supports command execution, environment interpolation, and literals: -- **Shell command:** `"!command"` executes and uses stdout (cached for process lifetime) +- **Shell command:** `"!command"` at the start executes the whole value as a command and uses stdout (cached for process lifetime) ```json { "type": "api_key", "key": "!security find-generic-password -ws 'anthropic'" } { "type": "api_key", "key": "!op read 'op://vault/item/credential'" } ``` -- **Environment variable:** Uses the value of the named variable +- **Environment interpolation:** `"$ENV_VAR"` or `"${ENV_VAR}"` uses the value of the named variable. Interpolation works inside larger literals. ```json - { "type": "api_key", "key": "MY_ANTHROPIC_KEY" } + { "type": "api_key", "key": "$MY_ANTHROPIC_KEY" } + { "type": "api_key", "key": "${KEY_PREFIX}_${KEY_SUFFIX}" } + ``` + `$FOO_BAR` is the variable `FOO_BAR`; use `${FOO}_BAR` when `BAR` is literal text. Missing environment variables make the value unresolved. +- **Escapes:** `"$$"` emits a literal `"$"`; `"$!"` emits a literal `"!"` without triggering command execution. + ```json + { "type": "api_key", "key": "$$literal-dollar-prefix" } + { "type": "api_key", "key": "$!literal-bang-prefix" } ``` - **Literal value:** Used directly ```json { "type": "api_key", "key": "sk-ant-..." } + { "type": "api_key", "key": "public" } ``` -OAuth credentials are also stored here after `/login` and managed automatically. +Legacy uppercase env-var-like values such as `MY_API_KEY` are migrated to `$MY_API_KEY` on startup. OAuth credentials are also stored here after `/login` and managed automatically. ## Cloud Providers diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts b/packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts index 69267f40..51426362 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts @@ -568,7 +568,7 @@ function streamCustomAnthropic( export default function (pi: ExtensionAPI) { pi.registerProvider("custom-anthropic", { baseUrl: "https://api.anthropic.com", - apiKey: "CUSTOM_ANTHROPIC_API_KEY", + apiKey: "$CUSTOM_ANTHROPIC_API_KEY", api: "custom-anthropic-api", models: [ diff --git a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/index.ts b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/index.ts index 23d0a40a..c23f0dac 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/index.ts +++ b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/index.ts @@ -327,7 +327,7 @@ export function streamGitLabDuo( export default function (pi: ExtensionAPI) { pi.registerProvider("gitlab-duo", { baseUrl: AI_GATEWAY_URL, - apiKey: "GITLAB_TOKEN", + apiKey: "$GITLAB_TOKEN", api: "gitlab-duo-api", models: MODELS.map(({ id, name, reasoning, input, cost, contextWindow, maxTokens }) => ({ id, diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 0d5bb7af..7696c62e 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -1256,7 +1256,7 @@ export interface ExtensionAPI { * // Register a new provider with custom models * pi.registerProvider("my-proxy", { * baseUrl: "https://proxy.example.com", - * apiKey: "PROXY_API_KEY", + * apiKey: "$PROXY_API_KEY", * api: "anthropic-messages", * models: [ * { @@ -1322,7 +1322,7 @@ export interface ProviderConfig { name?: string; /** Base URL for the API endpoint. Required when defining models. */ baseUrl?: string; - /** API key or environment variable name. Required when defining models (unless oauth provided). */ + /** API key literal, env interpolation ($ENV_VAR or ${ENV_VAR}), or leading !command. Required when defining models (unless oauth provided). */ apiKey?: string; /** API type. Required at provider or model level when defining models. */ api?: Api; diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index 712874d8..455ab71f 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -25,11 +25,17 @@ import { type Static, Type } from "typebox"; import { Compile } from "typebox/compile"; import type { TLocalizedValidationError } from "typebox/error"; import { getAgentDir } from "../config.ts"; +import { warnDeprecation } from "../utils/deprecation.ts"; +import { stripJsonComments } from "../utils/json.ts"; import { normalizePath } from "../utils/paths.ts"; import type { AuthStatus, AuthStorage } from "./auth-storage.ts"; import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "./provider-display-names.ts"; import { clearConfigValueCache, + getConfigValueEnvVarNames, + isCommandConfigValue, + isConfigValueConfigured, + isLegacyEnvVarNameConfigValue, resolveConfigValueOrThrow, resolveConfigValueUncached, resolveHeadersOrThrow, @@ -218,13 +224,6 @@ function formatValidationPath(error: TLocalizedValidationError): string { return path || "root"; } -/** Strip `//` line comments and trailing commas from JSON, leaving string literals untouched. */ -function stripJsonComments(input: string): string { - return input - .replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (m) => (m[0] === '"' ? m : "")) - .replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (m, tail) => tail ?? (m[0] === '"' ? m : "")); -} - /** Provider override config (baseUrl, compat) without request auth/headers */ interface ProviderOverride { baseUrl?: string; @@ -237,6 +236,77 @@ interface ProviderRequestConfig { authHeader?: boolean; } +function migrateLegacyRegisterProviderConfigValue(providerName: string, field: string, value: string): string { + if (!isLegacyEnvVarNameConfigValue(value)) return value; + warnDeprecation( + `registerProvider("${providerName}") ${field} value "${value}" is treated as a legacy environment variable reference. This will no longer be detected as an environment variable reference in a future release. Pass "$${value}" instead.`, + ); + return `$${value}`; +} + +function migrateLegacyRegisterProviderHeaders( + providerName: string, + field: string, + headers: Record | undefined, +): Record | undefined { + if (!headers) return undefined; + let migratedHeaders: Record | undefined; + for (const [key, value] of Object.entries(headers)) { + const migratedValue = migrateLegacyRegisterProviderConfigValue(providerName, `${field} header "${key}"`, value); + if (migratedValue === value) continue; + migratedHeaders ??= { ...headers }; + migratedHeaders[key] = migratedValue; + } + return migratedHeaders ?? headers; +} + +function migrateLegacyRegisterProviderConfigValues( + providerName: string, + config: ProviderConfigInput, +): ProviderConfigInput { + let migratedConfig: ProviderConfigInput | undefined; + + const setMigratedConfigValue = ( + key: TKey, + value: ProviderConfigInput[TKey], + ) => { + migratedConfig ??= { ...config }; + migratedConfig[key] = value; + }; + + if (config.apiKey) { + const apiKey = migrateLegacyRegisterProviderConfigValue(providerName, "apiKey", config.apiKey); + if (apiKey !== config.apiKey) { + setMigratedConfigValue("apiKey", apiKey); + } + } + + const headers = migrateLegacyRegisterProviderHeaders(providerName, "headers", config.headers); + if (headers !== config.headers) { + setMigratedConfigValue("headers", headers); + } + + if (config.models) { + let models: ProviderConfigInput["models"] | undefined; + for (let index = 0; index < config.models.length; index++) { + const model = config.models[index]; + const modelHeaders = migrateLegacyRegisterProviderHeaders( + providerName, + `model "${model.id}" headers`, + model.headers, + ); + if (modelHeaders === model.headers) continue; + models ??= [...config.models]; + models[index] = { ...model, headers: modelHeaders }; + } + if (models) { + setMigratedConfigValue("models", models); + } + } + + return migratedConfig ?? config; +} + export type ResolvedRequestAuth = | { ok: true; @@ -641,9 +711,10 @@ export class ModelRegistry { * Get API key for a model. */ hasConfiguredAuth(model: Model): boolean { + const providerApiKey = this.providerRequestConfigs.get(model.provider)?.apiKey; return ( this.authStorage.hasAuth(model.provider) || - this.providerRequestConfigs.get(model.provider)?.apiKey !== undefined + (providerApiKey !== undefined && isConfigValueConfigured(providerApiKey)) ); } @@ -738,12 +809,15 @@ export class ModelRegistry { return authStatus; } - if (providerApiKey.startsWith("!")) { + if (isCommandConfigValue(providerApiKey)) { return { configured: true, source: "models_json_command" }; } - if (process.env[providerApiKey]) { - return { configured: true, source: "environment", label: providerApiKey }; + const envVarNames = getConfigValueEnvVarNames(providerApiKey); + if (envVarNames.length > 0) { + return isConfigValueConfigured(providerApiKey) + ? { configured: true, source: "environment", label: envVarNames.join(", ") } + : { configured: false }; } return { configured: true, source: "models_json_key" }; @@ -794,9 +868,10 @@ export class ModelRegistry { * If provider has oauth: registers OAuth provider for /login support. */ registerProvider(providerName: string, config: ProviderConfigInput): void { - this.validateProviderConfig(providerName, config); - this.applyProviderConfig(providerName, config); - this.upsertRegisteredProvider(providerName, config); + const migratedConfig = migrateLegacyRegisterProviderConfigValues(providerName, config); + this.validateProviderConfig(providerName, migratedConfig); + this.applyProviderConfig(providerName, migratedConfig); + this.upsertRegisteredProvider(providerName, migratedConfig); } /** diff --git a/packages/coding-agent/src/core/resolve-config-value.ts b/packages/coding-agent/src/core/resolve-config-value.ts index 215914ba..119e379d 100644 --- a/packages/coding-agent/src/core/resolve-config-value.ts +++ b/packages/coding-agent/src/core/resolve-config-value.ts @@ -8,18 +8,151 @@ import { getShellConfig } from "../utils/shell.ts"; // Cache for shell command results (persists for process lifetime) const commandResultCache = new Map(); +const ENV_VAR_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; +const ENV_VAR_NAME_PREFIX_RE = /^[A-Za-z_][A-Za-z0-9_]*/; +const LEGACY_ENV_VAR_NAME_RE = /^[A-Z_][A-Z0-9_]*$/; + +type TemplatePart = { type: "literal"; value: string } | { type: "env"; name: string }; + +type ConfigValueReference = { type: "command"; config: string } | { type: "template"; parts: TemplatePart[] }; + +function appendLiteral(parts: TemplatePart[], value: string): void { + if (!value) return; + const previousPart = parts[parts.length - 1]; + if (previousPart?.type === "literal") { + previousPart.value += value; + return; + } + parts.push({ type: "literal", value }); +} + +function parseConfigValueTemplate(config: string): TemplatePart[] { + const parts: TemplatePart[] = []; + let index = 0; + + while (index < config.length) { + const dollarIndex = config.indexOf("$", index); + if (dollarIndex < 0) { + appendLiteral(parts, config.slice(index)); + break; + } + + appendLiteral(parts, config.slice(index, dollarIndex)); + const nextChar = config[dollarIndex + 1]; + + if (nextChar === "$" || nextChar === "!") { + appendLiteral(parts, nextChar); + index = dollarIndex + 2; + continue; + } + + if (nextChar === "{") { + const endIndex = config.indexOf("}", dollarIndex + 2); + if (endIndex < 0) { + appendLiteral(parts, "$"); + index = dollarIndex + 1; + continue; + } + + const name = config.slice(dollarIndex + 2, endIndex); + if (ENV_VAR_NAME_RE.test(name)) { + parts.push({ type: "env", name }); + } else { + appendLiteral(parts, config.slice(dollarIndex, endIndex + 1)); + } + index = endIndex + 1; + continue; + } + + const match = config.slice(dollarIndex + 1).match(ENV_VAR_NAME_PREFIX_RE); + if (match) { + parts.push({ type: "env", name: match[0] }); + index = dollarIndex + 1 + match[0].length; + continue; + } + + appendLiteral(parts, "$"); + index = dollarIndex + 1; + } + + return parts; +} + +function parseConfigValueReference(config: string): ConfigValueReference { + if (config.startsWith("!")) { + return { type: "command", config }; + } + + return { type: "template", parts: parseConfigValueTemplate(config) }; +} + +function resolveEnvConfigValue(name: string): string | undefined { + return process.env[name] || undefined; +} + +function getTemplateEnvVarNames(parts: TemplatePart[]): string[] { + const names: string[] = []; + for (const part of parts) { + if (part.type !== "env" || names.includes(part.name)) continue; + names.push(part.name); + } + return names; +} + +function resolveTemplate(parts: TemplatePart[]): string | undefined { + let resolved = ""; + for (const part of parts) { + if (part.type === "literal") { + resolved += part.value; + continue; + } + const envValue = resolveEnvConfigValue(part.name); + if (envValue === undefined) return undefined; + resolved += envValue; + } + return resolved; +} + +export function getConfigValueEnvVarName(config: string): string | undefined { + const reference = parseConfigValueReference(config); + if (reference.type !== "template") return undefined; + return reference.parts.length === 1 && reference.parts[0]?.type === "env" ? reference.parts[0].name : undefined; +} + +export function getConfigValueEnvVarNames(config: string): string[] { + const reference = parseConfigValueReference(config); + return reference.type === "template" ? getTemplateEnvVarNames(reference.parts) : []; +} + +export function getMissingConfigValueEnvVarNames(config: string): string[] { + return getConfigValueEnvVarNames(config).filter((name) => resolveEnvConfigValue(name) === undefined); +} + +export function isCommandConfigValue(config: string): boolean { + return parseConfigValueReference(config).type === "command"; +} + +export function isConfigValueConfigured(config: string): boolean { + return getMissingConfigValueEnvVarNames(config).length === 0; +} + +export function isLegacyEnvVarNameConfigValue(config: string): boolean { + return LEGACY_ENV_VAR_NAME_RE.test(config); +} /** * Resolve a config value (API key, header value, etc.) to an actual value. * - If starts with "!", executes the rest as a shell command and uses stdout (cached) - * - Otherwise checks environment variable first, then treats as literal (not cached) + * - Interpolates "$ENV_VAR" or "${ENV_VAR}" references with the named environment variable + * - In non-command values, "$$" escapes a literal "$" and "$!" escapes a literal "!" + * - Otherwise treats the value as a literal */ export function resolveConfigValue(config: string): string | undefined { - if (config.startsWith("!")) { - return executeCommand(config); + const reference = parseConfigValueReference(config); + if (reference.type === "command") { + return executeCommand(reference.config); } - const envValue = process.env[config]; - return envValue || config; + return resolveTemplate(reference.parts); } function executeWithConfiguredShell(command: string): { executed: boolean; value: string | undefined } { @@ -89,11 +222,11 @@ function executeCommand(commandConfig: string): string | undefined { * Resolve all header values using the same resolution logic as API keys. */ export function resolveConfigValueUncached(config: string): string | undefined { - if (config.startsWith("!")) { - return executeCommandUncached(config); + const reference = parseConfigValueReference(config); + if (reference.type === "command") { + return executeCommandUncached(reference.config); } - const envValue = process.env[config]; - return envValue || config; + return resolveTemplate(reference.parts); } export function resolveConfigValueOrThrow(config: string, description: string): string { @@ -102,8 +235,19 @@ export function resolveConfigValueOrThrow(config: string, description: string): return resolvedValue; } - if (config.startsWith("!")) { - throw new Error(`Failed to resolve ${description} from shell command: ${config.slice(1)}`); + const reference = parseConfigValueReference(config); + if (reference.type === "command") { + throw new Error(`Failed to resolve ${description} from shell command: ${reference.config.slice(1)}`); + } + + if (reference.type === "template") { + const missingEnvVars = getMissingConfigValueEnvVarNames(config); + if (missingEnvVars.length === 1) { + throw new Error(`Failed to resolve ${description} from environment variable: ${missingEnvVars[0]}`); + } + if (missingEnvVars.length > 1) { + throw new Error(`Failed to resolve ${description} from environment variables: ${missingEnvVars.join(", ")}`); + } } throw new Error(`Failed to resolve ${description}`); diff --git a/packages/coding-agent/src/migrations.ts b/packages/coding-agent/src/migrations.ts index 39aeea04..eb07f220 100644 --- a/packages/coding-agent/src/migrations.ts +++ b/packages/coding-agent/src/migrations.ts @@ -3,10 +3,12 @@ */ import chalk from "chalk"; -import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs"; +import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs"; import { dirname, join } from "path"; import { CONFIG_DIR_NAME, getAgentDir, getBinDir } from "./config.ts"; import { migrateKeybindingsConfig } from "./core/keybindings.ts"; +import { isLegacyEnvVarNameConfigValue } from "./core/resolve-config-value.ts"; +import { stripJsonComments } from "./utils/json.ts"; const MIGRATION_GUIDE_URL = "https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/CHANGELOG.md#extensions-migration"; @@ -72,6 +74,135 @@ export function migrateAuthToAuthJson(): string[] { return providers; } +interface ConfigValueMigration { + location: string; + from: string; + to: string; +} + +function migrateLegacyEnvVarString(value: string): string | undefined { + return isLegacyEnvVarNameConfigValue(value) ? `$${value}` : undefined; +} + +function migrateStringProperty( + record: Record, + key: string, + location: string, + migrations: ConfigValueMigration[], +): boolean { + const value = record[key]; + if (typeof value !== "string") return false; + const migrated = migrateLegacyEnvVarString(value); + if (migrated === undefined) return false; + record[key] = migrated; + migrations.push({ location, from: value, to: migrated }); + return true; +} + +function migrateHeadersConfig(headers: unknown, location: string, migrations: ConfigValueMigration[]): boolean { + if (typeof headers !== "object" || headers === null || Array.isArray(headers)) return false; + const headerRecord = headers as Record; + let migrated = false; + for (const [key, value] of Object.entries(headerRecord)) { + if (typeof value !== "string") continue; + const migratedValue = migrateLegacyEnvVarString(value); + if (migratedValue === undefined) continue; + headerRecord[key] = migratedValue; + migrations.push({ location: `${location}[${JSON.stringify(key)}]`, from: value, to: migratedValue }); + migrated = true; + } + return migrated; +} + +function migrateAuthJsonConfigValues(agentDir: string): ConfigValueMigration[] { + const authPath = join(agentDir, "auth.json"); + if (!existsSync(authPath)) return []; + + try { + const parsed = JSON.parse(readFileSync(authPath, "utf-8")) as unknown; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return []; + const authData = parsed as Record; + + const migrations: ConfigValueMigration[] = []; + for (const [provider, credential] of Object.entries(authData)) { + if (typeof credential !== "object" || credential === null || Array.isArray(credential)) continue; + const credentialRecord = credential as Record; + if (credentialRecord.type !== "api_key") continue; + migrateStringProperty(credentialRecord, "key", `auth.json[${JSON.stringify(provider)}].key`, migrations); + } + + if (migrations.length === 0) return []; + writeFileSync(authPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf-8"); + chmodSync(authPath, 0o600); + return migrations; + } catch { + return []; + } +} + +function migrateModelsJsonConfigValues(agentDir: string): ConfigValueMigration[] { + const modelsPath = join(agentDir, "models.json"); + if (!existsSync(modelsPath)) return []; + + const parsed = JSON.parse(stripJsonComments(readFileSync(modelsPath, "utf-8"))) as unknown; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return []; + const modelsData = parsed as Record; + const providers = modelsData.providers; + if (typeof providers !== "object" || providers === null || Array.isArray(providers)) return []; + + const migrations: ConfigValueMigration[] = []; + for (const [provider, providerConfig] of Object.entries(providers)) { + if (typeof providerConfig !== "object" || providerConfig === null || Array.isArray(providerConfig)) continue; + const providerRecord = providerConfig as Record; + const providerLocation = `models.json.providers[${JSON.stringify(provider)}]`; + migrateStringProperty(providerRecord, "apiKey", `${providerLocation}.apiKey`, migrations); + migrateHeadersConfig(providerRecord.headers, `${providerLocation}.headers`, migrations); + + if (Array.isArray(providerRecord.models)) { + for (let index = 0; index < providerRecord.models.length; index++) { + const modelConfig = providerRecord.models[index]; + if (typeof modelConfig !== "object" || modelConfig === null || Array.isArray(modelConfig)) continue; + const modelRecord = modelConfig as Record; + const modelKey = typeof modelRecord.id === "string" ? JSON.stringify(modelRecord.id) : String(index); + migrateHeadersConfig(modelRecord.headers, `${providerLocation}.models[${modelKey}].headers`, migrations); + } + } + + const modelOverrides = providerRecord.modelOverrides; + if (typeof modelOverrides === "object" && modelOverrides !== null && !Array.isArray(modelOverrides)) { + for (const [modelId, modelOverride] of Object.entries(modelOverrides)) { + if (typeof modelOverride !== "object" || modelOverride === null || Array.isArray(modelOverride)) continue; + const modelOverrideRecord = modelOverride as Record; + migrateHeadersConfig( + modelOverrideRecord.headers, + `${providerLocation}.modelOverrides[${JSON.stringify(modelId)}].headers`, + migrations, + ); + } + } + } + + if (migrations.length === 0) return []; + writeFileSync(modelsPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf-8"); + return migrations; +} + +function migrateExplicitEnvVarConfigValues(): void { + const agentDir = getAgentDir(); + const migrations = [...migrateAuthJsonConfigValues(agentDir), ...migrateModelsJsonConfigValues(agentDir)]; + if (migrations.length === 0) return; + + const details = migrations.map((migration) => ` - ${migration.location}: ${migration.from} -> ${migration.to}`); + console.log( + chalk.yellow( + [ + "Warning: Migrated API key/header environment references to explicit $ENV_VAR syntax. Plain strings will be treated as literals.", + ...details, + ].join("\n"), + ), + ); +} + /** * Migrate sessions from ~/.pi/agent/*.jsonl to proper session directories. * @@ -307,6 +438,7 @@ export function runMigrations(cwd: string): { deprecationWarnings: string[]; } { const migratedAuthProviders = migrateAuthToAuthJson(); + migrateExplicitEnvVarConfigValues(); migrateSessionsFromAgentRoot(); migrateToolsToBin(); migrateKeybindingsConfigFile(); diff --git a/packages/coding-agent/src/utils/deprecation.ts b/packages/coding-agent/src/utils/deprecation.ts new file mode 100644 index 00000000..78a2f146 --- /dev/null +++ b/packages/coding-agent/src/utils/deprecation.ts @@ -0,0 +1,14 @@ +import chalk from "chalk"; + +const emittedDeprecationWarnings = new Set(); + +export function warnDeprecation(message: string): void { + if (emittedDeprecationWarnings.has(message)) return; + emittedDeprecationWarnings.add(message); + console.warn(chalk.yellow(`Deprecation warning: ${message}`)); +} + +/** Clear deprecation warning state. Exported for tests. */ +export function clearDeprecationWarningsForTests(): void { + emittedDeprecationWarnings.clear(); +} diff --git a/packages/coding-agent/src/utils/json.ts b/packages/coding-agent/src/utils/json.ts new file mode 100644 index 00000000..9ee7b7b1 --- /dev/null +++ b/packages/coding-agent/src/utils/json.ts @@ -0,0 +1,6 @@ +/** Strip `//` line comments and trailing commas from JSON, leaving string literals untouched. */ +export function stripJsonComments(input: string): string { + return input + .replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (m) => (m[0] === '"' ? m : "")) + .replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (m, tail) => tail ?? (m[0] === '"' ? m : "")); +} diff --git a/packages/coding-agent/test/auth-storage.test.ts b/packages/coding-agent/test/auth-storage.test.ts index 9ec97100..24242b6b 100644 --- a/packages/coding-agent/test/auth-storage.test.ts +++ b/packages/coding-agent/test/auth-storage.test.ts @@ -112,13 +112,13 @@ describe("AuthStorage", () => { expect(apiKey).toBeUndefined(); }); - test("apiKey as environment variable name resolves to env value", async () => { + test("apiKey with $ prefix resolves to env value", async () => { const originalEnv = process.env.TEST_AUTH_API_KEY_12345; process.env.TEST_AUTH_API_KEY_12345 = "env-api-key-value"; try { writeAuthJson({ - anthropic: { type: "api_key", key: "TEST_AUTH_API_KEY_12345" }, + anthropic: { type: "api_key", key: "$TEST_AUTH_API_KEY_12345" }, }); authStorage = AuthStorage.create(authJsonPath); @@ -134,6 +134,140 @@ describe("AuthStorage", () => { } }); + test("apiKey with braced env syntax resolves to env value", async () => { + const originalEnv = process.env.TEST_AUTH_BRACED_API_KEY_12345; + process.env.TEST_AUTH_BRACED_API_KEY_12345 = "braced-env-api-key-value"; + const bracedKey = "$" + "{TEST_AUTH_BRACED_API_KEY_12345}"; + + try { + writeAuthJson({ + anthropic: { type: "api_key", key: bracedKey }, + }); + + authStorage = AuthStorage.create(authJsonPath); + const apiKey = await authStorage.getApiKey("anthropic"); + + expect(apiKey).toBe("braced-env-api-key-value"); + } finally { + if (originalEnv === undefined) { + delete process.env.TEST_AUTH_BRACED_API_KEY_12345; + } else { + process.env.TEST_AUTH_BRACED_API_KEY_12345 = originalEnv; + } + } + }); + + test("apiKey interpolates braced env references inside literals", async () => { + const originalPartA = process.env.TEST_AUTH_INTERPOLATED_PART_A_12345; + const originalPartB = process.env.TEST_AUTH_INTERPOLATED_PART_B_12345; + process.env.TEST_AUTH_INTERPOLATED_PART_A_12345 = "left"; + process.env.TEST_AUTH_INTERPOLATED_PART_B_12345 = "right"; + const interpolatedKey = [ + "$", + "{TEST_AUTH_INTERPOLATED_PART_A_12345}_$", + "{TEST_AUTH_INTERPOLATED_PART_B_12345}", + ].join(""); + + try { + writeAuthJson({ + anthropic: { type: "api_key", key: interpolatedKey }, + }); + + authStorage = AuthStorage.create(authJsonPath); + const apiKey = await authStorage.getApiKey("anthropic"); + + expect(apiKey).toBe("left_right"); + } finally { + if (originalPartA === undefined) { + delete process.env.TEST_AUTH_INTERPOLATED_PART_A_12345; + } else { + process.env.TEST_AUTH_INTERPOLATED_PART_A_12345 = originalPartA; + } + if (originalPartB === undefined) { + delete process.env.TEST_AUTH_INTERPOLATED_PART_B_12345; + } else { + process.env.TEST_AUTH_INTERPOLATED_PART_B_12345 = originalPartB; + } + } + }); + + test("apiKey with $$ prefix escapes a leading dollar", async () => { + writeAuthJson({ + anthropic: { type: "api_key", key: "$$TEST_AUTH_API_KEY_12345" }, + }); + + authStorage = AuthStorage.create(authJsonPath); + const apiKey = await authStorage.getApiKey("anthropic"); + + expect(apiKey).toBe("$TEST_AUTH_API_KEY_12345"); + }); + + test("apiKey with $! escapes a literal bang and still interpolates later env refs", async () => { + const originalEnv = process.env.TEST_AUTH_API_KEY_12345; + process.env.TEST_AUTH_API_KEY_12345 = "env-api-key-value"; + + try { + writeAuthJson({ + anthropic: { type: "api_key", key: "$!literal-$TEST_AUTH_API_KEY_12345" }, + }); + + authStorage = AuthStorage.create(authJsonPath); + const apiKey = await authStorage.getApiKey("anthropic"); + + expect(apiKey).toBe("!literal-env-api-key-value"); + } finally { + if (originalEnv === undefined) { + delete process.env.TEST_AUTH_API_KEY_12345; + } else { + process.env.TEST_AUTH_API_KEY_12345 = originalEnv; + } + } + }); + + test("plain API key is used directly even when it matches an env var", async () => { + const originalEnv = process.env.TEST_AUTH_API_KEY_12345; + process.env.TEST_AUTH_API_KEY_12345 = "env-api-key-value"; + + try { + writeAuthJson({ + anthropic: { type: "api_key", key: "TEST_AUTH_API_KEY_12345" }, + }); + + authStorage = AuthStorage.create(authJsonPath); + const apiKey = await authStorage.getApiKey("anthropic"); + + expect(apiKey).toBe("TEST_AUTH_API_KEY_12345"); + } finally { + if (originalEnv === undefined) { + delete process.env.TEST_AUTH_API_KEY_12345; + } else { + process.env.TEST_AUTH_API_KEY_12345 = originalEnv; + } + } + }); + + test("literal public API key is not corrupted by the Windows PUBLIC env var", async () => { + const originalPublic = process.env.PUBLIC; + process.env.PUBLIC = "C:\\Users\\Public"; + + try { + writeAuthJson({ + opencode: { type: "api_key", key: "public" }, + }); + + authStorage = AuthStorage.create(authJsonPath); + const apiKey = await authStorage.getApiKey("opencode"); + + expect(apiKey).toBe("public"); + } finally { + if (originalPublic === undefined) { + delete process.env.PUBLIC; + } else { + process.env.PUBLIC = originalPublic; + } + } + }); + test("apiKey as literal value is used directly when not an env var", async () => { // Make sure this isn't an env var delete process.env.literal_api_key_value; @@ -274,7 +408,7 @@ describe("AuthStorage", () => { process.env[envVarName] = "first-value"; writeAuthJson({ - anthropic: { type: "api_key", key: envVarName }, + anthropic: { type: "api_key", key: `$${envVarName}` }, }); authStorage = AuthStorage.create(authJsonPath); diff --git a/packages/coding-agent/test/config-value-migration.test.ts b/packages/coding-agent/test/config-value-migration.test.ts new file mode 100644 index 00000000..35d4f155 --- /dev/null +++ b/packages/coding-agent/test/config-value-migration.test.ts @@ -0,0 +1,138 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ENV_AGENT_DIR } from "../src/config.ts"; +import { runMigrations } from "../src/migrations.ts"; + +describe("config value env var syntax migration", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + vi.restoreAllMocks(); + }); + + function createAgentDir(): string { + const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-config-value-migration-test-")); + tempDirs.push(agentDir); + return agentDir; + } + + function withAgentDir(agentDir: string, fn: () => void): void { + const previousAgentDir = process.env[ENV_AGENT_DIR]; + process.env[ENV_AGENT_DIR] = agentDir; + try { + fn(); + } finally { + if (previousAgentDir === undefined) { + delete process.env[ENV_AGENT_DIR]; + } else { + process.env[ENV_AGENT_DIR] = previousAgentDir; + } + } + } + + it("rewrites legacy uppercase auth.json API key values to explicit env references", () => { + const agentDir = createAgentDir(); + fs.writeFileSync( + path.join(agentDir, "auth.json"), + `${JSON.stringify( + { + anthropic: { type: "api_key", key: "ANTHROPIC_API_KEY" }, + openai: { type: "api_key", key: "$OPENAI_API_KEY" }, + opencode: { type: "api_key", key: "public" }, + github: { type: "oauth", access: "ACCESS_TOKEN", refresh: "REFRESH_TOKEN", expires: 1 }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + withAgentDir(agentDir, () => runMigrations(agentDir)); + + const migrated = JSON.parse(fs.readFileSync(path.join(agentDir, "auth.json"), "utf-8")) as Record< + string, + Record + >; + expect(migrated.anthropic.key).toBe("$ANTHROPIC_API_KEY"); + expect(migrated.openai.key).toBe("$OPENAI_API_KEY"); + expect(migrated.opencode.key).toBe("public"); + expect(migrated.github.access).toBe("ACCESS_TOKEN"); + const logMessage = String(logSpy.mock.calls[0]?.[0] ?? ""); + expect(logMessage).toContain("explicit $ENV_VAR syntax"); + expect(logMessage).toContain('auth.json["anthropic"].key: ANTHROPIC_API_KEY -> $ANTHROPIC_API_KEY'); + }); + + it("rewrites legacy uppercase models.json API key and header values", () => { + const agentDir = createAgentDir(); + fs.writeFileSync( + path.join(agentDir, "models.json"), + `${JSON.stringify( + { + providers: { + "custom-provider": { + baseUrl: "https://example.com/v1", + apiKey: "CUSTOM_API_KEY", + api: "openai-completions", + headers: { + "x-api-key": "HEADER_API_KEY", + "x-literal": "literal", + }, + models: [ + { + id: "model-a", + headers: { "x-model-key": "MODEL_API_KEY" }, + }, + ], + modelOverrides: { + "model-b": { headers: { "x-override-key": "OVERRIDE_API_KEY" } }, + }, + }, + }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + withAgentDir(agentDir, () => runMigrations(agentDir)); + + const migrated = JSON.parse(fs.readFileSync(path.join(agentDir, "models.json"), "utf-8")) as { + providers: Record< + string, + { + apiKey?: string; + headers?: Record; + models?: Array<{ headers?: Record }>; + modelOverrides?: Record }>; + } + >; + }; + const provider = migrated.providers["custom-provider"]!; + expect(provider.apiKey).toBe("$CUSTOM_API_KEY"); + expect(provider.headers?.["x-api-key"]).toBe("$HEADER_API_KEY"); + expect(provider.headers?.["x-literal"]).toBe("literal"); + expect(provider.models?.[0]?.headers?.["x-model-key"]).toBe("$MODEL_API_KEY"); + expect(provider.modelOverrides?.["model-b"]?.headers?.["x-override-key"]).toBe("$OVERRIDE_API_KEY"); + const logMessage = String(logSpy.mock.calls[0]?.[0] ?? ""); + expect(logMessage).toContain( + 'models.json.providers["custom-provider"].apiKey: CUSTOM_API_KEY -> $CUSTOM_API_KEY', + ); + expect(logMessage).toContain( + 'models.json.providers["custom-provider"].headers["x-api-key"]: HEADER_API_KEY -> $HEADER_API_KEY', + ); + expect(logMessage).toContain( + 'models.json.providers["custom-provider"].models["model-a"].headers["x-model-key"]: MODEL_API_KEY -> $MODEL_API_KEY', + ); + expect(logMessage).toContain( + 'models.json.providers["custom-provider"].modelOverrides["model-b"].headers["x-override-key"]: OVERRIDE_API_KEY -> $OVERRIDE_API_KEY', + ); + }); +}); diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index 03de2d77..2e7b0c01 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -36,7 +36,7 @@ describe("ExtensionRunner", () => { const providerModelConfig: ProviderConfig = { baseUrl: "https://provider.test/v1", - apiKey: "PROVIDER_TEST_KEY", + apiKey: "provider-test-key", api: "openai-completions", models: [ { diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index ad706ab5..95b70fbd 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -4,9 +4,10 @@ import { join } from "node:path"; import type { AnthropicMessagesCompat, Api, Context, Model, OpenAICompletionsCompat } from "@earendil-works/pi-ai"; import { getApiProvider } from "@earendil-works/pi-ai"; import { getOAuthProvider } from "@earendil-works/pi-ai/oauth"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.ts"; import { clearApiKeyCache, ModelRegistry, type ProviderConfigInput } from "../src/core/model-registry.ts"; +import { clearDeprecationWarningsForTests } from "../src/utils/deprecation.ts"; describe("ModelRegistry", () => { let tempDir: string; @@ -18,6 +19,7 @@ describe("ModelRegistry", () => { mkdirSync(tempDir, { recursive: true }); modelsJsonPath = join(tempDir, "models.json"); authStorage = AuthStorage.create(join(tempDir, "auth.json")); + clearDeprecationWarningsForTests(); }); afterEach(() => { @@ -25,6 +27,8 @@ describe("ModelRegistry", () => { rmSync(tempDir, { recursive: true }); } clearApiKeyCache(); + clearDeprecationWarningsForTests(); + vi.restoreAllMocks(); }); /** Create minimal provider config */ @@ -35,7 +39,7 @@ describe("ModelRegistry", () => { ): ProviderConfigInput { return { baseUrl, - apiKey: "TEST_KEY", + apiKey: "test-key", api: api as Api, models: models.map((m) => ({ id: m.id, @@ -852,7 +856,7 @@ describe("ModelRegistry", () => { registry.registerProvider("named-provider", { name: "Named Provider", baseUrl: "https://provider.test/v1", - apiKey: "TEST_KEY", + apiKey: "test-key", api: "openai-completions", models: [ { @@ -892,6 +896,30 @@ describe("ModelRegistry", () => { expect(registry.getProviderDisplayName("oauth-provider")).toBe("OAuth Provider"); }); + test("registerProvider warns and temporarily treats uppercase apiKey as an env reference", async () => { + const originalEnv = process.env.CUSTOM_NAME; + process.env.CUSTOM_NAME = "legacy-env-key"; + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + const registry = ModelRegistry.create(authStorage, modelsJsonPath); + + registry.registerProvider("legacy-provider", { + ...providerConfig("https://provider.test/v1", [{ id: "demo-model" }], "openai-completions"), + apiKey: "CUSTOM_NAME", + }); + + expect(await registry.getApiKeyForProvider("legacy-provider")).toBe("legacy-env-key"); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Pass "$CUSTOM_NAME" instead')); + } finally { + if (originalEnv === undefined) { + delete process.env.CUSTOM_NAME; + } else { + process.env.CUSTOM_NAME = originalEnv; + } + } + }); + test("failed registerProvider does not persist invalid streamSimple config", () => { const registry = ModelRegistry.create(authStorage, modelsJsonPath); @@ -911,7 +939,7 @@ describe("ModelRegistry", () => { registry.registerProvider("demo-provider", { baseUrl: "https://provider.test/v1", - apiKey: "TEST_KEY", + apiKey: "test-key", api: "openai-completions", models: [ { @@ -931,7 +959,7 @@ describe("ModelRegistry", () => { expect(() => registry.registerProvider("demo-provider", { baseUrl: "https://provider.test/v2", - apiKey: "TEST_KEY", + apiKey: "test-key", models: [ { id: "broken-model", @@ -1187,7 +1215,117 @@ describe("ModelRegistry", () => { expect(apiKey).toBeUndefined(); }); - test("apiKey as environment variable name resolves to env value", async () => { + test("apiKey with $ prefix resolves to env value", async () => { + const originalEnv = process.env.TEST_API_KEY_12345; + process.env.TEST_API_KEY_12345 = "env-api-key-value"; + + try { + writeRawModelsJson({ + "custom-provider": providerWithApiKey("$TEST_API_KEY_12345"), + }); + + const registry = ModelRegistry.create(authStorage, modelsJsonPath); + const apiKey = await registry.getApiKeyForProvider("custom-provider"); + + expect(apiKey).toBe("env-api-key-value"); + } finally { + if (originalEnv === undefined) { + delete process.env.TEST_API_KEY_12345; + } else { + process.env.TEST_API_KEY_12345 = originalEnv; + } + } + }); + + test("apiKey with braced env syntax resolves to env value", async () => { + const originalEnv = process.env.TEST_BRACED_API_KEY_12345; + process.env.TEST_BRACED_API_KEY_12345 = "braced-env-api-key-value"; + const bracedKey = "$" + "{TEST_BRACED_API_KEY_12345}"; + + try { + writeRawModelsJson({ + "custom-provider": providerWithApiKey(bracedKey), + }); + + const registry = ModelRegistry.create(authStorage, modelsJsonPath); + const apiKey = await registry.getApiKeyForProvider("custom-provider"); + + expect(apiKey).toBe("braced-env-api-key-value"); + } finally { + if (originalEnv === undefined) { + delete process.env.TEST_BRACED_API_KEY_12345; + } else { + process.env.TEST_BRACED_API_KEY_12345 = originalEnv; + } + } + }); + + test("apiKey interpolates braced env references inside literals", async () => { + const originalPartA = process.env.TEST_INTERPOLATED_PART_A_12345; + const originalPartB = process.env.TEST_INTERPOLATED_PART_B_12345; + process.env.TEST_INTERPOLATED_PART_A_12345 = "left"; + process.env.TEST_INTERPOLATED_PART_B_12345 = "right"; + const interpolatedKey = ["$", "{TEST_INTERPOLATED_PART_A_12345}_$", "{TEST_INTERPOLATED_PART_B_12345}"].join( + "", + ); + + try { + writeRawModelsJson({ + "custom-provider": providerWithApiKey(interpolatedKey), + }); + + const registry = ModelRegistry.create(authStorage, modelsJsonPath); + const apiKey = await registry.getApiKeyForProvider("custom-provider"); + + expect(apiKey).toBe("left_right"); + } finally { + if (originalPartA === undefined) { + delete process.env.TEST_INTERPOLATED_PART_A_12345; + } else { + process.env.TEST_INTERPOLATED_PART_A_12345 = originalPartA; + } + if (originalPartB === undefined) { + delete process.env.TEST_INTERPOLATED_PART_B_12345; + } else { + process.env.TEST_INTERPOLATED_PART_B_12345 = originalPartB; + } + } + }); + + test("apiKey with $$ prefix escapes a leading dollar", async () => { + writeRawModelsJson({ + "custom-provider": providerWithApiKey("$$TEST_API_KEY_12345"), + }); + + const registry = ModelRegistry.create(authStorage, modelsJsonPath); + const apiKey = await registry.getApiKeyForProvider("custom-provider"); + + expect(apiKey).toBe("$TEST_API_KEY_12345"); + }); + + test("apiKey with $! escapes a literal bang and still interpolates later env refs", async () => { + const originalEnv = process.env.TEST_API_KEY_12345; + process.env.TEST_API_KEY_12345 = "env-api-key-value"; + + try { + writeRawModelsJson({ + "custom-provider": providerWithApiKey("$!literal-$TEST_API_KEY_12345"), + }); + + const registry = ModelRegistry.create(authStorage, modelsJsonPath); + const apiKey = await registry.getApiKeyForProvider("custom-provider"); + + expect(apiKey).toBe("!literal-env-api-key-value"); + } finally { + if (originalEnv === undefined) { + delete process.env.TEST_API_KEY_12345; + } else { + process.env.TEST_API_KEY_12345 = originalEnv; + } + } + }); + + test("plain apiKey is used directly even when it matches an env var", async () => { const originalEnv = process.env.TEST_API_KEY_12345; process.env.TEST_API_KEY_12345 = "env-api-key-value"; @@ -1199,7 +1337,7 @@ describe("ModelRegistry", () => { const registry = ModelRegistry.create(authStorage, modelsJsonPath); const apiKey = await registry.getApiKeyForProvider("custom-provider"); - expect(apiKey).toBe("env-api-key-value"); + expect(apiKey).toBe("TEST_API_KEY_12345"); } finally { if (originalEnv === undefined) { delete process.env.TEST_API_KEY_12345; @@ -1318,7 +1456,7 @@ describe("ModelRegistry", () => { process.env[envVarName] = "status-test-key"; writeRawModelsJson({ - "custom-provider": providerWithApiKey(envVarName), + "custom-provider": providerWithApiKey(`$${envVarName}`), }); const registry = ModelRegistry.create(authStorage, modelsJsonPath); @@ -1337,6 +1475,41 @@ describe("ModelRegistry", () => { } }); + test("provider auth status reports interpolated apiKey environment variables", () => { + const envVarNameA = "TEST_API_KEY_STATUS_PART_A_98765"; + const envVarNameB = "TEST_API_KEY_STATUS_PART_B_98765"; + const originalEnvA = process.env[envVarNameA]; + const originalEnvB = process.env[envVarNameB]; + process.env[envVarNameA] = "left"; + process.env[envVarNameB] = "right"; + const interpolatedKey = ["$", "{", envVarNameA, "}_$", "{", envVarNameB, "}"].join(""); + + try { + writeRawModelsJson({ + "custom-provider": providerWithApiKey(interpolatedKey), + }); + + const registry = ModelRegistry.create(authStorage, modelsJsonPath); + + expect(registry.getProviderAuthStatus("custom-provider")).toEqual({ + configured: true, + source: "environment", + label: `${envVarNameA}, ${envVarNameB}`, + }); + } finally { + if (originalEnvA === undefined) { + delete process.env[envVarNameA]; + } else { + process.env[envVarNameA] = originalEnvA; + } + if (originalEnvB === undefined) { + delete process.env[envVarNameB]; + } else { + process.env[envVarNameB] = originalEnvB; + } + } + }); + test("provider auth status reports non-env apiKey values from models.json as a config key", () => { writeRawModelsJson({ "custom-provider": providerWithApiKey("literal_api_key_value"), @@ -1350,6 +1523,29 @@ describe("ModelRegistry", () => { }); }); + test("missing explicit env apiKey keeps provider unavailable", () => { + const envVarName = "TEST_API_KEY_MISSING_TEST_98765"; + const originalEnv = process.env[envVarName]; + delete process.env[envVarName]; + + try { + writeRawModelsJson({ + "custom-provider": providerWithApiKey(`$${envVarName}`), + }); + + const registry = ModelRegistry.create(authStorage, modelsJsonPath); + + expect(registry.getProviderAuthStatus("custom-provider")).toEqual({ configured: false }); + expect(registry.getAvailable().some((model) => model.provider === "custom-provider")).toBe(false); + } finally { + if (originalEnv === undefined) { + delete process.env[envVarName]; + } else { + process.env[envVarName] = originalEnv; + } + } + }); + test("provider auth status reports command apiKey values from models.json without executing them", () => { const counterFile = join(tempDir, "status-counter"); writeFileSync(counterFile, "0"); @@ -1376,7 +1572,7 @@ describe("ModelRegistry", () => { process.env[envVarName] = "first-value"; writeRawModelsJson({ - "custom-provider": providerWithApiKey(envVarName), + "custom-provider": providerWithApiKey(`$${envVarName}`), }); const registry = ModelRegistry.create(authStorage, modelsJsonPath); From 458a7bc27ca0ff58c1ece28dffff114808609949 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 28 May 2026 12:02:54 +0200 Subject: [PATCH 067/352] Fix Anthropic empty thinking signature replay closes #4464 --- AGENTS.md | 2 +- packages/ai/CHANGELOG.md | 4 + packages/ai/scripts/generate-models.ts | 11 +- packages/ai/src/models.generated.ts | 10 +- packages/ai/src/providers/anthropic.ts | 30 +++-- packages/ai/src/types.ts | 2 + ...ic-empty-thinking-signature-compat.test.ts | 88 ++++++++++++++ ...ms-anthropic-empty-signature-smoke.test.ts | 114 ++++++++++++++++++ packages/coding-agent/docs/custom-provider.md | 3 +- packages/coding-agent/docs/models.md | 6 +- 10 files changed, 252 insertions(+), 18 deletions(-) create mode 100644 packages/ai/test/anthropic-empty-thinking-signature-compat.test.ts create mode 100644 packages/ai/test/xiaomi-token-plan-ams-anthropic-empty-signature-smoke.test.ts diff --git a/AGENTS.md b/AGENTS.md index 933835e3..8662a98d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ - Always ask before removing functionality or code that appears intentional. - Do not preserve backward compatibility unless the user asks for it. - Never hardcode key checks (e.g. `matchesKey(keyData, "ctrl+x")`). Add defaults to `DEFAULT_EDITOR_KEYBINDINGS` or `DEFAULT_APP_KEYBINDINGS` so they stay configurable. -- Never modify `packages/ai/src/models.generated.ts` directly; update `packages/ai/scripts/generate-models.ts` instead. +- Never modify `packages/ai/src/models.generated.ts` directly; update `packages/ai/scripts/generate-models.ts` instead, then regenerate. Including the resulting `models.generated.ts` diff is always OK, even if regeneration includes unrelated upstream model metadata changes. ## Commands diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index ba60d6c4..87e9f22c 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -6,6 +6,10 @@ - Added OpenAI Codex subscription device-code login as a selectable headless alternative while keeping browser login as the default. +### Fixed + +- Fixed Anthropic-compatible replay for providers that return empty thinking signatures by adding an opt-in `allowEmptySignature` compatibility flag ([#4464](https://github.com/earendil-works/pi/issues/4464)). + ## [0.76.0] - 2026-05-27 ### Fixed diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index a61813c2..791dff0f 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -262,9 +262,14 @@ function applyThinkingLevelMetadata(model: Model): void { } function getAnthropicMessagesCompat(provider: string, modelId: string): AnthropicMessagesCompat | undefined { - return EAGER_TOOL_INPUT_STREAMING_UNSUPPORTED_ANTHROPIC_MODELS.has(`${provider}:${modelId}`) - ? { supportsEagerToolInputStreaming: false } - : undefined; + const compat: AnthropicMessagesCompat = {}; + if (EAGER_TOOL_INPUT_STREAMING_UNSUPPORTED_ANTHROPIC_MODELS.has(`${provider}:${modelId}`)) { + compat.supportsEagerToolInputStreaming = false; + } + if (provider === "xiaomi" || provider.startsWith("xiaomi-token-plan-")) { + compat.allowEmptySignature = true; + } + return Object.keys(compat).length > 0 ? compat : undefined; } function getBedrockBaseUrl(modelId: string): string { diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 7bfc3c59..e881ae0e 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -9307,7 +9307,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 204800, + contextWindow: 262144, maxTokens: 8192, } satisfies Model<"openai-completions">, "minimax/minimax-m2.7": { @@ -11915,13 +11915,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.06599999999999999, - output: 0.26, - cacheRead: 0.029, + input: 0.063, + output: 0.21, + cacheRead: 0.020999999999999998, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 4096, } satisfies Model<"openai-completions">, "thedrummer/rocinante-12b": { id: "thedrummer/rocinante-12b", diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 5fb5ffda..2de6198f 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -177,6 +177,7 @@ function getAnthropicCompat( sendSessionAffinityHeaders: model.compat?.sendSessionAffinityHeaders ?? !!(isFireworks || isCloudflareAiGatewayAnthropic), supportsCacheControlOnTools: model.compat?.supportsCacheControlOnTools ?? !isFireworks, + allowEmptySignature: model.compat?.allowEmptySignature ?? false, }; } @@ -895,7 +896,13 @@ function buildParams( const { cacheControl } = getCacheControl(model, options?.cacheRetention); const params: MessageCreateParamsStreaming = { model: model.id, - messages: convertMessages(context.messages, model, isOAuthToken, cacheControl), + messages: convertMessages( + context.messages, + model, + isOAuthToken, + cacheControl, + getAnthropicCompat(model).allowEmptySignature, + ), max_tokens: options?.maxTokens ?? model.maxTokens, stream: true, }; @@ -1001,6 +1008,7 @@ function convertMessages( model: Model<"anthropic-messages">, isOAuthToken: boolean, cacheControl?: CacheControlEphemeral, + allowEmptySignature = false, ): MessageParam[] { const params: MessageParam[] = []; @@ -1069,13 +1077,21 @@ function convertMessages( } if (block.thinking.trim().length === 0) continue; // If thinking signature is missing/empty (e.g., from aborted stream), - // convert to plain text block without tags to avoid API rejection - // and prevent Claude from mimicking the tags in responses + // convert to plain text for Anthropic. Some compatible providers emit + // and accept empty signatures, so let marked models preserve the block. if (!block.thinkingSignature || block.thinkingSignature.trim().length === 0) { - blocks.push({ - type: "text", - text: sanitizeSurrogates(block.thinking), - }); + blocks.push( + allowEmptySignature + ? { + type: "thinking", + thinking: sanitizeSurrogates(block.thinking), + signature: "", + } + : { + type: "text", + text: sanitizeSurrogates(block.thinking), + }, + ); } else { blocks.push({ type: "thinking", diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index b27e78b7..4345e3bf 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -451,6 +451,8 @@ export interface AnthropicMessagesCompat { * Default: false. */ forceAdaptiveThinking?: boolean; + /** Whether to replay empty thinking signatures as `signature: ""` instead of converting thinking to text. Default: false. */ + allowEmptySignature?: boolean; } /** diff --git a/packages/ai/test/anthropic-empty-thinking-signature-compat.test.ts b/packages/ai/test/anthropic-empty-thinking-signature-compat.test.ts new file mode 100644 index 00000000..69e58e27 --- /dev/null +++ b/packages/ai/test/anthropic-empty-thinking-signature-compat.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { streamSimple } from "../src/stream.ts"; +import type { AssistantMessage, Context, Model } from "../src/types.ts"; + +interface AnthropicPayload { + messages?: Array<{ + role: string; + content: Array<{ type: string; text?: string; thinking?: string; signature?: string }>; + }>; +} + +class PayloadCaptured extends Error { + constructor() { + super("payload captured"); + this.name = "PayloadCaptured"; + } +} + +function makeModel(allowEmptySignature?: boolean): Model<"anthropic-messages"> { + return { + id: "mimo-v2.5-pro", + name: "MiMo-V2.5-Pro", + api: "anthropic-messages", + provider: "xiaomi-token-plan-ams", + baseUrl: "http://127.0.0.1:9/anthropic", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1048576, + maxTokens: 1024, + ...(allowEmptySignature === undefined ? {} : { compat: { allowEmptySignature } }), + }; +} + +function makeContext(thinkingSignature: string): Context { + const assistant: AssistantMessage = { + role: "assistant", + content: [{ type: "thinking", thinking: "internal reasoning", thinkingSignature }], + provider: "xiaomi-token-plan-ams", + api: "anthropic-messages", + model: "mimo-v2.5-pro", + timestamp: Date.now(), + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + }; + return { + messages: [ + { role: "user", content: "first", timestamp: Date.now() }, + assistant, + { role: "user", content: "second", timestamp: Date.now() }, + ], + }; +} + +async function capturePayload(model: Model<"anthropic-messages">, context: Context): Promise { + let capturedPayload: AnthropicPayload | undefined; + const stream = streamSimple(model, context, { + apiKey: "fake-key", + onPayload: (payload) => { + capturedPayload = payload as AnthropicPayload; + throw new PayloadCaptured(); + }, + }); + await stream.result(); + if (!capturedPayload) throw new Error("Expected payload capture before request"); + return capturedPayload; +} + +describe("Anthropic empty thinking signature compat", () => { + it("converts empty-signature thinking to text by default", async () => { + const payload = await capturePayload(makeModel(), makeContext("")); + const assistant = payload.messages?.find((message) => message.role === "assistant"); + expect(assistant?.content).toEqual([{ type: "text", text: "internal reasoning" }]); + }); + + it("preserves empty-signature thinking when allowEmptySignature is enabled", async () => { + const payload = await capturePayload(makeModel(true), makeContext(" ")); + const assistant = payload.messages?.find((message) => message.role === "assistant"); + expect(assistant?.content).toEqual([{ type: "thinking", thinking: "internal reasoning", signature: "" }]); + }); +}); diff --git a/packages/ai/test/xiaomi-token-plan-ams-anthropic-empty-signature-smoke.test.ts b/packages/ai/test/xiaomi-token-plan-ams-anthropic-empty-signature-smoke.test.ts new file mode 100644 index 00000000..ab2fec30 --- /dev/null +++ b/packages/ai/test/xiaomi-token-plan-ams-anthropic-empty-signature-smoke.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { completeSimple, getEnvApiKey, streamSimple } from "../src/stream.ts"; +import type { AssistantMessage, Context, Model } from "../src/types.ts"; + +const provider = "xiaomi-token-plan-ams"; +const apiKey = getEnvApiKey(provider); + +const model: Model<"anthropic-messages"> = { + id: "mimo-v2.5-pro", + name: "MiMo-V2.5-Pro Anthropic smoke", + api: "anthropic-messages", + provider, + baseUrl: "https://token-plan-ams.xiaomimimo.com/anthropic", + reasoning: true, + input: ["text"], + cost: { input: 1, output: 3, cacheRead: 0.2, cacheWrite: 0 }, + contextWindow: 1048576, + maxTokens: 1024, + compat: { allowEmptySignature: true }, +}; + +interface AnthropicPayload { + messages?: Array<{ + role: string; + content: string | Array<{ type: string; text?: string; thinking?: string; signature?: string }>; + }>; +} + +class PayloadCaptured extends Error { + constructor() { + super("payload captured"); + this.name = "PayloadCaptured"; + } +} + +function makeInitialContext(): Context { + return { + systemPrompt: "You are concise. Follow the requested output format exactly.", + messages: [ + { + role: "user", + content: "Think internally if you need to, then reply with exactly this text and nothing else: first-ok", + timestamp: Date.now(), + }, + ], + }; +} + +function getThinkingBlocks(message: AssistantMessage) { + return message.content.filter((block) => block.type === "thinking"); +} + +async function captureReplayPayload(context: Context): Promise { + let capturedPayload: AnthropicPayload | undefined; + const stream = streamSimple(model, context, { + apiKey, + maxTokens: 512, + reasoning: "high", + onPayload: (payload) => { + capturedPayload = payload as AnthropicPayload; + throw new PayloadCaptured(); + }, + }); + + await stream.result(); + + if (!capturedPayload) { + throw new Error("Expected payload capture before request"); + } + return capturedPayload; +} + +describe.skipIf(!apiKey)("Xiaomi Token Plan AMS Anthropic empty thinking signature smoke", () => { + it("reproduces empty thinking signatures and preserves them for replay", { timeout: 60000, retry: 1 }, async () => { + const firstContext = makeInitialContext(); + const first = await completeSimple(model, firstContext, { + apiKey, + maxTokens: 512, + reasoning: "high", + }); + + expect(first.stopReason, first.errorMessage).toBe("stop"); + + const thinkingBlocks = getThinkingBlocks(first); + expect(thinkingBlocks.length).toBeGreaterThan(0); + expect(thinkingBlocks.some((block) => block.thinkingSignature === "")).toBe(true); + + const replayContext: Context = { + ...firstContext, + messages: [ + ...firstContext.messages, + first, + { + role: "user", + content: "Reply with exactly this text and nothing else: second-ok", + timestamp: Date.now(), + }, + ], + }; + + const replayPayload = await captureReplayPayload(replayContext); + const assistantPayload = replayPayload.messages?.find((message) => message.role === "assistant"); + expect(assistantPayload).toBeDefined(); + expect(Array.isArray(assistantPayload!.content)).toBe(true); + const replayedThinking = (assistantPayload!.content as Array<{ type: string; text?: string }>).filter( + (block) => block.type === "thinking", + ); + const replayedText = (assistantPayload!.content as Array<{ type: string; text?: string }>).filter( + (block) => block.type === "text", + ); + expect(replayedThinking).toEqual([{ type: "thinking", thinking: thinkingBlocks[0].thinking, signature: "" }]); + expect(replayedText.some((block) => block.text === thinkingBlocks[0].thinking)).toBe(false); + }); +}); diff --git a/packages/coding-agent/docs/custom-provider.md b/packages/coding-agent/docs/custom-provider.md index 50cb1ef0..c2bf4455 100644 --- a/packages/coding-agent/docs/custom-provider.md +++ b/packages/coding-agent/docs/custom-provider.md @@ -232,7 +232,7 @@ models: [{ Use `openrouter` for OpenRouter-style `reasoning: { effort }` controls. Use `together` for Together-style `reasoning: { enabled }` controls; with `supportsReasoningEffort`, it also sends `reasoning_effort`. Use `qwen-chat-template` instead for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking`. Use `cacheControlFormat: "anthropic"` for OpenAI-compatible providers that expose Anthropic-style prompt caching via `cache_control` on the system prompt, last tool definition, and last user/assistant text content. -For Anthropic-compatible providers using `api: "anthropic-messages"`, set `compat.forceAdaptiveThinking: true` on models or providers whose upstream model requires adaptive thinking (`thinking.type: "adaptive"` plus `output_config.effort`). Built-in adaptive Claude models set this automatically. +For Anthropic-compatible providers using `api: "anthropic-messages"`, set `compat.forceAdaptiveThinking: true` on models or providers whose upstream model requires adaptive thinking (`thinking.type: "adaptive"` plus `output_config.effort`). Built-in adaptive Claude models set this automatically. Set `compat.allowEmptySignature: true` only for providers that emit empty thinking signatures and expect `signature: ""` on replay. > Migration note: Mistral moved from `openai-completions` to `mistral-conversations`. > Use `mistral-conversations` for native Mistral models. @@ -727,6 +727,7 @@ interface ProviderModelConfig { sendSessionAffinityHeaders?: boolean; supportsCacheControlOnTools?: boolean; forceAdaptiveThinking?: boolean; + allowEmptySignature?: boolean; }; } ``` diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index c97a6cf3..d457643d 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -330,6 +330,8 @@ By default pi sends per-tool `eager_input_streaming: true`. If a proxy or Anthro Some Anthropic models require adaptive thinking (`thinking.type: "adaptive"` plus `output_config.effort`) instead of the legacy budget-based thinking payload. Built-in models set this automatically. For custom providers or aliases that route to those models, set `forceAdaptiveThinking` to `true`. +Some Anthropic-compatible providers emit thinking blocks with empty signatures and still expect them on replay. Set `allowEmptySignature` to `true` only for those providers; real Anthropic rejects empty thinking signatures. + ```json { "providers": { @@ -340,7 +342,8 @@ Some Anthropic models require adaptive thinking (`thinking.type: "adaptive"` plu "compat": { "supportsEagerToolInputStreaming": false, "supportsLongCacheRetention": true, - "forceAdaptiveThinking": true + "forceAdaptiveThinking": true, + "allowEmptySignature": true }, "models": [ { @@ -361,6 +364,7 @@ Some Anthropic models require adaptive thinking (`thinking.type: "adaptive"` plu | `sendSessionAffinityHeaders` | Whether to send `x-session-affinity` from the session id when caching is enabled. Default: auto-detected for known providers. | | `supportsCacheControlOnTools` | Whether the provider accepts Anthropic-style `cache_control` markers on tool definitions. Default: `true`. | | `forceAdaptiveThinking` | Whether to send adaptive thinking (`thinking.type: "adaptive"` plus `output_config.effort`) for this model. Built-in adaptive models set this automatically. Default: `false`. | +| `allowEmptySignature` | Whether to replay empty thinking signatures as `signature: ""` instead of converting thinking to text. Default: `false`. | ## OpenAI Compatibility From b63d26332f35b9b8456f4abd3bc03e2d4bf2cc3e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 28 May 2026 12:34:02 +0200 Subject: [PATCH 068/352] Finish harness tool registry semantics --- packages/agent/docs/agent-harness.md | 9 +- packages/agent/docs/durable-harness.md | 37 +++++- packages/agent/src/harness/agent-harness.ts | 107 ++++++++++++++---- .../compaction/branch-summarization.ts | 1 + .../src/harness/compaction/compaction.ts | 1 + packages/agent/src/harness/session/session.ts | 16 ++- packages/agent/src/harness/types.ts | 36 ++++-- .../agent/test/harness/agent-harness.test.ts | 106 ++++++++++++++++- 8 files changed, 272 insertions(+), 41 deletions(-) diff --git a/packages/agent/docs/agent-harness.md b/packages/agent/docs/agent-harness.md index 65828889..64a8baec 100644 --- a/packages/agent/docs/agent-harness.md +++ b/packages/agent/docs/agent-harness.md @@ -258,13 +258,14 @@ Done: - Exported `QueueMode` from core types. - Added `AgentHarnessOptions.steeringMode` and `followUpMode`. - Added live `getSteeringMode()` / `setSteeringMode()` and `getFollowUpMode()` / `setFollowUpMode()`. +- Added `getTools()` and `getActiveTools()`. +- Added `tools_update` observability events, including active-tool-only updates. +- Active tool changes are persisted as branch-scoped `active_tools_change` entries. +- Duplicate tool names and duplicate active tool names reject. Remaining: -- Add `getTools()` semantics. -- Add `getActiveTools()` semantics. -- Decide and implement tool update observability events. -- Include active-tool-only updates in the runtime config observability plan. +- None. Notes: diff --git a/packages/agent/docs/durable-harness.md b/packages/agent/docs/durable-harness.md index 64e313df..ce5aa46d 100644 --- a/packages/agent/docs/durable-harness.md +++ b/packages/agent/docs/durable-harness.md @@ -14,6 +14,8 @@ A fully durable `AgentHarness` is not realistic by itself because important depe - resource loaders - system-prompt callbacks/modifiers +Tool registries are runtime dependencies. The harness should persist serializable tool configuration, such as active tool names, but not concrete tool implementations. + The practical target is a semi-durable harness: - session is the durable append-only state tree @@ -29,6 +31,7 @@ Existing session state already includes harness state: - model changes - thinking-level changes +- active-tool changes - leaf entries - labels - compactions and branch summaries @@ -50,10 +53,38 @@ The app must recreate compatible runtime dependencies: Harness can validate stable IDs/versions/hashes when available, but it cannot serialize these dependencies itself. +## Runtime configuration and restore + +Constructor options remain explicit runtime configuration and do not read session state. Hidden async restore in a constructor would make failure handling ambiguous. + +A future async builder/factory should own durable restore: + +```ts +const harness = await AgentHarness.builder() + .env(env) + .session(session) + .model(defaultModel) + .tools(runtimeTools) + .defaultActiveTools(["read", "edit"]) + .restore({ missingActiveTools: "fail" }); +``` + +`restore()` should read the active branch, reduce durable harness configuration, apply defaults for missing entries, validate against app-supplied runtime dependencies, construct the harness, and optionally emit `source: "restore"` update events after construction. + +For active tools: + +- `active_tools_change` entries are branch-scoped durable config. +- If no `active_tools_change` exists on the branch, restore uses builder defaults, or all registered tools if no default active names were supplied. +- Active tool names must be unique. +- Tool registry names must be unique. +- Missing restored active tool names should fail restore by default; permissive drop/disable policies can be added explicitly later. +- Concrete tools are never restored from session; the host app must provide compatible tools. + ## What harness should persist Minimum useful durability entries: +- branch-scoped active tool names - queued steer/followUp/nextTurn messages - queue consumption tied to a turn - pending session writes accepted during active operations @@ -93,11 +124,11 @@ On startup: 3. Harness reduces session entries into: - current leaf - conversation branch - - harness config + - harness config, including active tool names - queues - pending writes - active operation/turn/tool state -4. Harness validates required runtime dependencies. +4. Harness validates required runtime dependencies, including restored active tool names against the app-provided tool registry. 5. Harness reconciles unfinished operation state. Provider streams are not resumable. Recovery can only retry from a durable boundary or mark the operation interrupted. @@ -173,7 +204,7 @@ recovery: "mark_interrupted" | "retry_unfinished" ## Open questions -- Which harness config entries should move into session first: tools, active tools, resources, stream options, system prompt refs? +- Which remaining harness config entries should move into session first: resources, stream options, system prompt refs? - Should resolved system prompt text be snapshotted per turn for audit/debug? - Do we require strict dependency ID/version matching on resume? - How much provider request data should be journaled? diff --git a/packages/agent/src/harness/agent-harness.ts b/packages/agent/src/harness/agent-harness.ts index 80535e8e..96563465 100644 --- a/packages/agent/src/harness/agent-harness.ts +++ b/packages/agent/src/harness/agent-harness.ts @@ -86,6 +86,16 @@ function mergeHeaders(...headers: Array | undefined>): Re return hasHeaders ? merged : undefined; } +function findDuplicateNames(names: string[]): string[] { + const seen = new Set(); + const duplicates = new Set(); + for (const name of names) { + if (seen.has(name)) duplicates.add(name); + seen.add(name); + } + return [...duplicates]; +} + function applyStreamOptionsPatch( base: AgentHarnessStreamOptions, patch?: AgentHarnessStreamOptionsPatch, @@ -194,12 +204,20 @@ export class AgentHarness< this.streamOptions = cloneStreamOptions(options.streamOptions); this.systemPrompt = options.systemPrompt; this.getApiKeyAndHeaders = options.getApiKeyAndHeaders; + this.validateUniqueNames( + (options.tools ?? []).map((tool) => tool.name), + "Duplicate tool name(s)", + ); for (const tool of options.tools ?? []) { this.tools.set(tool.name, tool); } this.model = options.model; this.thinkingLevel = options.thinkingLevel ?? "off"; - this.activeToolNames = options.activeToolNames ?? (options.tools ?? []).map((tool) => tool.name); + this.activeToolNames = options.activeToolNames + ? [...options.activeToolNames] + : (options.tools ?? []).map((tool) => tool.name); + this.validateUniqueNames(this.activeToolNames, "Duplicate active tool name(s)"); + this.validateToolNames(this.activeToolNames); this.steeringQueueMode = options.steeringMode ?? "one-at-a-time"; this.followUpQueueMode = options.followUpMode ?? "one-at-a-time"; } @@ -451,7 +469,14 @@ export class AgentHarness< }; } + private validateUniqueNames(names: string[], message: string): void { + const duplicates = findDuplicateNames(names); + if (duplicates.length > 0) + throw new AgentHarnessError("invalid_argument", `${message}: ${duplicates.join(", ")}`); + } + private validateToolNames(toolNames: string[], tools: Map = this.tools): void { + this.validateUniqueNames(toolNames, "Duplicate active tool name(s)"); const missing = toolNames.filter((name) => !tools.has(name)); if (missing.length > 0) throw new AgentHarnessError("invalid_argument", `Unknown tool(s): ${missing.join(", ")}`); } @@ -465,6 +490,8 @@ export class AgentHarness< await this.session.appendModelChange(write.provider, write.modelId); } else if (write.type === "thinking_level_change") { await this.session.appendThinkingLevelChange(write.thinkingLevel); + } else if (write.type === "active_tools_change") { + await this.session.appendActiveToolsChange(write.activeToolNames); } else if (write.type === "custom") { await this.session.appendCustomEntry(write.customType, write.data); } else if (write.type === "custom_message") { @@ -838,10 +865,6 @@ export class AgentHarness< return this.model; } - getThinkingLevel(): ThinkingLevel { - return this.thinkingLevel; - } - async setModel(model: Model): Promise { try { const previousModel = this.model; @@ -851,12 +874,16 @@ export class AgentHarness< this.pendingSessionWrites.push({ type: "model_change", provider: model.provider, modelId: model.id }); } this.model = model; - await this.emitOwn({ type: "model_select", model, previousModel, source: "set" }); + await this.emitOwn({ type: "model_update", model, previousModel, source: "set" }); } catch (error) { throw normalizeHarnessError(error, "session"); } } + getThinkingLevel(): ThinkingLevel { + return this.thinkingLevel; + } + async setThinkingLevel(level: ThinkingLevel): Promise { try { const previousLevel = this.thinkingLevel; @@ -866,16 +893,70 @@ export class AgentHarness< this.pendingSessionWrites.push({ type: "thinking_level_change", thinkingLevel: level }); } this.thinkingLevel = level; - await this.emitOwn({ type: "thinking_level_select", level, previousLevel }); + await this.emitOwn({ type: "thinking_level_update", level, previousLevel }); } catch (error) { throw normalizeHarnessError(error, "session"); } } + getTools(): TTool[] { + return [...this.tools.values()]; + } + + async setTools(tools: TTool[], activeToolNames?: string[]): Promise { + try { + this.validateUniqueNames( + tools.map((tool) => tool.name), + "Duplicate tool name(s)", + ); + const nextTools = new Map(tools.map((tool) => [tool.name, tool])); + const nextActiveToolNames = activeToolNames ? [...activeToolNames] : this.activeToolNames; + this.validateToolNames(nextActiveToolNames, nextTools); + const previousToolNames = [...this.tools.keys()]; + const previousActiveToolNames = [...this.activeToolNames]; + if (this.phase === "idle") { + await this.session.appendActiveToolsChange(nextActiveToolNames); + } else { + this.pendingSessionWrites.push({ type: "active_tools_change", activeToolNames: [...nextActiveToolNames] }); + } + this.tools = nextTools; + this.activeToolNames = [...nextActiveToolNames]; + await this.emitOwn({ + type: "tools_update", + toolNames: [...this.tools.keys()], + previousToolNames, + activeToolNames: [...this.activeToolNames], + previousActiveToolNames, + source: "set", + }); + } catch (error) { + throw normalizeHarnessError(error, "invalid_argument"); + } + } + + getActiveTools(): TTool[] { + return this.activeToolNames.map((name) => this.tools.get(name)!); + } + async setActiveTools(toolNames: string[]): Promise { try { this.validateToolNames(toolNames); + const previousToolNames = [...this.tools.keys()]; + const previousActiveToolNames = [...this.activeToolNames]; + if (this.phase === "idle") { + await this.session.appendActiveToolsChange(toolNames); + } else { + this.pendingSessionWrites.push({ type: "active_tools_change", activeToolNames: [...toolNames] }); + } this.activeToolNames = [...toolNames]; + await this.emitOwn({ + type: "tools_update", + toolNames: [...this.tools.keys()], + previousToolNames, + activeToolNames: [...this.activeToolNames], + previousActiveToolNames, + source: "set", + }); } catch (error) { throw normalizeHarnessError(error, "invalid_argument"); } @@ -921,18 +1002,6 @@ export class AgentHarness< this.streamOptions = cloneStreamOptions(streamOptions); } - async setTools(tools: TTool[], activeToolNames?: string[]): Promise { - try { - const nextTools = new Map(tools.map((tool) => [tool.name, tool])); - const nextActiveToolNames = activeToolNames ? [...activeToolNames] : this.activeToolNames; - this.validateToolNames(nextActiveToolNames, nextTools); - this.tools = nextTools; - this.activeToolNames = [...nextActiveToolNames]; - } catch (error) { - throw normalizeHarnessError(error, "invalid_argument"); - } - } - async abort(): Promise { const clearedSteer = [...this.steerQueue]; const clearedFollowUp = [...this.followUpQueue]; diff --git a/packages/agent/src/harness/compaction/branch-summarization.ts b/packages/agent/src/harness/compaction/branch-summarization.ts index 6ca4a430..c1824ebf 100644 --- a/packages/agent/src/harness/compaction/branch-summarization.ts +++ b/packages/agent/src/harness/compaction/branch-summarization.ts @@ -112,6 +112,7 @@ function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp); case "thinking_level_change": case "model_change": + case "active_tools_change": case "custom": case "label": case "session_info": diff --git a/packages/agent/src/harness/compaction/compaction.ts b/packages/agent/src/harness/compaction/compaction.ts index a7ae33e9..e3a9f114 100644 --- a/packages/agent/src/harness/compaction/compaction.ts +++ b/packages/agent/src/harness/compaction/compaction.ts @@ -281,6 +281,7 @@ function findValidCutPoints(entries: SessionTreeEntry[], startIndex: number, end } case "thinking_level_change": case "model_change": + case "active_tools_change": case "compaction": case "branch_summary": case "custom": diff --git a/packages/agent/src/harness/session/session.ts b/packages/agent/src/harness/session/session.ts index 4baf50ec..6f136208 100644 --- a/packages/agent/src/harness/session/session.ts +++ b/packages/agent/src/harness/session/session.ts @@ -2,6 +2,7 @@ import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; import type { AgentMessage } from "../../types.ts"; import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage } from "../messages.ts"; import type { + ActiveToolsChangeEntry, BranchSummaryEntry, CompactionEntry, CustomEntry, @@ -21,6 +22,7 @@ import { SessionError } from "../types.ts"; export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionContext { let thinkingLevel = "off"; let model: { provider: string; modelId: string } | null = null; + let activeToolNames: string[] | null = null; let compaction: CompactionEntry | null = null; for (const entry of pathEntries) { @@ -30,6 +32,8 @@ export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionCon model = { provider: entry.provider, modelId: entry.modelId }; } else if (entry.type === "message" && entry.message.role === "assistant") { model = { provider: entry.message.provider, modelId: entry.message.model }; + } else if (entry.type === "active_tools_change") { + activeToolNames = [...entry.activeToolNames]; } else if (entry.type === "compaction") { compaction = entry; } @@ -72,7 +76,7 @@ export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionCon } } - return { messages, thinkingLevel, model }; + return { messages, thinkingLevel, model, activeToolNames }; } export class Session { @@ -156,6 +160,16 @@ export class Session { } satisfies ModelChangeEntry); } + async appendActiveToolsChange(activeToolNames: string[]): Promise { + return this.appendTypedEntry({ + type: "active_tools_change", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + activeToolNames: [...activeToolNames], + } satisfies ActiveToolsChangeEntry); + } + async appendCompaction( summary: string, firstKeptEntryId: string, diff --git a/packages/agent/src/harness/types.ts b/packages/agent/src/harness/types.ts index 2d9096c4..4756ca84 100644 --- a/packages/agent/src/harness/types.ts +++ b/packages/agent/src/harness/types.ts @@ -354,6 +354,11 @@ export interface ModelChangeEntry extends SessionTreeEntryBase { modelId: string; } +export interface ActiveToolsChangeEntry extends SessionTreeEntryBase { + type: "active_tools_change"; + activeToolNames: string[]; +} + export interface CompactionEntry extends SessionTreeEntryBase { type: "compaction"; summary: string; @@ -405,6 +410,7 @@ export type SessionTreeEntry = | MessageEntry | ThinkingLevelChangeEntry | ModelChangeEntry + | ActiveToolsChangeEntry | CompactionEntry | BranchSummaryEntry | CustomEntry @@ -417,6 +423,7 @@ export interface SessionContext { messages: AgentMessage[]; thinkingLevel: string; model: { provider: string; modelId: string } | null; + activeToolNames: string[] | null; } export interface SessionMetadata { @@ -593,19 +600,28 @@ export interface SessionTreeEvent { fromHook?: boolean; } -export interface ModelSelectEvent { - type: "model_select"; +export interface ModelUpdateEvent { + type: "model_update"; model: Model; previousModel: Model | undefined; source: "set" | "restore"; } -export interface ThinkingLevelSelectEvent { - type: "thinking_level_select"; +export interface ThinkingLevelUpdateEvent { + type: "thinking_level_update"; level: ThinkingLevel; previousLevel: ThinkingLevel; } +export interface ToolsUpdateEvent { + type: "tools_update"; + toolNames: string[]; + previousToolNames: string[]; + activeToolNames: string[]; + previousActiveToolNames: string[]; + source: "set" | "restore"; +} + export interface ResourcesUpdateEvent< TSkill extends Skill = Skill, TPromptTemplate extends PromptTemplate = PromptTemplate, @@ -634,9 +650,10 @@ export type AgentHarnessOwnEvent< | SessionCompactEvent | SessionBeforeTreeEvent | SessionTreeEvent - | ModelSelectEvent - | ThinkingLevelSelectEvent - | ResourcesUpdateEvent; + | ModelUpdateEvent + | ThinkingLevelUpdateEvent + | ResourcesUpdateEvent + | ToolsUpdateEvent; export type AgentHarnessEvent = | AgentEvent @@ -696,9 +713,10 @@ export type AgentHarnessEventResultMap = { session_compact: undefined; session_before_tree: SessionBeforeTreeResult | undefined; session_tree: undefined; - model_select: undefined; - thinking_level_select: undefined; + model_update: undefined; + thinking_level_update: undefined; resources_update: undefined; + tools_update: undefined; queue_update: undefined; save_point: undefined; abort: undefined; diff --git a/packages/agent/test/harness/agent-harness.test.ts b/packages/agent/test/harness/agent-harness.test.ts index 8a395532..1d24eb4c 100644 --- a/packages/agent/test/harness/agent-harness.test.ts +++ b/packages/agent/test/harness/agent-harness.test.ts @@ -17,10 +17,6 @@ interface AppPromptTemplate extends PromptTemplate { source: "project" | "user"; } -interface AppTool extends AgentTool { - source: "builtin" | "extension"; -} - const registrations: Array<{ unregister(): void }> = []; function textFromUserMessages(messages: Array<{ role: string; content: unknown }>): string[] { @@ -458,11 +454,111 @@ describe("AgentHarness", () => { }); }); + it("preserves app tool types for getters and update events", async () => { + const session = new Session(new InMemorySessionStorage()); + const env = new NodeExecutionEnv({ cwd: process.cwd() }); + const model = getModel("anthropic", "claude-sonnet-4-5"); + type AppTool = AgentTool & { source: "builtin" | "extension" }; + const inspectTool: AppTool = { ...calculateTool, name: "inspect", source: "builtin" }; + const searchTool: AppTool = { ...calculateTool, name: "search", source: "extension" }; + const harness = new AgentHarness({ + env, + session, + model, + tools: [inspectTool, searchTool], + activeToolNames: ["inspect"], + }); + const updates: Array<{ + toolNames: string[]; + previousToolNames: string[]; + activeToolNames: string[]; + previousActiveToolNames: string[]; + source: "set" | "restore"; + }> = []; + harness.subscribe((event) => { + if (event.type === "tools_update") { + updates.push({ + toolNames: event.toolNames, + previousToolNames: event.previousToolNames, + activeToolNames: event.activeToolNames, + previousActiveToolNames: event.previousActiveToolNames, + source: event.source, + }); + expect(harness.getActiveTools().map((tool) => tool.name)).toEqual(event.activeToolNames); + } + }); + + const tools = harness.getTools(); + const activeTools = harness.getActiveTools(); + tools.pop(); + activeTools.pop(); + expect(harness.getTools().map((tool) => tool.name)).toEqual(["inspect", "search"]); + expect(harness.getActiveTools().map((tool) => tool.source)).toEqual(["builtin"]); + + await harness.setActiveTools(["search"]); + await harness.setTools([searchTool], ["search"]); + await expect(harness.setActiveTools(["missing"])).rejects.toMatchObject({ code: "invalid_argument" }); + await expect(harness.setActiveTools(["search", "search"])).rejects.toMatchObject({ code: "invalid_argument" }); + await expect(harness.setTools([inspectTool])).rejects.toMatchObject({ code: "invalid_argument" }); + await expect(harness.setTools([inspectTool, inspectTool], ["inspect"])).rejects.toMatchObject({ + code: "invalid_argument", + }); + + expect(updates).toEqual([ + { + toolNames: ["inspect", "search"], + previousToolNames: ["inspect", "search"], + activeToolNames: ["search"], + previousActiveToolNames: ["inspect"], + source: "set", + }, + { + toolNames: ["search"], + previousToolNames: ["inspect", "search"], + activeToolNames: ["search"], + previousActiveToolNames: ["search"], + source: "set", + }, + ]); + expect(harness.getTools().map((tool) => tool.source)).toEqual(["extension"]); + expect(harness.getActiveTools().map((tool) => tool.name)).toEqual(["search"]); + expect((await session.buildContext()).activeToolNames).toEqual(["search"]); + }); + + it("validates constructor tool names", () => { + const session = new Session(new InMemorySessionStorage()); + const env = new NodeExecutionEnv({ cwd: process.cwd() }); + const model = getModel("anthropic", "claude-sonnet-4-5"); + expect( + () => new AgentHarness({ env, session, model, tools: [calculateTool], activeToolNames: ["missing"] }), + ).toThrow(/Unknown tool/); + expect( + () => + new AgentHarness({ + env, + session, + model, + tools: [calculateTool, calculateTool], + activeToolNames: [calculateTool.name], + }), + ).toThrow(/Duplicate tool/); + expect( + () => + new AgentHarness({ + env, + session, + model, + tools: [calculateTool], + activeToolNames: [calculateTool.name, calculateTool.name], + }), + ).toThrow(/Duplicate active tool/); + }); + it("preserves app resource types for getters and update events", async () => { const session = new Session(new InMemorySessionStorage()); const env = new NodeExecutionEnv({ cwd: process.cwd() }); const model = getModel("anthropic", "claude-sonnet-4-5"); - const harness = new AgentHarness({ env, session, model }); + const harness = new AgentHarness({ env, session, model }); const skill: AppSkill = { name: "inspect", description: "Inspect things", From 53ca936adb000b656a75e493e3573bd8ddecc1db Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 28 May 2026 16:59:44 +0200 Subject: [PATCH 069/352] Update clipboard native addon closes #5028 --- package-lock.json | 127 ++++++++++------------ packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/npm-shrinkwrap.json | 103 ++++++++++-------- packages/coding-agent/package.json | 2 +- 4 files changed, 120 insertions(+), 113 deletions(-) diff --git a/package-lock.json b/package-lock.json index 421dd353..b4c74b18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1357,31 +1357,31 @@ } }, "node_modules/@mariozechner/clipboard": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.6.tgz", - "integrity": "sha512-MXdtr+6+ntlIVHdrZYuZNQydu6o8yZswFJ2Ln81j2O/Y9B/LDHvEaIm95xWNPkjGTWriSOeLnQJRFs6dYb60bg==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", + "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", "license": "MIT", "optional": true, "engines": { "node": ">= 10" }, "optionalDependencies": { - "@mariozechner/clipboard-darwin-arm64": "0.3.6", - "@mariozechner/clipboard-darwin-universal": "0.3.6", - "@mariozechner/clipboard-darwin-x64": "0.3.6", - "@mariozechner/clipboard-linux-arm64-gnu": "0.3.6", - "@mariozechner/clipboard-linux-arm64-musl": "0.3.6", - "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.6", - "@mariozechner/clipboard-linux-x64-gnu": "0.3.6", - "@mariozechner/clipboard-linux-x64-musl": "0.3.6", - "@mariozechner/clipboard-win32-arm64-msvc": "0.3.6", - "@mariozechner/clipboard-win32-x64-msvc": "0.3.6" + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" } }, "node_modules/@mariozechner/clipboard-darwin-arm64": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.6.tgz", - "integrity": "sha512-HjaisYCAbHi/1+N1yDAQHc8ZXGffufIUT5NSOSVR3f3AuMDusxTtnbK8tZ7JFDkShua1oNGZoNwQHsc8MPtE0Q==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", + "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", "cpu": [ "arm64" ], @@ -1395,9 +1395,9 @@ } }, "node_modules/@mariozechner/clipboard-darwin-universal": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.6.tgz", - "integrity": "sha512-8BWtPjOtJOJoykml3w0fx0zRrfWP31mXrJwfoA7xzNprkZw1uolCNfgmjDiVBseoKjp16EGITz7bN+61qn8dWA==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", + "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", "license": "MIT", "optional": true, "os": [ @@ -1408,9 +1408,9 @@ } }, "node_modules/@mariozechner/clipboard-darwin-x64": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.6.tgz", - "integrity": "sha512-p9syiZD1kU4I+1ya7f7g+zD1GiUvR8fdlRlNmgsZNWlyjtc8rlV2EjTLd/35x1LsdBq020GVvtzp0ZmPgBI09Q==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", + "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", "cpu": [ "x64" ], @@ -1424,12 +1424,15 @@ } }, "node_modules/@mariozechner/clipboard-linux-arm64-gnu": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.6.tgz", - "integrity": "sha512-5JFf5rGofrm+V29HNF+wLthXphHdQpMbKDUYJ5tML6/Z5DLlLOV/9Ak4kDPtYyZ+Dzf+kAusE0VsFg4+tfP1IA==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", + "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1440,12 +1443,15 @@ } }, "node_modules/@mariozechner/clipboard-linux-arm64-musl": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.6.tgz", - "integrity": "sha512-JlVjxxw0GbGC0djXYWRIqyteO3J1KZ/QG3udlEFaOD5TLOM1FnmXXAPDQBqr+aBVr720ef9K00dirYnJ0LDCtw==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", + "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1456,12 +1462,15 @@ } }, "node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.6.tgz", - "integrity": "sha512-4t8BUi5zZ+L77otFQVnVSlaTyAX4TVk9EqQm4syMrEQp96trFEHEwwNHcNEBGzYv5+K7mxay50TthYkz47OWzQ==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", + "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1472,12 +1481,15 @@ } }, "node_modules/@mariozechner/clipboard-linux-x64-gnu": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.6.tgz", - "integrity": "sha512-trtPwcNLW37irwQCJLtCxLw757jjJZk3TSnY/MU9bhtWtA3K9b/eLW0e4RGhUXDoFRds9opNWWaUDuFLa8dm0w==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", + "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1488,12 +1500,15 @@ } }, "node_modules/@mariozechner/clipboard-linux-x64-musl": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.6.tgz", - "integrity": "sha512-WfnzIvOCCWQiN0MmltCEo6cLceUDbYe+I7xyFZjaps5A+2Op/M2CY7Rey+C4ucQhrvmpoHmTSFgY9ODWk7snoA==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", + "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1504,9 +1519,9 @@ } }, "node_modules/@mariozechner/clipboard-win32-arm64-msvc": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.6.tgz", - "integrity": "sha512-+8+1aHYsBPUjmW3otmWlg+Hijt0iJvoBBs5e0mxFeUd4gDaKMB8Bn6x7c6KVtscg7E5j5NFXnwQqNSIAO4p8zQ==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", + "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", "cpu": [ "arm64" ], @@ -1520,9 +1535,9 @@ } }, "node_modules/@mariozechner/clipboard-win32-x64-msvc": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.6.tgz", - "integrity": "sha512-S4xfPmERC8ZkiLHe3vekZCjdDwNEETCuvCgQK2kP6/TnvmUkq1y2Pk+DjM4t8uh9KMX9bH4zs5ePcKa8GTXmfg==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", + "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", "cpu": [ "x64" ], @@ -5998,30 +6013,6 @@ "node": ">=22.19.0" } }, - "packages/agent/node_modules/@earendil-works/pi-ai": { - "version": "0.75.5", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.75.5.tgz", - "integrity": "sha512-zf1F5kXk1pqZeFShXOqq9ibUk8QdtRoLCDPAjO+hj44e3EUs9/GFO2qnhTC5+JA2uwVCx+WCNe1PiCjlBYWm5w==", - "license": "MIT", - "dependencies": { - "@anthropic-ai/sdk": "0.91.1", - "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.1", - "@smithy/node-http-handler": "4.7.3", - "http-proxy-agent": "7.0.2", - "https-proxy-agent": "7.0.6", - "openai": "6.26.0", - "partial-json": "0.1.7", - "typebox": "1.1.38" - }, - "bin": { - "pi-ai": "dist/cli.js" - }, - "engines": { - "node": ">=22.19.0" - } - }, "packages/agent/node_modules/@types/node": { "version": "24.12.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", @@ -6125,7 +6116,7 @@ "node": ">=22.19.0" }, "optionalDependencies": { - "@mariozechner/clipboard": "0.3.6" + "@mariozechner/clipboard": "0.3.9" } }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1225fdb6..875dd7f4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed Windows startup crashes under MSYS2 ucrt64 Node.js by updating the native clipboard addon to napi-rs 3.x ([#5028](https://github.com/earendil-works/pi/issues/5028)). - Fixed API key and header config resolution to treat plain strings as literals, support `$ENV_VAR` / `${ENV_VAR}` interpolation and `$!` bang escaping, and require explicit env syntax for config files, avoiding Windows case-insensitive env matches corrupting literal keys ([#5095](https://github.com/earendil-works/pi/issues/5095)). - Fixed session disposal to abort in-flight agent, compaction, branch summary, retry, and bash work ([#5029](https://github.com/earendil-works/pi/pull/5029) by [@TerminallyChilI](https://github.com/TerminallyChilI)). - Fixed `pi.getAllTools()` to expose each tool's `promptGuidelines` for extensions that need per-tool guideline attribution ([#4879](https://github.com/earendil-works/pi/issues/4879)). diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 71ce72ed..eb93f468 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -28,7 +28,7 @@ "yaml": "2.9.0" }, "optionalDependencies": { - "@mariozechner/clipboard": "0.3.6" + "@mariozechner/clipboard": "0.3.9" }, "bin": { "pi": "dist/cli.js" @@ -546,21 +546,21 @@ "hasInstallScript": true }, "node_modules/@mariozechner/clipboard": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.6.tgz", - "integrity": "sha512-MXdtr+6+ntlIVHdrZYuZNQydu6o8yZswFJ2Ln81j2O/Y9B/LDHvEaIm95xWNPkjGTWriSOeLnQJRFs6dYb60bg==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", + "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", "license": "MIT", "optionalDependencies": { - "@mariozechner/clipboard-darwin-arm64": "0.3.6", - "@mariozechner/clipboard-darwin-universal": "0.3.6", - "@mariozechner/clipboard-darwin-x64": "0.3.6", - "@mariozechner/clipboard-linux-arm64-gnu": "0.3.6", - "@mariozechner/clipboard-linux-arm64-musl": "0.3.6", - "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.6", - "@mariozechner/clipboard-linux-x64-gnu": "0.3.6", - "@mariozechner/clipboard-linux-x64-musl": "0.3.6", - "@mariozechner/clipboard-win32-arm64-msvc": "0.3.6", - "@mariozechner/clipboard-win32-x64-msvc": "0.3.6" + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" }, "engines": { "node": ">= 10" @@ -568,9 +568,9 @@ "optional": true }, "node_modules/@mariozechner/clipboard-darwin-arm64": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.6.tgz", - "integrity": "sha512-HjaisYCAbHi/1+N1yDAQHc8ZXGffufIUT5NSOSVR3f3AuMDusxTtnbK8tZ7JFDkShua1oNGZoNwQHsc8MPtE0Q==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", + "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", "license": "MIT", "engines": { "node": ">= 10" @@ -584,9 +584,9 @@ "optional": true }, "node_modules/@mariozechner/clipboard-darwin-universal": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.6.tgz", - "integrity": "sha512-8BWtPjOtJOJoykml3w0fx0zRrfWP31mXrJwfoA7xzNprkZw1uolCNfgmjDiVBseoKjp16EGITz7bN+61qn8dWA==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", + "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", "license": "MIT", "engines": { "node": ">= 10" @@ -597,9 +597,9 @@ "optional": true }, "node_modules/@mariozechner/clipboard-darwin-x64": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.6.tgz", - "integrity": "sha512-p9syiZD1kU4I+1ya7f7g+zD1GiUvR8fdlRlNmgsZNWlyjtc8rlV2EjTLd/35x1LsdBq020GVvtzp0ZmPgBI09Q==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", + "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", "license": "MIT", "engines": { "node": ">= 10" @@ -613,9 +613,9 @@ "optional": true }, "node_modules/@mariozechner/clipboard-linux-arm64-gnu": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.6.tgz", - "integrity": "sha512-5JFf5rGofrm+V29HNF+wLthXphHdQpMbKDUYJ5tML6/Z5DLlLOV/9Ak4kDPtYyZ+Dzf+kAusE0VsFg4+tfP1IA==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", + "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", "license": "MIT", "engines": { "node": ">= 10" @@ -626,12 +626,15 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "optional": true }, "node_modules/@mariozechner/clipboard-linux-arm64-musl": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.6.tgz", - "integrity": "sha512-JlVjxxw0GbGC0djXYWRIqyteO3J1KZ/QG3udlEFaOD5TLOM1FnmXXAPDQBqr+aBVr720ef9K00dirYnJ0LDCtw==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", + "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", "license": "MIT", "engines": { "node": ">= 10" @@ -642,12 +645,15 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "optional": true }, "node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.6.tgz", - "integrity": "sha512-4t8BUi5zZ+L77otFQVnVSlaTyAX4TVk9EqQm4syMrEQp96trFEHEwwNHcNEBGzYv5+K7mxay50TthYkz47OWzQ==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", + "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", "license": "MIT", "engines": { "node": ">= 10" @@ -658,12 +664,15 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "optional": true }, "node_modules/@mariozechner/clipboard-linux-x64-gnu": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.6.tgz", - "integrity": "sha512-trtPwcNLW37irwQCJLtCxLw757jjJZk3TSnY/MU9bhtWtA3K9b/eLW0e4RGhUXDoFRds9opNWWaUDuFLa8dm0w==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", + "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", "license": "MIT", "engines": { "node": ">= 10" @@ -674,12 +683,15 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "optional": true }, "node_modules/@mariozechner/clipboard-linux-x64-musl": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.6.tgz", - "integrity": "sha512-WfnzIvOCCWQiN0MmltCEo6cLceUDbYe+I7xyFZjaps5A+2Op/M2CY7Rey+C4ucQhrvmpoHmTSFgY9ODWk7snoA==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", + "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", "license": "MIT", "engines": { "node": ">= 10" @@ -690,12 +702,15 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "optional": true }, "node_modules/@mariozechner/clipboard-win32-arm64-msvc": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.6.tgz", - "integrity": "sha512-+8+1aHYsBPUjmW3otmWlg+Hijt0iJvoBBs5e0mxFeUd4gDaKMB8Bn6x7c6KVtscg7E5j5NFXnwQqNSIAO4p8zQ==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", + "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", "license": "MIT", "engines": { "node": ">= 10" @@ -709,9 +724,9 @@ "optional": true }, "node_modules/@mariozechner/clipboard-win32-x64-msvc": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.6.tgz", - "integrity": "sha512-S4xfPmERC8ZkiLHe3vekZCjdDwNEETCuvCgQK2kP6/TnvmUkq1y2Pk+DjM4t8uh9KMX9bH4zs5ePcKa8GTXmfg==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", + "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", "license": "MIT", "engines": { "node": ">= 10" diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 720b4de3..d495acbd 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -64,7 +64,7 @@ } }, "optionalDependencies": { - "@mariozechner/clipboard": "0.3.6" + "@mariozechner/clipboard": "0.3.9" }, "devDependencies": { "@types/cross-spawn": "6.0.6", From ae50dec12197823e4f15f16626979824945abcd4 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 28 May 2026 17:42:33 +0200 Subject: [PATCH 070/352] chore(release): publish packages from CI --- .github/workflows/build-binaries.yml | 52 +++++++++++- AGENTS.md | 30 ++----- README.md | 2 +- package.json | 10 +-- scripts/publish.mjs | 115 +++++++++++++++++++++++++++ scripts/release.mjs | 31 ++++---- 6 files changed, 193 insertions(+), 47 deletions(-) create mode 100644 scripts/publish.mjs diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index ccc4eab8..9190ec22 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -11,12 +11,13 @@ on: required: true type: string -permissions: - contents: write +permissions: {} jobs: build: runs-on: ubuntu-latest + permissions: + contents: write env: RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} steps: @@ -80,3 +81,50 @@ jobs: pi-windows-x64.zip \ pi-windows-arm64.zip \ --clobber + + publish-npm: + runs-on: ubuntu-latest + needs: build + environment: npm-publish + permissions: + contents: read + id-token: write + env: + RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ env.RELEASE_TAG }} + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + with: + node-version: '22' + registry-url: 'https://registry.npmjs.org' + cache: npm + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev fd-find ripgrep + sudo ln -s $(which fdfind) /usr/local/bin/fd + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Build + run: npm run build + + - name: Check + run: npm run check + + - name: Test + run: npm test + + - name: Verify release artifacts are committed + run: git diff --exit-code + + - name: Publish npm packages + run: node scripts/publish.mjs diff --git a/AGENTS.md b/AGENTS.md index 8662a98d..6686b5e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -143,36 +143,16 @@ Attribution: ``` Verify both Node and Bun startup, model/account listing, interactive startup, and at least one real prompt with the intended default provider. The bare commands `/tmp/pi-local-release/node/pi` and `/tmp/pi-local-release/bun/pi` start interactive mode; run each in tmux, submit a prompt, and wait for the model reply before considering the interactive smoke test passed. Failures are release blockers unless the user explicitly accepts the risk. -3. **Verify npm authentication**: run `npm whoami` before starting the release script. If it fails, stop and tell the user to run `npm login` manually first, then retry after they confirm `npm whoami` succeeds. - -4. **Brief the user on the WebAuthn flow before running anything**. Print exactly the following message and then stop and wait for the user to confirm in their next message: - - ``` - Before the release publish step, read this carefully: - - - `npm publish` uses WebAuthn 2FA. - - The safest flow is for you to run the publish command yourself, because you can see and open the npm authentication URL immediately. - - I will tell you the exact command to run. - - When npm prints an auth URL, cmd/ctrl-click it, log in in the browser, and select the "don't ask again for N minutes" option if available. - - This may happen more than once during publish. - - Do not rerun `npm run release:patch` or `npm run release:minor` after a failed publish; only rerun the publish command I give you. - - Reply "ready" once you have read this and are ready to run the command locally. - ``` - - Do not proceed to step 5 until the user explicitly confirms. - -5. **Run the release script**: +3. **Run the release script**: ```bash npm run release:patch # fixes + additions npm run release:minor # breaking changes ``` - Do not pass a `timeout` to the bash tool for this call. If publish fails during the WebAuthn/OTP step after version bump, stop and tell the user to run `npm run publish` themselves from the repo root. Never rerun the version bump on your own. After the user reports publish success, continue with the post-publish steps. + The release script bumps all package versions, updates changelogs, regenerates release artifacts, runs `npm run check`, commits `Release vX.Y.Z`, tags `vX.Y.Z`, adds fresh `## [Unreleased]` changelog sections, commits `Add [Unreleased] section for next cycle`, then pushes `main` and the tag. Do not rerun the release script after a tag was pushed. -6. **After publish succeeds**: - - Add fresh `## [Unreleased]` sections to package changelogs. - - Commit with `Add [Unreleased] section for next cycle`. - - Push `main` and the release tag. +4. **CI publishes npm packages**: pushing the `vX.Y.Z` tag triggers `.github/workflows/build-binaries.yml`. The `publish-npm` job uses npm trusted publishing through GitHub Actions OIDC with environment `npm-publish`; no local `npm publish`, `npm whoami`, OTP, or WebAuthn flow is required. + +5. **If CI publish fails**: inspect the failed `publish-npm` job. The publish helper is idempotent and skips package versions already present on npm, so rerun the tag workflow after fixing CI or transient npm issues. Do not rerun `npm run release:patch` or `npm run release:minor` for the same version. ## User Override diff --git a/README.md b/README.md index b9a45021..92ad3d7b 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ We treat npm dependency changes as reviewed code changes. - `package-lock.json` is the dependency ground truth. Pre-commit blocks accidental lockfile commits unless `PI_ALLOW_LOCKFILE_CHANGE=1` is set. - `npm run check` verifies pinned direct deps, native TypeScript import compatibility, and the generated coding-agent shrinkwrap. - The published CLI package includes `packages/coding-agent/npm-shrinkwrap.json`, generated from the root lockfile, to pin transitive deps for npm users. -- Release smoke tests use `npm run release:local` to build, pack, and create isolated npm and Bun installs outside the repo before publishing. +- Release smoke tests use `npm run release:local` to build, pack, and create isolated npm and Bun installs outside the repo before tagging a release. - Local release installs, documented npm installs, and `pi update --self` use `--ignore-scripts` where supported. - CI installs with `npm ci --ignore-scripts`, and a scheduled GitHub workflow runs `npm audit --omit=dev` plus `npm audit signatures --omit=dev`. - Shrinkwrap generation has an explicit allowlist for dependency lifecycle scripts; new lifecycle-script deps fail checks until reviewed. diff --git a/package.json b/package.json index 7a62003d..072c908a 100644 --- a/package.json +++ b/package.json @@ -20,13 +20,13 @@ "profile:tui": "node scripts/profile-coding-agent-node.mjs --mode tui", "profile:rpc": "node scripts/profile-coding-agent-node.mjs --mode rpc", "test": "npm run test --workspaces --if-present", - "version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only", - "version:minor": "npm version minor -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only", - "version:major": "npm version major -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only", + "version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts", + "version:minor": "npm version minor -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts", + "version:major": "npm version major -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts", "version:set": "npm version -ws", "prepublishOnly": "npm run clean && npm run build && npm run check", - "publish": "npm run prepublishOnly && npm publish -ws --access public", - "publish:dry": "npm run prepublishOnly && npm publish -ws --access public --dry-run", + "publish": "npm run prepublishOnly && node scripts/publish.mjs", + "publish:dry": "npm run prepublishOnly && node scripts/publish.mjs --dry-run", "release:local": "node scripts/local-release.mjs", "shrinkwrap:coding-agent": "node scripts/generate-coding-agent-shrinkwrap.mjs", "release:patch": "node scripts/release.mjs patch", diff --git a/scripts/publish.mjs b/scripts/publish.mjs new file mode 100644 index 00000000..513ab36f --- /dev/null +++ b/scripts/publish.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const packages = [ + { directory: "packages/ai", name: "@earendil-works/pi-ai" }, + { directory: "packages/agent", name: "@earendil-works/pi-agent-core" }, + { directory: "packages/tui", name: "@earendil-works/pi-tui" }, + { directory: "packages/coding-agent", name: "@earendil-works/pi-coding-agent" }, +]; + +const dryRun = process.argv.includes("--dry-run"); +const unknownArgs = process.argv.slice(2).filter((arg) => arg !== "--dry-run"); + +if (unknownArgs.length > 0) { + console.error(`Usage: node scripts/publish.mjs [--dry-run]`); + process.exit(1); +} + +function commandForPlatform(command) { + return process.platform === "win32" ? `${command}.cmd` : command; +} + +function run(command, args, options = {}) { + console.log(`$ ${[command, ...args].join(" ")}`); + const result = spawnSync(commandForPlatform(command), args, { + cwd: options.cwd, + encoding: "utf8", + stdio: options.capture ? ["inherit", "pipe", "pipe"] : "inherit", + }); + + if (result.status !== 0) { + const output = [result.stdout, result.stderr].filter(Boolean).join("\n"); + throw new Error(output ? `Command failed: ${command} ${args.join(" ")}\n${output}` : `Command failed: ${command} ${args.join(" ")}`); + } + + return result; +} + +function readPackageJson(directory) { + return JSON.parse(readFileSync(join(directory, "package.json"), "utf8")); +} + +function assertBuildOutputExists(directory) { + if (!existsSync(join(directory, "dist"))) { + throw new Error(`${directory}/dist does not exist. Run npm run build before publishing.`); + } +} + +function validatePack(directory) { + const result = run("npm", ["pack", "--dry-run", "--ignore-scripts", "--json"], { capture: true, cwd: directory }); + const packed = JSON.parse(result.stdout)[0]; + console.log(` ${packed.filename}: ${packed.files.length} files, ${packed.size} bytes packed, ${packed.unpackedSize} bytes unpacked`); +} + +function isPublished(name, version) { + const result = spawnSync(commandForPlatform("npm"), ["view", `${name}@${version}`, "version", "--json"], { + encoding: "utf8", + stdio: ["inherit", "pipe", "pipe"], + }); + + if (result.status === 0 && result.stdout.trim()) { + return true; + } + + const output = [result.stdout, result.stderr].filter(Boolean).join("\n"); + if (result.status !== 0 && (output.includes("E404") || output.includes("404 Not Found"))) { + return false; + } + + throw new Error(output ? `Failed to query ${name}@${version}\n${output}` : `Failed to query ${name}@${version}`); +} + +const packageVersions = new Map(); +for (const pkg of packages) { + const packageJson = readPackageJson(pkg.directory); + if (packageJson.name !== pkg.name) { + throw new Error(`${pkg.directory}/package.json has name ${packageJson.name}, expected ${pkg.name}`); + } + packageVersions.set(pkg.name, packageJson.version); +} + +const versions = [...new Set(packageVersions.values())]; +if (versions.length !== 1) { + throw new Error(`Publish packages are not lockstep versioned: ${versions.join(", ")}`); +} + +console.log(`Publishing pi packages at ${versions[0]}${dryRun ? " (dry run)" : ""}\n`); + +for (const pkg of packages) { + const version = packageVersions.get(pkg.name); + assertBuildOutputExists(pkg.directory); + const published = isPublished(pkg.name, version); + + if (dryRun) { + if (published) { + console.log(`${pkg.name}@${version} is already published; validating package contents only.`); + } else { + console.log(`${pkg.name}@${version} is not published; validating package contents before publish.`); + } + validatePack(pkg.directory); + console.log(); + continue; + } + + if (published) { + console.log(`Skipping ${pkg.name}@${version}: already published\n`); + continue; + } + + run("npm", ["publish", "--access", "public", "--provenance", "--ignore-scripts"], { cwd: pkg.directory }); + console.log(); +} diff --git a/scripts/release.mjs b/scripts/release.mjs index 59b51509..c3ff6c7a 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -10,11 +10,12 @@ * 1. Check for uncommitted changes * 2. Bump version via npm run version:xxx or set an explicit version * 3. Update CHANGELOG.md files: [Unreleased] -> [version] - date - * 4. Generate the coding-agent npm-shrinkwrap.json - * 5. Commit and tag - * 6. Publish to npm + * 4. Regenerate release artifacts + * 5. Run checks + * 6. Commit and tag the release * 7. Add new [Unreleased] section to changelogs - * 8. Commit + * 8. Commit next-cycle changelog updates + * 9. Push main and the tag to trigger CI publishing */ import { execSync } from "child_process"; @@ -91,7 +92,7 @@ function bumpOrSetVersion(target) { } console.log(`Setting explicit version (${target})...`); - run(`npm version ${target} -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only`); + run(`npm version ${target} -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts`); return getVersion(); } @@ -163,23 +164,25 @@ console.log("Updating CHANGELOG.md files..."); updateChangelogsForRelease(version); console.log(); -// 4. Generate publish shrinkwrap -console.log("Generating coding-agent shrinkwrap..."); +// 4. Regenerate release artifacts +console.log("Regenerating release artifacts..."); +run("npm --prefix packages/ai run generate-models"); +run("npm --prefix packages/ai run generate-image-models"); run("npm run shrinkwrap:coding-agent"); console.log(); -// 5. Commit and tag +// 5. Run checks +console.log("Running checks..."); +run("npm run check"); +console.log(); + +// 6. Commit and tag console.log("Committing and tagging..."); stageChangedFiles(); run(`git commit -m "Release v${version}"`); run(`git tag v${version}`); console.log(); -// 6. Publish -console.log("Publishing to npm..."); -run("npm run publish"); -console.log(); - // 7. Add new [Unreleased] sections console.log("Adding [Unreleased] sections for next cycle..."); addUnreleasedSection(); @@ -197,4 +200,4 @@ run("git push origin main"); run(`git push origin v${version}`); console.log(); -console.log(`=== Released v${version} ===`); +console.log(`=== Prepared release v${version}; CI publishing starts after the tag push ===`); From bfa3d1fa6039b9d41c9cebd9ea56b7aec5931822 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 28 May 2026 21:51:40 +0200 Subject: [PATCH 071/352] Update Claude Opus and GPT thinking metadata --- packages/ai/CHANGELOG.md | 2 + packages/ai/scripts/generate-models.ts | 33 ++- packages/ai/src/models.generated.ts | 215 +++++++++++++++++- packages/ai/src/providers/amazon-bedrock.ts | 8 +- ...anthropic-adaptive-thinking-models.test.ts | 27 +-- .../anthropic-eager-tool-input-compat.test.ts | 4 +- .../anthropic-force-adaptive-thinking.test.ts | 2 +- ...st.ts => anthropic-opus-4-8-smoke.test.ts} | 10 +- .../test/anthropic-thinking-disable.test.ts | 12 +- .../test/bedrock-endpoint-resolution.test.ts | 4 +- .../ai/test/bedrock-thinking-payload.test.ts | 16 +- packages/ai/test/fireworks-models.test.ts | 4 +- .../openai-completions-response-model.test.ts | 6 +- packages/ai/test/supports-xhigh.test.ts | 22 +- .../coding-agent/src/core/model-resolver.ts | 2 +- 15 files changed, 306 insertions(+), 61 deletions(-) rename packages/ai/test/{anthropic-opus-4-7-smoke.test.ts => anthropic-opus-4-8-smoke.test.ts} (91%) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 87e9f22c..948bcb32 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -5,10 +5,12 @@ ### Added - Added OpenAI Codex subscription device-code login as a selectable headless alternative while keeping browser login as the default. +- Added Claude Opus 4.8 model metadata for Anthropic and updated Opus adaptive-thinking coverage to use it. ### Fixed - Fixed Anthropic-compatible replay for providers that return empty thinking signatures by adding an opt-in `allowEmptySignature` compatibility flag ([#4464](https://github.com/earendil-works/pi/issues/4464)). +- Fixed OpenAI and OpenRouter GPT-5.5 Pro thinking level metadata to expose only supported medium, high, and xhigh efforts. ## [0.76.0] - 2026-05-27 diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 791dff0f..927401f7 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -184,6 +184,8 @@ function isAnthropicAdaptiveThinkingModel(modelId: string): boolean { modelId.includes("opus-4.6") || modelId.includes("opus-4-7") || modelId.includes("opus-4.7") || + modelId.includes("opus-4-8") || + modelId.includes("opus-4.8") || modelId.includes("sonnet-4-6") || modelId.includes("sonnet-4.6") ); @@ -225,10 +227,18 @@ function applyThinkingLevelMetadata(model: Model): void { if (supportsOpenAiXhigh(model.id)) { mergeThinkingLevelMap(model, { xhigh: "xhigh" }); } + if (model.id.endsWith("gpt-5.5-pro")) { + mergeThinkingLevelMap(model, { off: null, minimal: null, low: null }); + } if (model.id.includes("opus-4-6") || model.id.includes("opus-4.6")) { mergeThinkingLevelMap(model, { xhigh: "max" }); } - if (model.id.includes("opus-4-7") || model.id.includes("opus-4.7")) { + if ( + model.id.includes("opus-4-7") || + model.id.includes("opus-4.7") || + model.id.includes("opus-4-8") || + model.id.includes("opus-4.8") + ) { mergeThinkingLevelMap(model, { xhigh: "xhigh" }); } if (model.api === "anthropic-messages" && isAnthropicAdaptiveThinkingModel(model.id)) { @@ -1273,6 +1283,27 @@ async function generateModels() { }); } + // Add missing Claude Opus 4.8 + if (!allModels.some(m => m.provider === "anthropic" && m.id === "claude-opus-4-8")) { + allModels.push({ + id: "claude-opus-4-8", + name: "Claude Opus 4.8", + api: "anthropic-messages", + baseUrl: "https://api.anthropic.com", + provider: "anthropic", + reasoning: true, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + }); + } + // Add missing Claude Sonnet 4.6 if (!allModels.some(m => m.provider === "anthropic" && m.id === "claude-sonnet-4-6")) { allModels.push({ diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index e881ae0e..1996cb02 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -160,6 +160,24 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"bedrock-converse-stream">, + "anthropic.claude-opus-4-8": { + id: "anthropic.claude-opus-4-8", + name: "Claude Opus 4.8", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, "anthropic.claude-sonnet-4-5-20250929-v1:0": { id: "anthropic.claude-sonnet-4-5-20250929-v1:0", name: "Claude Sonnet 4.5", @@ -229,6 +247,24 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"bedrock-converse-stream">, + "au.anthropic.claude-opus-4-8": { + id: "au.anthropic.claude-opus-4-8", + name: "Claude Opus 4.8 (AU)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, "au.anthropic.claude-sonnet-4-5-20250929-v1:0": { id: "au.anthropic.claude-sonnet-4-5-20250929-v1:0", name: "Claude Sonnet 4.5 (AU)", @@ -384,6 +420,24 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"bedrock-converse-stream">, + "eu.anthropic.claude-opus-4-8": { + id: "eu.anthropic.claude-opus-4-8", + name: "Claude Opus 4.8 (EU)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.eu-central-1.amazonaws.com", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, "eu.anthropic.claude-sonnet-4-5-20250929-v1:0": { id: "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", name: "Claude Sonnet 4.5 (EU)", @@ -488,6 +542,24 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"bedrock-converse-stream">, + "global.anthropic.claude-opus-4-8": { + id: "global.anthropic.claude-opus-4-8", + name: "Claude Opus 4.8 (Global)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, "global.anthropic.claude-sonnet-4-5-20250929-v1:0": { id: "global.anthropic.claude-sonnet-4-5-20250929-v1:0", name: "Claude Sonnet 4.5 (Global)", @@ -574,6 +646,24 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"bedrock-converse-stream">, + "jp.anthropic.claude-opus-4-8": { + id: "jp.anthropic.claude-opus-4-8", + name: "Claude Opus 4.8 (JP)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, "jp.anthropic.claude-sonnet-4-5-20250929-v1:0": { id: "jp.anthropic.claude-sonnet-4-5-20250929-v1:0", name: "Claude Sonnet 4.5 (JP)", @@ -1273,6 +1363,24 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"bedrock-converse-stream">, + "us.anthropic.claude-opus-4-8": { + id: "us.anthropic.claude-opus-4-8", + name: "Claude Opus 4.8 (US)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, "us.anthropic.claude-sonnet-4-5-20250929-v1:0": { id: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", name: "Claude Sonnet 4.5 (US)", @@ -1755,6 +1863,25 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"anthropic-messages">, + "claude-opus-4-8": { + id: "claude-opus-4-8", + name: "Claude Opus 4.8", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, "claude-sonnet-4-0": { id: "claude-sonnet-4-0", name: "Claude Sonnet 4 (latest)", @@ -2434,7 +2561,7 @@ export const MODELS = { provider: "azure-openai-responses", baseUrl: "", reasoning: true, - thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + thinkingLevelMap: {"off":null,"xhigh":"xhigh","minimal":null,"low":null}, input: ["text", "image"], cost: { input: 30, @@ -6760,7 +6887,7 @@ export const MODELS = { provider: "openai", baseUrl: "https://api.openai.com/v1", reasoning: true, - thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + thinkingLevelMap: {"off":null,"xhigh":"xhigh","minimal":null,"low":null}, input: ["text", "image"], cost: { input: 30, @@ -7125,6 +7252,25 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"anthropic-messages">, + "claude-opus-4-8": { + id: "claude-opus-4-8", + name: "Claude Opus 4.8", + api: "anthropic-messages", + provider: "opencode", + baseUrl: "https://opencode.ai/zen", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, "claude-sonnet-4": { id: "claude-sonnet-4", name: "Claude Sonnet 4", @@ -7561,7 +7707,7 @@ export const MODELS = { provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", reasoning: true, - thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + thinkingLevelMap: {"off":null,"xhigh":"xhigh","minimal":null,"low":null}, input: ["text", "image"], cost: { input: 30, @@ -8232,6 +8378,42 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"openai-completions">, + "anthropic/claude-opus-4.8": { + id: "anthropic/claude-opus-4.8", + name: "Anthropic: Claude Opus 4.8", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "anthropic/claude-opus-4.8-fast": { + id: "anthropic/claude-opus-4.8-fast", + name: "Anthropic: Claude Opus 4.8 (Fast)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 10, + output: 50, + cacheRead: 1, + cacheWrite: 12.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "anthropic/claude-sonnet-4": { id: "anthropic/claude-sonnet-4", name: "Anthropic: Claude Sonnet 4", @@ -10637,7 +10819,7 @@ export const MODELS = { provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, - thinkingLevelMap: {"xhigh":"xhigh"}, + thinkingLevelMap: {"xhigh":"xhigh","off":null,"minimal":null,"low":null}, input: ["text", "image"], cost: { input: 30, @@ -12154,8 +12336,8 @@ export const MODELS = { input: ["text"], cost: { input: 0.125, - output: 0.84, - cacheRead: 0, + output: 0.85, + cacheRead: 0.06, cacheWrite: 0, }, contextWindow: 131072, @@ -13273,6 +13455,25 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"anthropic-messages">, + "anthropic/claude-opus-4.8": { + id: "anthropic/claude-opus-4.8", + name: "Claude Opus 4.8", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, "anthropic/claude-sonnet-4": { id: "anthropic/claude-sonnet-4", name: "Claude Sonnet 4", @@ -14873,7 +15074,7 @@ export const MODELS = { provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", reasoning: true, - thinkingLevelMap: {"xhigh":"xhigh"}, + thinkingLevelMap: {"xhigh":"xhigh","off":null,"minimal":null,"low":null}, input: ["text", "image"], cost: { input: 30, diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index 429098a6..c0da66e2 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -66,7 +66,7 @@ export interface BedrockOptions extends StreamOptions { * - "omitted": Thinking content is redacted but the signature still travels back * for multi-turn continuity, reducing time-to-first-text-token. * - * Note: Anthropic's API default for Claude Opus 4.7 and Mythos Preview is + * Note: Anthropic's API default for Claude Opus 4.8 and Mythos Preview is * "omitted". We default to "summarized" here to keep behavior consistent with * older Claude 4 models. Only applies to Claude models on Bedrock. */ @@ -481,12 +481,14 @@ function getModelMatchCandidates(modelId: string, modelName?: string): string[] function supportsAdaptiveThinking(modelId: string, modelName?: string): boolean { const candidates = getModelMatchCandidates(modelId, modelName); - return candidates.some((s) => s.includes("opus-4-6") || s.includes("opus-4-7") || s.includes("sonnet-4-6")); + return candidates.some( + (s) => s.includes("opus-4-6") || s.includes("opus-4-7") || s.includes("opus-4-8") || s.includes("sonnet-4-6"), + ); } function supportsNativeXhighEffort(model: Model<"bedrock-converse-stream">): boolean { const candidates = getModelMatchCandidates(model.id, model.name); - return candidates.some((s) => s.includes("opus-4-7")); + return candidates.some((s) => s.includes("opus-4-7") || s.includes("opus-4-8")); } function mapThinkingLevelToEffort( diff --git a/packages/ai/test/anthropic-adaptive-thinking-models.test.ts b/packages/ai/test/anthropic-adaptive-thinking-models.test.ts index f118401b..9386ce0b 100644 --- a/packages/ai/test/anthropic-adaptive-thinking-models.test.ts +++ b/packages/ai/test/anthropic-adaptive-thinking-models.test.ts @@ -2,22 +2,10 @@ import { describe, expect, it } from "vitest"; import { getModels, getProviders } from "../src/models.ts"; import type { Api, Model } from "../src/types.ts"; -const EXPECTED_ADAPTIVE_THINKING_MODELS = [ - "anthropic/claude-opus-4-6", - "anthropic/claude-opus-4-7", - "anthropic/claude-sonnet-4-6", - "cloudflare-ai-gateway/claude-opus-4-6", - "cloudflare-ai-gateway/claude-opus-4-7", - "cloudflare-ai-gateway/claude-sonnet-4-6", - "github-copilot/claude-opus-4.6", - "github-copilot/claude-opus-4.7", - "github-copilot/claude-sonnet-4.6", - "opencode/claude-opus-4-6", - "opencode/claude-opus-4-7", - "opencode/claude-sonnet-4-6", - "vercel-ai-gateway/anthropic/claude-opus-4.6", - "vercel-ai-gateway/anthropic/claude-opus-4.7", - "vercel-ai-gateway/anthropic/claude-sonnet-4.6", +const EXPECTED_CURRENT_ADAPTIVE_THINKING_MODELS = [ + "anthropic/claude-opus-4-8", + "opencode/claude-opus-4-8", + "vercel-ai-gateway/anthropic/claude-opus-4.8", ]; function getAllModels(): Model[] { @@ -25,13 +13,16 @@ function getAllModels(): Model[] { } describe("Anthropic adaptive thinking model metadata", () => { - it("marks exactly the built-in Anthropic Messages models that use adaptive thinking", () => { + it("marks built-in Anthropic Messages models that use adaptive thinking", () => { const flaggedModels = getAllModels() .filter((model): model is Model<"anthropic-messages"> => model.api === "anthropic-messages") .filter((model) => model.compat?.forceAdaptiveThinking === true) .map((model) => `${model.provider}/${model.id}`) .sort(); - expect(flaggedModels).toEqual([...EXPECTED_ADAPTIVE_THINKING_MODELS].sort()); + expect(flaggedModels).toEqual(expect.arrayContaining([...EXPECTED_CURRENT_ADAPTIVE_THINKING_MODELS].sort())); + expect(flaggedModels).toEqual( + flaggedModels.filter((modelId) => /(opus[-.]4[-.][678]|sonnet[-.]4[-.]6)/.test(modelId)), + ); }); }); diff --git a/packages/ai/test/anthropic-eager-tool-input-compat.test.ts b/packages/ai/test/anthropic-eager-tool-input-compat.test.ts index 0560563a..22ac0e59 100644 --- a/packages/ai/test/anthropic-eager-tool-input-compat.test.ts +++ b/packages/ai/test/anthropic-eager-tool-input-compat.test.ts @@ -12,8 +12,8 @@ interface CapturedRequest { function createModel(baseUrl: string, compat?: Model<"anthropic-messages">["compat"]): Model<"anthropic-messages"> { return { - id: "claude-opus-4-7", - name: "Claude Opus 4.7", + id: "claude-opus-4-8", + name: "Claude Opus 4.8", api: "anthropic-messages", provider: "test-anthropic", baseUrl, diff --git a/packages/ai/test/anthropic-force-adaptive-thinking.test.ts b/packages/ai/test/anthropic-force-adaptive-thinking.test.ts index fd18df96..5b3511ca 100644 --- a/packages/ai/test/anthropic-force-adaptive-thinking.test.ts +++ b/packages/ai/test/anthropic-force-adaptive-thinking.test.ts @@ -85,7 +85,7 @@ describe("Anthropic forceAdaptiveThinking compat override", () => { it("allows built-in adaptive models to opt out with compat.forceAdaptiveThinking false", async () => { const model: Model<"anthropic-messages"> = { - ...getModel("anthropic", "claude-opus-4-7"), + ...getModel("anthropic", "claude-opus-4-8"), compat: { forceAdaptiveThinking: false }, }; const payload = await capturePayload(model, { reasoning: "medium" }); diff --git a/packages/ai/test/anthropic-opus-4-7-smoke.test.ts b/packages/ai/test/anthropic-opus-4-8-smoke.test.ts similarity index 91% rename from packages/ai/test/anthropic-opus-4-7-smoke.test.ts rename to packages/ai/test/anthropic-opus-4-8-smoke.test.ts index 7314f8af..bb4b739b 100644 --- a/packages/ai/test/anthropic-opus-4-7-smoke.test.ts +++ b/packages/ai/test/anthropic-opus-4-8-smoke.test.ts @@ -22,9 +22,9 @@ function makeContext(): Context { }; } -describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic Opus 4.7 smoke", () => { - it("streams Claude Opus 4.7 with reasoning enabled", { retry: 2, timeout: 30000 }, async () => { - const model = getModel("anthropic", "claude-opus-4-7"); +describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic Opus 4.8 smoke", () => { + it("streams Claude Opus 4.8 with reasoning enabled", { retry: 2, timeout: 30000 }, async () => { + const model = getModel("anthropic", "claude-opus-4-8"); let capturedPayload: AnthropicThinkingPayload | undefined; const s = streamSimple(model, makeContext(), { reasoning: "high", @@ -53,12 +53,12 @@ describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic Opus 4.7 smoke", () = const thinkingBlock = response.content.find((block) => block.type === "thinking"); expect(thinkingBlock?.type).toBe("thinking"); if (!thinkingBlock || thinkingBlock.type !== "thinking") { - throw new Error("Expected thinking block from Claude Opus 4.7"); + throw new Error("Expected thinking block from Claude Opus 4.8"); } expect(typeof thinkingBlock.thinkingSignature).toBe("string"); const thinkingSignature = thinkingBlock.thinkingSignature; if (!thinkingSignature) { - throw new Error("Expected thinking signature from Claude Opus 4.7"); + throw new Error("Expected thinking signature from Claude Opus 4.8"); } expect(thinkingSignature.length).toBeGreaterThan(0); diff --git a/packages/ai/test/anthropic-thinking-disable.test.ts b/packages/ai/test/anthropic-thinking-disable.test.ts index ea53043c..5ee89b1e 100644 --- a/packages/ai/test/anthropic-thinking-disable.test.ts +++ b/packages/ai/test/anthropic-thinking-disable.test.ts @@ -125,22 +125,22 @@ describe("Anthropic thinking disable payload", () => { expect(payload.output_config).toBeUndefined(); }); - it("sends thinking.type=disabled for Claude Opus 4.7 when thinking is off", async () => { - const payload = await capturePayload(getModel("anthropic", "claude-opus-4-7")); + it("sends thinking.type=disabled for Claude Opus 4.8 when thinking is off", async () => { + const payload = await capturePayload(getModel("anthropic", "claude-opus-4-8")); expect(payload.thinking).toEqual({ type: "disabled" }); expect(payload.output_config).toBeUndefined(); }); - it("uses adaptive thinking for Claude Opus 4.7 when reasoning is enabled", async () => { - const payload = await capturePayload(getModel("anthropic", "claude-opus-4-7"), { reasoning: "high" }); + it("uses adaptive thinking for Claude Opus 4.8 when reasoning is enabled", async () => { + const payload = await capturePayload(getModel("anthropic", "claude-opus-4-8"), { reasoning: "high" }); expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" }); expect(payload.output_config).toEqual({ effort: "high" }); }); - it("maps xhigh reasoning to effort=xhigh for Claude Opus 4.7", async () => { - const payload = await capturePayload(getModel("anthropic", "claude-opus-4-7"), { reasoning: "xhigh" }); + it("maps xhigh reasoning to effort=xhigh for Claude Opus 4.8", async () => { + const payload = await capturePayload(getModel("anthropic", "claude-opus-4-8"), { reasoning: "xhigh" }); expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" }); expect(payload.output_config).toEqual({ effort: "xhigh" }); diff --git a/packages/ai/test/bedrock-endpoint-resolution.test.ts b/packages/ai/test/bedrock-endpoint-resolution.test.ts index a45bc6cf..2483fcae 100644 --- a/packages/ai/test/bedrock-endpoint-resolution.test.ts +++ b/packages/ai/test/bedrock-endpoint-resolution.test.ts @@ -98,7 +98,7 @@ describe("bedrock endpoint resolution", () => { it("does not pin standard AWS endpoints when AWS_REGION is configured", async () => { process.env.AWS_REGION = "us-east-2"; - const model = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-7"); + const model = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8"); const config = await captureClientConfig(model); @@ -117,7 +117,7 @@ describe("bedrock endpoint resolution", () => { it("still passes custom Bedrock endpoints through to the SDK client", async () => { process.env.AWS_REGION = "us-west-2"; - const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-7"); + const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8"); const model: Model<"bedrock-converse-stream"> = { ...baseModel, baseUrl: "https://bedrock-vpc.example.com", diff --git a/packages/ai/test/bedrock-thinking-payload.test.ts b/packages/ai/test/bedrock-thinking-payload.test.ts index 46a5277b..93143e38 100644 --- a/packages/ai/test/bedrock-thinking-payload.test.ts +++ b/packages/ai/test/bedrock-thinking-payload.test.ts @@ -53,12 +53,12 @@ async function capturePayload( } describe("Bedrock thinking payload", () => { - it("uses adaptive thinking for Claude Opus 4.7 when reasoning is enabled", async () => { + it("uses adaptive thinking for Claude Opus 4.8 when reasoning is enabled", async () => { const baseModel = getModel("amazon-bedrock", "global.anthropic.claude-opus-4-6-v1"); const model: Model<"bedrock-converse-stream"> = { ...baseModel, - id: "global.anthropic.claude-opus-4-7-v1", - name: "Claude Opus 4.7 (Global)", + id: "global.anthropic.claude-opus-4-8-v1", + name: "Claude Opus 4.8 (Global)", }; const payload = await capturePayload(model); @@ -68,12 +68,12 @@ describe("Bedrock thinking payload", () => { expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined(); }); - it("maps xhigh reasoning to effort=xhigh for Claude Opus 4.7", async () => { + it("maps xhigh reasoning to effort=xhigh for Claude Opus 4.8", async () => { const baseModel = getModel("amazon-bedrock", "global.anthropic.claude-opus-4-6-v1"); const model: Model<"bedrock-converse-stream"> = { ...baseModel, - id: "global.anthropic.claude-opus-4-7-v1", - name: "Claude Opus 4.7 (Global)", + id: "global.anthropic.claude-opus-4-8-v1", + name: "Claude Opus 4.8 (Global)", }; const payload = await capturePayload(model, { reasoning: "xhigh" }); @@ -101,8 +101,8 @@ describe("Bedrock thinking payload", () => { const baseModel = getModel("amazon-bedrock", "global.anthropic.claude-opus-4-6-v1"); const model: Model<"bedrock-converse-stream"> = { ...baseModel, - id: "global.anthropic.claude-opus-4-7-v1", - name: "Claude Opus 4.7 (Global)", + id: "global.anthropic.claude-opus-4-8-v1", + name: "Claude Opus 4.8 (Global)", }; const payload = await capturePayload(model, { region: "us-gov-west-1" }); diff --git a/packages/ai/test/fireworks-models.test.ts b/packages/ai/test/fireworks-models.test.ts index 6d9bebc6..8b291b89 100644 --- a/packages/ai/test/fireworks-models.test.ts +++ b/packages/ai/test/fireworks-models.test.ts @@ -97,8 +97,8 @@ function createFireworksModel(compat?: Model<"anthropic-messages">["compat"]): M function createAnthropicModel(): Model<"anthropic-messages"> { return { - id: "claude-opus-4-7", - name: "Claude Opus 4.7", + id: "claude-opus-4-8", + name: "Claude Opus 4.8", api: "anthropic-messages", provider: "anthropic", baseUrl: "http://127.0.0.1:0", // overridden by captureAnthropicRequest diff --git a/packages/ai/test/openai-completions-response-model.test.ts b/packages/ai/test/openai-completions-response-model.test.ts index aab20dea..d8e4d2f9 100644 --- a/packages/ai/test/openai-completions-response-model.test.ts +++ b/packages/ai/test/openai-completions-response-model.test.ts @@ -60,10 +60,10 @@ describe("openai-completions responseModel", () => { it("surfaces routed chunk.model on responseModel without changing model", async () => { mockState.chunks = [ - { id: "chatcmpl-1", model: "anthropic/claude-opus-4.7", choices: [{ index: 0, delta: { content: "hi" } }] }, + { id: "chatcmpl-1", model: "anthropic/claude-opus-4.8", choices: [{ index: 0, delta: { content: "hi" } }] }, { id: "chatcmpl-1", - model: "anthropic/claude-opus-4.7", + model: "anthropic/claude-opus-4.8", choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 10, @@ -81,7 +81,7 @@ describe("openai-completions responseModel", () => { ); expect(message.model).toBe("openrouter/auto"); - expect(message.responseModel).toBe("anthropic/claude-opus-4.7"); + expect(message.responseModel).toBe("anthropic/claude-opus-4.8"); expect(message.provider).toBe("openrouter"); expect(message.stopReason).toBe("stop"); }); diff --git a/packages/ai/test/supports-xhigh.test.ts b/packages/ai/test/supports-xhigh.test.ts index 4fb6f3b2..5d9bdd9e 100644 --- a/packages/ai/test/supports-xhigh.test.ts +++ b/packages/ai/test/supports-xhigh.test.ts @@ -8,8 +8,14 @@ describe("getSupportedThinkingLevels", () => { expect(getSupportedThinkingLevels(model!)).toContain("xhigh"); }); - it("includes xhigh for Anthropic Opus 4.7 on anthropic-messages API", () => { - const model = getModel("anthropic", "claude-opus-4-7"); + it("includes xhigh for Anthropic Opus 4.8 on anthropic-messages API", () => { + const model = getModel("anthropic", "claude-opus-4-8"); + expect(model).toBeDefined(); + expect(getSupportedThinkingLevels(model!)).toContain("xhigh"); + }); + + it("includes xhigh for Anthropic Opus 4.8 on anthropic-messages API", () => { + const model = getModel("anthropic", "claude-opus-4-8"); expect(model).toBeDefined(); expect(getSupportedThinkingLevels(model!)).toContain("xhigh"); }); @@ -26,6 +32,18 @@ describe("getSupportedThinkingLevels", () => { expect(getSupportedThinkingLevels(model!)).toContain("xhigh"); }); + it("includes only medium/high/xhigh for OpenAI GPT-5.5 Pro", () => { + const model = getModel("openai", "gpt-5.5-pro"); + expect(model).toBeDefined(); + expect(getSupportedThinkingLevels(model!)).toEqual(["medium", "high", "xhigh"]); + }); + + it("includes only medium/high/xhigh for OpenRouter GPT-5.5 Pro", () => { + const model = getModel("openrouter", "openai/gpt-5.5-pro"); + expect(model).toBeDefined(); + expect(getSupportedThinkingLevels(model!)).toEqual(["medium", "high", "xhigh"]); + }); + it("includes only high/xhigh plus off for DeepSeek V4 Flash on the DeepSeek provider", () => { const model = getModel("deepseek", "deepseek-v4-flash"); expect(model).toBeDefined(); diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 764c06f5..87f7354e 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -13,7 +13,7 @@ import type { ModelRegistry } from "./model-registry.ts"; /** Default model IDs for each known provider */ export const defaultModelPerProvider: Record = { "amazon-bedrock": "us.anthropic.claude-opus-4-6-v1", - anthropic: "claude-opus-4-7", + anthropic: "claude-opus-4-8", openai: "gpt-5.4", "azure-openai-responses": "gpt-5.4", "openai-codex": "gpt-5.5", From 7e708866525f10fbecdb7915c35a0bbaa2f91bd9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 28 May 2026 20:05:12 +0000 Subject: [PATCH 072/352] chore: approve contributor MichaelYochpaz --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 0e41ae89..1fd2560b 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -219,3 +219,5 @@ mbazso pr AJM10565 pr DanielThomas pr + +MichaelYochpaz pr From 97ef3173896a441d39336f515779c2ede7c84d6d Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 28 May 2026 22:19:15 +0200 Subject: [PATCH 073/352] Fix Kimi and Xiaomi model metadata Closes #5075, closes #5078. Refs #5087. --- packages/ai/CHANGELOG.md | 2 + packages/ai/scripts/generate-models.ts | 7 +++ packages/ai/src/models.generated.ts | 56 +------------------ .../ai/src/providers/openai-completions.ts | 7 +++ packages/ai/src/types.ts | 12 +++- .../openai-completions-tool-choice.test.ts | 45 +++++++++++++++ packages/ai/test/xiaomi-models.test.ts | 15 +++++ 7 files changed, 88 insertions(+), 56 deletions(-) create mode 100644 packages/ai/test/xiaomi-models.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 948bcb32..6966d94a 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -11,6 +11,8 @@ - Fixed Anthropic-compatible replay for providers that return empty thinking signatures by adding an opt-in `allowEmptySignature` compatibility flag ([#4464](https://github.com/earendil-works/pi/issues/4464)). - Fixed OpenAI and OpenRouter GPT-5.5 Pro thinking level metadata to expose only supported medium, high, and xhigh efforts. +- Fixed OpenCode Go Kimi K2.6 thinking-off requests to send `thinking: "none"` ([#5078](https://github.com/earendil-works/pi/issues/5078)). +- Fixed Xiaomi Token Plan model metadata to omit unsupported `mimo-v2-flash` variants ([#5075](https://github.com/earendil-works/pi/issues/5075)). ## [0.76.0] - 2026-05-27 diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 927401f7..fc4e2c3f 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -269,6 +269,9 @@ function applyThinkingLevelMetadata(model: Model): void { // Pi's low/medium/high pass through verbatim; OpenRouter normalizes to Mercury's vocabulary. mergeThinkingLevelMap(model, { off: null }); } + if (model.provider === "opencode-go" && model.id === "kimi-k2.6") { + mergeThinkingLevelMap(model, { off: "none" }); + } } function getAnthropicMessagesCompat(provider: string, modelId: string): AnthropicMessagesCompat | undefined { @@ -900,6 +903,9 @@ async function loadModelsDevData(): Promise[]> { api = "openai-completions"; baseUrl = `${variant.basePath}/v1`; } + if (modelId === "kimi-k2.6") { + compat = { ...(compat ?? {}), thinkingFormat: "string-thinking" }; + } if (modelId === "qwen3.5-plus" || modelId === "qwen3.6-plus") { api = "openai-completions"; baseUrl = `${variant.basePath}/v1`; @@ -1116,6 +1122,7 @@ async function loadModelsDevData(): Promise[]> { for (const [modelId, model] of Object.entries(data.xiaomi.models)) { const m = model as ModelsDevModel; if (m.tool_call !== true) continue; + if (provider.startsWith("xiaomi-token-plan-") && modelId === "mimo-v2-flash") continue; models.push({ id: modelId, diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 1996cb02..8d95054a 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -7968,7 +7968,9 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"thinkingFormat":"string-thinking"}, reasoning: true, + thinkingLevelMap: {"off":"none"}, input: ["text", "image"], cost: { input: 0.95, @@ -15929,24 +15931,6 @@ export const MODELS = { } satisfies Model<"openai-completions">, }, "xiaomi-token-plan-ams": { - "mimo-v2-flash": { - id: "mimo-v2-flash", - name: "MiMo-V2-Flash", - api: "openai-completions", - provider: "xiaomi-token-plan-ams", - baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", - compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, - reasoning: true, - input: ["text"], - cost: { - input: 0.1, - output: 0.3, - cacheRead: 0.01, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 65536, - } satisfies Model<"openai-completions">, "mimo-v2-omni": { id: "mimo-v2-omni", name: "MiMo-V2-Omni", @@ -16021,24 +16005,6 @@ export const MODELS = { } satisfies Model<"openai-completions">, }, "xiaomi-token-plan-cn": { - "mimo-v2-flash": { - id: "mimo-v2-flash", - name: "MiMo-V2-Flash", - api: "openai-completions", - provider: "xiaomi-token-plan-cn", - baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", - compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, - reasoning: true, - input: ["text"], - cost: { - input: 0.1, - output: 0.3, - cacheRead: 0.01, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 65536, - } satisfies Model<"openai-completions">, "mimo-v2-omni": { id: "mimo-v2-omni", name: "MiMo-V2-Omni", @@ -16113,24 +16079,6 @@ export const MODELS = { } satisfies Model<"openai-completions">, }, "xiaomi-token-plan-sgp": { - "mimo-v2-flash": { - id: "mimo-v2-flash", - name: "MiMo-V2-Flash", - api: "openai-completions", - provider: "xiaomi-token-plan-sgp", - baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", - compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, - reasoning: true, - input: ["text"], - cost: { - input: 0.1, - output: 0.3, - cacheRead: 0.01, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 65536, - } satisfies Model<"openai-completions">, "mimo-v2-omni": { id: "mimo-v2-omni", name: "MiMo-V2-Omni", diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 0d37b955..2e243fb7 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -594,6 +594,13 @@ function buildParams( if (options?.reasoningEffort && compat.supportsReasoningEffort) { togetherParams.reasoning_effort = model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort; } + } else if (compat.thinkingFormat === "string-thinking" && model.reasoning) { + const stringThinkingParams = params as typeof params & { thinking?: string }; + if (options?.reasoningEffort) { + stringThinkingParams.thinking = model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort; + } else if (model.thinkingLevelMap?.off !== null) { + stringThinkingParams.thinking = model.thinkingLevelMap?.off ?? "none"; + } } else if (options?.reasoningEffort && model.reasoning && compat.supportsReasoningEffort) { // OpenAI-style reasoning_effort (params as any).reasoning_effort = model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 4345e3bf..bf997317 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -387,8 +387,16 @@ export interface OpenAICompletionsCompat { requiresThinkingAsText?: boolean; /** Whether all replayed assistant messages must include an empty reasoning_content field when reasoning is enabled. Default: auto-detected from URL. */ requiresReasoningContentOnAssistantMessages?: boolean; - /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses top-level enable_thinking: boolean, "qwen" uses top-level enable_thinking: boolean, and "qwen-chat-template" uses chat_template_kwargs.enable_thinking. Default: "openai". */ - thinkingFormat?: "openai" | "openrouter" | "deepseek" | "together" | "zai" | "qwen" | "qwen-chat-template"; + /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses top-level enable_thinking: boolean, "qwen" uses top-level enable_thinking: boolean, "qwen-chat-template" uses chat_template_kwargs.enable_thinking, and "string-thinking" uses top-level thinking: string. Default: "openai". */ + thinkingFormat?: + | "openai" + | "openrouter" + | "deepseek" + | "together" + | "zai" + | "qwen" + | "qwen-chat-template" + | "string-thinking"; /** OpenRouter-specific routing preferences. Only used when baseUrl points to OpenRouter. */ openRouterRouting?: OpenRouterRouting; /** Vercel AI Gateway routing preferences. Only used when baseUrl points to Vercel AI Gateway. */ diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index ec98427b..66cfb562 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -995,6 +995,51 @@ describe("openai-completions tool_choice", () => { expect(messages[0]).not.toHaveProperty("reasoning"); }); + it("sends thinking none for OpenCode Go Kimi K2.6 when thinking is off", async () => { + const model = getModel("opencode-go", "kimi-k2.6")!; + let payload: unknown; + + await streamSimple( + model, + { + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = (payload ?? mockState.lastParams) as { thinking?: string; reasoning_effort?: string }; + expect(params.thinking).toBe("none"); + expect(params.reasoning_effort).toBeUndefined(); + }); + + it("sends thinking effort for OpenCode Go Kimi K2.6 when thinking is enabled", async () => { + const model = getModel("opencode-go", "kimi-k2.6")!; + let payload: unknown; + + await streamSimple( + model, + { + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + reasoning: "high", + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = (payload ?? mockState.lastParams) as { thinking?: string; reasoning_effort?: string }; + expect(params.thinking).toBe("high"); + expect(params.reasoning_effort).toBeUndefined(); + }); + it("does not double-count reasoning tokens in completion usage", async () => { mockState.chunks = [ { diff --git a/packages/ai/test/xiaomi-models.test.ts b/packages/ai/test/xiaomi-models.test.ts new file mode 100644 index 00000000..6fb1f4b8 --- /dev/null +++ b/packages/ai/test/xiaomi-models.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { getModel, getModels } from "../src/models.ts"; + +describe("Xiaomi MiMo models", () => { + it("keeps mimo-v2-flash on the API billing provider", () => { + expect(getModel("xiaomi", "mimo-v2-flash")).toBeDefined(); + }); + + it.each(["xiaomi-token-plan-cn", "xiaomi-token-plan-ams", "xiaomi-token-plan-sgp"] as const)( + "omits mimo-v2-flash from %s", + (provider) => { + expect(getModels(provider).some((model) => model.id === "mimo-v2-flash")).toBe(false); + }, + ); +}); From f9832ccb822652b6cf172d7406aa515a364c6107 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 28 May 2026 20:54:28 +0000 Subject: [PATCH 074/352] chore: approve contributor stephanmck --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 1fd2560b..8f2fc0a2 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -221,3 +221,5 @@ AJM10565 pr DanielThomas pr MichaelYochpaz pr + +stephanmck pr From 3f1ce9b6e91bd881d95fb818e006d58009c87421 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 28 May 2026 23:06:47 +0200 Subject: [PATCH 075/352] fix(ai): avoid duplicate Codex replay message ids closes #5148 --- packages/ai/CHANGELOG.md | 1 + .../src/providers/openai-responses-shared.ts | 6 ++- .../test/openai-responses-message-id.test.ts | 48 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 packages/ai/test/openai-responses-message-id.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 6966d94a..d5f1525f 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed +- Fixed OpenAI Codex Responses replay after switching from Anthropic extended-thinking sessions by generating unique fallback message item IDs for converted thinking/text blocks ([#5148](https://github.com/earendil-works/pi/issues/5148)). - Fixed Anthropic-compatible replay for providers that return empty thinking signatures by adding an opt-in `allowEmptySignature` compatibility flag ([#4464](https://github.com/earendil-works/pi/issues/4464)). - Fixed OpenAI and OpenRouter GPT-5.5 Pro thinking level metadata to expose only supported medium, high, and xhigh efforts. - Fixed OpenCode Go Kimi K2.6 thinking-off requests to send `thinking: "none"` ([#5078](https://github.com/earendil-works/pi/issues/5078)). diff --git a/packages/ai/src/providers/openai-responses-shared.ts b/packages/ai/src/providers/openai-responses-shared.ts index b7bcd192..d769ce19 100644 --- a/packages/ai/src/providers/openai-responses-shared.ts +++ b/packages/ai/src/providers/openai-responses-shared.ts @@ -166,6 +166,7 @@ export function convertResponsesMessages( assistantMsg.model !== model.id && assistantMsg.provider === model.provider && assistantMsg.api === model.api; + let textBlockIndex = 0; for (const block of msg.content) { if (block.type === "thinking") { @@ -176,10 +177,13 @@ export function convertResponsesMessages( } else if (block.type === "text") { const textBlock = block as TextContent; const parsedSignature = parseTextSignature(textBlock.textSignature); + const fallbackMessageId = + textBlockIndex === 0 ? `pi_msg_${msgIndex}` : `pi_msg_${msgIndex}_${textBlockIndex}`; + textBlockIndex++; // OpenAI requires id to be max 64 characters let msgId = parsedSignature?.id; if (!msgId) { - msgId = `msg_${msgIndex}`; + msgId = fallbackMessageId; } else if (msgId.length > 64) { msgId = `msg_${shortHash(msgId)}`; } diff --git a/packages/ai/test/openai-responses-message-id.test.ts b/packages/ai/test/openai-responses-message-id.test.ts new file mode 100644 index 00000000..07669d8c --- /dev/null +++ b/packages/ai/test/openai-responses-message-id.test.ts @@ -0,0 +1,48 @@ +import type { ResponseOutputMessage } from "openai/resources/responses/responses.js"; +import { describe, expect, it } from "vitest"; +import { getModel } from "../src/models.ts"; +import { convertResponsesMessages } from "../src/providers/openai-responses-shared.ts"; +import type { AssistantMessage, Context, Usage } from "../src/types.ts"; + +const usage: Usage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +describe("OpenAI Responses message ID conversion", () => { + it("generates unique fallback message IDs for multiple text blocks in one assistant turn", () => { + const model = getModel("openai-codex", "gpt-5.5"); + const assistant: AssistantMessage = { + role: "assistant", + content: [ + { type: "thinking", thinking: "private reasoning" }, + { type: "text", text: "visible answer" }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-opus-4-8", + usage, + stopReason: "stop", + timestamp: Date.now() - 1000, + }; + const context: Context = { + systemPrompt: "You are concise.", + messages: [{ role: "user", content: "hello", timestamp: Date.now() - 2000 }, assistant], + }; + + const input = convertResponsesMessages(model, context, new Set(["openai", "openai-codex", "opencode"])); + const messageIds = input + .filter( + (item): item is ResponseOutputMessage => + item.type === "message" && "id" in item && typeof item.id === "string", + ) + .map((item) => item.id); + + expect(messageIds).toEqual(["pi_msg_1", "pi_msg_1_1"]); + expect(new Set(messageIds).size).toBe(messageIds.length); + }); +}); From d1fb34bc8d1db3aefd30a0d11b8425bb9e0d9be3 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 28 May 2026 23:13:15 +0200 Subject: [PATCH 076/352] fix(ai): use valid synthetic Responses message ids closes #5148 --- packages/ai/src/providers/openai-responses-shared.ts | 2 +- packages/ai/test/openai-responses-message-id.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/providers/openai-responses-shared.ts b/packages/ai/src/providers/openai-responses-shared.ts index d769ce19..11a977d5 100644 --- a/packages/ai/src/providers/openai-responses-shared.ts +++ b/packages/ai/src/providers/openai-responses-shared.ts @@ -178,7 +178,7 @@ export function convertResponsesMessages( const textBlock = block as TextContent; const parsedSignature = parseTextSignature(textBlock.textSignature); const fallbackMessageId = - textBlockIndex === 0 ? `pi_msg_${msgIndex}` : `pi_msg_${msgIndex}_${textBlockIndex}`; + textBlockIndex === 0 ? `msg_pi_${msgIndex}` : `msg_pi_${msgIndex}_${textBlockIndex}`; textBlockIndex++; // OpenAI requires id to be max 64 characters let msgId = parsedSignature?.id; diff --git a/packages/ai/test/openai-responses-message-id.test.ts b/packages/ai/test/openai-responses-message-id.test.ts index 07669d8c..f675cc8e 100644 --- a/packages/ai/test/openai-responses-message-id.test.ts +++ b/packages/ai/test/openai-responses-message-id.test.ts @@ -42,7 +42,7 @@ describe("OpenAI Responses message ID conversion", () => { ) .map((item) => item.id); - expect(messageIds).toEqual(["pi_msg_1", "pi_msg_1_1"]); + expect(messageIds).toEqual(["msg_pi_1", "msg_pi_1_1"]); expect(new Set(messageIds).size).toBe(messageIds.length); }); }); From b64f3f5eaec4a14df03fe43f06124bb31e99d9f7 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 28 May 2026 23:13:20 +0200 Subject: [PATCH 077/352] fix(coding-agent): run extension cleanup and restore terminal on signal exits Signal-triggered shutdown (SIGTERM/SIGHUP) now emits session_shutdown before any terminal writes, and SIGHUP no longer hard-exits, so extension resources (e.g. sockets) are released and the terminal is restored even when the terminal is gone. A genuinely dead terminal still exits without hot-spinning via the EIO error path. closes #5080 --- packages/coding-agent/CHANGELOG.md | 1 + .../src/modes/interactive/interactive-mode.ts | 28 ++++- ...-signal-shutdown-extension-cleanup.test.ts | 103 ++++++++++++++++++ 3 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/5080-signal-shutdown-extension-cleanup.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 875dd7f4..392fd0bd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed SIGTERM/SIGHUP exits to run extension `session_shutdown` cleanup and restore the terminal: signal-triggered shutdown now emits `session_shutdown` before any terminal writes, and SIGHUP no longer hard-exits, so extension resources (e.g. sockets) are released even when the terminal is gone ([#5080](https://github.com/earendil-works/pi/issues/5080)). - Fixed Windows startup crashes under MSYS2 ucrt64 Node.js by updating the native clipboard addon to napi-rs 3.x ([#5028](https://github.com/earendil-works/pi/issues/5028)). - Fixed API key and header config resolution to treat plain strings as literals, support `$ENV_VAR` / `${ENV_VAR}` interpolation and `$!` bang escaping, and require explicit env syntax for config files, avoiding Windows case-insensitive env matches corrupting literal keys ([#5095](https://github.com/earendil-works/pi/issues/5095)). - Fixed session disposal to abort in-flight agent, compaction, branch summary, retry, and bash work ([#5029](https://github.com/earendil-works/pi/pull/5029) by [@TerminallyChilI](https://github.com/TerminallyChilI)). diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 3068c89c..b470070f 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -3248,11 +3248,28 @@ export class InteractiveMode { */ private isShuttingDown = false; - private async shutdown(): Promise { + private async shutdown(options?: { fromSignal?: boolean }): Promise { if (this.isShuttingDown) return; this.isShuttingDown = true; this.unregisterSignalHandlers(); + if (options?.fromSignal) { + // Signal-triggered shutdown (SIGTERM/SIGHUP). Emit extension cleanup + // (session_shutdown) BEFORE touching the terminal. Extension teardown + // such as removing sockets does not write to the tty, so it must not be + // skipped if a later terminal-restore write fails on a dead or stalled + // terminal. If the terminal is gone, the restore writes below emit EIO, + // which the stdout/stderr error handler turns into emergencyTerminalExit; + // the render loop is already idle, so this cannot hot-spin (see #4144). + await this.runtimeHost.dispose(); + await this.ui.terminal.drainInput(1000); + this.stop(); + process.exit(0); + } + + // Interactive quit (Ctrl+D, Ctrl+C, /quit, extension shutdown()). Stop the + // TUI before emitting shutdown events so extension UI cleanup cannot repaint + // the final frame while the process is exiting. // Drain any in-flight Kitty key release events before stopping. // This prevents escape sequences from leaking to the parent shell over slow SSH. await this.ui.terminal.drainInput(1000); @@ -3319,11 +3336,12 @@ export class InteractiveMode { for (const signal of signals) { const handler = () => { - if (signal === "SIGHUP") { - this.emergencyTerminalExit(); - } + // SIGHUP no longer hard-exits: graceful shutdown emits session_shutdown + // first, then attempts terminal restore. A genuinely dead terminal + // surfaces as an EIO on the restore writes, which the stdout/stderr + // error handler converts into emergencyTerminalExit (see #4144, #5080). killTrackedDetachedChildren(); - void this.shutdown(); + void this.shutdown({ fromSignal: true }); }; process.prependListener(signal, handler); this.signalCleanupHandlers.push(() => process.off(signal, handler)); diff --git a/packages/coding-agent/test/suite/regressions/5080-signal-shutdown-extension-cleanup.test.ts b/packages/coding-agent/test/suite/regressions/5080-signal-shutdown-extension-cleanup.test.ts new file mode 100644 index 00000000..d8bcd867 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/5080-signal-shutdown-extension-cleanup.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts"; + +// Regression for https://github.com/earendil-works/pi/issues/5080 +// +// On SIGTERM/SIGHUP the graceful shutdown must emit `session_shutdown` +// (runtimeHost.dispose) BEFORE touching the terminal. Extension teardown such +// as removing a socket does not write to the tty, so it must not be skipped if +// a later terminal-restore write fails on a dead or stalled terminal. The +// interactive quit path (Ctrl+D, /quit) keeps the opposite order to preserve +// the final TUI frame. + +type ShutdownThis = { + isShuttingDown: boolean; + unregisterSignalHandlers: () => void; + runtimeHost: { dispose: () => Promise }; + ui: { terminal: { drainInput: (ms: number) => Promise } }; + stop: () => void; +}; + +type InteractiveModePrototypeWithShutdown = { + shutdown(this: ShutdownThis, options?: { fromSignal?: boolean }): Promise; +}; + +const interactiveModePrototype = InteractiveMode.prototype as unknown; + +class ProcessExitError extends Error {} + +function createContext(order: string[]): ShutdownThis { + return { + isShuttingDown: false, + unregisterSignalHandlers: vi.fn(), + runtimeHost: { + dispose: vi.fn(async () => { + order.push("dispose"); + }), + }, + ui: { + terminal: { + drainInput: vi.fn(async () => { + order.push("drainInput"); + }), + }, + }, + stop: vi.fn(() => { + order.push("stop"); + }), + }; +} + +async function callShutdown(context: ShutdownThis, options?: { fromSignal?: boolean }): Promise { + try { + await (interactiveModePrototype as InteractiveModePrototypeWithShutdown).shutdown.call(context, options); + } catch (error) { + if (!(error instanceof ProcessExitError)) throw error; + } +} + +describe("InteractiveMode.shutdown ordering (#5080)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("signal-triggered shutdown emits session_shutdown before terminal writes", async () => { + vi.spyOn(process, "exit").mockImplementation((() => { + throw new ProcessExitError(); + }) as typeof process.exit); + const order: string[] = []; + const context = createContext(order); + + await callShutdown(context, { fromSignal: true }); + + expect(order).toEqual(["dispose", "drainInput", "stop"]); + expect(context.isShuttingDown).toBe(true); + expect(context.unregisterSignalHandlers).toHaveBeenCalledTimes(1); + }); + + test("interactive quit stops the TUI before emitting session_shutdown", async () => { + vi.spyOn(process, "exit").mockImplementation((() => { + throw new ProcessExitError(); + }) as typeof process.exit); + const order: string[] = []; + const context = createContext(order); + + await callShutdown(context); + + expect(order).toEqual(["drainInput", "stop", "dispose"]); + }); + + test("re-entrant shutdown is a no-op", async () => { + vi.spyOn(process, "exit").mockImplementation((() => { + throw new ProcessExitError(); + }) as typeof process.exit); + const order: string[] = []; + const context = createContext(order); + context.isShuttingDown = true; + + await callShutdown(context, { fromSignal: true }); + + expect(order).toEqual([]); + expect(context.runtimeHost.dispose).not.toHaveBeenCalled(); + }); +}); From 4b4641c6b07aa77c26f97e8f7e4a93c92a0ba707 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 28 May 2026 23:24:00 +0200 Subject: [PATCH 078/352] fix(coding-agent): scope custom session dir lookups --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/session-manager.ts | 73 +++++++++++++---- packages/coding-agent/src/main.ts | 4 +- .../src/modes/interactive/interactive-mode.ts | 5 +- .../session-manager/file-operations.test.ts | 80 +++++++++++++++++++ 5 files changed, 146 insertions(+), 17 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 392fd0bd..cffe6369 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed custom session directories so current-folder resume/continue lookups stay scoped to the active cwd while all-session listings cover the custom directory. - Fixed SIGTERM/SIGHUP exits to run extension `session_shutdown` cleanup and restore the terminal: signal-triggered shutdown now emits `session_shutdown` before any terminal writes, and SIGHUP no longer hard-exits, so extension resources (e.g. sockets) are released even when the terminal is gone ([#5080](https://github.com/earendil-works/pi/issues/5080)). - Fixed Windows startup crashes under MSYS2 ucrt64 Node.js by updating the native clipboard addon to napi-rs 3.x ([#5028](https://github.com/earendil-works/pi/issues/5028)). - Fixed API key and header config resolution to treat plain strings as literals, support `$ENV_VAR` / `${ENV_VAR}` interpolation and `$!` bang escaping, and require explicit env syntax for config files, avoiding Windows case-insensitive env matches corrupting literal keys ([#5095](https://github.com/earendil-works/pi/issues/5095)). diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 22c1f436..914da08d 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -433,11 +433,15 @@ export function buildSessionContext( * Compute the default session directory for a cwd. * Encodes cwd into a safe directory name under ~/.pi/agent/sessions/. */ -export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string { +function getDefaultSessionDirPath(cwd: string, agentDir: string = getDefaultAgentDir()): string { const resolvedCwd = resolvePath(cwd); const resolvedAgentDir = resolvePath(agentDir); const safePath = `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; - const sessionDir = join(resolvedAgentDir, "sessions", safePath); + return join(resolvedAgentDir, "sessions", safePath); +} + +export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string { + const sessionDir = getDefaultSessionDirPath(cwd, agentDir); if (!existsSync(sessionDir)) { mkdirSync(sessionDir, { recursive: true }); } @@ -473,30 +477,48 @@ export function loadEntriesFromFile(filePath: string): FileEntry[] { return entries; } -function isValidSessionFile(filePath: string): boolean { +function readSessionHeader(filePath: string): SessionHeader | null { try { const fd = openSync(filePath, "r"); const buffer = Buffer.alloc(512); const bytesRead = readSync(fd, buffer, 0, 512, 0); closeSync(fd); const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n")[0]; - if (!firstLine) return false; - const header = JSON.parse(firstLine); - return header.type === "session" && typeof header.id === "string"; + if (!firstLine) return null; + const header = JSON.parse(firstLine) as Record; + if (header.type !== "session" || typeof header.id !== "string") { + return null; + } + return header as unknown as SessionHeader; } catch { - return false; + return null; } } +function getSessionHeaderCwd(header: SessionHeader): string | undefined { + const cwd = (header as { cwd?: unknown }).cwd; + return typeof cwd === "string" ? cwd : undefined; +} + +function sessionCwdMatches(cwd: string | undefined, resolvedCwd: string): boolean { + return cwd !== undefined && cwd !== "" && resolvePath(cwd) === resolvedCwd; +} + /** Exported for testing */ -export function findMostRecentSession(sessionDir: string): string | null { +export function findMostRecentSession(sessionDir: string, cwd?: string): string | null { const resolvedSessionDir = normalizePath(sessionDir); + const resolvedCwd = cwd ? resolvePath(cwd) : undefined; try { const files = readdirSync(resolvedSessionDir) .filter((f) => f.endsWith(".jsonl")) .map((f) => join(resolvedSessionDir, f)) - .filter(isValidSessionFile) - .map((path) => ({ path, mtime: statSync(path).mtime })) + .map((path) => ({ path, header: readSessionHeader(path) })) + .filter( + (file): file is { path: string; header: SessionHeader } => + file.header !== null && + (!resolvedCwd || sessionCwdMatches(getSessionHeaderCwd(file.header), resolvedCwd)), + ) + .map(({ path }) => ({ path, mtime: statSync(path).mtime })) .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); return files[0]?.path || null; @@ -849,6 +871,10 @@ export class SessionManager { return this.sessionDir; } + usesDefaultSessionDir(): boolean { + return this.sessionDir === getDefaultSessionDirPath(this.cwd); + } + getSessionId(): string { return this.sessionId; } @@ -1363,7 +1389,8 @@ export class SessionManager { */ static continueRecent(cwd: string, sessionDir?: string): SessionManager { const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd); - const mostRecent = findMostRecentSession(dir); + const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd); + const mostRecent = findMostRecentSession(dir, filterCwd ? cwd : undefined); if (mostRecent) { return new SessionManager(cwd, dir, mostRecent, true); } @@ -1443,7 +1470,11 @@ export class SessionManager { */ static async list(cwd: string, sessionDir?: string, onProgress?: SessionListProgress): Promise { const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd); - const sessions = await listSessionsFromDir(dir, onProgress); + const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd); + const resolvedCwd = resolvePath(cwd); + const sessions = (await listSessionsFromDir(dir, onProgress)).filter( + (session) => !filterCwd || sessionCwdMatches(session.cwd, resolvedCwd), + ); sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); return sessions; } @@ -1452,7 +1483,21 @@ export class SessionManager { * List all sessions across all project directories. * @param onProgress Optional callback for progress updates (loaded, total) */ - static async listAll(onProgress?: SessionListProgress): Promise { + static async listAll(onProgress?: SessionListProgress): Promise; + static async listAll(sessionDir?: string, onProgress?: SessionListProgress): Promise; + static async listAll( + sessionDirOrOnProgress?: string | SessionListProgress, + onProgress?: SessionListProgress, + ): Promise { + const customSessionDir = + typeof sessionDirOrOnProgress === "string" ? normalizePath(sessionDirOrOnProgress) : undefined; + const progress = typeof sessionDirOrOnProgress === "function" ? sessionDirOrOnProgress : onProgress; + if (customSessionDir) { + const sessions = await listSessionsFromDir(customSessionDir, progress); + sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); + return sessions; + } + const sessionsDir = getSessionsDir(); try { @@ -1482,7 +1527,7 @@ export class SessionManager { const results = await buildSessionInfosWithConcurrency(allFiles, () => { loaded++; - onProgress?.(loaded, totalFiles); + progress?.(loaded, totalFiles); }); for (const info of results) { diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 7e69d933..04865b9a 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -171,7 +171,7 @@ async function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: } // Try global search across all projects - const allSessions = await SessionManager.listAll(); + const allSessions = await SessionManager.listAll(sessionDir); const globalMatch = allSessions.find((s) => s.id === sessionArg) ?? allSessions.find((s) => s.id.startsWith(sessionArg)); @@ -309,7 +309,7 @@ async function createSessionManager( try { const selectedPath = await selectSession( (onProgress) => SessionManager.list(cwd, sessionDir, onProgress), - SessionManager.listAll, + (onProgress) => SessionManager.listAll(sessionDir, onProgress), ); if (!selectedPath) { console.log(chalk.dim("No session selected")); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index b470070f..012e2f2a 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -4401,7 +4401,10 @@ export class InteractiveMode { const selector = new SessionSelectorComponent( (onProgress) => SessionManager.list(this.sessionManager.getCwd(), this.sessionManager.getSessionDir(), onProgress), - SessionManager.listAll, + (onProgress) => + this.sessionManager.usesDefaultSessionDir() + ? SessionManager.listAll(onProgress) + : SessionManager.listAll(this.sessionManager.getSessionDir(), onProgress), async (sessionPath) => { done(); await this.handleResumeSession(sessionPath); diff --git a/packages/coding-agent/test/session-manager/file-operations.test.ts b/packages/coding-agent/test/session-manager/file-operations.test.ts index c744c3c3..fd771ef9 100644 --- a/packages/coding-agent/test/session-manager/file-operations.test.ts +++ b/packages/coding-agent/test/session-manager/file-operations.test.ts @@ -124,6 +124,86 @@ describe("findMostRecentSession", () => { expect(findMostRecentSession(tempDir)).toBe(valid); }); + + it("filters most recent session by cwd", async () => { + const projectA = join(tempDir, "project-a"); + const projectB = join(tempDir, "project-b"); + const fileA = join(tempDir, "a.jsonl"); + const fileB = join(tempDir, "b.jsonl"); + + writeFileSync( + fileA, + `${JSON.stringify({ type: "session", id: "a", timestamp: "2025-01-01T00:00:00Z", cwd: projectA })}\n`, + ); + await new Promise((r) => setTimeout(r, 10)); + writeFileSync( + fileB, + `${JSON.stringify({ type: "session", id: "b", timestamp: "2025-01-01T00:00:00Z", cwd: projectB })}\n`, + ); + + expect(findMostRecentSession(tempDir, projectA)).toBe(fileA); + expect(findMostRecentSession(tempDir, projectB)).toBe(fileB); + }); +}); + +describe("SessionManager custom flat session directory", () => { + let tempDir: string; + let projectA: string; + let projectB: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `session-test-${Date.now()}`); + projectA = join(tempDir, "project-a"); + projectB = join(tempDir, "project-b"); + mkdirSync(projectA, { recursive: true }); + mkdirSync(projectB, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + function createPersistedSession(cwd: string, label: string): string { + const session = SessionManager.create(cwd, tempDir); + session.appendMessage({ role: "user", content: label, timestamp: Date.now() }); + session.appendMessage({ + role: "assistant", + content: [{ type: "text", text: `reply to ${label}` }], + api: "anthropic-messages", + provider: "anthropic", + model: "test", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }); + const sessionFile = session.getSessionFile(); + if (!sessionFile) { + throw new Error("Expected persisted session file"); + } + return sessionFile; + } + + it("scopes current-folder APIs by cwd while listing all flat sessions", async () => { + const sessionA = createPersistedSession(projectA, "from A"); + await new Promise((r) => setTimeout(r, 10)); + const sessionB = createPersistedSession(projectB, "from B"); + + const currentA = await SessionManager.list(projectA, tempDir); + expect(currentA.map((session) => session.path)).toEqual([sessionA]); + + const all = await SessionManager.listAll(tempDir); + expect(new Set(all.map((session) => session.path))).toEqual(new Set([sessionA, sessionB])); + + const continuedA = SessionManager.continueRecent(projectA, tempDir); + expect(continuedA.getSessionFile()).toBe(sessionA); + }); }); describe("SessionManager.setSessionFile with corrupted files", () => { From 1ab2899800b1d7120cd6188e6eead24459a725b1 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 28 May 2026 23:36:49 +0200 Subject: [PATCH 079/352] Remove unavailable tool preference guideline Closes #5132 --- packages/coding-agent/src/core/system-prompt.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index 8664d871..7580782b 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -112,8 +112,6 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string { // File exploration guidelines if (hasBash && !hasGrep && !hasFind && !hasLs) { addGuideline("Use bash for file operations like ls, rg, find"); - } else if (hasBash && (hasGrep || hasFind || hasLs)) { - addGuideline("Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)"); } for (const guideline of promptGuidelines ?? []) { From 9380d5f2e4293fa4ebae8468b258f09dd0b22079 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 28 May 2026 23:49:01 +0200 Subject: [PATCH 080/352] feat(coding-agent): add exclude tools option closes #5109 --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/README.md | 4 + packages/coding-agent/docs/sdk.md | 6 ++ packages/coding-agent/docs/usage.md | 4 + packages/coding-agent/src/cli/args.ts | 11 +++ .../src/core/agent-session-services.ts | 2 + .../coding-agent/src/core/agent-session.ts | 8 +- packages/coding-agent/src/core/sdk.ts | 13 +-- packages/coding-agent/src/main.ts | 4 + packages/coding-agent/test/args.test.ts | 10 +++ packages/coding-agent/test/suite/harness.ts | 6 ++ .../regressions/5109-exclude-tools.test.ts | 81 +++++++++++++++++++ 12 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/5109-exclude-tools.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index cffe6369..15a4327c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `--exclude-tools` / `-xt` to disable specific built-in, extension, or custom tools while leaving the rest available ([#5109](https://github.com/earendil-works/pi/issues/5109)). + ### Fixed - Fixed custom session directories so current-folder resume/continue lookups stay scoped to the active cwd while all-session listings cover the custom directory. diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 00fca81e..a326d2f0 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -551,6 +551,7 @@ cat README.md | pi -p "Summarize this text" | Option | Description | |--------|-------------| | `--tools `, `-t ` | Allowlist specific tool names across built-in, extension, and custom tools | +| `--exclude-tools `, `-xt ` | Disable specific tool names across built-in, extension, and custom tools | | `--no-builtin-tools`, `-nbt` | Disable built-in tools by default but keep extension/custom tools enabled | | `--no-tools`, `-nt` | Disable all tools by default | @@ -619,6 +620,9 @@ pi --models "claude-*,gpt-4o" # Read-only mode pi --tools read,grep,find,ls -p "Review the code" +# Disable one extension or built-in tool while keeping the rest available +pi --exclude-tools ask_question + # High thinking level pi --thinking high "Solve this complex problem" ``` diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index 8377185e..2be5f531 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -472,6 +472,7 @@ Specify which built-in tools to enable: - 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 +- `excludeTools` disables specific built-in, extension, or custom tool names after any `tools` allowlist is applied The `edit` tool returns `details.diff` for Pi's TUI display and `details.patch` as a standard unified patch for SDK consumers. @@ -487,6 +488,11 @@ const { session } = await createAgentSession({ const { session } = await createAgentSession({ tools: ["read", "bash", "grep"], }); + +// Disable one tool while keeping the rest available +const { session } = await createAgentSession({ + excludeTools: ["ask_question"], +}); ``` #### Tools with Custom cwd diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index ae69afaf..6e606912 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -184,6 +184,7 @@ cat README.md | pi -p "Summarize this text" | Option | Description | |--------|-------------| | `--tools `, `-t ` | Allowlist specific built-in, extension, and custom tools | +| `--exclude-tools `, `-xt ` | Disable specific built-in, extension, and custom tools | | `--no-builtin-tools`, `-nbt` | Disable built-in tools but keep extension/custom tools enabled | | `--no-tools`, `-nt` | Disable all tools | @@ -255,6 +256,9 @@ pi --models "claude-*,gpt-4o" # Read-only mode pi --tools read,grep,find,ls -p "Review the code" + +# Disable one extension or built-in tool while keeping the rest available +pi --exclude-tools ask_question ``` ### Environment Variables diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 19053db5..72317cfd 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -28,6 +28,7 @@ export interface Args { sessionDir?: string; models?: string[]; tools?: string[]; + excludeTools?: string[]; noTools?: boolean; noBuiltinTools?: boolean; extensions?: string[]; @@ -113,6 +114,11 @@ export function parseArgs(args: string[]): Args { .split(",") .map((s) => s.trim()) .filter((name) => name.length > 0); + } else if ((arg === "--exclude-tools" || arg === "-xt") && i + 1 < args.length) { + result.excludeTools = args[++i] + .split(",") + .map((s) => s.trim()) + .filter((name) => name.length > 0); } else if (arg === "--thinking" && i + 1 < args.length) { const level = args[++i]; if (isValidThinkingLevel(level)) { @@ -237,6 +243,8 @@ ${chalk.bold("Options:")} --no-builtin-tools, -nbt Disable built-in tools by default but keep extension/custom tools enabled --tools, -t Comma-separated allowlist of tool names to enable Applies to built-in, extension, and custom tools + --exclude-tools, -xt Comma-separated denylist of tool names to disable + Applies to built-in, extension, and custom tools --thinking Set thinking level: off, minimal, low, medium, high, xhigh --extension, -e Load an extension file (can be used multiple times) --no-extensions, -ne Disable extension discovery (explicit -e paths still work) @@ -299,6 +307,9 @@ ${chalk.bold("Examples:")} # Read-only mode (no file modifications possible) ${APP_NAME} --tools read,grep,find,ls -p "Review the code in src/" + # Disable one tool while keeping the rest available + ${APP_NAME} --exclude-tools ask_question + # Export a session file to HTML ${APP_NAME} --export ~/${CONFIG_DIR_NAME}/agent/sessions/--path--/session.jsonl ${APP_NAME} --export session.jsonl output.html diff --git a/packages/coding-agent/src/core/agent-session-services.ts b/packages/coding-agent/src/core/agent-session-services.ts index adf6f9e0..d66adc13 100644 --- a/packages/coding-agent/src/core/agent-session-services.ts +++ b/packages/coding-agent/src/core/agent-session-services.ts @@ -54,6 +54,7 @@ export interface CreateAgentSessionFromServicesOptions { thinkingLevel?: ThinkingLevel; scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; tools?: string[]; + excludeTools?: CreateAgentSessionOptions["excludeTools"]; noTools?: CreateAgentSessionOptions["noTools"]; customTools?: ToolDefinition[]; } @@ -192,6 +193,7 @@ export async function createAgentSessionFromServices( thinkingLevel: options.thinkingLevel, scopedModels: options.scopedModels, tools: options.tools, + excludeTools: options.excludeTools, noTools: options.noTools, customTools: options.customTools, sessionStartEvent: options.sessionStartEvent, diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 4ac9d18c..608a6153 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -170,6 +170,8 @@ export interface AgentSessionConfig { initialActiveToolNames?: string[]; /** Optional allowlist of tool names. When provided, only these tool names are exposed. */ allowedToolNames?: string[]; + /** Optional denylist of tool names. When provided, these tool names are not exposed. */ + excludedToolNames?: string[]; /** * Override base tools (useful for custom runtimes). * @@ -294,6 +296,7 @@ export class AgentSession { private _extensionRunnerRef?: { current?: ExtensionRunner }; private _initialActiveToolNames?: string[]; private _allowedToolNames?: Set; + private _excludedToolNames?: Set; private _baseToolsOverride?: Record; private _sessionStartEvent: SessionStartEvent; private _extensionUIContext?: ExtensionUIContext; @@ -328,6 +331,7 @@ export class AgentSession { this._extensionRunnerRef = config.extensionRunnerRef; this._initialActiveToolNames = config.initialActiveToolNames; this._allowedToolNames = config.allowedToolNames ? new Set(config.allowedToolNames) : undefined; + this._excludedToolNames = config.excludedToolNames ? new Set(config.excludedToolNames) : undefined; this._baseToolsOverride = config.baseToolsOverride; this._sessionStartEvent = config.sessionStartEvent ?? { type: "session_start", reason: "startup" }; @@ -2272,7 +2276,9 @@ export class AgentSession { const previousRegistryNames = new Set(this._toolRegistry.keys()); const previousActiveToolNames = this.getActiveToolNames(); const allowedToolNames = this._allowedToolNames; - const isAllowedTool = (name: string): boolean => !allowedToolNames || allowedToolNames.has(name); + const excludedToolNames = this._excludedToolNames; + const isAllowedTool = (name: string): boolean => + (!allowedToolNames || allowedToolNames.has(name)) && !excludedToolNames?.has(name); const registeredTools = this._extensionRunner.getAllRegisteredTools(); const allCustomTools = [ diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 569b1a39..a4401177 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -65,6 +65,8 @@ export interface CreateAgentSessionOptions { * When provided, only the listed tool names are enabled. */ tools?: string[]; + /** Optional denylist of tool names to disable. Applies after `tools` when both are provided. */ + excludeTools?: string[]; /** Custom tools to register (in addition to built-in tools). */ customTools?: ToolDefinition[]; @@ -279,11 +281,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const defaultActiveToolNames: ToolName[] = ["read", "bash", "edit", "write"]; const allowedToolNames = options.tools ?? (options.noTools === "all" ? [] : undefined); - const initialActiveToolNames: string[] = options.tools - ? [...options.tools] - : options.noTools - ? [] - : defaultActiveToolNames; + const excludedToolNames = options.excludeTools; + const excludedToolNameSet = excludedToolNames ? new Set(excludedToolNames) : undefined; + const initialActiveToolNames: string[] = ( + options.tools ? [...options.tools] : options.noTools ? [] : defaultActiveToolNames + ).filter((name) => !excludedToolNameSet?.has(name)); let agent: Agent; @@ -416,6 +418,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} modelRegistry, initialActiveToolNames, allowedToolNames, + excludedToolNames, extensionRunnerRef, sessionStartEvent: options.sessionStartEvent, }); diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 04865b9a..71206d9d 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -425,6 +425,9 @@ function buildSessionOptions( if (parsed.tools) { options.tools = [...parsed.tools]; } + if (parsed.excludeTools) { + options.excludeTools = [...parsed.excludeTools]; + } return { options, cliThinkingFromModel, diagnostics }; } @@ -646,6 +649,7 @@ export async function main(args: string[], options?: MainOptions) { thinkingLevel: sessionOptions.thinkingLevel, scopedModels: sessionOptions.scopedModels, tools: sessionOptions.tools, + excludeTools: sessionOptions.excludeTools, noTools: sessionOptions.noTools, customTools: sessionOptions.customTools, }); diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index 2f6d9ea9..8f26a0ee 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -308,6 +308,16 @@ describe("parseArgs", () => { expect(result.tools).toEqual(["read", "bash"]); }); + test("parses --exclude-tools flag", () => { + const result = parseArgs(["--exclude-tools", "read,bash"]); + expect(result.excludeTools).toEqual(["read", "bash"]); + }); + + test("parses -xt shorthand", () => { + const result = parseArgs(["-xt", "read,bash"]); + expect(result.excludeTools).toEqual(["read", "bash"]); + }); + test("parses --no-tools with explicit --tools flags", () => { const result = parseArgs(["--no-tools", "--tools", "read,bash"]); expect(result.noTools).toBe(true); diff --git a/packages/coding-agent/test/suite/harness.ts b/packages/coding-agent/test/suite/harness.ts index 090d5683..cec82013 100644 --- a/packages/coding-agent/test/suite/harness.ts +++ b/packages/coding-agent/test/suite/harness.ts @@ -60,6 +60,9 @@ export interface HarnessOptions { settings?: Partial; systemPrompt?: string; tools?: AgentTool[]; + initialActiveToolNames?: string[]; + allowedToolNames?: string[]; + excludedToolNames?: string[]; resourceLoader?: ResourceLoader; extensionFactories?: Array; withConfiguredAuth?: boolean; @@ -173,6 +176,9 @@ export async function createHarness(options: HarnessOptions = {}): Promise): string[] { + return tools.map((tool) => tool.name).sort(); +} + +describe("regression #5109: exclude tools", () => { + const extensionFactories: ExtensionFactory[] = [ + (pi) => { + pi.on("session_start", () => { + pi.registerTool({ + name: "ask_question", + label: "Ask Question", + description: "Ask a question", + promptSnippet: "Ask a question", + parameters: Type.Object({}), + execute: async () => ({ + content: [{ type: "text", text: "ok" }], + details: {}, + }), + }); + pi.registerTool({ + name: "dynamic_tool", + label: "Dynamic Tool", + description: "Dynamic test tool", + promptSnippet: "Run dynamic test behavior", + parameters: Type.Object({}), + execute: async () => ({ + content: [{ type: "text", text: "ok" }], + details: {}, + }), + }); + }); + }, + ]; + + it("filters built-in and extension tools from available and active tools", async () => { + const harness = await createHarness({ + excludedToolNames: ["read", "ask_question"], + extensionFactories, + }); + try { + await harness.session.bindExtensions({}); + + const allToolNames = toolNames(harness.session.getAllTools()); + expect(allToolNames).not.toContain("read"); + expect(allToolNames).not.toContain("ask_question"); + expect(allToolNames).toContain("bash"); + expect(allToolNames).toContain("dynamic_tool"); + expect(harness.session.getActiveToolNames().sort()).toEqual(["bash", "dynamic_tool", "edit", "write"]); + expect(harness.session.systemPrompt).not.toContain("- read:"); + expect(harness.session.systemPrompt).not.toContain("ask_question"); + expect(harness.session.systemPrompt).toContain("- dynamic_tool: Run dynamic test behavior"); + } finally { + harness.cleanup(); + } + }); + + it("lets excluded tools override the allowlist", async () => { + const harness = await createHarness({ + allowedToolNames: ["read", "bash", "ask_question"], + excludedToolNames: ["read", "ask_question"], + initialActiveToolNames: ["read", "bash", "ask_question"], + extensionFactories, + }); + try { + await harness.session.bindExtensions({}); + + expect(toolNames(harness.session.getAllTools())).toEqual(["bash"]); + expect(harness.session.getActiveToolNames()).toEqual(["bash"]); + expect(harness.session.systemPrompt).toContain("- bash:"); + expect(harness.session.systemPrompt).not.toContain("- read:"); + expect(harness.session.systemPrompt).not.toContain("ask_question"); + } finally { + harness.cleanup(); + } + }); +}); From f9fa077bbbf05ca996ffef91ac914c18db2131e4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 28 May 2026 23:53:20 +0200 Subject: [PATCH 081/352] Fix startup timing attribution closes #4829 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/main.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 15a4327c..2caea4ec 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed startup timing output so `readPipedStdin` no longer includes `createAgentSessionRuntime` work ([#4829](https://github.com/earendil-works/pi/issues/4829)). - Fixed custom session directories so current-folder resume/continue lookups stay scoped to the active cwd while all-session listings cover the custom directory. - Fixed SIGTERM/SIGHUP exits to run extension `session_shutdown` cleanup and restore the terminal: signal-triggered shutdown now emits `session_shutdown` before any terminal writes, and SIGHUP no longer hard-exits, so extension resources (e.g. sockets) are released even when the terminal is gone ([#5080](https://github.com/earendil-works/pi/issues/5080)). - Fixed Windows startup crashes under MSYS2 ucrt64 Node.js by updating the native clipboard addon to napi-rs 3.x ([#5028](https://github.com/earendil-works/pi/issues/5028)). diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 71206d9d..4b6c4e16 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -670,6 +670,7 @@ export async function main(args: string[], options?: MainOptions) { agentDir, sessionManager, }); + time("createAgentSessionRuntime"); const { services, session, modelFallbackMessage } = runtime; const { settingsManager, modelRegistry, resourceLoader } = services; configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs()); From 0127caea7b215e0a64044a334d7b5097347dd854 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 28 May 2026 23:56:59 +0200 Subject: [PATCH 082/352] Fix OpenRouter DeepSeek V4 xhigh reasoning closes #4801 --- packages/ai/CHANGELOG.md | 1 + packages/ai/scripts/generate-models.ts | 9 ++++++--- packages/ai/src/models.generated.ts | 12 ++++++------ 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index d5f1525f..f36c7543 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed +- Fixed OpenRouter DeepSeek V4 `xhigh` reasoning metadata to preserve OpenRouter's native effort instead of sending DeepSeek's `max` effort ([#4801](https://github.com/earendil-works/pi/issues/4801)). - Fixed OpenAI Codex Responses replay after switching from Anthropic extended-thinking sessions by generating unique fallback message item IDs for converted thinking/text blocks ([#5148](https://github.com/earendil-works/pi/issues/5148)). - Fixed Anthropic-compatible replay for providers that return empty thinking signatures by adding an opt-in `allowEmptySignature` compatibility flag ([#4464](https://github.com/earendil-works/pi/issues/4464)). - Fixed OpenAI and OpenRouter GPT-5.5 Pro thinking level metadata to expose only supported medium, high, and xhigh efforts. diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index fc4e2c3f..a8853ac5 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -245,7 +245,12 @@ function applyThinkingLevelMetadata(model: Model): void { mergeAnthropicMessagesCompat(model, { forceAdaptiveThinking: true }); } if (model.api === "openai-completions" && model.id.includes("deepseek-v4")) { - mergeThinkingLevelMap(model, DEEPSEEK_V4_THINKING_LEVEL_MAP); + mergeThinkingLevelMap( + model, + model.provider === "openrouter" + ? { ...DEEPSEEK_V4_THINKING_LEVEL_MAP, xhigh: "xhigh" } + : DEEPSEEK_V4_THINKING_LEVEL_MAP, + ); } if (isGoogleThinkingApi(model) && isGemini3ProModel(model.id)) { mergeThinkingLevelMap(model, { off: null, minimal: null, low: "LOW", medium: null, high: "HIGH" }); @@ -1520,11 +1525,9 @@ async function generateModels() { ? { requiresReasoningContentOnAssistantMessages: deepseekCompat.requiresReasoningContentOnAssistantMessages, - thinkingFormat: deepseekCompat.thinkingFormat, } : deepseekCompat), }; - mergeThinkingLevelMap(candidate, DEEPSEEK_V4_THINKING_LEVEL_MAP); } } diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 8d95054a..79b97309 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -8813,9 +8813,9 @@ export const MODELS = { api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + compat: {"requiresReasoningContentOnAssistantMessages":true}, reasoning: true, - thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh"}, input: ["text"], cost: { input: 0.09999999999999999, @@ -8832,9 +8832,9 @@ export const MODELS = { api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + compat: {"requiresReasoningContentOnAssistantMessages":true}, reasoning: true, - thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh"}, input: ["text"], cost: { input: 0, @@ -8851,9 +8851,9 @@ export const MODELS = { api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + compat: {"requiresReasoningContentOnAssistantMessages":true}, reasoning: true, - thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh"}, input: ["text"], cost: { input: 0.435, From f29472c387a91d96bfaf58fd3cdbf02d799e8383 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 29 May 2026 00:06:06 +0200 Subject: [PATCH 083/352] Audit unreleased changelog entries --- packages/agent/CHANGELOG.md | 8 ++++++++ packages/ai/CHANGELOG.md | 2 +- packages/coding-agent/CHANGELOG.md | 22 ++++++++++++++++++++-- packages/tui/CHANGELOG.md | 4 ++++ 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 5e99aada..7050de22 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Breaking Changes + +- Renamed agent harness `model_select` and `thinking_level_select` events to `model_update` and `thinking_level_update`. + +### Added + +- Added agent harness tool registry APIs, `tools_update` events, branch-scoped active-tool persistence, and duplicate tool validation. + ## [0.76.0] - 2026-05-27 ### Fixed diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index f36c7543..3c33e20a 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Added OpenAI Codex subscription device-code login as a selectable headless alternative while keeping browser login as the default. +- Added OpenAI Codex subscription device-code login as a selectable headless alternative while keeping browser login as the default ([#4911](https://github.com/earendil-works/pi/pull/4911) by [@vegarsti](https://github.com/vegarsti)). - Added Claude Opus 4.8 model metadata for Anthropic and updated Opus adaptive-thinking coverage to use it. ### Fixed diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2caea4ec..c99480f7 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,21 +2,39 @@ ## [Unreleased] +### New Features + +- **Claude Opus 4.8 support** - Adds Anthropic Claude Opus 4.8 metadata and updates Opus adaptive-thinking coverage. +- **Selective tool disablement** - `--exclude-tools` / `-xt` disables specific built-in, extension, or custom tools while leaving the rest available. See [Tool Options](docs/usage.md#tool-options). +- **Headless Codex subscription login** - `/login` can use device-code auth for ChatGPT Plus/Pro Codex subscriptions. See [Subscriptions](docs/providers.md#subscriptions) and [OpenAI Codex](docs/providers.md#openai-codex). +- **Streaming-aware extension input** - extensions can distinguish idle prompts, mid-stream steers, and queued follow-ups with `InputEvent.streamingBehavior`. See [Input Events](docs/extensions.md#input-events). + ### Added - Added `--exclude-tools` / `-xt` to disable specific built-in, extension, or custom tools while leaving the rest available ([#5109](https://github.com/earendil-works/pi/issues/5109)). +- Added OpenAI Codex subscription device-code login as a selectable headless alternative while keeping browser login as the default ([#4911](https://github.com/earendil-works/pi/pull/4911) by [@vegarsti](https://github.com/vegarsti)). +- Added `streamingBehavior` to extension input events so extensions can distinguish idle prompts from mid-stream steers and queued follow-ups ([#5107](https://github.com/earendil-works/pi/pull/5107) by [@DanielThomas](https://github.com/DanielThomas)). +- Added Claude Opus 4.8 model metadata for Anthropic and updated Opus adaptive-thinking coverage to use it. ### Fixed - Fixed startup timing output so `readPipedStdin` no longer includes `createAgentSessionRuntime` work ([#4829](https://github.com/earendil-works/pi/issues/4829)). +- Fixed OpenRouter DeepSeek V4 `xhigh` reasoning metadata to preserve OpenRouter's native effort instead of sending DeepSeek's `max` effort ([#4801](https://github.com/earendil-works/pi/issues/4801)). - Fixed custom session directories so current-folder resume/continue lookups stay scoped to the active cwd while all-session listings cover the custom directory. - Fixed SIGTERM/SIGHUP exits to run extension `session_shutdown` cleanup and restore the terminal: signal-triggered shutdown now emits `session_shutdown` before any terminal writes, and SIGHUP no longer hard-exits, so extension resources (e.g. sockets) are released even when the terminal is gone ([#5080](https://github.com/earendil-works/pi/issues/5080)). +- Fixed keyboard protocol negotiation to ignore mismatched or delayed terminal responses, avoiding false Kitty keyboard protocol detection ([#5091](https://github.com/earendil-works/pi/pull/5091) by [@mitsuhiko](https://github.com/mitsuhiko)). - Fixed Windows startup crashes under MSYS2 ucrt64 Node.js by updating the native clipboard addon to napi-rs 3.x ([#5028](https://github.com/earendil-works/pi/issues/5028)). - Fixed API key and header config resolution to treat plain strings as literals, support `$ENV_VAR` / `${ENV_VAR}` interpolation and `$!` bang escaping, and require explicit env syntax for config files, avoiding Windows case-insensitive env matches corrupting literal keys ([#5095](https://github.com/earendil-works/pi/issues/5095)). - Fixed session disposal to abort in-flight agent, compaction, branch summary, retry, and bash work ([#5029](https://github.com/earendil-works/pi/pull/5029) by [@TerminallyChilI](https://github.com/TerminallyChilI)). - Fixed `pi.getAllTools()` to expose each tool's `promptGuidelines` for extensions that need per-tool guideline attribution ([#4879](https://github.com/earendil-works/pi/issues/4879)). -- Fixed follow-up messages queued by `agent_end` extension handlers to drain before the agent becomes idle ([#5115](https://github.com/earendil-works/pi-mono/pull/5115) by [@DanielThomas](https://github.com/DanielThomas)). -- Fixed extension input events to report `streamingBehavior` only for prompts actually queued during streaming ([#5107](https://github.com/earendil-works/pi-mono/pull/5107)). +- Fixed OpenAI Codex Responses replay after switching from Anthropic extended-thinking sessions by generating unique fallback message item IDs for converted thinking/text blocks ([#5148](https://github.com/earendil-works/pi/issues/5148)). +- Fixed Anthropic-compatible replay for providers that return empty thinking signatures by adding an opt-in `allowEmptySignature` compatibility flag ([#4464](https://github.com/earendil-works/pi/issues/4464)). +- Fixed OpenAI and OpenRouter GPT-5.5 Pro thinking level metadata to expose only supported medium, high, and xhigh efforts. +- Fixed OpenCode Go Kimi K2.6 thinking-off requests to send `thinking: "none"` ([#5078](https://github.com/earendil-works/pi/issues/5078)). +- Fixed Xiaomi Token Plan model metadata to omit unsupported `mimo-v2-flash` variants ([#5075](https://github.com/earendil-works/pi/issues/5075)). +- Fixed follow-up messages queued by `agent_end` extension handlers to drain before the agent becomes idle ([#5115](https://github.com/earendil-works/pi/pull/5115) by [@DanielThomas](https://github.com/DanielThomas)). +- Fixed extension input events to report `streamingBehavior` only for prompts actually queued during streaming ([#5107](https://github.com/earendil-works/pi/pull/5107) by [@DanielThomas](https://github.com/DanielThomas)). +- Fixed system prompt tool-selection guidance to avoid preferring unavailable file exploration tools ([#5132](https://github.com/earendil-works/pi/issues/5132)). - Fixed fenced `diff` code blocks and other highlight.js scopes to keep theme-aware syntax colors after the `cli-highlight` replacement ([#5092](https://github.com/earendil-works/pi/issues/5092)). ## [0.76.0] - 2026-05-27 diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 201dd2e1..dea7f4ef 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed keyboard protocol negotiation to ignore mismatched or delayed terminal responses, avoiding false Kitty keyboard protocol detection ([#5091](https://github.com/earendil-works/pi/pull/5091) by [@mitsuhiko](https://github.com/mitsuhiko)). + ## [0.76.0] - 2026-05-27 ### Added From 8322745e28f47225e9f8c8161c1dca5c0bd74dfb Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 29 May 2026 00:19:06 +0200 Subject: [PATCH 084/352] Release v0.77.0 --- package-lock.json | 24 +++++++++---------- packages/agent/CHANGELOG.md | 2 +- packages/agent/package.json | 4 ++-- packages/ai/CHANGELOG.md | 2 +- packages/ai/package.json | 2 +- packages/ai/src/models.generated.ts | 2 +- packages/coding-agent/CHANGELOG.md | 2 +- .../package-lock.json | 4 ++-- .../custom-provider-anthropic/package.json | 2 +- .../custom-provider-gitlab-duo/package.json | 2 +- .../extensions/sandbox/package-lock.json | 4 ++-- .../examples/extensions/sandbox/package.json | 2 +- .../extensions/with-deps/package-lock.json | 4 ++-- .../extensions/with-deps/package.json | 2 +- packages/coding-agent/npm-shrinkwrap.json | 24 +++++++++---------- packages/coding-agent/package.json | 8 +++---- packages/tui/CHANGELOG.md | 2 +- packages/tui/package.json | 2 +- 18 files changed, 47 insertions(+), 47 deletions(-) diff --git a/package-lock.json b/package-lock.json index b4c74b18..52967666 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5995,10 +5995,10 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.76.0", + "version": "0.77.0", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.76.0", + "@earendil-works/pi-ai": "^0.77.0", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -6032,7 +6032,7 @@ }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.76.0", + "version": "0.77.0", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -6077,12 +6077,12 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "0.76.0", + "version": "0.77.0", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.76.0", - "@earendil-works/pi-ai": "^0.76.0", - "@earendil-works/pi-tui": "^0.76.0", + "@earendil-works/pi-agent-core": "^0.77.0", + "@earendil-works/pi-ai": "^0.77.0", + "@earendil-works/pi-tui": "^0.77.0", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -6121,25 +6121,25 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.76.0", + "version": "0.77.0", "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.76.0" + "version": "0.77.0" }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.6.0", + "version": "1.7.0", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.76.0", + "version": "0.77.0", "dependencies": { "ms": "2.1.3" }, @@ -6175,7 +6175,7 @@ }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.76.0", + "version": "0.77.0", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 7050de22..d9e4320f 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.77.0] - 2026-05-28 ### Breaking Changes diff --git a/packages/agent/package.json b/packages/agent/package.json index 3ff40436..b17b6925 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "0.76.0", + "version": "0.77.0", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -29,7 +29,7 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "^0.76.0", + "@earendil-works/pi-ai": "^0.77.0", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 3c33e20a..43d09bbe 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.77.0] - 2026-05-28 ### Added diff --git a/packages/ai/package.json b/packages/ai/package.json index f9f3bfdf..cb87a1f3 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.76.0", + "version": "0.77.0", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 79b97309..8f131d77 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -9491,7 +9491,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 262144, + contextWindow: 204800, maxTokens: 8192, } satisfies Model<"openai-completions">, "minimax/minimax-m2.7": { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c99480f7..ef66ae28 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.77.0] - 2026-05-28 ### 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 85ae7420..4337d48d 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.76.0", + "version": "0.77.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "0.76.0", + "version": "0.77.0", "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 8e22c249..7c097a31 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.76.0", + "version": "0.77.0", "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 c7479743..4beae8ce 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.76.0", + "version": "0.77.0", "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 9a2c4774..56832466 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.6.0", + "version": "1.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.6.0", + "version": "1.7.0", "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 ec1cf105..fd8529bd 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.6.0", + "version": "1.7.0", "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 e79685e7..32fac214 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.76.0", + "version": "0.77.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "0.76.0", + "version": "0.77.0", "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 2855a465..fbc17fed 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.76.0", + "version": "0.77.0", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index eb93f468..09ef2b3e 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -1,17 +1,17 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.76.0", + "version": "0.77.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent", - "version": "0.76.0", + "version": "0.77.0", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.76.0", - "@earendil-works/pi-ai": "^0.76.0", - "@earendil-works/pi-tui": "^0.76.0", + "@earendil-works/pi-agent-core": "^0.77.0", + "@earendil-works/pi-ai": "^0.77.0", + "@earendil-works/pi-tui": "^0.77.0", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -473,11 +473,11 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.76.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.76.0.tgz", + "version": "0.77.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.77.0.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.76.0", + "@earendil-works/pi-ai": "^0.77.0", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -487,8 +487,8 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.76.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.76.0.tgz", + "version": "0.77.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.77.0.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -510,8 +510,8 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.76.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.76.0.tgz", + "version": "0.77.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.77.0.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 d495acbd..2af9e198 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.76.0", + "version": "0.77.0", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -39,9 +39,9 @@ "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" }, "dependencies": { - "@earendil-works/pi-agent-core": "^0.76.0", - "@earendil-works/pi-ai": "^0.76.0", - "@earendil-works/pi-tui": "^0.76.0", + "@earendil-works/pi-agent-core": "^0.77.0", + "@earendil-works/pi-ai": "^0.77.0", + "@earendil-works/pi-tui": "^0.77.0", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index dea7f4ef..36f028c4 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.77.0] - 2026-05-28 ### Fixed diff --git a/packages/tui/package.json b/packages/tui/package.json index d10547de..e9a730ca 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-tui", - "version": "0.76.0", + "version": "0.77.0", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", From abf07d0c4df6f9879497e9b4deae4e908ffcc3a0 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 29 May 2026 00:19:23 +0200 Subject: [PATCH 085/352] Add [Unreleased] section for next cycle --- packages/agent/CHANGELOG.md | 2 ++ packages/ai/CHANGELOG.md | 2 ++ packages/coding-agent/CHANGELOG.md | 2 ++ packages/tui/CHANGELOG.md | 2 ++ 4 files changed, 8 insertions(+) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index d9e4320f..3685ec28 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.77.0] - 2026-05-28 ### Breaking Changes diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 43d09bbe..7266c79b 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.77.0] - 2026-05-28 ### Added diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ef66ae28..0f803a92 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.77.0] - 2026-05-28 ### New Features diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 36f028c4..c4eb0d08 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.77.0] - 2026-05-28 ### Fixed From f3b4e1285c790a78e3847f88e7070fc65e3e479a Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 29 May 2026 00:36:43 +0200 Subject: [PATCH 086/352] fix(release): upgrade npm for trusted publishing --- .github/workflows/build-binaries.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 9190ec22..6dad6762 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -126,5 +126,10 @@ jobs: - name: Verify release artifacts are committed run: git diff --exit-code + - name: Upgrade npm for trusted publishing + run: | + npm install -g npm@11.16.0 --ignore-scripts + npm --version + - name: Publish npm packages run: node scripts/publish.mjs From 93600d89802a253decdf2d8c3cd56fb362538f38 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 29 May 2026 00:44:58 +0200 Subject: [PATCH 087/352] fix(release): align package repository metadata --- .github/workflows/build-binaries.yml | 10 ++++++++-- packages/agent/package.json | 2 +- packages/ai/package.json | 2 +- packages/coding-agent/package.json | 2 +- packages/tui/package.json | 2 +- 5 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 6dad6762..6535fe28 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -10,6 +10,10 @@ on: description: 'Tag to build (e.g., v0.12.0)' required: true type: string + source_ref: + description: 'Source ref to build/publish (defaults to tag; use only for release recovery)' + required: false + type: string permissions: {} @@ -20,11 +24,12 @@ jobs: contents: write env: RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} + SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }} steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - ref: ${{ env.RELEASE_TAG }} + ref: ${{ env.SOURCE_REF }} persist-credentials: false - name: Setup Bun @@ -91,11 +96,12 @@ jobs: id-token: write env: RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} + SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }} steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - ref: ${{ env.RELEASE_TAG }} + ref: ${{ env.SOURCE_REF }} persist-credentials: false - name: Setup Node.js diff --git a/packages/agent/package.json b/packages/agent/package.json index b17b6925..1606d965 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -45,7 +45,7 @@ "license": "MIT", "repository": { "type": "git", - "url": "git+https://github.com/earendil-works/pi-mono.git", + "url": "git+https://github.com/earendil-works/pi.git", "directory": "packages/agent" }, "engines": { diff --git a/packages/ai/package.json b/packages/ai/package.json index cb87a1f3..3ccc240b 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -92,7 +92,7 @@ "license": "MIT", "repository": { "type": "git", - "url": "git+https://github.com/earendil-works/pi-mono.git", + "url": "git+https://github.com/earendil-works/pi.git", "directory": "packages/ai" }, "engines": { diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 2af9e198..c03cc2a6 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -89,7 +89,7 @@ "license": "MIT", "repository": { "type": "git", - "url": "git+https://github.com/earendil-works/pi-mono.git", + "url": "git+https://github.com/earendil-works/pi.git", "directory": "packages/coding-agent" }, "engines": { diff --git a/packages/tui/package.json b/packages/tui/package.json index e9a730ca..e14f4e4e 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -29,7 +29,7 @@ "license": "MIT", "repository": { "type": "git", - "url": "git+https://github.com/earendil-works/pi-mono.git", + "url": "git+https://github.com/earendil-works/pi.git", "directory": "packages/tui" }, "engines": { From 20bcab26e06ae075f9c95cd5cc9e1bc980aac35e Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 29 May 2026 00:51:11 +0200 Subject: [PATCH 088/352] feat(ci): Update setup-bun to 2.2.0 --- .github/workflows/build-binaries.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 6535fe28..58c54f39 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -33,7 +33,7 @@ jobs: persist-credentials: false - name: Setup Bun - uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.1 + uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.2.0 with: bun-version: 1.3.10 From b6b0f6923f049abc0e58ee2eb4d1fead3d38eccf Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 29 May 2026 01:03:12 +0200 Subject: [PATCH 089/352] feat(ci): Actually bump setup-bun to 2.2.0 --- .github/workflows/build-binaries.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 58c54f39..622888b3 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -33,7 +33,7 @@ jobs: persist-credentials: false - name: Setup Bun - uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.2.0 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.3.10 From 7be8a10d2358fe60f1cf4507140aa9cfa81682ee Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 28 May 2026 23:26:43 +0000 Subject: [PATCH 090/352] chore: approve contributor rolfvreijdenberger --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 8f2fc0a2..0a99f0c1 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -223,3 +223,5 @@ DanielThomas pr MichaelYochpaz pr stephanmck pr + +rolfvreijdenberger pr From c8df99a795e9d8d5d030d6b7f0327f3ed7193f5d Mon Sep 17 00:00:00 2001 From: smoose Date: Thu, 28 May 2026 10:42:03 +0800 Subject: [PATCH 091/352] fix(tui): keep hardware cursor marker during slash-command autocomplete Remove the !autocompleteState guard so CURSOR_MARKER is still emitted while the slash-command menu is visible. This lets the TUI position the hardware cursor correctly, which fixes IME candidate-window placement for CJK input methods. --- packages/tui/src/components/editor.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index ddbd98ee..673fc641 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -469,8 +469,10 @@ export class Editor implements Component, Focusable { } // Render each visible layout line - // Emit hardware cursor marker only when focused and not showing autocomplete - const emitCursorMarker = this.focused && !this.autocompleteState; + // Emit hardware cursor marker when focused so TUI can position the + // hardware cursor for IME candidate-window placement even while + // autocomplete (e.g. slash-command menu) is visible. + const emitCursorMarker = this.focused; for (const layoutLine of visibleLines) { let displayText = layoutLine.text; From 7a5dc0d0db91e830678eaf58a5f278f180107cd4 Mon Sep 17 00:00:00 2001 From: Alexey Zaytsev Date: Fri, 29 May 2026 00:44:51 -0500 Subject: [PATCH 092/352] feat(coding-agent): Export convertToPng for extensions --- packages/coding-agent/CHANGELOG.md | 4 ++++ packages/coding-agent/src/index.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0f803a92..7444d7a0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Exported `convertToPng` for extension authors. + ## [0.77.0] - 2026-05-28 ### New Features diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 8627bdcd..97533893 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -349,6 +349,7 @@ export { // Clipboard utilities export { copyToClipboard } from "./utils/clipboard.ts"; 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"; From 17e9e87576d57e2da36f2dc5caf419c4393ff0cc Mon Sep 17 00:00:00 2001 From: Michael Yu Date: Fri, 29 May 2026 15:56:42 +0800 Subject: [PATCH 093/352] feat(coding-agent): print resume hint on interactive exit --- .../src/modes/interactive/interactive-mode.ts | 29 ++++ .../test/format-resume-command.test.ts | 135 ++++++++++++++++++ ...-signal-shutdown-extension-cleanup.test.ts | 83 ++++++++++- 3 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/test/format-resume-command.test.ts diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 012e2f2a..901e1245 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -47,6 +47,7 @@ import { TUI, visibleWidth, } from "@earendil-works/pi-tui"; +import chalk from "chalk"; import { spawn, spawnSync } from "child_process"; import { APP_NAME, @@ -194,6 +195,28 @@ function isUnknownModel(model: Model | undefined): boolean { return !!model && model.provider === "unknown" && model.id === "unknown" && model.api === "unknown"; } +function quoteIfNeeded(value: string): string { + if (value.length > 0 && !/[^a-zA-Z0-9_\-./~:@]/.test(value)) { + return value; + } + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +export function formatResumeCommand(sessionManager: SessionManager): string | undefined { + if (!process.stdout.isTTY) return undefined; + if (!sessionManager.isPersisted()) return undefined; + + const sessionFile = sessionManager.getSessionFile(); + if (!sessionFile || !fs.existsSync(sessionFile)) return undefined; + + const args = [APP_NAME]; + if (!sessionManager.usesDefaultSessionDir()) { + args.push("--session-dir", quoteIfNeeded(sessionManager.getSessionDir())); + } + args.push("--session", sessionManager.getSessionId()); + return args.join(" "); +} + function hasDefaultModelProvider(providerId: string): providerId is keyof typeof defaultModelPerProvider { return providerId in defaultModelPerProvider; } @@ -3276,6 +3299,12 @@ export class InteractiveMode { this.stop(); await this.runtimeHost.dispose(); + + const resumeCommand = formatResumeCommand(this.sessionManager); + if (resumeCommand) { + process.stdout.write(` ${chalk.dim("To resume this session:")} ${resumeCommand}\n`); + } + process.exit(0); } diff --git a/packages/coding-agent/test/format-resume-command.test.ts b/packages/coding-agent/test/format-resume-command.test.ts new file mode 100644 index 00000000..86c4475b --- /dev/null +++ b/packages/coding-agent/test/format-resume-command.test.ts @@ -0,0 +1,135 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { APP_NAME } from "../src/config.ts"; +import type { SessionManager } from "../src/core/session-manager.ts"; +import { formatResumeCommand } from "../src/modes/interactive/interactive-mode.ts"; + +const tempDirs: string[] = []; +const originalStdoutIsTTY = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + +afterEach(() => { + if (originalStdoutIsTTY) { + Object.defineProperty(process.stdout, "isTTY", originalStdoutIsTTY); + } else { + Reflect.deleteProperty(process.stdout, "isTTY"); + } + + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function setStdoutIsTTY(value: boolean): void { + Object.defineProperty(process.stdout, "isTTY", { configurable: true, value }); +} + +function createTempFile(): string { + const dir = mkdtempSync(join(tmpdir(), "pi-format-resume-command-")); + tempDirs.push(dir); + const file = join(dir, "session.jsonl"); + writeFileSync(file, "\n"); + return file; +} + +function createSessionManager(options: { + persisted?: boolean; + sessionFile?: string; + sessionId?: string; + sessionDir?: string; + usesDefaultSessionDir?: boolean; +}): SessionManager { + return { + isPersisted: () => options.persisted ?? true, + getSessionFile: () => options.sessionFile, + getSessionId: () => options.sessionId ?? "0197f6e4-4cf9-7f44-a2d8-f8f7f49ee9d3", + getSessionDir: () => options.sessionDir ?? "/tmp/pi-sessions", + usesDefaultSessionDir: () => options.usesDefaultSessionDir ?? true, + } as unknown as SessionManager; +} + +describe("formatResumeCommand", () => { + it("returns a session resume command for default session dirs", () => { + setStdoutIsTTY(true); + const sessionFile = createTempFile(); + const sessionManager = createSessionManager({ sessionFile, sessionId: "test-session" }); + + expect(formatResumeCommand(sessionManager)).toBe(`${APP_NAME} --session test-session`); + }); + + it("includes unquoted safe session dirs for non-default session dirs", () => { + setStdoutIsTTY(true); + const sessionFile = createTempFile(); + const sessionManager = createSessionManager({ + sessionFile, + sessionId: "test-session", + sessionDir: "/tmp/custom-pi-sessions", + usesDefaultSessionDir: false, + }); + + expect(formatResumeCommand(sessionManager)).toBe( + `${APP_NAME} --session-dir /tmp/custom-pi-sessions --session test-session`, + ); + }); + + it("quotes session dirs containing spaces", () => { + setStdoutIsTTY(true); + const sessionFile = createTempFile(); + const sessionManager = createSessionManager({ + sessionFile, + sessionId: "test-session", + sessionDir: "/tmp/custom pi sessions", + usesDefaultSessionDir: false, + }); + + expect(formatResumeCommand(sessionManager)).toBe( + `${APP_NAME} --session-dir '/tmp/custom pi sessions' --session test-session`, + ); + }); + + it("quotes session dirs containing single quotes", () => { + setStdoutIsTTY(true); + const sessionFile = createTempFile(); + const sessionManager = createSessionManager({ + sessionFile, + sessionId: "test-session", + sessionDir: "/tmp/custom pi's sessions", + usesDefaultSessionDir: false, + }); + + expect(formatResumeCommand(sessionManager)).toBe( + `${APP_NAME} --session-dir '/tmp/custom pi'\\''s sessions' --session test-session`, + ); + }); + + it("returns undefined when stdout is not a TTY", () => { + setStdoutIsTTY(false); + const sessionFile = createTempFile(); + const sessionManager = createSessionManager({ sessionFile }); + + expect(formatResumeCommand(sessionManager)).toBeUndefined(); + }); + + it("returns undefined for in-memory sessions", () => { + setStdoutIsTTY(true); + const sessionFile = createTempFile(); + const sessionManager = createSessionManager({ persisted: false, sessionFile }); + + expect(formatResumeCommand(sessionManager)).toBeUndefined(); + }); + + it("returns undefined when the session file is missing", () => { + setStdoutIsTTY(true); + const sessionManager = createSessionManager({ sessionFile: "/tmp/pi-missing-session.jsonl" }); + + expect(formatResumeCommand(sessionManager)).toBeUndefined(); + }); + + it("returns undefined when the session file is not set", () => { + setStdoutIsTTY(true); + const sessionManager = createSessionManager({ sessionFile: undefined }); + + expect(formatResumeCommand(sessionManager)).toBeUndefined(); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/5080-signal-shutdown-extension-cleanup.test.ts b/packages/coding-agent/test/suite/regressions/5080-signal-shutdown-extension-cleanup.test.ts index d8bcd867..3f221f4b 100644 --- a/packages/coding-agent/test/suite/regressions/5080-signal-shutdown-extension-cleanup.test.ts +++ b/packages/coding-agent/test/suite/regressions/5080-signal-shutdown-extension-cleanup.test.ts @@ -1,4 +1,10 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import chalk from "chalk"; import { afterEach, describe, expect, test, vi } from "vitest"; +import { APP_NAME } from "../../../src/config.ts"; +import type { SessionManager } from "../../../src/core/session-manager.ts"; import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts"; // Regression for https://github.com/earendil-works/pi/issues/5080 @@ -16,6 +22,7 @@ type ShutdownThis = { runtimeHost: { dispose: () => Promise }; ui: { terminal: { drainInput: (ms: number) => Promise } }; stop: () => void; + sessionManager: SessionManager; }; type InteractiveModePrototypeWithShutdown = { @@ -23,10 +30,42 @@ type InteractiveModePrototypeWithShutdown = { }; const interactiveModePrototype = InteractiveMode.prototype as unknown; +const tempDirs: string[] = []; +const originalStdoutIsTTY = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); class ProcessExitError extends Error {} -function createContext(order: string[]): ShutdownThis { +function createSessionManager(options: { sessionFile?: string } = {}): SessionManager { + return { + isPersisted: () => options.sessionFile !== undefined, + getSessionFile: () => options.sessionFile, + getSessionId: () => "test-session", + getSessionDir: () => "/tmp/pi-sessions", + usesDefaultSessionDir: () => true, + } as unknown as SessionManager; +} + +function createTempFile(): string { + const dir = mkdtempSync(join(tmpdir(), "pi-shutdown-resume-hint-")); + tempDirs.push(dir); + const file = join(dir, "session.jsonl"); + writeFileSync(file, "\n"); + return file; +} + +function setStdoutIsTTY(value: boolean): void { + Object.defineProperty(process.stdout, "isTTY", { configurable: true, value }); +} + +function restoreStdoutIsTTY(): void { + if (originalStdoutIsTTY) { + Object.defineProperty(process.stdout, "isTTY", originalStdoutIsTTY); + } else { + Reflect.deleteProperty(process.stdout, "isTTY"); + } +} + +function createContext(order: string[], sessionManager = createSessionManager()): ShutdownThis { return { isShuttingDown: false, unregisterSignalHandlers: vi.fn(), @@ -45,6 +84,7 @@ function createContext(order: string[]): ShutdownThis { stop: vi.fn(() => { order.push("stop"); }), + sessionManager, }; } @@ -59,6 +99,10 @@ async function callShutdown(context: ShutdownThis, options?: { fromSignal?: bool describe("InteractiveMode.shutdown ordering (#5080)", () => { afterEach(() => { vi.restoreAllMocks(); + restoreStdoutIsTTY(); + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } }); test("signal-triggered shutdown emits session_shutdown before terminal writes", async () => { @@ -87,6 +131,43 @@ describe("InteractiveMode.shutdown ordering (#5080)", () => { expect(order).toEqual(["drainInput", "stop", "dispose"]); }); + test("interactive quit prints a resume hint for persisted sessions", async () => { + vi.spyOn(process, "exit").mockImplementation((() => { + throw new ProcessExitError(); + }) as typeof process.exit); + const stdoutWrite = vi + .spyOn(process.stdout, "write") + .mockImplementation((() => true) as typeof process.stdout.write); + setStdoutIsTTY(true); + const order: string[] = []; + const context = createContext(order, createSessionManager({ sessionFile: createTempFile() })); + + await callShutdown(context); + + expect(order).toEqual(["drainInput", "stop", "dispose"]); + expect(stdoutWrite).toHaveBeenCalledWith( + ` ${chalk.dim("To resume this session:")} ${APP_NAME} --session test-session\n`, + ); + }); + + test("signal-triggered shutdown does not print a resume hint", async () => { + vi.spyOn(process, "exit").mockImplementation((() => { + throw new ProcessExitError(); + }) as typeof process.exit); + const stdoutWrite = vi + .spyOn(process.stdout, "write") + .mockImplementation((() => true) as typeof process.stdout.write); + setStdoutIsTTY(true); + const order: string[] = []; + const context = createContext(order, createSessionManager({ sessionFile: createTempFile() })); + + await callShutdown(context, { fromSignal: true }); + + for (const call of stdoutWrite.mock.calls) { + expect(call[0]).not.toContain("To resume this session:"); + } + }); + test("re-entrant shutdown is a no-op", async () => { vi.spyOn(process, "exit").mockImplementation((() => { throw new ProcessExitError(); From 7619aaefa9a6bb29d3fc623de303220b813e2fcd Mon Sep 17 00:00:00 2001 From: Stephan Schneider Date: Fri, 29 May 2026 10:21:44 +0200 Subject: [PATCH 094/352] ai: add custom-header support to Bedrock provider Honour StreamOptions.headers in the Bedrock provider by attaching a Smithy build-step middleware that merges caller headers into the request before SigV4 signing. Reserved headers (x-amz-*, authorization, host) are silently skipped to preserve signing and the bearer-token auth path. No-op when no headers are supplied. Updates the StreamOptions.headers JSDoc to drop the Bedrock caveat. --- packages/ai/src/providers/amazon-bedrock.ts | 41 +++- packages/ai/src/types.ts | 6 +- .../ai/test/bedrock-custom-headers.test.ts | 202 ++++++++++++++++++ 3 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 packages/ai/test/bedrock-custom-headers.test.ts diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index c0da66e2..b5c88bc5 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -21,7 +21,7 @@ import { ToolResultStatus, } from "@aws-sdk/client-bedrock-runtime"; import { NodeHttpHandler } from "@smithy/node-http-handler"; -import type { DocumentType } from "@smithy/types"; +import type { BuildMiddleware, DocumentType, MetadataBearer } from "@smithy/types"; import { calculateCost } from "../models.ts"; import type { Api, @@ -182,6 +182,9 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt try { const client = new BedrockRuntimeClient(config); + if (options.headers && Object.keys(options.headers).length > 0) { + addCustomHeadersMiddleware(client, options.headers); + } const cacheRetention = resolveCacheRetention(options.cacheRetention); const inferenceMaxTokens = options.maxTokens ?? (isAnthropicClaudeModel(model) ? model.maxTokens : undefined); let commandInput = { @@ -296,6 +299,42 @@ function formatBedrockError(error: unknown): string { return message; } +/** + * Header keys that must never be overwritten by caller-supplied headers. + * `host` and `x-amz-*` participate in the SigV4 canonical request; `authorization` + * is owned by SigV4 or the bearer-token path (config.token + authSchemePreference). + * Compared case-insensitively (caller key is lower-cased before lookup). + */ +const RESERVED_HEADER_EXACT = new Set(["authorization", "host"]); + +function isReservedHeader(key: string): boolean { + const lower = key.toLowerCase(); + return lower.startsWith("x-amz-") || RESERVED_HEADER_EXACT.has(lower); +} + +/** + * Attach caller-supplied headers to the outgoing Bedrock request via a Smithy + * `build`-step middleware. The `build` step runs after request serialisation but + * before SigV4 signing, so injected headers are covered by the signature. Reserved + * SigV4 / auth headers (`x-amz-*`, `authorization`, `host`) are silently skipped; + * all other caller headers override any existing same-named header on the request. + */ +function addCustomHeadersMiddleware(client: BedrockRuntimeClient, headers: Record): void { + const middleware: BuildMiddleware = (next) => async (args) => { + const request = args.request; + if (request && typeof request === "object" && "headers" in request) { + const requestHeaders = (request as { headers: Record }).headers; + for (const [key, value] of Object.entries(headers)) { + if (!isReservedHeader(key)) { + requestHeaders[key] = value; + } + } + } + return next(args); + }; + client.middlewareStack.add(middleware, { step: "build", name: "pi-ai-custom-headers", priority: "low" }); +} + export const streamSimpleBedrock: StreamFunction<"bedrock-converse-stream", SimpleStreamOptions> = ( model: Model<"bedrock-converse-stream">, context: Context, diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index bf997317..a99d1416 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -114,8 +114,10 @@ export interface StreamOptions { onResponse?: (response: ProviderResponse, model: Model) => void | Promise; /** * Optional custom HTTP headers to include in API requests. - * Merged with provider defaults; can override default headers. - * Not supported by all providers (e.g., AWS Bedrock uses SDK auth). + * Merged with provider defaults; caller values override default headers. + * On AWS Bedrock these are injected via a Smithy `build`-step middleware so + * they are covered by SigV4 signing; reserved headers (`x-amz-*`, + * `authorization`, `host`) are silently ignored to preserve SigV4 / bearer auth. */ headers?: Record; /** diff --git a/packages/ai/test/bedrock-custom-headers.test.ts b/packages/ai/test/bedrock-custom-headers.test.ts new file mode 100644 index 00000000..1017d089 --- /dev/null +++ b/packages/ai/test/bedrock-custom-headers.test.ts @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type MiddlewareHandler = (next: (args: unknown) => Promise) => (args: unknown) => Promise; + +const bedrockMock = vi.hoisted(() => ({ + middlewareRegistrations: [] as Array<{ + handler: MiddlewareHandler; + opts: { step?: string; name?: string; priority?: string }; + }>, +})); + +vi.mock("@aws-sdk/client-bedrock-runtime", () => { + class BedrockRuntimeServiceException extends Error {} + + class BedrockRuntimeClient { + middlewareStack = { + add: (handler: MiddlewareHandler, opts: { step?: string; name?: string; priority?: string }) => { + bedrockMock.middlewareRegistrations.push({ handler, opts }); + }, + }; + + send(): Promise { + return Promise.reject(new Error("mock send")); + } + } + + class ConverseStreamCommand { + readonly input: unknown; + + constructor(input: unknown) { + this.input = input; + } + } + + return { + BedrockRuntimeClient, + BedrockRuntimeServiceException, + ConverseStreamCommand, + StopReason: { + END_TURN: "end_turn", + STOP_SEQUENCE: "stop_sequence", + MAX_TOKENS: "max_tokens", + MODEL_CONTEXT_WINDOW_EXCEEDED: "model_context_window_exceeded", + TOOL_USE: "tool_use", + }, + CachePointType: { DEFAULT: "default" }, + CacheTTL: { ONE_HOUR: "ONE_HOUR" }, + ConversationRole: { ASSISTANT: "assistant", USER: "user" }, + ImageFormat: { JPEG: "jpeg", PNG: "png", GIF: "gif", WEBP: "webp" }, + ToolResultStatus: { ERROR: "error", SUCCESS: "success" }, + }; +}); + +import { getModel } from "../src/models.ts"; +import type { BedrockOptions } from "../src/providers/amazon-bedrock.ts"; +import { streamBedrock, streamSimpleBedrock } from "../src/providers/amazon-bedrock.ts"; +import type { Context, Model } from "../src/types.ts"; + +const context: Context = { + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], +}; + +const MIDDLEWARE_NAME = "pi-ai-custom-headers"; + +function getModelFixture(): Model<"bedrock-converse-stream"> { + return getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8"); +} + +/** + * Drive a stream to completion so the middleware (registered before `client.send`) + * is captured even though the mocked `send()` rejects. Errors are swallowed because + * the rejecting mock is expected — we only care about the recorded registrations. + */ +async function driveBedrock(options: BedrockOptions): Promise { + await streamBedrock(getModelFixture(), context, options) + .result() + .catch(() => undefined); +} + +function findCustomHeadersRegistration() { + const matches = bedrockMock.middlewareRegistrations.filter((r) => r.opts.name === MIDDLEWARE_NAME); + return matches; +} + +beforeEach(() => { + bedrockMock.middlewareRegistrations.length = 0; +}); + +describe("bedrock custom headers middleware", () => { + it("VC1: registers a build-step middleware that injects the caller header (happy path)", async () => { + await driveBedrock({ cacheRetention: "none", headers: { "x-custom": "v" } }); + + const registrations = findCustomHeadersRegistration(); + expect(registrations).toHaveLength(1); + + const [reg] = registrations; + expect(reg.opts.step).toBe("build"); + expect(reg.opts.priority).toBe("low"); + expect(reg.opts.name).toBe(MIDDLEWARE_NAME); + + const nextSpy = vi.fn(async (a: unknown) => a); + const fakeArgs = { request: { headers: {} as Record } }; + await reg.handler(nextSpy)(fakeArgs); + + expect(fakeArgs.request.headers["x-custom"]).toBe("v"); + expect(nextSpy).toHaveBeenCalledTimes(1); + expect(nextSpy).toHaveBeenCalledWith(fakeArgs); + }); + + it("VC2: skips reserved headers case-insensitively while applying allowed ones", async () => { + await driveBedrock({ + cacheRetention: "none", + headers: { + authorization: "evil", + "x-amz-date": "evil", + "x-allowed": "ok", + Authorization: "evil2", + "X-Amz-Date": "evil2", + HOST: "evil3", + }, + }); + + const [reg] = findCustomHeadersRegistration(); + expect(reg).toBeDefined(); + + const nextSpy = vi.fn(async (a: unknown) => a); + const fakeArgs = { + request: { + headers: { + authorization: "real-auth", + "x-amz-date": "real-date", + host: "real-host", + } as Record, + }, + }; + await reg.handler(nextSpy)(fakeArgs); + + expect(fakeArgs.request.headers.authorization).toBe("real-auth"); + expect(fakeArgs.request.headers["x-amz-date"]).toBe("real-date"); + expect(fakeArgs.request.headers.host).toBe("real-host"); + expect(fakeArgs.request.headers["x-allowed"]).toBe("ok"); + // Mixed-case reserved keys must be skipped too: a case-sensitive guard would + // add them back as distinct capitalised keys. Assert no such leak occurred and + // that the only new key beyond the three pre-existing ones is `x-allowed`. + expect(fakeArgs.request.headers.Authorization).toBeUndefined(); + expect(fakeArgs.request.headers["X-Amz-Date"]).toBeUndefined(); + expect(fakeArgs.request.headers.HOST).toBeUndefined(); + expect(Object.keys(fakeArgs.request.headers).sort()).toEqual( + ["authorization", "host", "x-allowed", "x-amz-date"].sort(), + ); + expect(nextSpy).toHaveBeenCalledTimes(1); + }); + + it("VC3: registers no middleware when headers is undefined", async () => { + await driveBedrock({ cacheRetention: "none" }); + + expect(findCustomHeadersRegistration()).toHaveLength(0); + }); + + it("VC3: registers no middleware when headers is empty", async () => { + await driveBedrock({ cacheRetention: "none", headers: {} }); + + expect(findCustomHeadersRegistration()).toHaveLength(0); + }); + + it("VC3 (structural guard): passes through unchanged when the request has no headers", async () => { + await driveBedrock({ cacheRetention: "none", headers: { "x-custom": "v" } }); + + const [reg] = findCustomHeadersRegistration(); + expect(reg).toBeDefined(); + + const nextSpy = vi.fn(async (a: unknown) => a); + + const argsNoHeaders = { request: {} }; + await expect(reg.handler(nextSpy)(argsNoHeaders)).resolves.toBeDefined(); + expect(nextSpy).toHaveBeenCalledWith(argsNoHeaders); + + const argsUndefinedRequest = { request: undefined }; + await expect(reg.handler(nextSpy)(argsUndefinedRequest)).resolves.toBeDefined(); + expect(nextSpy).toHaveBeenCalledWith(argsUndefinedRequest); + + expect(nextSpy).toHaveBeenCalledTimes(2); + }); + + it("VC4: streamSimpleBedrock forwards headers end-to-end (regression guard)", async () => { + await streamSimpleBedrock(getModelFixture(), context, { headers: { "x-custom": "v" } }) + .result() + .catch(() => undefined); + + const registrations = findCustomHeadersRegistration(); + expect(registrations).toHaveLength(1); + + const [reg] = registrations; + expect(reg.opts.step).toBe("build"); + + const nextSpy = vi.fn(async (a: unknown) => a); + const fakeArgs = { request: { headers: {} as Record } }; + await reg.handler(nextSpy)(fakeArgs); + + expect(fakeArgs.request.headers["x-custom"]).toBe("v"); + }); +}); From ce554ad3dec5c675a737cc3bc4f5a62809b4c166 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 29 May 2026 11:40:44 +0200 Subject: [PATCH 095/352] Add startup session name flag closes #5153 --- packages/coding-agent/CHANGELOG.md | 2 + packages/coding-agent/README.md | 5 + packages/coding-agent/docs/quickstart.md | 1 + packages/coding-agent/docs/rpc.md | 3 +- packages/coding-agent/docs/session-format.md | 2 +- packages/coding-agent/docs/sessions.md | 8 ++ packages/coding-agent/docs/usage.md | 5 + packages/coding-agent/src/cli/args.ts | 11 ++ packages/coding-agent/src/main.ts | 8 ++ packages/coding-agent/test/args.test.ts | 30 ++++ .../test/startup-session-name.test.ts | 135 ++++++++++++++++++ 11 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 packages/coding-agent/test/startup-session-name.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7444d7a0..86e63527 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,7 @@ ### New Features +- **Startup session naming** - `--name` / `-n` sets the session display name at startup for interactive, print, JSON, and RPC modes. - **Claude Opus 4.8 support** - Adds Anthropic Claude Opus 4.8 metadata and updates Opus adaptive-thinking coverage. - **Selective tool disablement** - `--exclude-tools` / `-xt` disables specific built-in, extension, or custom tools while leaving the rest available. See [Tool Options](docs/usage.md#tool-options). - **Headless Codex subscription login** - `/login` can use device-code auth for ChatGPT Plus/Pro Codex subscriptions. See [Subscriptions](docs/providers.md#subscriptions) and [OpenAI Codex](docs/providers.md#openai-codex). @@ -17,6 +18,7 @@ ### Added +- Added `--name` / `-n` to set the session display name at startup ([#5153](https://github.com/earendil-works/pi/issues/5153)). - Added `--exclude-tools` / `-xt` to disable specific built-in, extension, or custom tools while leaving the rest available ([#5109](https://github.com/earendil-works/pi/issues/5109)). - Added OpenAI Codex subscription device-code login as a selectable headless alternative while keeping browser login as the default ([#4911](https://github.com/earendil-works/pi/pull/4911) by [@vegarsti](https://github.com/vegarsti)). - Added `streamingBehavior` to extension input events so extensions can distinguish idle prompts from mid-stream steers and queued follow-ups ([#5107](https://github.com/earendil-works/pi/pull/5107) by [@DanielThomas](https://github.com/DanielThomas)). diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index a326d2f0..0cf35255 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -239,6 +239,7 @@ Sessions auto-save to `~/.pi/agent/sessions/` organized by working directory. pi -c # Continue most recent session pi -r # Browse and select from past sessions pi --no-session # Ephemeral mode (don't save) +pi --name "my task" # Set session display name at startup pi --session # Use specific session file or ID pi --fork # Fork specific session file or ID into a new session ``` @@ -545,6 +546,7 @@ cat README.md | pi -p "Summarize this text" | `--fork ` | Fork specific session file or partial UUID into a new session | | `--session-dir ` | Custom session storage directory | | `--no-session` | Ephemeral mode (don't save) | +| `--name `, `-n ` | Set session display name at startup | ### Tool Options @@ -605,6 +607,9 @@ pi -p "Summarize this codebase" # Non-interactive with piped stdin cat README.md | pi -p "Summarize this text" +# Named one-shot session +pi --name "release audit" -p "Audit this repository" + # Different model pi --provider openai --model gpt-4o "Help me refactor" diff --git a/packages/coding-agent/docs/quickstart.md b/packages/coding-agent/docs/quickstart.md index c7266346..59026738 100644 --- a/packages/coding-agent/docs/quickstart.md +++ b/packages/coding-agent/docs/quickstart.md @@ -136,6 +136,7 @@ Sessions are saved automatically: ```bash pi -c # Continue most recent session pi -r # Browse previous sessions +pi --name "my task" # Set session display name at startup pi --session # Open a specific session ``` diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index 7a284453..846162c3 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -13,6 +13,7 @@ pi --mode rpc [options] Common options: - `--provider `: Set the LLM provider (anthropic, openai, google, etc.) - `--model `: Model pattern or ID (supports `provider/id` and optional `:`) +- `--name ` / `-n `: Set the session display name at startup - `--no-session`: Disable session persistence - `--session-dir `: Custom session storage directory @@ -694,7 +695,7 @@ Response: } ``` -The current session name is available via `get_state` in the `sessionName` field. +The current session name is available via `get_state` in the `sessionName` field. To set the initial name when starting RPC mode, pass `--name ` or `-n ` to the `pi --mode rpc` process. ### Commands diff --git a/packages/coding-agent/docs/session-format.md b/packages/coding-agent/docs/session-format.md index 848fc61a..c32ce390 100644 --- a/packages/coding-agent/docs/session-format.md +++ b/packages/coding-agent/docs/session-format.md @@ -282,7 +282,7 @@ Set `label` to `undefined` to clear a label. ### SessionInfoEntry -Session metadata (e.g., user-defined display name). Set via `/name` command or `pi.setSessionName()` in extensions. +Session metadata (e.g., user-defined display name). Set via `/name`, `--name` / `-n`, or `pi.setSessionName()` in extensions. ```json {"type":"session_info","id":"k1l2m3n4","parentId":"j0k1l2m3","timestamp":"2024-12-03T14:35:00.000Z","name":"Refactor auth module"} diff --git a/packages/coding-agent/docs/sessions.md b/packages/coding-agent/docs/sessions.md index d262f853..1a50ee02 100644 --- a/packages/coding-agent/docs/sessions.md +++ b/packages/coding-agent/docs/sessions.md @@ -10,6 +10,7 @@ Sessions auto-save to `~/.pi/agent/sessions/`, organized by working directory. E pi -c # Continue most recent session pi -r # Browse and select from past sessions pi --no-session # Ephemeral mode; do not save +pi --name "my task" # Set session display name at startup pi --session # Use a specific session file or partial session ID pi --fork # Fork a session file or partial session ID into a new session ``` @@ -56,6 +57,13 @@ Use `/name ` to set a human-readable session name: /name Refactor auth module ``` +Set the name at startup with `--name` or `-n`: + +```bash +pi --name "Refactor auth module" +pi --name "CI audit" -p "Review this build failure" +``` + Named sessions are easier to find in `/resume` and `pi -r`. ## Branching with `/tree` diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 6e606912..47d08769 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -76,6 +76,7 @@ Sessions are saved automatically to `~/.pi/agent/sessions/`, organized by workin pi -c # Continue most recent session pi -r # Browse and select a session pi --no-session # Ephemeral mode; do not save +pi --name "my task" # Set session display name at startup pi --session # Use a specific session file or session ID pi --fork # Fork a session into a new session file ``` @@ -178,6 +179,7 @@ cat README.md | pi -p "Summarize this text" | `--fork ` | Fork a session file or partial UUID into a new session | | `--session-dir ` | Custom session storage directory | | `--no-session` | Ephemeral mode; do not save | +| `--name `, `-n ` | Set session display name at startup | ### Tool Options @@ -242,6 +244,9 @@ pi -p "Summarize this codebase" # Non-interactive with piped stdin cat README.md | pi -p "Summarize this text" +# Named one-shot session +pi --name "release audit" -p "Audit this repository" + # Different model pi --provider openai --model gpt-4o "Help me refactor" diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 72317cfd..2decae24 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -21,6 +21,7 @@ export interface Args { help?: boolean; version?: boolean; mode?: Mode; + name?: string; noSession?: boolean; session?: string; sessionId?: string; @@ -93,6 +94,12 @@ export function parseArgs(args: string[]): Args { } else if (arg === "--append-system-prompt" && i + 1 < args.length) { result.appendSystemPrompt = result.appendSystemPrompt ?? []; result.appendSystemPrompt.push(args[++i]); + } else if (arg === "--name" || arg === "-n") { + if (i + 1 < args.length) { + result.name = args[++i]; + } else { + result.diagnostics.push({ type: "error", message: "--name requires a value" }); + } } else if (arg === "--no-session") { result.noSession = true; } else if (arg === "--session" && i + 1 < args.length) { @@ -237,6 +244,7 @@ ${chalk.bold("Options:")} --fork Fork specific session file or partial UUID into a new session --session-dir Directory for session storage and lookup --no-session Don't save session (ephemeral) + --name, -n Set session display name --models Comma-separated model patterns for Ctrl+P cycling Supports globs (anthropic/*, *sonnet*) and fuzzy matching --no-tools, -nt Disable all tools by default (built-in and extension) @@ -283,6 +291,9 @@ ${chalk.bold("Examples:")} # Continue previous session ${APP_NAME} --continue "What did we discuss?" + # Start a named session + ${APP_NAME} --name "Refactor auth module" + # Use different model ${APP_NAME} --provider openai --model gpt-4o-mini "Help me refactor this code" diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 4b6c4e16..353e4815 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -571,6 +571,14 @@ export async function main(args: string[], options?: MainOptions) { process.exit(1); } } + if (parsed.name !== undefined) { + const name = parsed.name.trim(); + if (!name) { + console.error(chalk.red("Error: --name requires a non-empty value")); + process.exit(1); + } + sessionManager.appendSessionInfo(name); + } time("createSessionManager"); const resolvedExtensionPaths = resolveCliPaths(cwd, parsed.extensions); diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index 8f26a0ee..78fd832c 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -157,6 +157,36 @@ describe("parseArgs", () => { }); }); + describe("--name flag", () => { + test("parses --name flag with value", () => { + const result = parseArgs(["--name", "my-session"]); + expect(result.name).toBe("my-session"); + }); + + test("parses -n shorthand", () => { + const result = parseArgs(["-n", "quick-session"]); + expect(result.name).toBe("quick-session"); + }); + + test("preserves empty values for main validation", () => { + const result = parseArgs(["--name", ""]); + expect(result.name).toBe(""); + }); + + test("reports missing value", () => { + const result = parseArgs(["--name"]); + expect(result.diagnostics).toEqual([{ type: "error", message: "--name requires a value" }]); + }); + + test("works alongside other flags", () => { + const result = parseArgs(["--name", "named-run", "--print", "--model", "gpt-4o", "hello"]); + expect(result.name).toBe("named-run"); + expect(result.print).toBe(true); + expect(result.model).toBe("gpt-4o"); + expect(result.messages).toEqual(["hello"]); + }); + }); + describe("--no-session flag", () => { test("parses --no-session flag", () => { const result = parseArgs(["--no-session"]); diff --git a/packages/coding-agent/test/startup-session-name.test.ts b/packages/coding-agent/test/startup-session-name.test.ts new file mode 100644 index 00000000..2d6f94ce --- /dev/null +++ b/packages/coding-agent/test/startup-session-name.test.ts @@ -0,0 +1,135 @@ +import { spawn } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { ENV_AGENT_DIR } from "../src/config.ts"; + +const cliPath = resolve(__dirname, "../src/cli.ts"); +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function createTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "pi-startup-session-name-")); + tempDirs.push(dir); + return dir; +} + +interface CliDirs { + agentDir: string; + projectDir: string; + sessionFile: string; +} + +interface CliResult { + code: number | null; + signal: NodeJS.Signals | null; + stderr: string; +} + +function createSessionFile(projectDir: string, sessionFile: string): void { + const timestamp = new Date().toISOString(); + writeFileSync( + sessionFile, + `${JSON.stringify({ type: "session", version: 3, id: "existing-session", timestamp, cwd: projectDir })}\n${JSON.stringify( + { + type: "message", + id: "assistant-1", + parentId: null, + timestamp, + message: { + role: "assistant", + content: [{ type: "text", text: "hello" }], + provider: "anthropic", + model: "claude-sonnet-4-5", + timestamp: Date.now(), + }, + }, + )}\n`, + ); +} + +function readSessionInfoNames(sessionFile: string): string[] { + return readFileSync(sessionFile, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { type?: string; name?: string }) + .filter((entry) => entry.type === "session_info") + .map((entry) => entry.name ?? ""); +} + +async function runCli(args: string[], dirs: CliDirs): Promise { + let stderr = ""; + const child = spawn(process.execPath, [cliPath, ...args], { + cwd: dirs.projectDir, + env: { + ...process.env, + [ENV_AGENT_DIR]: dirs.agentDir, + PI_OFFLINE: "1", + TSX_TSCONFIG_PATH: resolve(__dirname, "../../../tsconfig.json"), + }, + stdio: ["ignore", "ignore", "pipe"], + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + return new Promise((resolvePromise, reject) => { + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + }, 10_000); + child.on("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + child.on("close", (code, signal) => { + clearTimeout(timeout); + resolvePromise({ code, signal, stderr }); + }); + }); +} + +function setup(): CliDirs { + const tempRoot = createTempDir(); + const dirs = { + agentDir: join(tempRoot, "agent"), + projectDir: join(tempRoot, "project"), + sessionFile: join(tempRoot, "session.jsonl"), + }; + mkdirSync(dirs.agentDir, { recursive: true }); + mkdirSync(dirs.projectDir, { recursive: true }); + createSessionFile(dirs.projectDir, dirs.sessionFile); + return dirs; +} + +describe("startup session name", () => { + it("sets --name on the selected session before runtime model validation", async () => { + const dirs = setup(); + const result = await runCli( + ["--session", dirs.sessionFile, "--name", " CLI Named Session ", "--model", "missing-model", "-p", "hi"], + dirs, + ); + + expect(result.code).toBe(1); + expect(result.signal).toBeNull(); + expect(readSessionInfoNames(dirs.sessionFile)).toEqual(["CLI Named Session"]); + }); + + it("rejects empty --name values without appending session metadata", async () => { + const dirs = setup(); + const result = await runCli( + ["--session", dirs.sessionFile, "--name", " ", "--model", "missing-model", "-p", "hi"], + dirs, + ); + + expect(result.code).toBe(1); + expect(result.signal).toBeNull(); + expect(result.stderr).toContain("--name requires a non-empty value"); + expect(readSessionInfoNames(dirs.sessionFile)).toEqual([]); + }); +}); From 4faac05419befa45e051c539e8f712b13a76758a Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 29 May 2026 12:21:37 +0200 Subject: [PATCH 096/352] fix(ai): handle OpenCode reasoning params --- packages/ai/CHANGELOG.md | 4 +++ packages/ai/README.md | 2 +- packages/ai/scripts/generate-models.ts | 15 ++++++-- .../ai/src/providers/openai-completions.ts | 2 +- packages/ai/src/types.ts | 2 +- .../openai-completions-tool-choice.test.ts | 34 +++++++++++++++---- packages/ai/test/supports-xhigh.test.ts | 12 +++++++ 7 files changed, 60 insertions(+), 11 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 7266c79b..6b7b6542 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed OpenCode Go Kimi K2.6 thinking requests to send `thinking` objects instead of invalid string values, and fixed OpenCode Zen Grok Build thinking requests to omit unsupported `reasoning_effort` ([#5169](https://github.com/earendil-works/pi/issues/5169)). + ## [0.77.0] - 2026-05-28 ### Added diff --git a/packages/ai/README.md b/packages/ai/README.md index 1790a64c..e6624d3b 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -938,7 +938,7 @@ interface OpenAICompletionsCompat { requiresAssistantAfterToolResult?: boolean; // Whether tool results must be followed by an assistant message (default: false) requiresThinkingAsText?: boolean; // Whether thinking blocks must be converted to text (default: false) requiresReasoningContentOnAssistantMessages?: boolean; // Whether all replayed assistant messages must include empty reasoning_content when reasoning is enabled (default: auto-detected for DeepSeek) - thinkingFormat?: 'openai' | 'openrouter' | 'deepseek' | 'together' | 'zai' | 'qwen' | 'qwen-chat-template'; // Format for reasoning param: 'openai' uses reasoning_effort, 'openrouter' uses reasoning: { effort }, 'deepseek' uses thinking: { type } plus reasoning_effort, 'together' uses reasoning: { enabled } plus reasoning_effort when supported, 'zai' uses enable_thinking, 'qwen' uses enable_thinking, 'qwen-chat-template' uses chat_template_kwargs.enable_thinking (default: openai) + thinkingFormat?: 'openai' | 'openrouter' | 'deepseek' | 'together' | 'zai' | 'qwen' | 'qwen-chat-template'; // Format for reasoning param: 'openai' uses reasoning_effort, 'openrouter' uses reasoning: { effort }, 'deepseek' uses thinking: { type } plus reasoning_effort when supported, 'together' uses reasoning: { enabled } plus reasoning_effort when supported, 'zai' uses enable_thinking, 'qwen' uses enable_thinking, 'qwen-chat-template' uses chat_template_kwargs.enable_thinking (default: openai) cacheControlFormat?: 'anthropic'; // Anthropic-style cache_control on system prompt, last tool, and last user/assistant text content openRouterRouting?: OpenRouterRouting; // OpenRouter routing preferences (default: {}) vercelGatewayRouting?: VercelGatewayRouting; // Vercel AI Gateway routing preferences (default: {}) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index a8853ac5..b95089ce 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -275,7 +275,12 @@ function applyThinkingLevelMetadata(model: Model): void { mergeThinkingLevelMap(model, { off: null }); } if (model.provider === "opencode-go" && model.id === "kimi-k2.6") { - mergeThinkingLevelMap(model, { off: "none" }); + // OpenCode Go exposes Kimi K2.6 thinking as on/off, not distinct effort tiers. + mergeThinkingLevelMap(model, { minimal: null, low: null, medium: null }); + } + if (model.provider === "opencode" && model.id === "grok-build-0.1") { + // OpenCode Zen Grok Build reasons by default but rejects explicit reasoningEffort. + mergeThinkingLevelMap(model, { off: null, minimal: null, low: null, medium: null }); } } @@ -896,6 +901,10 @@ async function loadModelsDevData(): Promise[]> { baseUrl = `${variant.basePath}/v1`; } + if (variant.provider === "opencode" && modelId === "grok-build-0.1") { + compat = { ...(compat ?? {}), supportsReasoningEffort: false }; + } + // Fix known mismatches between models.dev npm data and actual // OpenCode Go endpoint behaviour. models.dev reports these models // as @ai-sdk/anthropic, but the OpenCode Go endpoints either don't @@ -909,7 +918,9 @@ async function loadModelsDevData(): Promise[]> { baseUrl = `${variant.basePath}/v1`; } if (modelId === "kimi-k2.6") { - compat = { ...(compat ?? {}), thinkingFormat: "string-thinking" }; + // OpenCode Go Kimi K2.6 accepts Anthropic-style thinking objects + // and rejects string thinking values or combined reasoning_effort. + compat = { ...(compat ?? {}), thinkingFormat: "deepseek", supportsReasoningEffort: false }; } if (modelId === "qwen3.5-plus" || modelId === "qwen3.6-plus") { api = "openai-completions"; diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 2e243fb7..6834d1e7 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -571,7 +571,7 @@ function buildParams( }; } else if (compat.thinkingFormat === "deepseek" && model.reasoning) { (params as any).thinking = { type: options?.reasoningEffort ? "enabled" : "disabled" }; - if (options?.reasoningEffort) { + if (options?.reasoningEffort && compat.supportsReasoningEffort) { (params as any).reasoning_effort = model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort; } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index bf997317..9475d26c 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -387,7 +387,7 @@ export interface OpenAICompletionsCompat { requiresThinkingAsText?: boolean; /** Whether all replayed assistant messages must include an empty reasoning_content field when reasoning is enabled. Default: auto-detected from URL. */ requiresReasoningContentOnAssistantMessages?: boolean; - /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses top-level enable_thinking: boolean, "qwen" uses top-level enable_thinking: boolean, "qwen-chat-template" uses chat_template_kwargs.enable_thinking, and "string-thinking" uses top-level thinking: string. Default: "openai". */ + /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort when supported, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses top-level enable_thinking: boolean, "qwen" uses top-level enable_thinking: boolean, "qwen-chat-template" uses chat_template_kwargs.enable_thinking, and "string-thinking" uses top-level thinking: string. Default: "openai". */ thinkingFormat?: | "openai" | "openrouter" diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index 66cfb562..81dfb1d4 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -995,7 +995,7 @@ describe("openai-completions tool_choice", () => { expect(messages[0]).not.toHaveProperty("reasoning"); }); - it("sends thinking none for OpenCode Go Kimi K2.6 when thinking is off", async () => { + it("sends thinking disabled for OpenCode Go Kimi K2.6 when thinking is off", async () => { const model = getModel("opencode-go", "kimi-k2.6")!; let payload: unknown; @@ -1012,12 +1012,12 @@ describe("openai-completions tool_choice", () => { }, ).result(); - const params = (payload ?? mockState.lastParams) as { thinking?: string; reasoning_effort?: string }; - expect(params.thinking).toBe("none"); + const params = (payload ?? mockState.lastParams) as { thinking?: unknown; reasoning_effort?: string }; + expect(params.thinking).toEqual({ type: "disabled" }); expect(params.reasoning_effort).toBeUndefined(); }); - it("sends thinking effort for OpenCode Go Kimi K2.6 when thinking is enabled", async () => { + it("sends thinking enabled for OpenCode Go Kimi K2.6 when thinking is enabled", async () => { const model = getModel("opencode-go", "kimi-k2.6")!; let payload: unknown; @@ -1035,8 +1035,30 @@ describe("openai-completions tool_choice", () => { }, ).result(); - const params = (payload ?? mockState.lastParams) as { thinking?: string; reasoning_effort?: string }; - expect(params.thinking).toBe("high"); + const params = (payload ?? mockState.lastParams) as { thinking?: unknown; reasoning_effort?: string }; + expect(params.thinking).toEqual({ type: "enabled" }); + expect(params.reasoning_effort).toBeUndefined(); + }); + + it("omits reasoning effort for OpenCode Grok Build", async () => { + const model = getModel("opencode", "grok-build-0.1")!; + let payload: unknown; + + await streamSimple( + model, + { + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + reasoning: "high", + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = (payload ?? mockState.lastParams) as { reasoning_effort?: string }; expect(params.reasoning_effort).toBeUndefined(); }); diff --git a/packages/ai/test/supports-xhigh.test.ts b/packages/ai/test/supports-xhigh.test.ts index 5d9bdd9e..d5ec3b5b 100644 --- a/packages/ai/test/supports-xhigh.test.ts +++ b/packages/ai/test/supports-xhigh.test.ts @@ -56,6 +56,18 @@ describe("getSupportedThinkingLevels", () => { expect(getSupportedThinkingLevels(model!)).toEqual(["off", "high", "xhigh"]); }); + it("includes only high plus off for OpenCode Go Kimi K2.6", () => { + const model = getModel("opencode-go", "kimi-k2.6"); + expect(model).toBeDefined(); + expect(getSupportedThinkingLevels(model!)).toEqual(["off", "high"]); + }); + + it("includes only high for OpenCode Grok Build", () => { + const model = getModel("opencode", "grok-build-0.1"); + expect(model).toBeDefined(); + expect(getSupportedThinkingLevels(model!)).toEqual(["high"]); + }); + it("includes only high/xhigh plus off for DeepSeek V4 Flash on OpenRouter", () => { const model = getModel("openrouter", "deepseek/deepseek-v4-flash"); expect(model).toBeDefined(); From 42ce989a62df55c78425c8c6c0ac69691b3ee080 Mon Sep 17 00:00:00 2001 From: Marek Pazik Date: Fri, 29 May 2026 21:06:26 +1000 Subject: [PATCH 097/352] feat(coding-agent): hyperlink file paths in tool titles Wrap the path shown in read/write/edit/ls tool titles in an OSC 8 file:// hyperlink when the terminal advertises hyperlink support. The link target is always the absolute path, while the visible text stays relative/home-shortened and line annotations (e.g. :120-140) are kept out of the target. --- packages/coding-agent/src/core/tools/edit.ts | 31 ++++++++++--------- packages/coding-agent/src/core/tools/find.ts | 8 ++--- packages/coding-agent/src/core/tools/grep.ts | 5 +-- packages/coding-agent/src/core/tools/ls.ts | 18 +++++------ packages/coding-agent/src/core/tools/read.ts | 13 ++++---- .../src/core/tools/render-utils.ts | 25 +++++++++++++-- packages/coding-agent/src/core/tools/write.ts | 15 ++++----- 7 files changed, 66 insertions(+), 49 deletions(-) diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index 2812b661..25aa1e17 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -4,6 +4,7 @@ import { constants } from "fs"; import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "fs/promises"; import { type Static, Type } from "typebox"; import { renderDiff } from "../../modes/interactive/components/diff.ts"; +import type { Theme } from "../../modes/interactive/theme/theme.ts"; import type { ToolDefinition } from "../extensions/types.ts"; import { applyEditsToNormalizedContent, @@ -20,7 +21,7 @@ import { } from "./edit-diff.ts"; import { withFileMutationQueue } from "./file-mutation-queue.ts"; import { resolveToCwd } from "./path-utils.ts"; -import { invalidArgText, shortenPath, str } from "./render-utils.ts"; +import { renderToolPath, str } from "./render-utils.ts"; import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; type EditPreview = EditDiffResult | EditDiffError; @@ -191,14 +192,8 @@ function getRenderablePreviewInput(args: RenderableEditArgs | undefined): { path return null; } -function formatEditCall( - args: RenderableEditArgs | undefined, - theme: typeof import("../../modes/interactive/theme/theme.ts").theme, -): string { - const invalidArg = invalidArgText(theme); - const rawPath = str(args?.file_path ?? args?.path); - const path = rawPath !== null ? shortenPath(rawPath) : null; - const pathDisplay = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."); +function formatEditCall(args: RenderableEditArgs | undefined, theme: Theme, cwd: string): string { + const pathDisplay = renderToolPath(str(args?.file_path ?? args?.path), theme, cwd); return `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`; } @@ -206,7 +201,7 @@ function formatEditResult( args: RenderableEditArgs | undefined, preview: EditPreview | undefined, result: EditToolResultLike, - theme: typeof import("../../modes/interactive/theme/theme.ts").theme, + theme: Theme, isError: boolean, ): string | undefined { const rawPath = str(args?.file_path ?? args?.path); @@ -234,7 +229,7 @@ function formatEditResult( function getEditHeaderBg( preview: EditPreview | undefined, settledError: boolean | undefined, - theme: typeof import("../../modes/interactive/theme/theme.ts").theme, + theme: Theme, ): (text: string) => string { if (preview) { if ("error" in preview) { @@ -251,11 +246,12 @@ function getEditHeaderBg( function buildEditCallComponent( component: EditCallRenderComponent, args: RenderableEditArgs | undefined, - theme: typeof import("../../modes/interactive/theme/theme.ts").theme, + theme: Theme, + cwd: string, ): EditCallRenderComponent { component.setBgFn(getEditHeaderBg(component.preview, component.settledError, theme)); component.clear(); - component.addChild(new Text(formatEditCall(args, theme), 0, 0)); + component.addChild(new Text(formatEditCall(args, theme, cwd), 0, 0)); if (!component.preview) { return component; @@ -389,7 +385,7 @@ export function createEditToolDefinition( }); } - return buildEditCallComponent(component, args, theme); + return buildEditCallComponent(component, args, theme, context.cwd); }, renderResult(result, _options, theme, context) { const callComponent = context.state.callComponent; @@ -414,7 +410,12 @@ export function createEditToolDefinition( changed = true; } if (changed) { - buildEditCallComponent(callComponent, context.args as RenderableEditArgs | undefined, theme); + buildEditCallComponent( + callComponent, + context.args as RenderableEditArgs | undefined, + theme, + context.cwd, + ); } } diff --git a/packages/coding-agent/src/core/tools/find.ts b/packages/coding-agent/src/core/tools/find.ts index d833fcb2..550dacef 100644 --- a/packages/coding-agent/src/core/tools/find.ts +++ b/packages/coding-agent/src/core/tools/find.ts @@ -5,6 +5,7 @@ import { spawn } from "child_process"; import path from "path"; import { type Static, Type } from "typebox"; import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; +import type { Theme } from "../../modes/interactive/theme/theme.ts"; import { ensureTool } from "../../utils/tools-manager.ts"; import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; import { pathExists, resolveToCwd } from "./path-utils.ts"; @@ -55,10 +56,7 @@ export interface FindToolOptions { operations?: FindOperations; } -function formatFindCall( - args: { pattern: string; path?: string; limit?: number } | undefined, - theme: typeof import("../../modes/interactive/theme/theme.ts").theme, -): string { +function formatFindCall(args: { pattern: string; path?: string; limit?: number } | undefined, theme: Theme): string { const pattern = str(args?.pattern); const rawPath = str(args?.path); const path = rawPath !== null ? shortenPath(rawPath || ".") : null; @@ -81,7 +79,7 @@ function formatFindResult( details?: FindToolDetails; }, options: ToolRenderResultOptions, - theme: typeof import("../../modes/interactive/theme/theme.ts").theme, + theme: Theme, showImages: boolean, ): string { const output = getTextOutput(result, showImages).trim(); diff --git a/packages/coding-agent/src/core/tools/grep.ts b/packages/coding-agent/src/core/tools/grep.ts index 010500df..1c4286c7 100644 --- a/packages/coding-agent/src/core/tools/grep.ts +++ b/packages/coding-agent/src/core/tools/grep.ts @@ -6,6 +6,7 @@ import { spawn } from "child_process"; import path from "path"; import { type Static, Type } from "typebox"; import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; +import type { Theme } from "../../modes/interactive/theme/theme.ts"; import { ensureTool } from "../../utils/tools-manager.ts"; import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; import { resolveToCwd } from "./path-utils.ts"; @@ -66,7 +67,7 @@ export interface GrepToolOptions { function formatGrepCall( args: { pattern: string; path?: string; glob?: string; limit?: number } | undefined, - theme: typeof import("../../modes/interactive/theme/theme.ts").theme, + theme: Theme, ): string { const pattern = str(args?.pattern); const rawPath = str(args?.path); @@ -90,7 +91,7 @@ function formatGrepResult( details?: GrepToolDetails; }, options: ToolRenderResultOptions, - theme: typeof import("../../modes/interactive/theme/theme.ts").theme, + theme: Theme, showImages: boolean, ): string { const output = getTextOutput(result, showImages).trim(); diff --git a/packages/coding-agent/src/core/tools/ls.ts b/packages/coding-agent/src/core/tools/ls.ts index 8da367a1..5c78c625 100644 --- a/packages/coding-agent/src/core/tools/ls.ts +++ b/packages/coding-agent/src/core/tools/ls.ts @@ -4,9 +4,10 @@ import { Text } from "@earendil-works/pi-tui"; import nodePath from "path"; import { type Static, Type } from "typebox"; import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; +import type { Theme } from "../../modes/interactive/theme/theme.ts"; import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; import { pathExists, resolveToCwd } from "./path-utils.ts"; -import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.ts"; +import { getTextOutput, renderToolPath, str } from "./render-utils.ts"; import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts"; @@ -48,15 +49,10 @@ export interface LsToolOptions { operations?: LsOperations; } -function formatLsCall( - args: { path?: string; limit?: number } | undefined, - theme: typeof import("../../modes/interactive/theme/theme.ts").theme, -): string { - const rawPath = str(args?.path); - const path = rawPath !== null ? shortenPath(rawPath || ".") : null; +function formatLsCall(args: { path?: string; limit?: number } | undefined, theme: Theme, cwd: string): string { const limit = args?.limit; - const invalidArg = invalidArgText(theme); - let text = `${theme.fg("toolTitle", theme.bold("ls"))} ${path === null ? invalidArg : theme.fg("accent", path)}`; + const pathDisplay = renderToolPath(str(args?.path), theme, cwd, { emptyFallback: "." }); + let text = `${theme.fg("toolTitle", theme.bold("ls"))} ${pathDisplay}`; if (limit !== undefined) { text += theme.fg("toolOutput", ` (limit ${limit})`); } @@ -69,7 +65,7 @@ function formatLsResult( details?: LsToolDetails; }, options: ToolRenderResultOptions, - theme: typeof import("../../modes/interactive/theme/theme.ts").theme, + theme: Theme, showImages: boolean, ): string { const output = getTextOutput(result, showImages).trim(); @@ -213,7 +209,7 @@ export function createLsToolDefinition( }, renderCall(args, theme, context) { const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); - text.setText(formatLsCall(args, theme)); + text.setText(formatLsCall(args, theme, context.cwd)); return text; }, renderResult(result, options, theme, context) { diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index 8f39b1ea..2d0dd838 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -13,7 +13,7 @@ import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.ts"; import { formatPathRelativeToCwdOrAbsolute } from "../../utils/paths.ts"; import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; import { resolveReadPathAsync, resolveToCwd } from "./path-utils.ts"; -import { getTextOutput, invalidArgText, replaceTabs, shortenPath, str } from "./render-utils.ts"; +import { getTextOutput, renderToolPath, replaceTabs, str } from "./render-utils.ts"; import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts"; @@ -71,11 +71,8 @@ function formatReadLineRange(args: ReadRenderArgs | undefined, theme: Theme): st return theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`); } -function formatReadCall(args: ReadRenderArgs | undefined, theme: Theme): string { - const rawPath = str(args?.file_path ?? args?.path); - const path = rawPath !== null ? shortenPath(rawPath) : null; - const invalidArg = invalidArgText(theme); - const pathDisplay = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."); +function formatReadCall(args: ReadRenderArgs | undefined, theme: Theme, cwd: string): string { + const pathDisplay = renderToolPath(str(args?.file_path ?? args?.path), theme, cwd); return `${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}${formatReadLineRange(args, theme)}`; } @@ -344,7 +341,9 @@ export function createReadToolDefinition( const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); const classification = !context.expanded ? getCompactReadClassification(args, context.cwd) : undefined; text.setText( - classification ? formatCompactReadCall(classification, args, theme) : formatReadCall(args, theme), + classification + ? formatCompactReadCall(classification, args, theme) + : formatReadCall(args, theme, context.cwd), ); return text; }, diff --git a/packages/coding-agent/src/core/tools/render-utils.ts b/packages/coding-agent/src/core/tools/render-utils.ts index 1614ed64..48a5b0af 100644 --- a/packages/coding-agent/src/core/tools/render-utils.ts +++ b/packages/coding-agent/src/core/tools/render-utils.ts @@ -1,7 +1,10 @@ import * as os from "node:os"; +import { pathToFileURL } from "node:url"; import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; -import { getCapabilities, getImageDimensions, imageFallback } from "@earendil-works/pi-tui"; +import { getCapabilities, getImageDimensions, hyperlink, imageFallback } from "@earendil-works/pi-tui"; +import type { Theme } from "../../modes/interactive/theme/theme.ts"; import { stripAnsi } from "../../utils/ansi.ts"; +import { resolvePath } from "../../utils/paths.ts"; import { sanitizeBinaryOutput } from "../../utils/shell.ts"; export function shortenPath(path: unknown): string { @@ -13,6 +16,12 @@ export function shortenPath(path: unknown): string { return path; } +export function linkPath(styledText: string, rawPath: string, cwd: string): string { + if (!getCapabilities().hyperlinks) return styledText; + const absolutePath = resolvePath(rawPath, cwd); + return hyperlink(styledText, pathToFileURL(absolutePath).href); +} + export function str(value: unknown): string | null { if (typeof value === "string") return value; if (value == null) return ""; @@ -59,6 +68,18 @@ export type ToolRenderResultLike = { details: TDetails; }; -export function invalidArgText(theme: { fg: (name: any, text: string) => string }): string { +export function invalidArgText(theme: Theme): string { return theme.fg("error", "[invalid arg]"); } + +export function renderToolPath( + rawPath: string | null, + theme: Theme, + cwd: string, + options?: { emptyFallback?: string }, +): string { + if (rawPath === null) return invalidArgText(theme); + const value = rawPath || options?.emptyFallback; + if (!value) return theme.fg("toolOutput", "..."); + return linkPath(theme.fg("accent", shortenPath(value)), value, cwd); +} diff --git a/packages/coding-agent/src/core/tools/write.ts b/packages/coding-agent/src/core/tools/write.ts index 3eaa1740..a1b755ad 100644 --- a/packages/coding-agent/src/core/tools/write.ts +++ b/packages/coding-agent/src/core/tools/write.ts @@ -4,11 +4,11 @@ import { mkdir as fsMkdir, writeFile as fsWriteFile } from "fs/promises"; import { dirname } from "path"; import { type Static, Type } from "typebox"; import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; -import { getLanguageFromPath, highlightCode } from "../../modes/interactive/theme/theme.ts"; +import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/interactive/theme/theme.ts"; import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; import { withFileMutationQueue } from "./file-mutation-queue.ts"; import { resolveToCwd } from "./path-utils.ts"; -import { invalidArgText, normalizeDisplayText, replaceTabs, shortenPath, str } from "./render-utils.ts"; +import { normalizeDisplayText, renderToolPath, replaceTabs, str } from "./render-utils.ts"; import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; const writeSchema = Type.Object({ @@ -131,14 +131,14 @@ function trimTrailingEmptyLines(lines: string[]): string[] { function formatWriteCall( args: { path?: string; file_path?: string; content?: string } | undefined, options: ToolRenderResultOptions, - theme: typeof import("../../modes/interactive/theme/theme.ts").theme, + theme: Theme, cache: WriteHighlightCache | undefined, + cwd: string, ): string { const rawPath = str(args?.file_path ?? args?.path); const fileContent = str(args?.content); - const path = rawPath !== null ? shortenPath(rawPath) : null; - const invalidArg = invalidArgText(theme); - let text = `${theme.fg("toolTitle", theme.bold("write"))} ${path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...")}`; + const pathDisplay = renderToolPath(rawPath, theme, cwd); + let text = `${theme.fg("toolTitle", theme.bold("write"))} ${pathDisplay}`; if (fileContent === null) { text += `\n\n${theme.fg("error", "[invalid content arg - expected string]")}`; @@ -163,7 +163,7 @@ function formatWriteCall( function formatWriteResult( result: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; isError?: boolean }, - theme: typeof import("../../modes/interactive/theme/theme.ts").theme, + theme: Theme, ): string | undefined { if (!result.isError) { return undefined; @@ -243,6 +243,7 @@ export function createWriteToolDefinition( { expanded: context.expanded, isPartial: context.isPartial }, theme, component.cache, + context.cwd, ), ); return component; From 9d2bceba5d9d6ba79f158ef813d7c29d920edba4 Mon Sep 17 00:00:00 2001 From: Marek Pazik Date: Fri, 29 May 2026 21:54:06 +1000 Subject: [PATCH 098/352] fix(tui): forward OSC 8 hyperlinks under tmux when the client supports them detectCapabilities previously disabled hyperlinks under tmux unconditionally. tmux re-emits OSC 8 to the outer terminal only when the attached client advertises the 'hyperlinks' feature in client_termfeatures, and strips them otherwise. Probe the running server with 'tmux display-message -p #{client_termfeatures}' and enable hyperlinks only when the feature is listed. The probe fails closed: any error (no tmux/server, timeout, old tmux) yields false. images stays null; only the hyperlink decision changed. The probe is injected into detectCapabilities so it stays testable without spawning a process. --- packages/tui/src/terminal-image.ts | 39 +++++++++++++++++++----- packages/tui/test/terminal-image.test.ts | 25 ++++++++++----- 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/packages/tui/src/terminal-image.ts b/packages/tui/src/terminal-image.ts index 22059c49..d7012c1d 100644 --- a/packages/tui/src/terminal-image.ts +++ b/packages/tui/src/terminal-image.ts @@ -1,3 +1,5 @@ +import { execSync } from "node:child_process"; + export type ImageProtocol = "kitty" | "iterm2" | null; export interface TerminalCapabilities { @@ -39,19 +41,42 @@ export function setCellDimensions(dims: CellDimensions): void { cellDimensions = dims; } -export function detectCapabilities(): TerminalCapabilities { +/** + * Checks whether the attached tmux client forwards OSC 8 hyperlinks to the + * outer terminal. tmux only re-emits them when its `client_termfeatures` lists + * `hyperlinks`, and strips them otherwise. On any error fallbacks `false`. + */ +function probeTmuxHyperlinks(): boolean { + try { + const termfeatures = execSync("tmux display-message -p '#{client_termfeatures}'", { + encoding: "utf8", + timeout: 250, + stdio: ["ignore", "pipe", "ignore"], + }); + return termfeatures + .split(",") + .map((feature) => feature.trim()) + .includes("hyperlinks"); + } catch { + return false; + } +} + +export function detectCapabilities(tmuxForwardsHyperlink: () => boolean = probeTmuxHyperlinks): TerminalCapabilities { const termProgram = process.env.TERM_PROGRAM?.toLowerCase() || ""; const terminalEmulator = process.env.TERMINAL_EMULATOR?.toLowerCase() || ""; const term = process.env.TERM?.toLowerCase() || ""; const colorTerm = process.env.COLORTERM?.toLowerCase() || ""; const hasTrueColorHint = colorTerm === "truecolor" || colorTerm === "24bit"; - // tmux and screen swallow OSC 8 by default (passthrough is opt-in and wraps - // sequences differently). Force hyperlinks off whenever we detect them, even - // when the outer terminal would otherwise support OSC 8. Image protocols are - // also unreliable under tmux/screen, so leave `images: null` for safety. - const inTmuxOrScreen = !!process.env.TMUX || term.startsWith("tmux") || term.startsWith("screen"); - if (inTmuxOrScreen) { + // Emit OSC 8 hyperlinks only when tmux confirms it forwards. + // Image protocols are unreliable under tmux, so leave `images: null`. + if (process.env.TMUX || term.startsWith("tmux")) { + return { images: null, trueColor: hasTrueColorHint, hyperlinks: tmuxForwardsHyperlink() }; + } + + // screen does not forward OSC 8 hyperlinks, so keep them off there. + if (term.startsWith("screen")) { return { images: null, trueColor: hasTrueColorHint, hyperlinks: false }; } diff --git a/packages/tui/test/terminal-image.test.ts b/packages/tui/test/terminal-image.test.ts index b179c329..4e9a3fc9 100644 --- a/packages/tui/test/terminal-image.test.ts +++ b/packages/tui/test/terminal-image.test.ts @@ -207,19 +207,30 @@ describe("detectCapabilities", () => { }); }); - it("forces hyperlinks: false under tmux even if outer terminal supports OSC 8", () => { + it("enables hyperlinks under tmux when the client forwards them", () => { withEnv({ TMUX: "/tmp/tmux-1000/default,1234,0", TERM_PROGRAM: "ghostty" }, () => { - const caps = detectCapabilities(); + const caps = detectCapabilities(() => true); + assert.strictEqual(caps.hyperlinks, true); + assert.strictEqual(caps.images, null); + }); + }); + + it("disables hyperlinks under tmux when the client does not forward them", () => { + withEnv({ TMUX: "/tmp/tmux-1000/default,1234,0", TERM_PROGRAM: "ghostty" }, () => { + const caps = detectCapabilities(() => false); assert.strictEqual(caps.hyperlinks, false); assert.strictEqual(caps.images, null); }); }); - it("forces hyperlinks: false when TERM starts with 'tmux'", () => { + it("checks tmux capability when TERM starts with 'tmux'", () => { withEnv({ TERM: "tmux-256color", TERM_PROGRAM: "iterm.app" }, () => { - const caps = detectCapabilities(); - assert.strictEqual(caps.hyperlinks, false); + const caps = detectCapabilities(() => true); + assert.strictEqual(caps.hyperlinks, true); assert.strictEqual(caps.images, null); + + const caps2 = detectCapabilities(() => false); + assert.strictEqual(caps2.hyperlinks, false); }); }); @@ -294,7 +305,7 @@ describe("detectCapabilities", () => { it("does not inherit Windows Terminal truecolor through tmux", () => { withEnv({ WT_SESSION: "session", TMUX: "/tmp/tmux-1000/default,1234,0", TERM: "tmux-256color" }, () => { - const caps = detectCapabilities(); + const caps = detectCapabilities(() => false); assert.strictEqual(caps.trueColor, false); assert.strictEqual(caps.hyperlinks, false); assert.strictEqual(caps.images, null); @@ -303,7 +314,7 @@ describe("detectCapabilities", () => { it("trusts explicit truecolor hints through tmux", () => { withEnv({ COLORTERM: "truecolor", TMUX: "/tmp/tmux-1000/default,1234,0", TERM: "tmux-256color" }, () => { - const caps = detectCapabilities(); + const caps = detectCapabilities(() => false); assert.strictEqual(caps.trueColor, true); assert.strictEqual(caps.hyperlinks, false); assert.strictEqual(caps.images, null); From edd1212200b7cb37db5bdb98e5f33e260a01fd04 Mon Sep 17 00:00:00 2001 From: Michael Yu Date: Fri, 29 May 2026 23:03:03 +0800 Subject: [PATCH 099/352] fix(coding-agent): buffer early input before prompt loop --- .../src/modes/interactive/interactive-mode.ts | 8 +++ .../interactive-mode-startup-input.test.ts | 72 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 packages/coding-agent/test/interactive-mode-startup-input.test.ts diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 901e1245..f3a2ba6a 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -277,6 +277,7 @@ export class InteractiveMode { private version: string; private isInitialized = false; private onInputCallback?: (text: string) => void; + private pendingUserInputs: string[] = []; private loadingAnimation: Loader | undefined = undefined; private workingMessage: string | undefined = undefined; private workingVisible = true; @@ -2660,6 +2661,8 @@ export class InteractiveMode { if (this.onInputCallback) { this.onInputCallback(text); + } else { + this.pendingUserInputs.push(text); } this.editor.addToHistory?.(text); }; @@ -3231,6 +3234,11 @@ export class InteractiveMode { } async getUserInput(): Promise { + const queuedInput = this.pendingUserInputs.shift(); + if (queuedInput !== undefined) { + return queuedInput; + } + return new Promise((resolve) => { this.onInputCallback = (text: string) => { this.onInputCallback = undefined; diff --git a/packages/coding-agent/test/interactive-mode-startup-input.test.ts b/packages/coding-agent/test/interactive-mode-startup-input.test.ts new file mode 100644 index 00000000..b784f376 --- /dev/null +++ b/packages/coding-agent/test/interactive-mode-startup-input.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from "vitest"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; + +type SubmitContext = { + defaultEditor: { onSubmit?: (text: string) => void }; + editor: { + addToHistory?: (text: string) => void; + setText: (text: string) => void; + }; + session: { + isCompacting: boolean; + isStreaming: boolean; + isBashRunning: boolean; + prompt: (text: string, options?: unknown) => Promise; + }; + flushPendingBashComponents: () => void; + onInputCallback?: (text: string) => void; + pendingUserInputs: string[]; +}; + +type InputContext = { + onInputCallback?: (text: string) => void; + pendingUserInputs: string[]; +}; + +type InteractiveModePrivate = { + setupEditorSubmitHandler(this: SubmitContext): void; + getUserInput(this: InputContext): Promise; +}; + +const interactiveModePrototype = InteractiveMode.prototype as unknown as InteractiveModePrivate; + +function createSubmitContext(): SubmitContext { + return { + defaultEditor: {}, + editor: { + addToHistory: vi.fn(), + setText: vi.fn(), + }, + session: { + isCompacting: false, + isStreaming: false, + isBashRunning: false, + prompt: vi.fn(async () => {}), + }, + flushPendingBashComponents: vi.fn(), + pendingUserInputs: [], + }; +} + +describe("InteractiveMode startup input", () => { + it("queues a normal prompt submitted before the input callback is installed", async () => { + const context = createSubmitContext(); + interactiveModePrototype.setupEditorSubmitHandler.call(context); + + await context.defaultEditor.onSubmit?.(" early prompt "); + + expect(context.pendingUserInputs).toEqual(["early prompt"]); + expect(context.flushPendingBashComponents).toHaveBeenCalledTimes(1); + expect(context.editor.addToHistory).toHaveBeenCalledWith("early prompt"); + }); + + it("returns queued startup input before installing a new input callback", async () => { + const context: InputContext = { + pendingUserInputs: ["queued prompt"], + }; + + await expect(interactiveModePrototype.getUserInput.call(context)).resolves.toBe("queued prompt"); + expect(context.onInputCallback).toBeUndefined(); + expect(context.pendingUserInputs).toEqual([]); + }); +}); From dbcfc167419b2d95820a933258f5ffb6ee4a678f Mon Sep 17 00:00:00 2001 From: Alexey Zaytsev Date: Fri, 29 May 2026 11:46:18 -0500 Subject: [PATCH 100/352] feat(coding-agent): Export CLI argument parser --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/index.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 86e63527..1f55cffe 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Exported `convertToPng` for extension authors. +- Exported `parseArgs` and type `Args` for extension authors. ## [0.77.0] - 2026-05-28 diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 97533893..0cdfc726 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -1,5 +1,7 @@ // Core session management +export { type Args, parseArgs } from "./cli/args.ts"; + // Config paths export { getAgentDir, VERSION } from "./config.ts"; export { From 7921ae499ae5f884cf4773e5e1fcff9f63a707e4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 29 May 2026 22:30:47 +0200 Subject: [PATCH 101/352] Require explicit provider API keys --- packages/ai/CHANGELOG.md | 4 ++++ packages/ai/src/providers/anthropic.ts | 8 +++++--- .../src/providers/azure-openai-responses.ts | 17 +++++------------ packages/ai/src/providers/google-vertex.ts | 2 +- packages/ai/src/providers/google.ts | 8 +++++--- .../ai/src/providers/images/openrouter.ts | 5 ++--- packages/ai/src/providers/mistral.ts | 5 ++--- .../src/providers/openai-codex-responses.ts | 5 ++--- .../ai/src/providers/openai-completions.ts | 19 ++++++------------- packages/ai/src/providers/openai-responses.ts | 19 ++++++------------- packages/ai/src/stream.ts | 19 +++++++++++++++++-- 11 files changed, 55 insertions(+), 56 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 6b7b6542..bda449eb 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Breaking Changes + +- Changed direct provider stream functions to require explicit `options.apiKey`; top-level `stream*`/`complete*` helpers still resolve built-in environment auth. + ### Fixed - Fixed OpenCode Go Kimi K2.6 thinking requests to send `thinking` objects instead of invalid string values, and fixed OpenCode Zen Grok Build thinking requests to omit unsupported `reasoning_effort` ([#5169](https://github.com/earendil-works/pi/issues/5169)). diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 2de6198f..74d4be38 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -6,7 +6,6 @@ import type { MessageParam, RawMessageStreamEvent, } from "@anthropic-ai/sdk/resources/messages.js"; -import { getEnvApiKey } from "../env-api-keys.ts"; import { calculateCost } from "../models.ts"; import type { AnthropicMessagesCompat, @@ -479,7 +478,10 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti client = options.client; isOAuth = false; } else { - const apiKey = options?.apiKey ?? getEnvApiKey(model.provider) ?? ""; + const apiKey = options?.apiKey; + if (!apiKey) { + throw new Error(`No API key for provider: ${model.provider}`); + } let copilotDynamicHeaders: Record | undefined; if (model.provider === "github-copilot") { @@ -735,7 +737,7 @@ export const streamSimpleAnthropic: StreamFunction<"anthropic-messages", SimpleS context: Context, options?: SimpleStreamOptions, ): AssistantMessageEventStream => { - const apiKey = options?.apiKey || getEnvApiKey(model.provider); + const apiKey = options?.apiKey; if (!apiKey) { throw new Error(`No API key for provider: ${model.provider}`); } diff --git a/packages/ai/src/providers/azure-openai-responses.ts b/packages/ai/src/providers/azure-openai-responses.ts index 770d5930..921a482e 100644 --- a/packages/ai/src/providers/azure-openai-responses.ts +++ b/packages/ai/src/providers/azure-openai-responses.ts @@ -1,6 +1,5 @@ import { AzureOpenAI } from "openai"; import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js"; -import { getEnvApiKey } from "../env-api-keys.ts"; import { clampThinkingLevel } from "../models.ts"; import type { Api, @@ -101,7 +100,10 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses" try { // Create Azure OpenAI client - const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; + const apiKey = options?.apiKey; + if (!apiKey) { + throw new Error(`No API key for provider: ${model.provider}`); + } const client = createClient(model, apiKey, options); let params = buildParams(model, context, options, deploymentName); const nextParams = await options?.onPayload?.(params, model); @@ -150,7 +152,7 @@ export const streamSimpleAzureOpenAIResponses: StreamFunction<"azure-openai-resp context: Context, options?: SimpleStreamOptions, ): AssistantMessageEventStream => { - const apiKey = options?.apiKey || getEnvApiKey(model.provider); + const apiKey = options?.apiKey; if (!apiKey) { throw new Error(`No API key for provider: ${model.provider}`); } @@ -224,15 +226,6 @@ function resolveAzureConfig( } function createClient(model: Model<"azure-openai-responses">, apiKey: string, options?: AzureOpenAIResponsesOptions) { - if (!apiKey) { - if (!process.env.AZURE_OPENAI_API_KEY) { - throw new Error( - "Azure OpenAI API key is required. Set AZURE_OPENAI_API_KEY environment variable or pass it as an argument.", - ); - } - apiKey = process.env.AZURE_OPENAI_API_KEY; - } - const headers = { ...model.headers }; if (options?.headers) { diff --git a/packages/ai/src/providers/google-vertex.ts b/packages/ai/src/providers/google-vertex.ts index e5f18dab..6feeabb6 100644 --- a/packages/ai/src/providers/google-vertex.ts +++ b/packages/ai/src/providers/google-vertex.ts @@ -395,7 +395,7 @@ function baseUrlIncludesApiVersion(baseUrl: string): boolean { } function resolveApiKey(options?: GoogleVertexOptions): string | undefined { - const apiKey = options?.apiKey?.trim() || process.env.GOOGLE_CLOUD_API_KEY?.trim(); + const apiKey = options?.apiKey?.trim(); if (!apiKey || apiKey === GCP_VERTEX_CREDENTIALS_MARKER || isPlaceholderApiKey(apiKey)) { return undefined; } diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index d0ddf673..d3f6f623 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -4,7 +4,6 @@ import { GoogleGenAI, type ThinkingConfig, } from "@google/genai"; -import { getEnvApiKey } from "../env-api-keys.ts"; import { calculateCost, clampThinkingLevel } from "../models.ts"; import type { Api, @@ -72,7 +71,10 @@ export const streamGoogle: StreamFunction<"google-generative-ai", GoogleOptions> }; try { - const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; + const apiKey = options?.apiKey; + if (!apiKey) { + throw new Error(`No API key for provider: ${model.provider}`); + } const client = createClient(model, apiKey, options?.headers); let params = buildParams(model, context, options); const nextParams = await options?.onPayload?.(params, model); @@ -280,7 +282,7 @@ export const streamSimpleGoogle: StreamFunction<"google-generative-ai", SimpleSt context: Context, options?: SimpleStreamOptions, ): AssistantMessageEventStream => { - const apiKey = options?.apiKey || getEnvApiKey(model.provider); + const apiKey = options?.apiKey; if (!apiKey) { throw new Error(`No API key for provider: ${model.provider}`); } diff --git a/packages/ai/src/providers/images/openrouter.ts b/packages/ai/src/providers/images/openrouter.ts index 13ba4f34..54caeaf0 100644 --- a/packages/ai/src/providers/images/openrouter.ts +++ b/packages/ai/src/providers/images/openrouter.ts @@ -6,7 +6,6 @@ import type { ChatCompletionContentPartText, ChatCompletionCreateParamsNonStreaming, } from "openai/resources/chat/completions.js"; -import { getEnvApiKey } from "../../env-api-keys.ts"; import type { AssistantImages, ImageContent, @@ -50,9 +49,9 @@ export const generateImagesOpenRouter: ImagesFunction<"openrouter-images", Image }; try { - const apiKey = options?.apiKey || getEnvApiKey(model.provider); + const apiKey = options?.apiKey; if (!apiKey) { - throw new Error(`No API key available for provider: ${model.provider}`); + throw new Error(`No API key for provider: ${model.provider}`); } const client = createClient(model, apiKey, options?.headers); let params = buildParams(model, context); diff --git a/packages/ai/src/providers/mistral.ts b/packages/ai/src/providers/mistral.ts index a15f141a..1bc7d4ce 100644 --- a/packages/ai/src/providers/mistral.ts +++ b/packages/ai/src/providers/mistral.ts @@ -6,7 +6,6 @@ import type { ContentChunk, FunctionTool, } from "@mistralai/mistralai/models/components"; -import { getEnvApiKey } from "../env-api-keys.ts"; import { calculateCost, clampThinkingLevel } from "../models.ts"; import type { AssistantMessage, @@ -57,7 +56,7 @@ export const streamMistral: StreamFunction<"mistral-conversations", MistralOptio const output = createOutput(model); try { - const apiKey = options?.apiKey || getEnvApiKey(model.provider); + const apiKey = options?.apiKey; if (!apiKey) { throw new Error(`No API key for provider: ${model.provider}`); } @@ -113,7 +112,7 @@ export const streamSimpleMistral: StreamFunction<"mistral-conversations", Simple context: Context, options?: SimpleStreamOptions, ): AssistantMessageEventStream => { - const apiKey = options?.apiKey || getEnvApiKey(model.provider); + const apiKey = options?.apiKey; if (!apiKey) { throw new Error(`No API key for provider: ${model.provider}`); } diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index f2c60af1..73991e12 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -20,7 +20,6 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version }); } -import { getEnvApiKey } from "../env-api-keys.ts"; import { clampThinkingLevel } from "../models.ts"; import { registerSessionResourceCleanup } from "../session-resources.ts"; import type { @@ -219,7 +218,7 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" }; try { - const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; + const apiKey = options?.apiKey; if (!apiKey) { throw new Error(`No API key for provider: ${model.provider}`); } @@ -410,7 +409,7 @@ export const streamSimpleOpenAICodexResponses: StreamFunction<"openai-codex-resp context: Context, options?: SimpleStreamOptions, ): AssistantMessageEventStream => { - const apiKey = options?.apiKey || getEnvApiKey(model.provider); + const apiKey = options?.apiKey; if (!apiKey) { throw new Error(`No API key for provider: ${model.provider}`); } diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 6834d1e7..245e0bc8 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -10,7 +10,6 @@ import type { ChatCompletionSystemMessageParam, ChatCompletionToolMessageParam, } from "openai/resources/chat/completions.js"; -import { getEnvApiKey } from "../env-api-keys.ts"; import { calculateCost, clampThinkingLevel } from "../models.ts"; import type { AssistantMessage, @@ -136,7 +135,10 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA }; try { - const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; + const apiKey = options?.apiKey; + if (!apiKey) { + throw new Error(`No API key for provider: ${model.provider}`); + } const compat = getCompat(model); const cacheRetention = resolveCacheRetention(options?.cacheRetention); const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId; @@ -428,7 +430,7 @@ export const streamSimpleOpenAICompletions: StreamFunction<"openai-completions", context: Context, options?: SimpleStreamOptions, ): AssistantMessageEventStream => { - const apiKey = options?.apiKey || getEnvApiKey(model.provider); + const apiKey = options?.apiKey; if (!apiKey) { throw new Error(`No API key for provider: ${model.provider}`); } @@ -448,20 +450,11 @@ export const streamSimpleOpenAICompletions: StreamFunction<"openai-completions", function createClient( model: Model<"openai-completions">, context: Context, - apiKey?: string, + apiKey: string, optionsHeaders?: Record, sessionId?: string, compat: ResolvedOpenAICompletionsCompat = getCompat(model), ) { - if (!apiKey) { - if (!process.env.OPENAI_API_KEY) { - throw new Error( - "OpenAI API key is required. Set OPENAI_API_KEY environment variable or pass it as an argument.", - ); - } - apiKey = process.env.OPENAI_API_KEY; - } - const headers = { ...model.headers }; if (model.provider === "github-copilot") { const hasImages = hasCopilotVisionInput(context.messages); diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index acf01cf9..4b35a24c 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -1,6 +1,5 @@ import OpenAI from "openai"; import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js"; -import { getEnvApiKey } from "../env-api-keys.ts"; import { clampThinkingLevel } from "../models.ts"; import type { Api, @@ -107,7 +106,10 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses", OpenAIRes try { // Create OpenAI client - const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; + const apiKey = options?.apiKey; + if (!apiKey) { + throw new Error(`No API key for provider: ${model.provider}`); + } const cacheRetention = resolveCacheRetention(options?.cacheRetention); const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId; const client = createClient(model, context, apiKey, options?.headers, cacheSessionId); @@ -161,7 +163,7 @@ export const streamSimpleOpenAIResponses: StreamFunction<"openai-responses", Sim context: Context, options?: SimpleStreamOptions, ): AssistantMessageEventStream => { - const apiKey = options?.apiKey || getEnvApiKey(model.provider); + const apiKey = options?.apiKey; if (!apiKey) { throw new Error(`No API key for provider: ${model.provider}`); } @@ -179,19 +181,10 @@ export const streamSimpleOpenAIResponses: StreamFunction<"openai-responses", Sim function createClient( model: Model<"openai-responses">, context: Context, - apiKey?: string, + apiKey: string, optionsHeaders?: Record, sessionId?: string, ) { - if (!apiKey) { - if (!process.env.OPENAI_API_KEY) { - throw new Error( - "OpenAI API key is required. Set OPENAI_API_KEY environment variable or pass it as an argument.", - ); - } - apiKey = process.env.OPENAI_API_KEY; - } - const compat = getCompat(model); const headers = { ...model.headers }; if (model.provider === "github-copilot") { diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index a38b01fd..3ae631a1 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -1,6 +1,7 @@ import "./providers/register-builtins.ts"; import { getApiProvider } from "./api-registry.ts"; +import { getEnvApiKey } from "./env-api-keys.ts"; import type { Api, AssistantMessage, @@ -14,6 +15,20 @@ import type { export { getEnvApiKey } from "./env-api-keys.ts"; +function hasExplicitApiKey(apiKey: string | undefined): apiKey is string { + return typeof apiKey === "string" && apiKey.trim().length > 0; +} + +function withEnvApiKey( + model: Model, + options: TOptions | undefined, +): TOptions | undefined { + if (hasExplicitApiKey(options?.apiKey)) return options; + const apiKey = getEnvApiKey(model.provider); + if (!apiKey) return options; + return { ...options, apiKey } as TOptions; +} + function resolveApiProvider(api: Api) { const provider = getApiProvider(api); if (!provider) { @@ -28,7 +43,7 @@ export function stream( options?: ProviderStreamOptions, ): AssistantMessageEventStream { const provider = resolveApiProvider(model.api); - return provider.stream(model, context, options as StreamOptions); + return provider.stream(model, context, withEnvApiKey(model, options) as StreamOptions); } export async function complete( @@ -46,7 +61,7 @@ export function streamSimple( options?: SimpleStreamOptions, ): AssistantMessageEventStream { const provider = resolveApiProvider(model.api); - return provider.streamSimple(model, context, options); + return provider.streamSimple(model, context, withEnvApiKey(model, options)); } export async function completeSimple( From a36a132c700f72a3cdbf6cf0442ce4de73f75d8f Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 29 May 2026 23:20:18 +0200 Subject: [PATCH 102/352] fix(ai): abort Codex SSE body reads --- .../src/providers/openai-codex-responses.ts | 15 ++- packages/ai/test/openai-codex-stream.test.ts | 108 ++++++++++++++++++ 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 73991e12..5836ee1b 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -543,7 +543,7 @@ async function processStream( model: Model<"openai-codex-responses">, options?: OpenAICodexResponsesOptions, ): Promise { - await processResponsesStream(mapCodexEvents(parseSSE(response)), output, stream, model, { + await processResponsesStream(mapCodexEvents(parseSSE(response, options?.signal)), output, stream, model, { serviceTier: options?.serviceTier, resolveServiceTier: resolveCodexServiceTier, applyServiceTierPricing: (usage, serviceTier) => applyServiceTierPricing(usage, serviceTier, model), @@ -621,16 +621,26 @@ function normalizeCodexStatus(status: unknown): CodexResponseStatus | undefined // SSE Parsing // ============================================================================ -async function* parseSSE(response: Response): AsyncGenerator> { +async function* parseSSE(response: Response, signal?: AbortSignal): AsyncGenerator> { if (!response.body) return; const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; + const onAbort = () => { + void reader.cancel().catch(() => {}); + }; + signal?.addEventListener("abort", onAbort, { once: true }); try { while (true) { + if (signal?.aborted) { + throw new Error("Request was aborted"); + } const { done, value } = await reader.read(); + if (signal?.aborted) { + throw new Error("Request was aborted"); + } if (done) break; buffer += decoder.decode(value, { stream: true }); @@ -660,6 +670,7 @@ async function* parseSSE(response: Response): AsyncGenerator { expect(result.errorMessage).toBe("Codex SSE response headers timed out after 10000ms"); }); + it("aborts SSE body reads after response headers arrive", async () => { + const token = mockToken(); + const encoder = new TextEncoder(); + const timers: ReturnType[] = []; + let cancelled = false; + const stream = new ReadableStream({ + start(controller) { + const enqueue = (chunk: string) => { + if (!cancelled) controller.enqueue(encoder.encode(chunk)); + }; + enqueue( + `${[ + `data: ${JSON.stringify({ + type: "response.output_item.added", + item: { type: "message", id: "msg_1", role: "assistant", status: "in_progress", content: [] }, + })}`, + `data: ${JSON.stringify({ type: "response.content_part.added", part: { type: "output_text", text: "" } })}`, + `data: ${JSON.stringify({ type: "response.output_text.delta", delta: "one" })}`, + ].join("\n\n")}\n\n`, + ); + timers.push( + setTimeout(() => { + enqueue(`data: ${JSON.stringify({ type: "response.output_text.delta", delta: "two" })}\n\n`); + }, 10), + ); + timers.push( + setTimeout(() => { + if (cancelled) return; + enqueue( + `${[ + `data: ${JSON.stringify({ + type: "response.output_item.done", + item: { + type: "message", + id: "msg_1", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "onetwo" }], + }, + })}`, + `data: ${JSON.stringify({ + type: "response.completed", + response: { + status: "completed", + usage: { + input_tokens: 5, + output_tokens: 3, + total_tokens: 8, + input_tokens_details: { cached_tokens: 0 }, + }, + }, + })}`, + ].join("\n\n")}\n\n`, + ); + controller.close(); + }, 20), + ); + }, + cancel() { + cancelled = true; + for (const timer of timers) clearTimeout(timer); + }, + }); + + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(stream, { status: 200, headers: { "content-type": "text/event-stream" } })), + ); + + const model: Model<"openai-codex-responses"> = { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + }; + const context: Context = { + systemPrompt: "You are a helpful assistant.", + messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], + }; + const controller = new AbortController(); + const events: string[] = []; + + const resultStream = streamOpenAICodexResponses(model, context, { + apiKey: token, + transport: "sse", + signal: controller.signal, + }); + for await (const event of resultStream) { + events.push(event.type === "text_delta" ? `text_delta:${event.delta}` : event.type); + if (event.type === "text_delta" && event.delta === "one") { + controller.abort(); + } + } + + const result = await resultStream.result(); + expect(result.stopReason).toBe("aborted"); + expect(result.errorMessage).toBe("Request was aborted"); + expect(events).toContain("text_delta:one"); + expect(events).not.toContain("text_delta:two"); + expect(cancelled).toBe(true); + }); + it("sets session-id/x-client-request-id headers and prompt_cache_key when sessionId is provided", async () => { const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-")); process.env.PI_CODING_AGENT_DIR = tempDir; From ba2d313858a10c3bdd1852d520272a98111095f8 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 29 May 2026 23:27:48 +0200 Subject: [PATCH 103/352] fix(ai): handle OpenCode Kimi reasoning params --- packages/ai/scripts/generate-models.ts | 11 +-- packages/ai/src/models.generated.ts | 120 +++++++++++++++---------- 2 files changed, 77 insertions(+), 54 deletions(-) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index b95089ce..9f742b0a 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -905,6 +905,12 @@ async function loadModelsDevData(): Promise[]> { compat = { ...(compat ?? {}), supportsReasoningEffort: false }; } + if ((variant.provider === "opencode" || variant.provider === "opencode-go") && modelId === "kimi-k2.6") { + // OpenCode Kimi K2.6 accepts Anthropic-style thinking objects + // and rejects string thinking values or combined reasoning_effort. + compat = { ...(compat ?? {}), thinkingFormat: "deepseek", supportsReasoningEffort: false }; + } + // Fix known mismatches between models.dev npm data and actual // OpenCode Go endpoint behaviour. models.dev reports these models // as @ai-sdk/anthropic, but the OpenCode Go endpoints either don't @@ -917,11 +923,6 @@ async function loadModelsDevData(): Promise[]> { api = "openai-completions"; baseUrl = `${variant.basePath}/v1`; } - if (modelId === "kimi-k2.6") { - // OpenCode Go Kimi K2.6 accepts Anthropic-style thinking objects - // and rejects string thinking values or combined reasoning_effort. - compat = { ...(compat ?? {}), thinkingFormat: "deepseek", supportsReasoningEffort: false }; - } if (modelId === "qwen3.5-plus" || modelId === "qwen3.6-plus") { api = "openai-completions"; baseUrl = `${variant.basePath}/v1`; diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 8f131d77..4ef13beb 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -3868,8 +3868,8 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 144000, - maxTokens: 32000, + contextWindow: 200000, + maxTokens: 64000, } satisfies Model<"anthropic-messages">, "claude-opus-4.5": { id: "claude-opus-4.5", @@ -3886,7 +3886,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 160000, + contextWindow: 200000, maxTokens: 32000, } satisfies Model<"anthropic-messages">, "claude-opus-4.6": { @@ -3907,7 +3907,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 1000000, - maxTokens: 64000, + maxTokens: 32000, } satisfies Model<"anthropic-messages">, "claude-opus-4.7": { id: "claude-opus-4.7", @@ -3926,7 +3926,27 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 144000, + contextWindow: 200000, + maxTokens: 32000, + } satisfies Model<"anthropic-messages">, + "claude-opus-4.8": { + id: "claude-opus-4.8", + name: "Claude Opus 4.8", + api: "anthropic-messages", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, maxTokens: 64000, } satisfies Model<"anthropic-messages">, "claude-sonnet-4.5": { @@ -3945,7 +3965,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 144000, + contextWindow: 200000, maxTokens: 32000, } satisfies Model<"anthropic-messages">, "claude-sonnet-4.6": { @@ -4021,7 +4041,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 128000, + contextWindow: 200000, maxTokens: 64000, } satisfies Model<"openai-completions">, "gemini-3.5-flash": { @@ -4040,7 +4060,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 128000, + contextWindow: 200000, maxTokens: 64000, } satisfies Model<"openai-completions">, "gpt-4.1": { @@ -4116,8 +4136,8 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 264000, - maxTokens: 64000, + contextWindow: 400000, + maxTokens: 128000, } satisfies Model<"openai-responses">, "gpt-5.2-codex": { id: "gpt-5.2-codex", @@ -7724,7 +7744,9 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"supportsReasoningEffort":false}, reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":null,"medium":null}, input: ["text", "image"], cost: { input: 1, @@ -7758,6 +7780,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"thinkingFormat":"deepseek","supportsReasoningEffort":false}, reasoning: true, input: ["text", "image"], cost: { @@ -7968,9 +7991,9 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", - compat: {"thinkingFormat":"string-thinking"}, + compat: {"thinkingFormat":"deepseek","supportsReasoningEffort":false}, reasoning: true, - thinkingLevelMap: {"off":"none"}, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, input: ["text", "image"], cost: { input: 0.95, @@ -8049,24 +8072,6 @@ export const MODELS = { contextWindow: 204800, maxTokens: 131072, } satisfies Model<"openai-completions">, - "qwen3.5-plus": { - id: "qwen3.5-plus", - name: "Qwen3.5 Plus", - api: "openai-completions", - provider: "opencode-go", - baseUrl: "https://opencode.ai/zen/go/v1", - compat: {"thinkingFormat":"qwen"}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.2, - output: 1.2, - cacheRead: 0.02, - cacheWrite: 0.25, - }, - contextWindow: 262144, - maxTokens: 65536, - } satisfies Model<"openai-completions">, "qwen3.6-plus": { id: "qwen3.6-plus", name: "Qwen3.6 Plus", @@ -8818,13 +8823,13 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh"}, input: ["text"], cost: { - input: 0.09999999999999999, - output: 0.19999999999999998, - cacheRead: 0.02, + input: 0.0983, + output: 0.1966, + cacheRead: 0.019700000000000002, cacheWrite: 0, }, contextWindow: 1048576, - maxTokens: 16384, + maxTokens: 131072, } satisfies Model<"openai-completions">, "deepseek/deepseek-v4-flash:free": { id: "deepseek/deepseek-v4-flash:free", @@ -8895,7 +8900,7 @@ export const MODELS = { cacheRead: 0.024999999999999998, cacheWrite: 0.08333333333333334, }, - contextWindow: 1000000, + contextWindow: 1048576, maxTokens: 8192, } satisfies Model<"openai-completions">, "google/gemini-2.0-flash-lite-001": { @@ -9945,13 +9950,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.73, - output: 3.49, - cacheRead: 0.25, + input: 0.684, + output: 3.42, + cacheRead: 0.144, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262142, + maxTokens: 262144, } satisfies Model<"openai-completions">, "moonshotai/kimi-k2.6:free": { id: "moonshotai/kimi-k2.6:free", @@ -12090,6 +12095,23 @@ export const MODELS = { contextWindow: 262144, maxTokens: 16384, } satisfies Model<"openai-completions">, + "stepfun/step-3.7-flash": { + id: "stepfun/step-3.7-flash", + name: "StepFun: Step 3.7 Flash", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.19999999999999998, + output: 1.15, + cacheRead: 0.04, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"openai-completions">, "tencent/hy3-preview": { id: "tencent/hy3-preview", name: "Tencent: Hy3 preview", @@ -12609,13 +12631,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.73, - output: 3.49, - cacheRead: 0.25, + input: 0.684, + output: 3.42, + cacheRead: 0.144, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262142, + maxTokens: 262144, } satisfies Model<"openai-completions">, "~openai/gpt-latest": { id: "~openai/gpt-latest", @@ -13013,20 +13035,20 @@ export const MODELS = { } satisfies Model<"anthropic-messages">, "alibaba/qwen-3-235b": { id: "alibaba/qwen-3-235b", - name: "Qwen3 235B A22b Instruct 2507", + name: "Qwen3 235B A22B", api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", reasoning: false, input: ["text"], cost: { - input: 0.6, - output: 1.2, - cacheRead: 0.6, + input: 0.22, + output: 0.88, + cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131000, - maxTokens: 40000, + contextWindow: 262144, + maxTokens: 16384, } satisfies Model<"anthropic-messages">, "alibaba/qwen-3-30b": { id: "alibaba/qwen-3-30b", From 778f519b23fcdb8e2dfa2a87251e9cf328b0d59e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 29 May 2026 23:30:02 +0200 Subject: [PATCH 104/352] Remove leading spaces from resume session hint --- packages/coding-agent/src/modes/interactive/interactive-mode.ts | 2 +- .../regressions/5080-signal-shutdown-extension-cleanup.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 901e1245..0cf8103b 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -3302,7 +3302,7 @@ export class InteractiveMode { const resumeCommand = formatResumeCommand(this.sessionManager); if (resumeCommand) { - process.stdout.write(` ${chalk.dim("To resume this session:")} ${resumeCommand}\n`); + process.stdout.write(`${chalk.dim("To resume this session:")} ${resumeCommand}\n`); } process.exit(0); diff --git a/packages/coding-agent/test/suite/regressions/5080-signal-shutdown-extension-cleanup.test.ts b/packages/coding-agent/test/suite/regressions/5080-signal-shutdown-extension-cleanup.test.ts index 3f221f4b..30810fe8 100644 --- a/packages/coding-agent/test/suite/regressions/5080-signal-shutdown-extension-cleanup.test.ts +++ b/packages/coding-agent/test/suite/regressions/5080-signal-shutdown-extension-cleanup.test.ts @@ -146,7 +146,7 @@ describe("InteractiveMode.shutdown ordering (#5080)", () => { expect(order).toEqual(["drainInput", "stop", "dispose"]); expect(stdoutWrite).toHaveBeenCalledWith( - ` ${chalk.dim("To resume this session:")} ${APP_NAME} --session test-session\n`, + `${chalk.dim("To resume this session:")} ${APP_NAME} --session test-session\n`, ); }); From a213abb97ed52945320afc9701ebbcfcf7d50904 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 29 May 2026 23:41:14 +0200 Subject: [PATCH 105/352] Fix OpenRouter Kimi K2.6 developer role closes #5159 --- packages/ai/CHANGELOG.md | 7 +++++++ packages/ai/scripts/generate-models.ts | 3 +++ packages/ai/src/models.generated.ts | 2 ++ 3 files changed, 12 insertions(+) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index bda449eb..246e7a5a 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -5,9 +5,16 @@ ### Breaking Changes - Changed direct provider stream functions to require explicit `options.apiKey`; top-level `stream*`/`complete*` helpers still resolve built-in environment auth. +- Changed top-level stream helpers to delegate to `defaultModels()`, so setup failures are reported through stream error results consistently. +- Moved the built-in API provider registration helpers from the `providers/register-builtins` subpath to `api-registry`/the root export. + +### Added + +- Added `Models`/`MutableModels`, `builtinModels()`, and `defaultModels()` for isolated or process-default model metadata, stream, and auth resolution. ### Fixed +- Fixed OpenRouter Moonshot Kimi K2.6 requests to use `system` instead of unsupported `developer` messages ([#5159](https://github.com/earendil-works/pi/issues/5159)). - Fixed OpenCode Go Kimi K2.6 thinking requests to send `thinking` objects instead of invalid string values, and fixed OpenCode Zen Grok Build thinking requests to omit unsupported `reasoning_effort` ([#5169](https://github.com/earendil-works/pi/issues/5169)). ## [0.77.0] - 2026-05-28 diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 9f742b0a..b4cedc04 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -1235,6 +1235,9 @@ async function generateModels() { candidate.cost.cacheRead = 0.07; candidate.maxTokens = 4096; } + if (candidate.provider === "openrouter" && candidate.id.startsWith("moonshotai/kimi-k2.6")) { + candidate.compat = { ...candidate.compat, supportsDeveloperRole: false }; + } if (candidate.provider === "openrouter" && candidate.id === "z-ai/glm-5") { candidate.cost.input = 0.6; candidate.cost.output = 1.9; diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 4ef13beb..c5700a23 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -9947,6 +9947,7 @@ export const MODELS = { api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", + compat: {"supportsDeveloperRole":false}, reasoning: true, input: ["text", "image"], cost: { @@ -9964,6 +9965,7 @@ export const MODELS = { api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", + compat: {"supportsDeveloperRole":false}, reasoning: true, input: ["text", "image"], cost: { From 31b961f2acb94699071840727b013eb6066884cd Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 30 May 2026 00:52:03 +0200 Subject: [PATCH 106/352] fix(coding-agent): sync clipboard binary archive deps closes #5184 --- packages/coding-agent/CHANGELOG.md | 4 ++++ scripts/build-binaries.sh | 18 ++++++++++-------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1f55cffe..284cc408 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -7,6 +7,10 @@ - Exported `convertToPng` for extension authors. - Exported `parseArgs` and type `Args` for extension authors. +### Fixed + +- Fixed Bun release archive creation to install and copy the matching `@mariozechner/clipboard` base package and native sidecars ([#5184](https://github.com/earendil-works/pi/issues/5184)). + ## [0.77.0] - 2026-05-28 ### New Features diff --git a/scripts/build-binaries.sh b/scripts/build-binaries.sh index 628097a7..95c9bf82 100755 --- a/scripts/build-binaries.sh +++ b/scripts/build-binaries.sh @@ -90,17 +90,19 @@ fi if [[ "$SKIP_DEPS" == "false" ]]; then echo "==> Installing cross-platform native bindings..." + CLIPBOARD_VERSION=$(node -p "require('./packages/coding-agent/package.json').optionalDependencies['@mariozechner/clipboard']") # npm ci only installs optional deps for the current platform - # We need all platform bindings for bun cross-compilation + # We need the base clipboard package and all platform bindings for bun cross-compilation # Use --force to bypass platform checks (os/cpu restrictions in package.json) # Install all in one command to avoid npm removing packages from previous installs - npm install --no-save --package-lock=false --force --ignore-scripts \ - @mariozechner/clipboard-darwin-arm64@0.3.6 \ - @mariozechner/clipboard-darwin-x64@0.3.6 \ - @mariozechner/clipboard-linux-x64-gnu@0.3.6 \ - @mariozechner/clipboard-linux-arm64-gnu@0.3.6 \ - @mariozechner/clipboard-win32-x64-msvc@0.3.6 \ - @mariozechner/clipboard-win32-arm64-msvc@0.3.6 + npm install --include=optional --no-save --package-lock=false --force --ignore-scripts \ + @mariozechner/clipboard@"$CLIPBOARD_VERSION" \ + @mariozechner/clipboard-darwin-arm64@"$CLIPBOARD_VERSION" \ + @mariozechner/clipboard-darwin-x64@"$CLIPBOARD_VERSION" \ + @mariozechner/clipboard-linux-x64-gnu@"$CLIPBOARD_VERSION" \ + @mariozechner/clipboard-linux-arm64-gnu@"$CLIPBOARD_VERSION" \ + @mariozechner/clipboard-win32-x64-msvc@"$CLIPBOARD_VERSION" \ + @mariozechner/clipboard-win32-arm64-msvc@"$CLIPBOARD_VERSION" else echo "==> Skipping cross-platform native bindings (--skip-deps)" fi From 9c4a3f35183991cb6e8ed4fb1eb2a06892df3d06 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 30 May 2026 01:02:48 +0200 Subject: [PATCH 107/352] Fix ANSI wrapping stack overflow closes #5185 --- packages/tui/CHANGELOG.md | 4 ++++ packages/tui/src/utils.ts | 9 +++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index c4eb0d08..97a30a79 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed ANSI text wrapping to avoid stack overflows on very long wrapped lines ([#5185](https://github.com/earendil-works/pi/issues/5185)). + ## [0.77.0] - 2026-05-28 ### Fixed diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 97f93f65..d613a76d 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -670,7 +670,10 @@ export function wrapTextWithAnsi(text: string, width: number): string[] { for (const inputLine of inputLines) { // Prepend active ANSI codes from previous lines (except for first line) const prefix = result.length > 0 ? tracker.getActiveCodes() : ""; - result.push(...wrapSingleLine(prefix + inputLine, width)); + const wrappedLines = wrapSingleLine(prefix + inputLine, width); + for (const wrappedLine of wrappedLines) { + result.push(wrappedLine); + } // Update tracker with codes from this line for next iteration updateTrackerFromText(inputLine, tracker); } @@ -714,7 +717,9 @@ function wrapSingleLine(line: string, width: number): string[] { // Break long token - breakLongWord handles its own resets const broken = breakLongWord(token, width, tracker); - wrapped.push(...broken.slice(0, -1)); + for (let i = 0; i < broken.length - 1; i++) { + wrapped.push(broken[i]!); + } currentLine = broken[broken.length - 1]; currentVisibleLength = visibleWidth(currentLine); continue; From 0ffa590a338b52be975ec4f2fa51be678181417f Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 30 May 2026 01:08:18 +0200 Subject: [PATCH 108/352] Fix GitLab Duo thinking metadata closes #5201 --- packages/coding-agent/CHANGELOG.md | 1 + .../custom-provider-gitlab-duo/index.ts | 55 ++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 284cc408..f2429123 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed +- Fixed the GitLab Duo custom provider example to use adaptive thinking for Claude models, expose xhigh thinking, and include newer verified model IDs ([#5201](https://github.com/earendil-works/pi/issues/5201)). - Fixed Bun release archive creation to install and copy the matching `@mariozechner/clipboard` base package and native sidecars ([#5184](https://github.com/earendil-works/pi/issues/5184)). ## [0.77.0] - 2026-05-28 diff --git a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/index.ts b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/index.ts index c23f0dac..49e9c353 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/index.ts +++ b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/index.ts @@ -20,6 +20,7 @@ import { type SimpleStreamOptions, streamSimpleAnthropic, streamSimpleOpenAIResponses, + type ThinkingLevelMap, } from "@earendil-works/pi-ai"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; @@ -49,6 +50,7 @@ interface GitLabModel { backend: Backend; baseUrl: string; reasoning: boolean; + thinkingLevelMap?: ThinkingLevelMap; input: ("text" | "image")[]; cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; contextWindow: number; @@ -57,12 +59,37 @@ interface GitLabModel { export const MODELS: GitLabModel[] = [ // Anthropic + { + id: "claude-opus-4-8", + name: "Claude Opus 4.8", + backend: "anthropic", + baseUrl: ANTHROPIC_PROXY_URL, + reasoning: true, + thinkingLevelMap: { xhigh: "max" }, + input: ["text", "image"], + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + contextWindow: 1000000, + maxTokens: 128000, + }, + { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + backend: "anthropic", + baseUrl: ANTHROPIC_PROXY_URL, + reasoning: true, + thinkingLevelMap: { xhigh: "max" }, + input: ["text", "image"], + cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + contextWindow: 1000000, + maxTokens: 64000, + }, { id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5", backend: "anthropic", baseUrl: ANTHROPIC_PROXY_URL, reasoning: true, + thinkingLevelMap: { xhigh: "max" }, input: ["text", "image"], cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, contextWindow: 200000, @@ -74,6 +101,7 @@ export const MODELS: GitLabModel[] = [ backend: "anthropic", baseUrl: ANTHROPIC_PROXY_URL, reasoning: true, + thinkingLevelMap: { xhigh: "max" }, input: ["text", "image"], cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, contextWindow: 200000, @@ -85,12 +113,24 @@ export const MODELS: GitLabModel[] = [ backend: "anthropic", baseUrl: ANTHROPIC_PROXY_URL, reasoning: true, + thinkingLevelMap: { xhigh: "max" }, input: ["text", "image"], cost: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, contextWindow: 200000, maxTokens: 8192, }, // OpenAI (all use Responses API) + { + id: "gpt-5.5-2026-04-23", + name: "GPT-5.5", + backend: "openai", + baseUrl: OPENAI_PROXY_URL, + reasoning: true, + input: ["text", "image"], + cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, + contextWindow: 272000, + maxTokens: 128000, + }, { id: "gpt-5.1-2025-11-13", name: "GPT-5.1", @@ -285,7 +325,17 @@ export function streamGitLabDuo( const innerStream = cfg.backend === "anthropic" - ? streamSimpleAnthropic(modelWithBaseUrl as Model<"anthropic-messages">, context, streamOptions) + ? streamSimpleAnthropic( + { + ...(modelWithBaseUrl as Model<"anthropic-messages">), + compat: { + ...(modelWithBaseUrl as Model<"anthropic-messages">).compat, + forceAdaptiveThinking: true, + }, + }, + context, + streamOptions, + ) : streamSimpleOpenAIResponses(modelWithBaseUrl as Model<"openai-responses">, context, streamOptions); for await (const event of innerStream) stream.push(event); @@ -329,10 +379,11 @@ export default function (pi: ExtensionAPI) { baseUrl: AI_GATEWAY_URL, apiKey: "$GITLAB_TOKEN", api: "gitlab-duo-api", - models: MODELS.map(({ id, name, reasoning, input, cost, contextWindow, maxTokens }) => ({ + models: MODELS.map(({ id, name, reasoning, thinkingLevelMap, input, cost, contextWindow, maxTokens }) => ({ id, name, reasoning, + thinkingLevelMap, input, cost, contextWindow, From c1633e609dd7a6e2ffda2ce10c654ff58f075c50 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 30 May 2026 01:20:01 +0200 Subject: [PATCH 109/352] Clarify hardware cursor docs closes #5200 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/settings.md | 2 +- packages/coding-agent/docs/terminal-setup.md | 2 ++ packages/coding-agent/docs/tui.md | 4 ++-- packages/tui/CHANGELOG.md | 1 + packages/tui/README.md | 4 ++-- 6 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f2429123..2e4cb68a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed +- Clarified the WezTerm/WSL IME hardware cursor docs to state that cursor visibility remains opt-in ([#5200](https://github.com/earendil-works/pi/issues/5200)). - Fixed the GitLab Duo custom provider example to use adaptive thinking for Claude models, expose xhigh thinking, and include newer verified model IDs ([#5201](https://github.com/earendil-works/pi/issues/5201)). - Fixed Bun release archive creation to install and copy the matching `@mariozechner/clipboard` base package and native sidecars ([#5184](https://github.com/earendil-works/pi/issues/5184)). diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 2e039c89..47aecc4a 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -46,7 +46,7 @@ Edit directly or use `/settings` for common options. | `treeFilterMode` | string | `"default"` | Default filter for `/tree`: `"default"`, `"no-tools"`, `"user-only"`, `"labeled-only"`, `"all"` | | `editorPaddingX` | number | `0` | Horizontal padding for input editor (0-3) | | `autocompleteMaxVisible` | number | `5` | Max visible items in autocomplete dropdown (3-20) | -| `showHardwareCursor` | boolean | `false` | Show terminal cursor | +| `showHardwareCursor` | boolean | `false` | Show the terminal cursor while TUI positions it for IME support | ### Telemetry and update checks diff --git a/packages/coding-agent/docs/terminal-setup.md b/packages/coding-agent/docs/terminal-setup.md index 5bf9f2b2..cccf683f 100644 --- a/packages/coding-agent/docs/terminal-setup.md +++ b/packages/coding-agent/docs/terminal-setup.md @@ -49,6 +49,8 @@ config.enable_kitty_keyboard = true return config ``` +On WSL, WezTerm may require a visible hardware cursor for IME candidate window positioning. If CJK IME candidates do not follow the text cursor, set `PI_HARDWARE_CURSOR=1` before running pi or set `showHardwareCursor` to `true` in settings. + ## VS Code (Integrated Terminal) `keybindings.json` locations: diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index bb78aab4..afdaf721 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -50,9 +50,9 @@ When a `Focusable` component has focus, TUI: 1. Sets `focused = true` on the component 2. Scans rendered output for `CURSOR_MARKER` (a zero-width APC escape sequence) 3. Positions the hardware terminal cursor at that location -4. Shows the hardware cursor +4. Shows the hardware cursor only when `showHardwareCursor` is enabled -This enables IME candidate windows to appear at the correct position for CJK input methods. The `Editor` and `Input` built-in components already implement this interface. +The cursor remains hidden by default. This keeps the fake cursor rendering, while still positioning the hardware cursor for terminals that track IME candidate windows with hidden cursors. Some terminals require a visible hardware cursor for IME positioning; enable it with `showHardwareCursor`, `setShowHardwareCursor(true)`, or `PI_HARDWARE_CURSOR=1`. The `Editor` and `Input` built-in components already implement this interface. ### Container Components with Embedded Inputs diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 97a30a79..e4833df0 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed ANSI text wrapping to avoid stack overflows on very long wrapped lines ([#5185](https://github.com/earendil-works/pi/issues/5185)). +- Clarified the IME hardware cursor docs to state that cursor visibility remains opt-in ([#5200](https://github.com/earendil-works/pi/issues/5200)). ## [0.77.0] - 2026-05-28 diff --git a/packages/tui/README.md b/packages/tui/README.md index 5a151afb..1da50a3a 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -176,9 +176,9 @@ When a `Focusable` component has focus, TUI: 1. Sets `focused = true` on the component 2. Scans rendered output for `CURSOR_MARKER` (a zero-width APC escape sequence) 3. Positions the hardware terminal cursor at that location -4. Shows the hardware cursor +4. Shows the hardware cursor only when `showHardwareCursor` is enabled -This enables IME candidate windows to appear at the correct position for CJK input methods. The `Editor` and `Input` built-in components already implement this interface. +The cursor remains hidden by default. This keeps the fake cursor rendering, while still positioning the hardware cursor for terminals that track IME candidate windows with hidden cursors. Some terminals require a visible hardware cursor for IME positioning; enable it with the `TUI` constructor option, `setShowHardwareCursor(true)`, or `PI_HARDWARE_CURSOR=1`. The `Editor` and `Input` built-in components already implement this interface. **Container components with embedded inputs:** When a container component (dialog, selector, etc.) contains an `Input` or `Editor` child, the container must implement `Focusable` and propagate the focus state to the child: From 886fa6cd11518069f843a5492ba3fdcd262e10b1 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 30 May 2026 01:27:28 +0200 Subject: [PATCH 110/352] Audit unreleased changelog entries --- packages/ai/CHANGELOG.md | 10 +++++----- packages/coding-agent/CHANGELOG.md | 28 +++++++++++++++++++++------- packages/tui/CHANGELOG.md | 5 +++-- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 246e7a5a..5f62e0b1 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -5,17 +5,17 @@ ### Breaking Changes - Changed direct provider stream functions to require explicit `options.apiKey`; top-level `stream*`/`complete*` helpers still resolve built-in environment auth. -- Changed top-level stream helpers to delegate to `defaultModels()`, so setup failures are reported through stream error results consistently. -- Moved the built-in API provider registration helpers from the `providers/register-builtins` subpath to `api-registry`/the root export. ### Added -- Added `Models`/`MutableModels`, `builtinModels()`, and `defaultModels()` for isolated or process-default model metadata, stream, and auth resolution. +- Added custom Amazon Bedrock request header support via `StreamOptions.headers`, excluding reserved AWS signing headers ([#5178](https://github.com/earendil-works/pi-mono/pull/5178) by [@stephanmck](https://github.com/stephanmck)). ### Fixed -- Fixed OpenRouter Moonshot Kimi K2.6 requests to use `system` instead of unsupported `developer` messages ([#5159](https://github.com/earendil-works/pi/issues/5159)). -- Fixed OpenCode Go Kimi K2.6 thinking requests to send `thinking` objects instead of invalid string values, and fixed OpenCode Zen Grok Build thinking requests to omit unsupported `reasoning_effort` ([#5169](https://github.com/earendil-works/pi/issues/5169)). +- Fixed OpenRouter Moonshot Kimi K2.6 requests to use `system` instead of unsupported `developer` messages ([#5159](https://github.com/earendil-works/pi-mono/issues/5159)). +- Fixed OpenCode Go Kimi K2.6 thinking requests to send `thinking` objects instead of invalid string values, and fixed OpenCode Zen Grok Build thinking requests to omit unsupported `reasoning_effort` ([#5169](https://github.com/earendil-works/pi-mono/issues/5169)). +- Fixed OpenAI Codex Responses SSE streams to abort response body reads after terminal events. +- Fixed OpenCode Kimi K2.6 generated metadata to use Anthropic-style thinking metadata instead of invalid reasoning-effort parameters. ## [0.77.0] - 2026-05-28 diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2e4cb68a..2f4fa0a8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,22 +2,37 @@ ## [Unreleased] +### New Features + +- **Named startup sessions** - `--name` / `-n` sets the session display name before startup across interactive, print, JSON, and RPC modes. See [Naming Sessions](docs/sessions.md#naming-sessions) and [Session Options](docs/usage.md#session-options). +- **Clickable file tool paths** - built-in file tool titles render OSC 8 `file://` hyperlinks when the terminal supports them, including supported tmux clients. + ### Added -- Exported `convertToPng` for extension authors. -- Exported `parseArgs` and type `Args` for extension authors. +- Exported `convertToPng` for extension authors ([#5167](https://github.com/earendil-works/pi-mono/pull/5167) by [@xl0](https://github.com/xl0)). +- Exported `parseArgs` and type `Args` for extension authors ([#5202](https://github.com/earendil-works/pi-mono/pull/5202) by [@xl0](https://github.com/xl0)). +- Added `--name` / `-n` to set the session display name at startup ([#5153](https://github.com/earendil-works/pi-mono/issues/5153)). +- Added a resume command hint when exiting interactive sessions ([#5176](https://github.com/earendil-works/pi-mono/pull/5176) by [@yzhg1983](https://github.com/yzhg1983)). +- Added OSC 8 `file://` hyperlinks to file paths shown in built-in file tool titles ([#5189](https://github.com/earendil-works/pi-mono/pull/5189) by [@mpazik](https://github.com/mpazik)). +- Added custom Amazon Bedrock request header support inherited from `@earendil-works/pi-ai` ([#5178](https://github.com/earendil-works/pi-mono/pull/5178) by [@stephanmck](https://github.com/stephanmck)). ### Fixed -- Clarified the WezTerm/WSL IME hardware cursor docs to state that cursor visibility remains opt-in ([#5200](https://github.com/earendil-works/pi/issues/5200)). -- Fixed the GitLab Duo custom provider example to use adaptive thinking for Claude models, expose xhigh thinking, and include newer verified model IDs ([#5201](https://github.com/earendil-works/pi/issues/5201)). -- Fixed Bun release archive creation to install and copy the matching `@mariozechner/clipboard` base package and native sidecars ([#5184](https://github.com/earendil-works/pi/issues/5184)). +- Clarified the WezTerm/WSL IME hardware cursor docs to state that cursor visibility remains opt-in ([#5200](https://github.com/earendil-works/pi-mono/issues/5200)). +- Fixed the GitLab Duo custom provider example to use adaptive thinking for Claude models, expose xhigh thinking, and include newer verified model IDs ([#5201](https://github.com/earendil-works/pi-mono/issues/5201)). +- Fixed Bun release archive creation to install and copy the matching `@mariozechner/clipboard` base package and native sidecars ([#5184](https://github.com/earendil-works/pi-mono/issues/5184)). +- Fixed early interactive input typed before the prompt loop starts so it is buffered instead of dropped ([#5195](https://github.com/earendil-works/pi-mono/pull/5195) by [@yzhg1983](https://github.com/yzhg1983)). +- Fixed OpenRouter Moonshot Kimi K2.6 requests to use `system` instead of unsupported `developer` messages ([#5159](https://github.com/earendil-works/pi-mono/issues/5159)). +- Fixed OpenCode Go Kimi K2.6 thinking requests to send `thinking` objects instead of invalid string values, and fixed OpenCode Zen Grok Build thinking requests to omit unsupported `reasoning_effort` ([#5169](https://github.com/earendil-works/pi-mono/issues/5169)). +- Fixed OpenAI Codex Responses SSE streams to abort response body reads after terminal events. +- Fixed OpenCode Kimi K2.6 generated metadata to use Anthropic-style thinking metadata instead of invalid reasoning-effort parameters. +- Fixed OSC 8 hyperlinks to pass through tmux when the client supports them ([#5189](https://github.com/earendil-works/pi-mono/pull/5189) by [@mpazik](https://github.com/mpazik)). +- Fixed ANSI text wrapping to avoid stack overflows on very long wrapped lines ([#5185](https://github.com/earendil-works/pi-mono/issues/5185)). ## [0.77.0] - 2026-05-28 ### New Features -- **Startup session naming** - `--name` / `-n` sets the session display name at startup for interactive, print, JSON, and RPC modes. - **Claude Opus 4.8 support** - Adds Anthropic Claude Opus 4.8 metadata and updates Opus adaptive-thinking coverage. - **Selective tool disablement** - `--exclude-tools` / `-xt` disables specific built-in, extension, or custom tools while leaving the rest available. See [Tool Options](docs/usage.md#tool-options). - **Headless Codex subscription login** - `/login` can use device-code auth for ChatGPT Plus/Pro Codex subscriptions. See [Subscriptions](docs/providers.md#subscriptions) and [OpenAI Codex](docs/providers.md#openai-codex). @@ -25,7 +40,6 @@ ### Added -- Added `--name` / `-n` to set the session display name at startup ([#5153](https://github.com/earendil-works/pi/issues/5153)). - Added `--exclude-tools` / `-xt` to disable specific built-in, extension, or custom tools while leaving the rest available ([#5109](https://github.com/earendil-works/pi/issues/5109)). - Added OpenAI Codex subscription device-code login as a selectable headless alternative while keeping browser login as the default ([#4911](https://github.com/earendil-works/pi/pull/4911) by [@vegarsti](https://github.com/vegarsti)). - Added `streamingBehavior` to extension input events so extensions can distinguish idle prompts from mid-stream steers and queued follow-ups ([#5107](https://github.com/earendil-works/pi/pull/5107) by [@DanielThomas](https://github.com/DanielThomas)). diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index e4833df0..09177afc 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -4,8 +4,9 @@ ### Fixed -- Fixed ANSI text wrapping to avoid stack overflows on very long wrapped lines ([#5185](https://github.com/earendil-works/pi/issues/5185)). -- Clarified the IME hardware cursor docs to state that cursor visibility remains opt-in ([#5200](https://github.com/earendil-works/pi/issues/5200)). +- Fixed ANSI text wrapping to avoid stack overflows on very long wrapped lines ([#5185](https://github.com/earendil-works/pi-mono/issues/5185)). +- Clarified the IME hardware cursor docs to state that cursor visibility remains opt-in ([#5200](https://github.com/earendil-works/pi-mono/issues/5200)). +- Fixed OSC 8 hyperlinks to pass through tmux when the client supports them ([#5189](https://github.com/earendil-works/pi-mono/pull/5189) by [@mpazik](https://github.com/mpazik)). ## [0.77.0] - 2026-05-28 From 36515a3793279e98a62e5e39c731fe5419450e24 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 30 May 2026 01:47:26 +0200 Subject: [PATCH 111/352] Document release npm age gate override --- AGENTS.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6686b5e1..729cb09e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,9 +145,11 @@ Attribution: 3. **Run the release script**: ```bash - npm run release:patch # fixes + additions - npm run release:minor # breaking changes + PI_ALLOW_LOCKFILE_CHANGE=1 npm_config_min_release_age=0 npm run release:patch # fixes + additions + PI_ALLOW_LOCKFILE_CHANGE=1 npm_config_min_release_age=0 npm run release:minor # breaking changes ``` + Use `npm_config_min_release_age=0` only for the release command. The repo's normal npm age gate can otherwise block the release lockfile refresh when the current workspace package version was published recently. Review any lockfile or shrinkwrap diffs the release creates before push. + The release script bumps all package versions, updates changelogs, regenerates release artifacts, runs `npm run check`, commits `Release vX.Y.Z`, tags `vX.Y.Z`, adds fresh `## [Unreleased]` changelog sections, commits `Add [Unreleased] section for next cycle`, then pushes `main` and the tag. Do not rerun the release script after a tag was pushed. 4. **CI publishes npm packages**: pushing the `vX.Y.Z` tag triggers `.github/workflows/build-binaries.yml`. The `publish-npm` job uses npm trusted publishing through GitHub Actions OIDC with environment `npm-publish`; no local `npm publish`, `npm whoami`, OTP, or WebAuthn flow is required. From 0897f175e4d46592c0f59b3a2d399f11b8d078af Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 30 May 2026 01:47:37 +0200 Subject: [PATCH 112/352] Release v0.78.0 --- package-lock.json | 48 ++++++++++++++----- packages/agent/CHANGELOG.md | 2 +- packages/agent/package.json | 4 +- packages/ai/CHANGELOG.md | 2 +- packages/ai/package.json | 2 +- packages/ai/src/models.generated.ts | 17 ------- packages/coding-agent/CHANGELOG.md | 2 +- .../package-lock.json | 4 +- .../custom-provider-anthropic/package.json | 2 +- .../custom-provider-gitlab-duo/package.json | 2 +- .../extensions/sandbox/package-lock.json | 4 +- .../examples/extensions/sandbox/package.json | 2 +- .../extensions/with-deps/package-lock.json | 4 +- .../extensions/with-deps/package.json | 2 +- packages/coding-agent/npm-shrinkwrap.json | 24 +++++----- packages/coding-agent/package.json | 8 ++-- packages/tui/CHANGELOG.md | 2 +- packages/tui/package.json | 2 +- 18 files changed, 70 insertions(+), 63 deletions(-) diff --git a/package-lock.json b/package-lock.json index 52967666..5ddf9899 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5995,10 +5995,10 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.77.0", + "version": "0.78.0", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.77.0", + "@earendil-works/pi-ai": "^0.78.0", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -6013,6 +6013,30 @@ "node": ">=22.19.0" } }, + "packages/agent/node_modules/@earendil-works/pi-ai": { + "version": "0.77.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.77.0.tgz", + "integrity": "sha512-H21BrQDPf3ydaeBmS5maNDHxUGFMiKBF/n3WnE+OTWloIZSayeL+/NVEgG3aKQw8fZL6HAMYAGpUIVJgFuKtnw==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.1", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.1.38" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, "packages/agent/node_modules/@types/node": { "version": "24.12.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", @@ -6032,7 +6056,7 @@ }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.77.0", + "version": "0.78.0", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -6077,12 +6101,12 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "0.77.0", + "version": "0.78.0", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.77.0", - "@earendil-works/pi-ai": "^0.77.0", - "@earendil-works/pi-tui": "^0.77.0", + "@earendil-works/pi-agent-core": "^0.78.0", + "@earendil-works/pi-ai": "^0.78.0", + "@earendil-works/pi-tui": "^0.78.0", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -6121,25 +6145,25 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.77.0", + "version": "0.78.0", "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.77.0" + "version": "0.78.0" }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.7.0", + "version": "1.8.0", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.77.0", + "version": "0.78.0", "dependencies": { "ms": "2.1.3" }, @@ -6175,7 +6199,7 @@ }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.77.0", + "version": "0.78.0", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 3685ec28..baddff93 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.78.0] - 2026-05-29 ## [0.77.0] - 2026-05-28 diff --git a/packages/agent/package.json b/packages/agent/package.json index 1606d965..2c6e36f0 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "0.77.0", + "version": "0.78.0", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -29,7 +29,7 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "^0.77.0", + "@earendil-works/pi-ai": "^0.78.0", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 5f62e0b1..cc36eae2 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.78.0] - 2026-05-29 ### Breaking Changes diff --git a/packages/ai/package.json b/packages/ai/package.json index 3ccc240b..555d073f 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.77.0", + "version": "0.78.0", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index c5700a23..a0adb111 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -10402,23 +10402,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, - "openai/gpt-4o-audio-preview": { - id: "openai/gpt-4o-audio-preview", - name: "OpenAI: GPT-4o Audio", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2.5, - output: 10, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "openai/gpt-4o-mini": { id: "openai/gpt-4o-mini", name: "OpenAI: GPT-4o-mini", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2f4fa0a8..f0ec8428 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.78.0] - 2026-05-29 ### 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 4337d48d..de32c5aa 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.77.0", + "version": "0.78.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "0.77.0", + "version": "0.78.0", "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 7c097a31..b33f5156 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.77.0", + "version": "0.78.0", "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 4beae8ce..17cde4b7 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.77.0", + "version": "0.78.0", "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 56832466..e6b78ad9 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.7.0", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.7.0", + "version": "1.8.0", "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 fd8529bd..da17fff9 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.7.0", + "version": "1.8.0", "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 32fac214..9037b8a8 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.77.0", + "version": "0.78.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "0.77.0", + "version": "0.78.0", "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 fbc17fed..00ef86f9 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.77.0", + "version": "0.78.0", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 09ef2b3e..39bb597b 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -1,17 +1,17 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.77.0", + "version": "0.78.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent", - "version": "0.77.0", + "version": "0.78.0", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.77.0", - "@earendil-works/pi-ai": "^0.77.0", - "@earendil-works/pi-tui": "^0.77.0", + "@earendil-works/pi-agent-core": "^0.78.0", + "@earendil-works/pi-ai": "^0.78.0", + "@earendil-works/pi-tui": "^0.78.0", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -473,11 +473,11 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.77.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.77.0.tgz", + "version": "0.78.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.78.0.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.77.0", + "@earendil-works/pi-ai": "^0.78.0", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -487,8 +487,8 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.77.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.77.0.tgz", + "version": "0.78.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.78.0.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -510,8 +510,8 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.77.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.77.0.tgz", + "version": "0.78.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.78.0.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 c03cc2a6..b9fdb289 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.77.0", + "version": "0.78.0", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -39,9 +39,9 @@ "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" }, "dependencies": { - "@earendil-works/pi-agent-core": "^0.77.0", - "@earendil-works/pi-ai": "^0.77.0", - "@earendil-works/pi-tui": "^0.77.0", + "@earendil-works/pi-agent-core": "^0.78.0", + "@earendil-works/pi-ai": "^0.78.0", + "@earendil-works/pi-tui": "^0.78.0", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 09177afc..47297b4f 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.78.0] - 2026-05-29 ### Fixed diff --git a/packages/tui/package.json b/packages/tui/package.json index e14f4e4e..ab65ebe9 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-tui", - "version": "0.77.0", + "version": "0.78.0", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", From dbb9911a547f697229e4e90c9a071794db315e5e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 30 May 2026 01:47:40 +0200 Subject: [PATCH 113/352] Add [Unreleased] section for next cycle --- packages/agent/CHANGELOG.md | 2 ++ packages/ai/CHANGELOG.md | 2 ++ packages/coding-agent/CHANGELOG.md | 2 ++ packages/tui/CHANGELOG.md | 2 ++ 4 files changed, 8 insertions(+) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index baddff93..fd816423 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.78.0] - 2026-05-29 ## [0.77.0] - 2026-05-28 diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index cc36eae2..c5b27a08 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.78.0] - 2026-05-29 ### Breaking Changes diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f0ec8428..8326b565 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.78.0] - 2026-05-29 ### New Features diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 47297b4f..ae628067 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.78.0] - 2026-05-29 ### Fixed From b55fe1bbd42630e8bb7518023c05d15a2ea936a1 Mon Sep 17 00:00:00 2001 From: PriNova Date: Sat, 30 May 2026 11:15:30 +0200 Subject: [PATCH 114/352] Fix OpenRouter reasoning instruction role --- .../ai/src/providers/openai-completions.ts | 5 ++- .../openai-completions-tool-choice.test.ts | 44 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 245e0bc8..ae06326e 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -1075,6 +1075,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet const isTogether = provider === "together" || baseUrl.includes("api.together.ai") || baseUrl.includes("api.together.xyz"); const isMoonshot = provider === "moonshotai" || provider === "moonshotai-cn" || baseUrl.includes("api.moonshot."); + const isOpenRouter = provider === "openrouter" || baseUrl.includes("openrouter.ai"); const isCloudflareWorkersAI = provider === "cloudflare-workers-ai" || baseUrl.includes("api.cloudflare.com"); const isCloudflareAiGateway = provider === "cloudflare-ai-gateway" || baseUrl.includes("gateway.ai.cloudflare.com"); @@ -1101,7 +1102,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet return { supportsStore: !isNonStandard, - supportsDeveloperRole: !isNonStandard, + supportsDeveloperRole: !isNonStandard && !isOpenRouter, supportsReasoningEffort: !isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAiGateway, supportsUsageInStreaming: true, maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens", @@ -1115,7 +1116,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet ? "zai" : isTogether ? "together" - : provider === "openrouter" || baseUrl.includes("openrouter.ai") + : isOpenRouter ? "openrouter" : "openai", openRouterRouting: {}, diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index 81dfb1d4..7ba1e911 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -817,6 +817,50 @@ describe("openai-completions tool_choice", () => { expect(writeCall).not.toHaveProperty("partialArgs"); }); + it("uses system messages for OpenRouter reasoning model instructions", async () => { + const model = getModel("openrouter", "deepseek/deepseek-v4-pro")!; + let payload: unknown; + + await streamSimple( + model, + { + systemPrompt: "Follow instructions.", + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = payload as { messages?: Array<{ role?: string }> }; + expect(params.messages?.[0]?.role).toBe("system"); + }); + + it("keeps developer messages for OpenAI reasoning model instructions", async () => { + const model = getModel("openai", "gpt-5.5")!; + let payload: unknown; + + await streamSimple( + model, + { + systemPrompt: "Follow instructions.", + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = payload as { messages?: Array<{ role?: string }> }; + expect(params.messages?.[0]?.role).toBe("developer"); + }); + it("stores Xiaomi MiMo reasoning replay compat in built-in metadata", () => { const providers = ["xiaomi", "xiaomi-token-plan-cn", "xiaomi-token-plan-ams", "xiaomi-token-plan-sgp"] as const; From 74d81bd36375cc9a42f32e70ac1bbf49d8a581f5 Mon Sep 17 00:00:00 2001 From: PriNova Date: Sat, 30 May 2026 12:24:19 +0200 Subject: [PATCH 115/352] Fix OpenAI completions developer role test --- packages/ai/test/openai-completions-tool-choice.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index 7ba1e911..5f40c817 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -840,7 +840,8 @@ describe("openai-completions tool_choice", () => { }); it("keeps developer messages for OpenAI reasoning model instructions", async () => { - const model = getModel("openai", "gpt-5.5")!; + const { compat: _compat, ...baseModel } = getModel("openai", "gpt-5.5")!; + const model = { ...baseModel, api: "openai-completions" } as const; let payload: unknown; await streamSimple( From 3911d6f5cde8335c576e14051578eeffe812ed53 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 30 May 2026 21:21:42 +0200 Subject: [PATCH 116/352] fix(coding-agent): stream large session files closes #5231 --- packages/coding-agent/CHANGELOG.md | 4 + .../coding-agent/src/core/session-manager.ts | 168 ++++++++++-------- .../session-manager/file-operations.test.ts | 32 +++- 3 files changed, 130 insertions(+), 74 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8326b565..6013abac 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed opening and listing very large JSONL session files by reading session entries line-by-line instead of materializing the full file as one string ([#5231](https://github.com/earendil-works/pi/issues/5231)). + ## [0.78.0] - 2026-05-29 ### New Features diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 914da08d..c2bae164 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -4,17 +4,19 @@ import { randomUUID } from "crypto"; import { appendFileSync, closeSync, + createReadStream, existsSync, mkdirSync, openSync, readdirSync, - readFileSync, readSync, statSync, writeFileSync, } from "fs"; -import { readdir, readFile, stat } from "fs/promises"; +import { readdir, stat } from "fs/promises"; import { join, resolve } from "path"; +import { createInterface } from "readline"; +import { StringDecoder } from "string_decoder"; import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts"; import { normalizePath, resolvePath } from "../utils/paths.ts"; import { @@ -448,29 +450,57 @@ export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultA return sessionDir; } +const SESSION_READ_BUFFER_SIZE = 1024 * 1024; + +function parseSessionEntryLine(line: string): FileEntry | null { + if (!line.trim()) return null; + try { + return JSON.parse(line) as FileEntry; + } catch { + // Skip malformed lines + return null; + } +} + /** Exported for testing */ export function loadEntriesFromFile(filePath: string): FileEntry[] { const resolvedFilePath = normalizePath(filePath); if (!existsSync(resolvedFilePath)) return []; - const content = readFileSync(resolvedFilePath, "utf8"); const entries: FileEntry[] = []; - const lines = content.trim().split("\n"); + const fd = openSync(resolvedFilePath, "r"); + try { + const decoder = new StringDecoder("utf8"); + const buffer = Buffer.allocUnsafe(SESSION_READ_BUFFER_SIZE); + let pending = ""; - for (const line of lines) { - if (!line.trim()) continue; - try { - const entry = JSON.parse(line) as FileEntry; - entries.push(entry); - } catch { - // Skip malformed lines + while (true) { + const bytesRead = readSync(fd, buffer, 0, buffer.length, null); + if (bytesRead === 0) break; + + pending += decoder.write(buffer.subarray(0, bytesRead)); + let lineStart = 0; + let newlineIndex = pending.indexOf("\n", lineStart); + while (newlineIndex !== -1) { + const entry = parseSessionEntryLine(pending.slice(lineStart, newlineIndex)); + if (entry) entries.push(entry); + lineStart = newlineIndex + 1; + newlineIndex = pending.indexOf("\n", lineStart); + } + pending = pending.slice(lineStart); } + + pending += decoder.end(); + const finalEntry = parseSessionEntryLine(pending); + if (finalEntry) entries.push(finalEntry); + } finally { + closeSync(fd); } // Validate session header if (entries.length === 0) return entries; const header = entries[0]; - if (header.type !== "session" || typeof (header as any).id !== "string") { + if (header.type !== "session" || typeof (header as { id?: unknown }).id !== "string") { return []; } @@ -542,80 +572,59 @@ function extractTextContent(message: Message): string { .join(" "); } -function getLastActivityTime(entries: FileEntry[]): number | undefined { - let lastActivityTime: number | undefined; +function getMessageActivityTime(entry: SessionMessageEntry): number | undefined { + const message = entry.message; + if (!isMessageWithContent(message)) return undefined; + if (message.role !== "user" && message.role !== "assistant") return undefined; - for (const entry of entries) { - if (entry.type !== "message") continue; - - const message = (entry as SessionMessageEntry).message; - if (!isMessageWithContent(message)) continue; - if (message.role !== "user" && message.role !== "assistant") continue; - - const msgTimestamp = (message as { timestamp?: number }).timestamp; - if (typeof msgTimestamp === "number") { - lastActivityTime = Math.max(lastActivityTime ?? 0, msgTimestamp); - continue; - } - - const entryTimestamp = (entry as SessionEntryBase).timestamp; - if (typeof entryTimestamp === "string") { - const t = new Date(entryTimestamp).getTime(); - if (!Number.isNaN(t)) { - lastActivityTime = Math.max(lastActivityTime ?? 0, t); - } - } + const msgTimestamp = (message as { timestamp?: number }).timestamp; + if (typeof msgTimestamp === "number") { + return msgTimestamp; } - return lastActivityTime; -} - -function getSessionModifiedDate(entries: FileEntry[], header: SessionHeader, statsMtime: Date): Date { - const lastActivityTime = getLastActivityTime(entries); - if (typeof lastActivityTime === "number" && lastActivityTime > 0) { - return new Date(lastActivityTime); - } - - const headerTime = typeof header.timestamp === "string" ? new Date(header.timestamp).getTime() : NaN; - return !Number.isNaN(headerTime) ? new Date(headerTime) : statsMtime; + const t = new Date(entry.timestamp).getTime(); + return Number.isNaN(t) ? undefined : t; } async function buildSessionInfo(filePath: string): Promise { try { - const content = await readFile(filePath, "utf8"); - const entries: FileEntry[] = []; - const lines = content.trim().split("\n"); - - for (const line of lines) { - if (!line.trim()) continue; - try { - entries.push(JSON.parse(line) as FileEntry); - } catch { - // Skip malformed lines - } - } - - if (entries.length === 0) return null; - const header = entries[0]; - if (header.type !== "session") return null; - const stats = await stat(filePath); + let header: SessionHeader | null = null; let messageCount = 0; let firstMessage = ""; const allMessages: string[] = []; let name: string | undefined; + let lastActivityTime: number | undefined; + + const rl = createInterface({ + input: createReadStream(filePath, { encoding: "utf8" }), + crlfDelay: Infinity, + }); + + for await (const line of rl) { + const entry = parseSessionEntryLine(line); + if (!entry) continue; + + if (!header) { + if (entry.type !== "session") return null; + header = entry; + continue; + } - for (const entry of entries) { // Extract session name (use latest, including explicit clears) if (entry.type === "session_info") { - const infoEntry = entry as SessionInfoEntry; - name = infoEntry.name?.trim() || undefined; + name = entry.name?.trim() || undefined; } if (entry.type !== "message") continue; messageCount++; - const message = (entry as SessionMessageEntry).message; + const activityTime = getMessageActivityTime(entry); + if (typeof activityTime === "number") { + lastActivityTime = Math.max(lastActivityTime ?? 0, activityTime); + } + + const message = entry.message; if (!isMessageWithContent(message)) continue; if (message.role !== "user" && message.role !== "assistant") continue; @@ -628,18 +637,25 @@ async function buildSessionInfo(filePath: string): Promise { } } - const cwd = typeof (header as SessionHeader).cwd === "string" ? (header as SessionHeader).cwd : ""; - const parentSessionPath = (header as SessionHeader).parentSession; + if (!header) return null; - const modified = getSessionModifiedDate(entries, header as SessionHeader, stats.mtime); + const cwd = typeof header.cwd === "string" ? header.cwd : ""; + const parentSessionPath = header.parentSession; + const headerTime = typeof header.timestamp === "string" ? new Date(header.timestamp).getTime() : NaN; + const modified = + typeof lastActivityTime === "number" && lastActivityTime > 0 + ? new Date(lastActivityTime) + : !Number.isNaN(headerTime) + ? new Date(headerTime) + : stats.mtime; return { path: filePath, - id: (header as SessionHeader).id, + id: header.id, cwd, name, parentSessionPath, - created: new Date((header as SessionHeader).timestamp), + created: new Date(header.timestamp), modified, messageCount, firstMessage: firstMessage || "(no messages)", @@ -855,8 +871,14 @@ export class SessionManager { private _rewriteFile(): void { if (!this.persist || !this.sessionFile) return; - const content = `${this.fileEntries.map((e) => JSON.stringify(e)).join("\n")}\n`; - writeFileSync(this.sessionFile, content); + const fd = openSync(this.sessionFile, "w"); + try { + for (const entry of this.fileEntries) { + writeFileSync(fd, `${JSON.stringify(entry)}\n`); + } + } finally { + closeSync(fd); + } } isPersisted(): boolean { diff --git a/packages/coding-agent/test/session-manager/file-operations.test.ts b/packages/coding-agent/test/session-manager/file-operations.test.ts index fd771ef9..626dd52c 100644 --- a/packages/coding-agent/test/session-manager/file-operations.test.ts +++ b/packages/coding-agent/test/session-manager/file-operations.test.ts @@ -1,4 +1,5 @@ -import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { constants as bufferConstants } from "buffer"; +import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -63,6 +64,35 @@ describe("loadEntriesFromFile", () => { const entries = loadEntriesFromFile(file); expect(entries).toHaveLength(2); }); + + it("opens session files larger than Node's max string length", () => { + const file = join(tempDir, "large.jsonl"); + writeFileSync( + file, + '{"type":"session","version":3,"id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n', + ); + + const fd = openSync(file, "r+"); + try { + const newline = Buffer.from("\n"); + const stride = 16 * 1024 * 1024; + for (let offset = stride; offset <= bufferConstants.MAX_STRING_LENGTH + stride; offset += stride) { + writeSync(fd, newline, 0, newline.length, offset); + } + } finally { + closeSync(fd); + } + + appendFileSync( + file, + '{"type":"message","id":"1","parentId":null,"timestamp":"2025-01-01T00:00:01Z","message":{"role":"user","content":"hi","timestamp":1}}\n', + ); + + const sessionManager = SessionManager.open(file, tempDir); + expect(sessionManager.getSessionId()).toBe("abc"); + expect(sessionManager.getEntries()).toHaveLength(1); + expect(sessionManager.buildSessionContext().messages).toEqual([{ role: "user", content: "hi", timestamp: 1 }]); + }); }); describe("findMostRecentSession", () => { From 5d9b28ee437fd300cf688bca27fb848b78022f62 Mon Sep 17 00:00:00 2001 From: Nico Bailon Date: Fri, 29 May 2026 00:18:57 -0700 Subject: [PATCH 117/352] fix(tui): keep focused overlays interactive after UI Preserve a focused visible overlay as the input owner across extension custom UI replacement, while letting the replacement receive and close its own input before focus is restored. Fixes #5129. --- packages/tui/CHANGELOG.md | 4 + packages/tui/README.md | 4 + packages/tui/src/tui.ts | 113 ++++++- .../tui/test/overlay-non-capturing.test.ts | 294 +++++++++++++++++- 4 files changed, 397 insertions(+), 18 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index ae628067..5bf01b73 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed focused visible overlays losing input to base components after focus restoration behind the overlay ([#5129](https://github.com/earendil-works/pi/issues/5129)). + ## [0.78.0] - 2026-05-29 ### Fixed diff --git a/packages/tui/README.md b/packages/tui/README.md index 1da50a3a..76e3317d 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -119,6 +119,10 @@ handle.focus(); // Focus and bring to visual front handle.unfocus(); // Release focus to previous target handle.isFocused(); // Check if overlay has focus +// A focused visible overlay keeps keyboard input until hidden or explicitly unfocused. +// If you want a base component to receive input while the overlay remains visible, +// call handle.unfocus() before focusing the base component. + // Hide topmost overlay tui.hideOverlay(); diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 2874ef02..cc6ac6f6 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -194,6 +194,19 @@ export interface OverlayHandle { isFocused(): boolean; } +type ActiveOverlayRestoreFocusState = { status: "eligible" } | { status: "blocked"; blockedBy: Component }; +type OverlayRestoreFocusState = { status: "inactive" } | ActiveOverlayRestoreFocusState; +type RestorableOverlayStackEntry = OverlayStackEntry & { restoreFocus: ActiveOverlayRestoreFocusState }; + +type OverlayStackEntry = { + component: Component; + options: OverlayOptions | undefined; + preFocus: Component | null; + hidden: boolean; + focusOrder: number; + restoreFocus: OverlayRestoreFocusState; +}; + /** * Container - a component that contains other components */ @@ -262,13 +275,7 @@ export class TUI extends Container { // Overlay stack for modal components rendered on top of base content private focusOrderCounter = 0; - private overlayStack: { - component: Component; - options?: OverlayOptions; - preFocus: Component | null; - hidden: boolean; - focusOrder: number; - }[] = []; + private overlayStack: OverlayStackEntry[] = []; constructor(terminal: Terminal, showHardwareCursor?: boolean) { super(); @@ -309,17 +316,76 @@ export class TUI extends Container { } setFocus(component: Component | null): void { + const previousFocus = this.focusedComponent; + let nextFocus = component; + const previousFocusedOverlay = previousFocus + ? this.overlayStack.find((entry) => entry.component === previousFocus && this.isOverlayVisible(entry)) + : undefined; + const nextFocusIsOverlay = nextFocus ? this.overlayStack.some((entry) => entry.component === nextFocus) : false; + if (nextFocus && !nextFocusIsOverlay) { + const restoreOverlay = this.getRestoreFocusOverlay(); + if ( + restoreOverlay?.restoreFocus.status === "blocked" && + restoreOverlay.restoreFocus.blockedBy === previousFocus + ) { + restoreOverlay.restoreFocus = { status: "eligible" }; + nextFocus = restoreOverlay.component; + } else if ( + previousFocusedOverlay && + previousFocusedOverlay.restoreFocus.status !== "inactive" && + !this.isOverlayFocusAncestor(previousFocusedOverlay, nextFocus) + ) { + previousFocusedOverlay.restoreFocus = { status: "blocked", blockedBy: nextFocus }; + } + } + // Clear focused flag on old component if (isFocusable(this.focusedComponent)) { this.focusedComponent.focused = false; } - this.focusedComponent = component; + this.focusedComponent = nextFocus; // Set focused flag on new component - if (isFocusable(component)) { - component.focused = true; + if (isFocusable(nextFocus)) { + nextFocus.focused = true; } + + const focusedOverlay = nextFocus + ? this.overlayStack.find((entry) => entry.component === nextFocus && this.isOverlayVisible(entry)) + : undefined; + if (focusedOverlay) { + this.markOverlayRestoreFocusEligible(focusedOverlay); + } + } + + private markOverlayRestoreFocusEligible(entry: OverlayStackEntry): void { + for (const overlay of this.overlayStack) { + this.clearOverlayRestoreFocus(overlay); + } + entry.restoreFocus = { status: "eligible" }; + } + + private clearOverlayRestoreFocus(entry: OverlayStackEntry): void { + entry.restoreFocus = { status: "inactive" }; + } + + private getRestoreFocusOverlay(): RestorableOverlayStackEntry | undefined { + return this.overlayStack.find( + (overlay): overlay is RestorableOverlayStackEntry => + overlay.restoreFocus.status !== "inactive" && this.isOverlayVisible(overlay), + ); + } + + private isOverlayFocusAncestor(entry: OverlayStackEntry, component: Component): boolean { + const visited = new Set(); + let current = entry.preFocus; + while (current && !visited.has(current)) { + visited.add(current); + if (current === component) return true; + current = this.overlayStack.find((overlay) => overlay.component === current)?.preFocus ?? null; + } + return false; } /** @@ -327,12 +393,13 @@ export class TUI extends Container { * Returns a handle to control the overlay's visibility. */ showOverlay(component: Component, options?: OverlayOptions): OverlayHandle { - const entry = { + const entry: OverlayStackEntry = { component, options, preFocus: this.focusedComponent, hidden: false, focusOrder: ++this.focusOrderCounter, + restoreFocus: { status: "inactive" }, }; this.overlayStack.push(entry); // Only focus if overlay is actually visible @@ -347,6 +414,7 @@ export class TUI extends Container { hide: () => { const index = this.overlayStack.indexOf(entry); if (index !== -1) { + this.clearOverlayRestoreFocus(entry); this.overlayStack.splice(index, 1); // Restore focus if this overlay had focus if (this.focusedComponent === component) { @@ -362,6 +430,7 @@ export class TUI extends Container { entry.hidden = hidden; // Update focus when hiding/showing if (hidden) { + this.clearOverlayRestoreFocus(entry); // If this overlay had focus, move focus to next visible or preFocus if (this.focusedComponent === component) { const topVisible = this.getTopmostVisibleOverlay(); @@ -379,14 +448,13 @@ export class TUI extends Container { isHidden: () => entry.hidden, focus: () => { if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry)) return; - if (this.focusedComponent !== component) { - this.setFocus(component); - } entry.focusOrder = ++this.focusOrderCounter; + this.setFocus(component); this.requestRender(); }, unfocus: () => { if (this.focusedComponent !== component) return; + this.clearOverlayRestoreFocus(entry); const topVisible = this.getTopmostVisibleOverlay(); this.setFocus(topVisible && topVisible !== entry ? topVisible.component : entry.preFocus); this.requestRender(); @@ -399,6 +467,7 @@ export class TUI extends Container { hideOverlay(): void { const overlay = this.overlayStack.pop(); if (!overlay) return; + this.clearOverlayRestoreFocus(overlay); if (this.focusedComponent === overlay.component) { // Find topmost visible overlay, or fall back to preFocus const topVisible = this.getTopmostVisibleOverlay(); @@ -414,7 +483,7 @@ export class TUI extends Container { } /** Check if an overlay entry is currently visible */ - private isOverlayVisible(entry: (typeof this.overlayStack)[number]): boolean { + private isOverlayVisible(entry: OverlayStackEntry): boolean { if (entry.hidden) return false; if (entry.options?.visible) { return entry.options.visible(this.terminal.columns, this.terminal.rows); @@ -423,7 +492,7 @@ export class TUI extends Container { } /** Find the topmost visible capturing overlay, if any */ - private getTopmostVisibleOverlay(): (typeof this.overlayStack)[number] | undefined { + private getTopmostVisibleOverlay(): OverlayStackEntry | undefined { for (let i = this.overlayStack.length - 1; i >= 0; i--) { if (this.overlayStack[i].options?.nonCapturing) continue; if (this.isOverlayVisible(this.overlayStack[i])) { @@ -584,6 +653,18 @@ export class TUI extends Container { } } + const focusIsOverlay = this.overlayStack.some((o) => o.component === this.focusedComponent); + if (!focusIsOverlay) { + const overlayToRestore = this.getRestoreFocusOverlay(); + if ( + overlayToRestore && + (overlayToRestore.restoreFocus.status === "eligible" || + overlayToRestore.restoreFocus.blockedBy !== this.focusedComponent) + ) { + this.setFocus(overlayToRestore.component); + } + } + // Pass input to focused component (including Ctrl+C) // The focused component can decide how to handle Ctrl+C if (this.focusedComponent?.handleInput) { diff --git a/packages/tui/test/overlay-non-capturing.test.ts b/packages/tui/test/overlay-non-capturing.test.ts index cb91e5e4..3c61b561 100644 --- a/packages/tui/test/overlay-non-capturing.test.ts +++ b/packages/tui/test/overlay-non-capturing.test.ts @@ -235,7 +235,9 @@ describe("TUI overlay non-capturing", () => { // Simulate showExtensionCustom: factory creates timer synchronously, // then .then() pushes controller as a microtask let timerHandle: ReturnType; - let doneFn: () => void; + let doneFn: () => void = () => { + throw new Error("doneFn was not initialized"); + }; const overlayPromise = new Promise((resolve) => { doneFn = () => { @@ -259,7 +261,7 @@ describe("TUI overlay non-capturing", () => { assert.strictEqual(editor.focused, false); // Simulate Esc: cleanup + close (from inside handleInput) - doneFn!(); + doneFn(); // Now await the promise (simulating showExtensionCustom resolving) await overlayPromise; await renderAndFlush(tui, terminal); @@ -305,6 +307,294 @@ describe("TUI overlay non-capturing", () => { } }); + it("active base focus replacement receives close input before overlay restore", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const replacement = new FocusableOverlay(["REPLACEMENT"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + overlay.handleInput = (data: string) => { + overlay.inputs.push(data); + if (data === "b") { + tui.setFocus(replacement); + } + }; + replacement.handleInput = (data: string) => { + replacement.inputs.push(data); + if (data === "\r") { + tui.setFocus(editor); + } + }; + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(overlay); + assert.strictEqual(overlay.focused, true); + terminal.sendInput("b"); + await renderAndFlush(tui, terminal); + assert.strictEqual(replacement.focused, true); + + terminal.sendInput("\r"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(replacement.inputs, ["\r"]); + assert.deepStrictEqual(overlay.inputs, ["b"]); + assert.strictEqual(overlay.focused, true); + + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, ["b", "x"]); + } finally { + tui.stop(); + } + }); + + it("active replacement still receives input when it is another overlay preFocus", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const replacement = new FocusableOverlay(["REPLACEMENT"]); + const passive = new FocusableOverlay(["PASSIVE"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + overlay.handleInput = (data: string) => { + overlay.inputs.push(data); + if (data === "b") { + tui.setFocus(replacement); + } + }; + replacement.handleInput = (data: string) => { + replacement.inputs.push(data); + if (data === "\r") { + tui.setFocus(editor); + } + }; + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.setFocus(replacement); + tui.showOverlay(passive, { nonCapturing: true }); + tui.setFocus(editor); + tui.showOverlay(overlay); + terminal.sendInput("b"); + await renderAndFlush(tui, terminal); + assert.strictEqual(replacement.focused, true); + + terminal.sendInput("1"); + terminal.sendInput("\r"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(replacement.inputs, ["1", "\r"]); + assert.deepStrictEqual(overlay.inputs, ["b"]); + assert.strictEqual(overlay.focused, true); + } finally { + tui.stop(); + } + }); + + it("handleInput restores focus to a visible focused overlay after base focus steal", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const replacement = new FocusableOverlay(["REPLACEMENT"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(overlay); + assert.strictEqual(overlay.focused, true); + tui.setFocus(replacement); + tui.setFocus(editor); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, ["x"]); + assert.deepStrictEqual(editor.inputs, []); + assert.strictEqual(overlay.focused, true); + } finally { + tui.stop(); + } + }); + + it("handleInput restores focus to explicitly focused raw sub-overlay after base focus steal", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const controller = new FocusableOverlay(["CONTROLLER"]); + const subOverlay = new FocusableOverlay(["SUB"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(controller); + const subHandle = tui.showOverlay(subOverlay, { nonCapturing: true }); + subHandle.focus(); + tui.setFocus(editor); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(subOverlay.inputs, ["x"]); + assert.deepStrictEqual(controller.inputs, []); + assert.deepStrictEqual(editor.inputs, []); + } finally { + tui.stop(); + } + }); + + it("passive non-capturing overlay does not regain input after base focus", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const passive = new FocusableOverlay(["PASSIVE"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(passive, { nonCapturing: true }); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(editor.inputs, ["x"]); + assert.deepStrictEqual(passive.inputs, []); + assert.strictEqual(editor.focused, true); + } finally { + tui.stop(); + } + }); + + it("explicitly focused non-capturing overlay regains input after base focus steal", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const overlay = new FocusableOverlay(["NC"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const handle = tui.showOverlay(overlay, { nonCapturing: true }); + handle.focus(); + tui.setFocus(editor); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, ["x"]); + assert.deepStrictEqual(editor.inputs, []); + } finally { + tui.stop(); + } + }); + + it("unfocus() prevents visible overlay from regaining input", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const handle = tui.showOverlay(overlay); + handle.unfocus(); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(editor.inputs, ["x"]); + assert.deepStrictEqual(overlay.inputs, []); + assert.strictEqual(editor.focused, true); + } finally { + tui.stop(); + } + }); + + it("temporarily invisible focused overlay falls back without losing restore eligibility", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + let visible = true; + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(overlay, { visible: () => visible }); + tui.setFocus(editor); + visible = false; + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(editor.inputs, ["x"]); + assert.deepStrictEqual(overlay.inputs, []); + visible = true; + terminal.sendInput("y"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(editor.inputs, ["x"]); + assert.deepStrictEqual(overlay.inputs, ["y"]); + } finally { + tui.stop(); + } + }); + + it("temporarily invisible focused overlay with null preFocus restores when visible again", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new FocusableOverlay(["OVERLAY"]); + let visible = true; + tui.addChild(new EmptyContent()); + tui.start(); + try { + tui.showOverlay(overlay, { visible: () => visible }); + visible = false; + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, []); + visible = true; + terminal.sendInput("y"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, ["y"]); + } finally { + tui.stop(); + } + }); + + it("cyclic overlay preFocus ancestry does not hang focus changes", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.setFocus(overlay); + tui.start(); + try { + const handle = tui.showOverlay(overlay, { nonCapturing: true }); + handle.focus(); + tui.setFocus(editor); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(editor.inputs, ["x"]); + assert.deepStrictEqual(overlay.inputs, []); + } finally { + tui.stop(); + } + }); + + it("handleInput restores the focus-order top overlay after base focus steal", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const lower = new FocusableOverlay(["LOWER"]); + const upper = new FocusableOverlay(["UPPER"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const lowerHandle = tui.showOverlay(lower); + tui.showOverlay(upper); + lowerHandle.focus(); + tui.setFocus(editor); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(lower.inputs, ["x"]); + assert.deepStrictEqual(upper.inputs, []); + assert.deepStrictEqual(editor.inputs, []); + } finally { + tui.stop(); + } + }); + it("hideOverlay() does not reassign focus when topmost overlay is non-capturing", async () => { const terminal = new VirtualTerminal(80, 24); const tui = new TUI(terminal); From 735ccbd00ff6ce091dd6505f2d07c265b78a9092 Mon Sep 17 00:00:00 2001 From: Nico Bailon Date: Fri, 29 May 2026 23:40:27 -0700 Subject: [PATCH 118/352] fix(tui): release overlay focus to explicit targets Add an explicit overlay unfocus target so callers can move input to the editor or another component while overlays remain visible. Align fallback overlay focus with visual focus order and cover blocked replacement release, null targets, and multi-overlay cycling. --- packages/tui/CHANGELOG.md | 4 + packages/tui/README.md | 10 +- packages/tui/src/index.ts | 1 + packages/tui/src/tui.ts | 44 ++++-- .../tui/test/overlay-non-capturing.test.ts | 129 ++++++++++++++++++ 5 files changed, 171 insertions(+), 17 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 5bf01b73..180b7df2 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `OverlayHandle.unfocus({ target })` for explicitly releasing overlay focus to a chosen component while overlays remain visible. + ### Fixed - Fixed focused visible overlays losing input to base components after focus restoration behind the overlay ([#5129](https://github.com/earendil-works/pi/issues/5129)). diff --git a/packages/tui/README.md b/packages/tui/README.md index 76e3317d..f07b785a 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -116,12 +116,14 @@ handle.setHidden(true); // Temporarily hide (can show again) handle.setHidden(false); // Show again after hiding handle.isHidden(); // Check if temporarily hidden handle.focus(); // Focus and bring to visual front -handle.unfocus(); // Release focus to previous target +handle.unfocus(); // Release focus to the next visible overlay or previous target +handle.unfocus({ target: baseComponent }); // Release this overlay to a specific component +handle.unfocus({ target: null }); // Release this overlay without focusing another component handle.isFocused(); // Check if overlay has focus -// A focused visible overlay keeps keyboard input until hidden or explicitly unfocused. -// If you want a base component to receive input while the overlay remains visible, -// call handle.unfocus() before focusing the base component. +// A focused visible overlay reclaims keyboard input after temporary replacement UI +// releases focus. If you want a specific component to receive input while overlays remain +// visible, call handle.unfocus({ target: component }). // Hide topmost overlay tui.hideOverlay(); diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index a645c8a6..fe0fed00 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -99,6 +99,7 @@ export { type OverlayHandle, type OverlayMargin, type OverlayOptions, + type OverlayUnfocusOptions, type SizeValue, TUI, } from "./tui.ts"; diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index cc6ac6f6..0d5a7ee6 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -176,6 +176,12 @@ export interface OverlayOptions { nonCapturing?: boolean; } +/** Options for {@link OverlayHandle.unfocus}. */ +export interface OverlayUnfocusOptions { + /** Explicit target to focus after releasing this overlay. */ + target: Component | null; +} + /** * Handle returned by showOverlay for controlling the overlay */ @@ -188,8 +194,8 @@ export interface OverlayHandle { isHidden(): boolean; /** Focus this overlay and bring it to the visual front */ focus(): void; - /** Release focus to the previous target */ - unfocus(): void; + /** Release focus to the next visible overlay or the previous target, or to an explicit target when provided */ + unfocus(options?: OverlayUnfocusOptions): void; /** Check if this overlay currently has focus */ isFocused(): boolean; } @@ -328,7 +334,6 @@ export class TUI extends Container { restoreOverlay?.restoreFocus.status === "blocked" && restoreOverlay.restoreFocus.blockedBy === previousFocus ) { - restoreOverlay.restoreFocus = { status: "eligible" }; nextFocus = restoreOverlay.component; } else if ( previousFocusedOverlay && @@ -452,11 +457,23 @@ export class TUI extends Container { this.setFocus(component); this.requestRender(); }, - unfocus: () => { - if (this.focusedComponent !== component) return; + unfocus: (unfocusOptions) => { + const isFocused = this.focusedComponent === component; + const hasPendingRestore = entry.restoreFocus.status !== "inactive"; + // Nothing to release: we neither hold focus nor have a pending reclaim. + if (!isFocused && !hasPendingRestore) return; + // True when this overlay is waiting to reclaim focus from the component that + // currently holds it; computed before clearing the (about-to-be-reset) state. + const blockedByFocused = + entry.restoreFocus.status === "blocked" && this.focusedComponent === entry.restoreFocus.blockedBy; this.clearOverlayRestoreFocus(entry); - const topVisible = this.getTopmostVisibleOverlay(); - this.setFocus(topVisible && topVisible !== entry ? topVisible.component : entry.preFocus); + // Move focus only if we currently hold it, or the caller named an explicit + // target and we're not mid-reclaim from the focused component. + if (isFocused || (unfocusOptions && !blockedByFocused)) { + const topVisible = this.getTopmostVisibleOverlay(); + const fallbackTarget = topVisible && topVisible !== entry ? topVisible.component : entry.preFocus; + this.setFocus(unfocusOptions ? unfocusOptions.target : fallbackTarget); + } this.requestRender(); }, isFocused: () => this.focusedComponent === component, @@ -491,15 +508,16 @@ export class TUI extends Container { return true; } - /** Find the topmost visible capturing overlay, if any */ + /** Find the visual-frontmost visible capturing overlay, if any */ private getTopmostVisibleOverlay(): OverlayStackEntry | undefined { - for (let i = this.overlayStack.length - 1; i >= 0; i--) { - if (this.overlayStack[i].options?.nonCapturing) continue; - if (this.isOverlayVisible(this.overlayStack[i])) { - return this.overlayStack[i]; + let topmost: OverlayStackEntry | undefined; + for (const overlay of this.overlayStack) { + if (overlay.options?.nonCapturing || !this.isOverlayVisible(overlay)) continue; + if (!topmost || overlay.focusOrder > topmost.focusOrder) { + topmost = overlay; } } - return undefined; + return topmost; } override invalidate(): void { diff --git a/packages/tui/test/overlay-non-capturing.test.ts b/packages/tui/test/overlay-non-capturing.test.ts index 3c61b561..e6490d4a 100644 --- a/packages/tui/test/overlay-non-capturing.test.ts +++ b/packages/tui/test/overlay-non-capturing.test.ts @@ -391,6 +391,46 @@ describe("TUI overlay non-capturing", () => { } }); + it("unfocus target releases a blocked overlay while replacement remains focused", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const replacement = new FocusableOverlay(["REPLACEMENT"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + replacement.handleInput = (data: string) => { + replacement.inputs.push(data); + if (data === "\r") { + tui.setFocus(editor); + } + }; + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const overlayHandle = tui.showOverlay(overlay); + overlay.handleInput = (data: string) => { + overlay.inputs.push(data); + if (data === "b") { + tui.setFocus(replacement); + overlayHandle.unfocus({ target: editor }); + } + }; + terminal.sendInput("b"); + await renderAndFlush(tui, terminal); + assert.strictEqual(replacement.focused, true); + + terminal.sendInput("\r"); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(replacement.inputs, ["\r"]); + assert.deepStrictEqual(overlay.inputs, ["b"]); + assert.deepStrictEqual(editor.inputs, ["x"]); + assert.strictEqual(editor.focused, true); + } finally { + tui.stop(); + } + }); + it("handleInput restores focus to a visible focused overlay after base focus steal", async () => { const terminal = new VirtualTerminal(80, 24); const tui = new TUI(terminal); @@ -771,6 +811,95 @@ describe("TUI overlay non-capturing", () => { tui.stop(); } }); + + it("explicit unfocus target supports cycling between three overlays and editor", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const a = new FocusableOverlay(["A"]); + const b = new FocusableOverlay(["B"]); + const c = new FocusableOverlay(["C"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const aHandle = tui.showOverlay(a); + const bHandle = tui.showOverlay(b); + const cHandle = tui.showOverlay(c); + + aHandle.focus(); + terminal.sendInput("a"); + await renderAndFlush(tui, terminal); + bHandle.focus(); + terminal.sendInput("b"); + await renderAndFlush(tui, terminal); + cHandle.focus(); + terminal.sendInput("c"); + await renderAndFlush(tui, terminal); + cHandle.unfocus({ target: editor }); + terminal.sendInput("e"); + await renderAndFlush(tui, terminal); + aHandle.focus(); + terminal.sendInput("A"); + await renderAndFlush(tui, terminal); + aHandle.unfocus({ target: editor }); + terminal.sendInput("E"); + await renderAndFlush(tui, terminal); + + assert.deepStrictEqual(a.inputs, ["a", "A"]); + assert.deepStrictEqual(b.inputs, ["b"]); + assert.deepStrictEqual(c.inputs, ["c"]); + assert.deepStrictEqual(editor.inputs, ["e", "E"]); + assert.strictEqual(editor.focused, true); + } finally { + tui.stop(); + } + }); + + it("explicit null unfocus target clears focus without restoring overlays", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.start(); + try { + const handle = tui.showOverlay(overlay); + handle.unfocus({ target: null }); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, []); + assert.strictEqual(handle.isFocused(), false); + } finally { + tui.stop(); + } + }); + + it("hiding focused overlay falls back to next visual-frontmost overlay", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const a = new FocusableOverlay(["A"]); + const b = new FocusableOverlay(["B"]); + const c = new FocusableOverlay(["C"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const aHandle = tui.showOverlay(a); + const bHandle = tui.showOverlay(b); + tui.showOverlay(c); + aHandle.focus(); + bHandle.focus(); + bHandle.setHidden(true); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(a.inputs, ["x"]); + assert.deepStrictEqual(c.inputs, []); + assert.strictEqual(a.focused, true); + } finally { + tui.stop(); + } + }); }); describe("rendering order", () => { From 82f29ea442fb32da275839e3a282526d52d43bb4 Mon Sep 17 00:00:00 2001 From: Nico Bailon Date: Sat, 30 May 2026 11:34:14 -0700 Subject: [PATCH 119/352] docs(coding-agent): expand overlay focus demo Update the overlay QA focus demo to exercise three visible overlays with text input, focus cycling, per-panel dismissal, and visual focus ordering. --- .../examples/extensions/overlay-qa-tests.ts | 186 ++++++++++++------ 1 file changed, 128 insertions(+), 58 deletions(-) diff --git a/packages/coding-agent/examples/extensions/overlay-qa-tests.ts b/packages/coding-agent/examples/extensions/overlay-qa-tests.ts index 306c139b..696a436b 100644 --- a/packages/coding-agent/examples/extensions/overlay-qa-tests.ts +++ b/packages/coding-agent/examples/extensions/overlay-qa-tests.ts @@ -15,13 +15,13 @@ * /overlay-sidepanel - Responsive sidepanel (hides when terminal < 100 cols) * /overlay-toggle - Toggle visibility demo (demonstrates OverlayHandle.setHidden) * /overlay-passive - Non-capturing overlay demo (passive info panel alongside active overlay) - * /overlay-focus - Focus cycling and rendering order with non-capturing overlays + * /overlay-focus - Focus cycling, input routing, dismissal, and rendering order with overlays * /overlay-streaming - Multiple input panels with simulated streaming (Tab to cycle focus) */ import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent"; import type { Component, OverlayAnchor, OverlayHandle, OverlayOptions, TUI } from "@earendil-works/pi-tui"; -import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; +import { CURSOR_MARKER, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import { spawn } from "child_process"; // Global handle for toggle demo (in real code, use a more elegant pattern) @@ -272,9 +272,9 @@ export default function (pi: ExtensionAPI) { }, }); - // Focus cycling demo - demonstrates focus(), unfocus(), isFocused() and rendering order + // Focus cycling demo - demonstrates focus(), input routing, per-panel dismissal, and rendering order pi.registerCommand("overlay-focus", { - description: "Test focus cycling and rendering order with non-capturing overlays", + description: "Test focus cycling, input routing, dismissal, and rendering order with overlays", handler: async (_args: string, ctx: ExtensionCommandContext) => { ctx.ui.setEditorText(""); await ctx.ui.custom((tui, theme, _kb, done) => new FocusDemoController(tui, theme, done), { @@ -1035,7 +1035,6 @@ class FocusDemoController extends BaseOverlay { private tui: TUI; private panels: FocusPanel[] = []; private handles: OverlayHandle[] = []; - private focusIndex = -1; private done: () => void; constructor(tui: TUI, theme: Theme, done: () => void) { @@ -1044,42 +1043,63 @@ class FocusDemoController extends BaseOverlay { this.done = done; const colors = ["error", "success", "accent"] as const; const labels = ["Alpha", "Beta", "Gamma"]; + const positions = [ + { row: 2, col: 4 }, + { row: 5, col: 28 }, + { row: 8, col: 52 }, + ]; for (let i = 0; i < 3; i++) { - const panel = new FocusPanel( - theme, - labels[i]!, - colors[i]!, - () => this.cycleFocus(), - () => this.close(), - ); + const panel = new FocusPanel(theme, labels[i]!, colors[i]!, this); const handle = this.tui.showOverlay(panel, { nonCapturing: true, - row: 2, - col: 5 + i * 6, - width: 28, + ...positions[i]!, + width: 34, }); panel.handle = handle; this.panels.push(panel); this.handles.push(handle); } + + this.focusFirstOpenPanel(); } - private cycleFocus(): void { - if (this.focusIndex >= 0 && this.focusIndex < this.handles.length) { - this.handles[this.focusIndex]!.unfocus(); - } - this.focusIndex++; - if (this.focusIndex >= this.handles.length) { - this.focusIndex = -1; - } else { - this.handles[this.focusIndex]!.focus(); + focusNext(current: FocusPanel, direction: 1 | -1 = 1): void { + const open = this.openPanelIndexes(); + if (open.length === 0) { + this.close(); + return; } + + const currentIndex = this.panels.indexOf(current); + const currentOpenPosition = open.indexOf(currentIndex); + const nextOpenPosition = + currentOpenPosition === -1 ? 0 : (currentOpenPosition + direction + open.length) % open.length; + this.handles[open[nextOpenPosition]!]!.focus(); this.tui.requestRender(); } - private close(): void { - for (const handle of this.handles) handle.hide(); + dismiss(panel: FocusPanel): void { + const index = this.panels.indexOf(panel); + if (index === -1 || panel.closed) return; + + panel.closed = true; + this.handles[index]?.hide(); + if (this.openPanelIndexes().length === 0) { + this.close(); + return; + } + + this.focusNext(panel); + } + + close(): void { + for (let i = 0; i < this.handles.length; i++) { + if (!this.panels[i]?.closed) { + this.panels[i]!.closed = true; + this.handles[i]!.hide(); + } + } this.handles = []; this.panels = []; this.done(); @@ -1089,64 +1109,102 @@ class FocusDemoController extends BaseOverlay { if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.close(); } else if (matchesKey(data, "tab")) { - this.cycleFocus(); + this.focusFirstOpenPanel(); } } render(width: number): string[] { const th = this.theme; - const focused = this.focusIndex === -1 ? "Controller" : (this.panels[this.focusIndex]?.label ?? "?"); + const focused = this.panels.find((panel) => panel.handle?.isFocused())?.label ?? "Controller"; return this.box( [ "", ` Current focus: ${th.fg("accent", focused)}`, "", " Three overlapping panels above are", - ` all ${th.fg("accent", "nonCapturing")}. Press Tab to`, - " cycle focus() between them.", + ` ${th.fg("accent", "nonCapturing")} overlays controlled with`, + " raw OverlayHandle.focus()/hide().", "", - " Focused panel renders on top", - " (focus-based rendering order).", + " Type in the focused panel's input.", + " Focused panel renders on top.", "", - th.fg("dim", " Tab = cycle focus | Esc = close"), + th.fg("dim", " Tab/Shift+Tab = cycle panels"), + th.fg("dim", " Esc/Ctrl+D = dismiss panel"), + th.fg("dim", " Ctrl+C = close all"), "", ], width, - "Focus Demo", + "Focus + Input Demo", ); } override dispose(): void { for (const handle of this.handles) handle.hide(); } + + private focusFirstOpenPanel(): void { + const firstOpen = this.openPanelIndexes()[0]; + if (firstOpen !== undefined) { + this.handles[firstOpen]!.focus(); + this.tui.requestRender(); + } + } + + private openPanelIndexes(): number[] { + return this.panels.flatMap((panel, index) => (panel.closed ? [] : [index])); + } } class FocusPanel extends BaseOverlay { handle: OverlayHandle | null = null; + closed = false; readonly label: string; private color: "error" | "success" | "accent"; - private onTab: () => void; - private onClose: () => void; + private controller: FocusDemoController; + private text = ""; + private cursor = 0; + private inputs: string[] = []; - constructor( - theme: Theme, - label: string, - color: "error" | "success" | "accent", - onTab: () => void, - onClose: () => void, - ) { + constructor(theme: Theme, label: string, color: "error" | "success" | "accent", controller: FocusDemoController) { super(theme); this.label = label; this.color = color; - this.onTab = onTab; - this.onClose = onClose; + this.controller = controller; } handleInput(data: string): void { if (matchesKey(data, "tab")) { - this.onTab(); - } else if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { - this.onClose(); + this.controller.focusNext(this); + } else if (matchesKey(data, "shift+tab")) { + this.controller.focusNext(this, -1); + } else if (matchesKey(data, "escape") || matchesKey(data, "ctrl+d")) { + this.controller.dismiss(this); + } else if (matchesKey(data, "ctrl+c")) { + this.controller.close(); + } else if (matchesKey(data, "backspace")) { + if (this.cursor > 0) { + this.text = this.text.slice(0, this.cursor - 1) + this.text.slice(this.cursor); + this.cursor--; + } + this.inputs.push("Backspace"); + } else if (matchesKey(data, "left")) { + this.cursor = Math.max(0, this.cursor - 1); + this.inputs.push("←"); + } else if (matchesKey(data, "right")) { + this.cursor = Math.min(this.text.length, this.cursor + 1); + this.inputs.push("→"); + } else if (matchesKey(data, "return")) { + this.inputs.push("Enter"); + } else if (matchesKey(data, "up")) { + this.inputs.push("↑"); + } else if (matchesKey(data, "down")) { + this.inputs.push("↓"); + } else if (data.length === 1 && data.charCodeAt(0) >= 32) { + this.text = this.text.slice(0, this.cursor) + data + this.text.slice(this.cursor); + this.cursor++; + this.inputs.push(JSON.stringify(data)); + } else { + this.inputs.push(JSON.stringify(data)); } } @@ -1154,25 +1212,37 @@ class FocusPanel extends BaseOverlay { const th = this.theme; const focused = this.handle?.isFocused() ?? false; const innerW = Math.max(1, width - 2); - const border = (c: string) => th.fg(this.color, c); + const border = (c: string) => th.fg(focused ? this.color : "dim", c); const padLine = (s: string) => truncateToWidth(s, innerW, "...", true); + const recent = this.inputs.length === 0 ? "(none)" : this.inputs.slice(-6).join(" "); const lines: string[] = []; lines.push(border(`╭${"─".repeat(innerW)}╮`)); - lines.push(border("│") + padLine(` ${th.fg("accent", this.label)}`) + border("│")); - lines.push(border("│") + padLine("") + border("│")); - if (focused) { - lines.push(border("│") + padLine(th.fg("success", " ● FOCUSED")) + border("│")); - lines.push(border("│") + padLine(th.fg("dim", " (receiving input)")) + border("│")); - } else { - lines.push(border("│") + padLine(th.fg("dim", " ○ unfocused")) + border("│")); - lines.push(border("│") + padLine(th.fg("dim", " (passive)")) + border("│")); - } + lines.push( + border("│") + + padLine( + ` ${th.fg(this.color, this.label)} ${focused ? th.fg("success", "FOCUSED") : th.fg("dim", "visible")}`, + ) + + border("│"), + ); lines.push(border("│") + padLine("") + border("│")); + lines.push(border("│") + padLine(` Input: ${this.renderInput(focused)}`) + border("│")); + lines.push(border("│") + padLine(` Keys: ${recent}`) + border("│")); + lines.push(border("│") + padLine(th.fg("dim", " Tab/Shift+Tab focus")) + border("│")); + lines.push(border("│") + padLine(th.fg("dim", " Esc/Ctrl+D dismiss")) + border("│")); lines.push(border(`╰${"─".repeat(innerW)}╯`)); return lines; } + + private renderInput(focused: boolean): string { + if (!focused) return this.text || this.theme.fg("dim", "(empty)"); + + const before = this.text.slice(0, this.cursor); + const cursorChar = this.cursor < this.text.length ? this.text[this.cursor] : " "; + const after = this.text.slice(this.cursor + 1); + return `${before}${CURSOR_MARKER}\x1b[7m${cursorChar}\x1b[27m${after}`; + } } // === Streaming input panel test (/overlay-streaming) === From 40832d1957ab0649a484735a17f8716502879c75 Mon Sep 17 00:00:00 2001 From: Michael Yu Date: Sun, 31 May 2026 16:20:22 +0800 Subject: [PATCH 120/352] fix(ai): suppress deprecated temperature param for Claude Opus 4.7+ --- packages/ai/scripts/generate-models.ts | 8 + packages/ai/src/models.generated.ts | 161 ++++++++---------- packages/ai/src/providers/anthropic.ts | 15 +- packages/ai/src/types.ts | 6 + .../test/anthropic-temperature-compat.test.ts | 104 +++++++++++ 5 files changed, 196 insertions(+), 98 deletions(-) create mode 100644 packages/ai/test/anthropic-temperature-compat.test.ts diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index b4cedc04..3875763c 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -191,6 +191,11 @@ function isAnthropicAdaptiveThinkingModel(modelId: string): boolean { ); } +function isAnthropicTemperatureUnsupportedModel(modelId: string): boolean { + const id = modelId.toLowerCase(); + return id.includes("opus-4-7") || id.includes("opus-4.7") || id.includes("opus-4-8") || id.includes("opus-4.8"); +} + function mergeAnthropicMessagesCompat(model: Model, compat: AnthropicMessagesCompat): void { model.compat = { ...(model.compat as AnthropicMessagesCompat | undefined), ...compat }; } @@ -244,6 +249,9 @@ function applyThinkingLevelMetadata(model: Model): void { if (model.api === "anthropic-messages" && isAnthropicAdaptiveThinkingModel(model.id)) { mergeAnthropicMessagesCompat(model, { forceAdaptiveThinking: true }); } + if (model.api === "anthropic-messages" && isAnthropicTemperatureUnsupportedModel(model.id)) { + mergeAnthropicMessagesCompat(model, { supportsTemperature: false }); + } if (model.api === "openai-completions" && model.id.includes("deepseek-v4")) { mergeThinkingLevelMap( model, diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index a0adb111..c28dc1a4 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -1850,7 +1850,7 @@ export const MODELS = { api: "anthropic-messages", provider: "anthropic", baseUrl: "https://api.anthropic.com", - compat: {"forceAdaptiveThinking":true}, + compat: {"forceAdaptiveThinking":true,"supportsTemperature":false}, reasoning: true, thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], @@ -1869,7 +1869,7 @@ export const MODELS = { api: "anthropic-messages", provider: "anthropic", baseUrl: "https://api.anthropic.com", - compat: {"forceAdaptiveThinking":true}, + compat: {"forceAdaptiveThinking":true,"supportsTemperature":false}, reasoning: true, thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], @@ -2958,7 +2958,26 @@ export const MODELS = { api: "anthropic-messages", provider: "cloudflare-ai-gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", - compat: {"forceAdaptiveThinking":true}, + compat: {"forceAdaptiveThinking":true,"supportsTemperature":false}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "claude-opus-4-8": { + id: "claude-opus-4-8", + name: "Claude Opus 4.8", + api: "anthropic-messages", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + compat: {"forceAdaptiveThinking":true,"supportsTemperature":false}, reasoning: true, thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], @@ -3916,7 +3935,7 @@ export const MODELS = { provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"forceAdaptiveThinking":true}, + compat: {"forceAdaptiveThinking":true,"supportsTemperature":false}, reasoning: true, thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], @@ -3936,7 +3955,7 @@ export const MODELS = { provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"forceAdaptiveThinking":true}, + compat: {"forceAdaptiveThinking":true,"supportsTemperature":false}, reasoning: true, thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], @@ -7259,7 +7278,7 @@ export const MODELS = { api: "anthropic-messages", provider: "opencode", baseUrl: "https://opencode.ai/zen", - compat: {"forceAdaptiveThinking":true}, + compat: {"forceAdaptiveThinking":true,"supportsTemperature":false}, reasoning: true, thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], @@ -7278,7 +7297,7 @@ export const MODELS = { api: "anthropic-messages", provider: "opencode", baseUrl: "https://opencode.ai/zen", - compat: {"forceAdaptiveThinking":true}, + compat: {"forceAdaptiveThinking":true,"supportsTemperature":false}, reasoning: true, thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], @@ -8540,23 +8559,6 @@ export const MODELS = { contextWindow: 2000000, maxTokens: 30000, } satisfies Model<"openai-completions">, - "baidu/ernie-4.5-21b-a3b": { - id: "baidu/ernie-4.5-21b-a3b", - name: "Baidu: ERNIE 4.5 21B A3B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.07, - output: 0.28, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8000, - } satisfies Model<"openai-completions">, "baidu/ernie-4.5-vl-28b-a3b": { id: "baidu/ernie-4.5-vl-28b-a3b", name: "Baidu: ERNIE 4.5 VL 28B A3B", @@ -9397,6 +9399,23 @@ export const MODELS = { contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, + "meta-llama/llama-4-maverick": { + id: "meta-llama/llama-4-maverick", + name: "Meta: Llama 4 Maverick", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "meta-llama/llama-4-scout": { id: "meta-llama/llama-4-scout", name: "Meta: Llama 4 Scout", @@ -9482,23 +9501,6 @@ export const MODELS = { contextWindow: 204800, maxTokens: 196608, } satisfies Model<"openai-completions">, - "minimax/minimax-m2.5:free": { - id: "minimax/minimax-m2.5:free", - name: "MiniMax: MiniMax M2.5 (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 204800, - maxTokens: 8192, - } satisfies Model<"openai-completions">, "minimax/minimax-m2.7": { id: "minimax/minimax-m2.7", name: "MiniMax: MiniMax M2.7", @@ -9508,13 +9510,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.27899999999999997, + input: 0.26, output: 1.2, cacheRead: 0, cacheWrite: 0, }, contextWindow: 204800, - maxTokens: 131072, + maxTokens: 4096, } satisfies Model<"openai-completions">, "mistralai/codestral-2508": { id: "mistralai/codestral-2508", @@ -10916,13 +10918,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.03, + input: 0.029, output: 0.14, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 131072, + maxTokens: 4096, } satisfies Model<"openai-completions">, "openai/gpt-oss-20b:free": { id: "openai/gpt-oss-20b:free", @@ -11230,23 +11232,6 @@ export const MODELS = { contextWindow: 131072, maxTokens: 16384, } satisfies Model<"openai-completions">, - "qwen/qwen-2.5-7b-instruct": { - id: "qwen/qwen-2.5-7b-instruct", - name: "Qwen: Qwen2.5 7B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.04, - output: 0.09999999999999999, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 32768, - } satisfies Model<"openai-completions">, "qwen/qwen-plus": { id: "qwen/qwen-plus", name: "Qwen: Qwen-Plus", @@ -11358,13 +11343,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.14950000000000002, - output: 1.495, - cacheRead: 0, + input: 0.09999999999999999, + output: 0.09999999999999999, + cacheRead: 0.09999999999999999, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 4096, + maxTokens: 262144, } satisfies Model<"openai-completions">, "qwen/qwen3-30b-a3b": { id: "qwen/qwen3-30b-a3b", @@ -11800,13 +11785,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.13899999999999998, + input: 0.14, output: 1, - cacheRead: 0, + cacheRead: 0.049999999999999996, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 4096, + maxTokens: 262144, } satisfies Model<"openai-completions">, "qwen/qwen3.5-397b-a17b": { id: "qwen/qwen3.5-397b-a17b", @@ -12148,23 +12133,6 @@ export const MODELS = { contextWindow: 32768, maxTokens: 32768, } satisfies Model<"openai-completions">, - "upstage/solar-pro-3": { - id: "upstage/solar-pro-3", - name: "Upstage: Solar Pro 3", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.15, - output: 0.6, - cacheRead: 0.015, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "x-ai/grok-4.20": { id: "x-ai/grok-4.20", name: "xAI: Grok 4.20", @@ -13451,7 +13419,7 @@ export const MODELS = { api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", - compat: {"forceAdaptiveThinking":true}, + compat: {"forceAdaptiveThinking":true,"supportsTemperature":false}, reasoning: true, thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], @@ -13470,7 +13438,7 @@ export const MODELS = { api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", - compat: {"forceAdaptiveThinking":true}, + compat: {"forceAdaptiveThinking":true,"supportsTemperature":false}, reasoning: true, thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], @@ -15264,6 +15232,23 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8000, } satisfies Model<"anthropic-messages">, + "stepfun/step-3.7-flash": { + id: "stepfun/step-3.7-flash", + name: "Step 3.7 Flash", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.19999999999999998, + output: 1.15, + cacheRead: 0.04, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"anthropic-messages">, "xai/grok-4.1-fast-non-reasoning": { id: "xai/grok-4.1-fast-non-reasoning", name: "Grok 4.1 Fast Non-Reasoning", diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 74d4be38..56b82d8c 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -176,6 +176,7 @@ function getAnthropicCompat( sendSessionAffinityHeaders: model.compat?.sendSessionAffinityHeaders ?? !!(isFireworks || isCloudflareAiGatewayAnthropic), supportsCacheControlOnTools: model.compat?.supportsCacheControlOnTools ?? !isFireworks, + supportsTemperature: model.compat?.supportsTemperature ?? true, allowEmptySignature: model.compat?.allowEmptySignature ?? false, }; } @@ -896,15 +897,10 @@ function buildParams( options?: AnthropicOptions, ): MessageCreateParamsStreaming { const { cacheControl } = getCacheControl(model, options?.cacheRetention); + const compat = getAnthropicCompat(model); const params: MessageCreateParamsStreaming = { model: model.id, - messages: convertMessages( - context.messages, - model, - isOAuthToken, - cacheControl, - getAnthropicCompat(model).allowEmptySignature, - ), + messages: convertMessages(context.messages, model, isOAuthToken, cacheControl, compat.allowEmptySignature), max_tokens: options?.maxTokens ?? model.maxTokens, stream: true, }; @@ -936,13 +932,12 @@ function buildParams( ]; } - // Temperature is incompatible with extended thinking (adaptive or budget-based). - if (options?.temperature !== undefined && !options?.thinkingEnabled) { + // Temperature is incompatible with extended thinking and unsupported on Claude Opus 4.7+. + if (options?.temperature !== undefined && !options?.thinkingEnabled && compat.supportsTemperature) { params.temperature = options.temperature; } if (context.tools && context.tools.length > 0) { - const compat = getAnthropicCompat(model); params.tools = convertTools( context.tools, isOAuthToken, diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 4d7e2e00..d0b053ea 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -451,6 +451,12 @@ export interface AnthropicMessagesCompat { * Default: true. */ supportsCacheControlOnTools?: boolean; + /** + * Whether the model accepts the Anthropic `temperature` request field. + * Claude Opus 4.7+ rejects non-default temperature values. + * Default: true. + */ + supportsTemperature?: boolean; /** * Whether to force adaptive thinking (`thinking.type: "adaptive"` plus * `output_config.effort`) regardless of the model id. Built-in models that diff --git a/packages/ai/test/anthropic-temperature-compat.test.ts b/packages/ai/test/anthropic-temperature-compat.test.ts new file mode 100644 index 00000000..00161ab8 --- /dev/null +++ b/packages/ai/test/anthropic-temperature-compat.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { getModel } from "../src/models.ts"; +import { streamSimple } from "../src/stream.ts"; +import type { Context, Model, SimpleStreamOptions } from "../src/types.ts"; + +interface AnthropicTemperaturePayload { + temperature?: number; +} + +class PayloadCaptured extends Error { + constructor() { + super("payload captured"); + this.name = "PayloadCaptured"; + } +} + +function makeContext(): Context { + return { + messages: [{ role: "user", content: "Hello", timestamp: Date.now() }], + }; +} + +function makeCustomModel(compat?: Model<"anthropic-messages">["compat"]): Model<"anthropic-messages"> { + return { + id: "vendor--claude-opus-4-7", + name: "Vendor Proxy Opus 4.7", + api: "anthropic-messages", + provider: "vendor-proxy", + baseUrl: "http://127.0.0.1:9", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 32000, + compat, + }; +} + +async function capturePayload( + model: Model<"anthropic-messages">, + options?: SimpleStreamOptions, +): Promise { + let capturedPayload: AnthropicTemperaturePayload | undefined; + + const payloadCaptureModel: Model<"anthropic-messages"> = { + ...model, + baseUrl: "http://127.0.0.1:9", + }; + + const s = streamSimple(payloadCaptureModel, makeContext(), { + ...options, + apiKey: "fake-key", + onPayload: (payload) => { + capturedPayload = payload as AnthropicTemperaturePayload; + throw new PayloadCaptured(); + }, + }); + + await s.result(); + + if (!capturedPayload) { + throw new Error("Expected payload to be captured before request failure"); + } + + return capturedPayload; +} + +describe("Anthropic temperature compatibility", () => { + it("omits temperature for Claude Opus 4.7", async () => { + const payload = await capturePayload(getModel("anthropic", "claude-opus-4-7"), { temperature: 0 }); + + expect(payload.temperature).toBeUndefined(); + }); + + it("omits temperature for Claude Opus 4.8", async () => { + const payload = await capturePayload(getModel("anthropic", "claude-opus-4-8"), { temperature: 0 }); + + expect(payload.temperature).toBeUndefined(); + }); + + it("omits default temperature for Claude Opus 4.7", async () => { + const payload = await capturePayload(getModel("anthropic", "claude-opus-4-7"), { temperature: 1 }); + + expect(payload.temperature).toBeUndefined(); + }); + + it("keeps temperature for Claude Opus 4.6", async () => { + const payload = await capturePayload(getModel("anthropic", "claude-opus-4-6"), { temperature: 0 }); + + expect(payload.temperature).toBe(0); + }); + + it("keeps temperature for Claude Sonnet 4.6", async () => { + const payload = await capturePayload(getModel("anthropic", "claude-sonnet-4-6"), { temperature: 0 }); + + expect(payload.temperature).toBe(0); + }); + + it("omits temperature for custom models with supportsTemperature disabled", async () => { + const payload = await capturePayload(makeCustomModel({ supportsTemperature: false }), { temperature: 0 }); + + expect(payload.temperature).toBeUndefined(); + }); +}); From d4fa41ae3c5778d7496c07db93647096c8ba7af7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 31 May 2026 22:22:22 +0000 Subject: [PATCH 121/352] chore: approve contributor psoukie --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 0a99f0c1..be423781 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -225,3 +225,5 @@ MichaelYochpaz pr stephanmck pr rolfvreijdenberger pr + +psoukie pr From f429ddbffd24dcd3a25212d8d16c7d074b5651dd Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 1 Jun 2026 00:39:38 +0200 Subject: [PATCH 122/352] Fix OpenAI GPT-5.5 thinking metadata closes #5243 --- packages/ai/CHANGELOG.md | 4 + packages/ai/scripts/generate-models.ts | 3 + packages/ai/src/models.generated.ts | 199 ++++++------------------- 3 files changed, 56 insertions(+), 150 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index c5b27a08..57a25241 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed OpenAI GPT-5.5 generated metadata to omit unsupported minimal thinking ([#5243](https://github.com/earendil-works/pi/issues/5243)). + ## [0.78.0] - 2026-05-29 ### Breaking Changes diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 3875763c..50df88f2 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -232,6 +232,9 @@ function applyThinkingLevelMetadata(model: Model): void { if (supportsOpenAiXhigh(model.id)) { mergeThinkingLevelMap(model, { xhigh: "xhigh" }); } + if (model.provider === "openai" && model.id === "gpt-5.5") { + mergeThinkingLevelMap(model, { minimal: null }); + } if (model.id.endsWith("gpt-5.5-pro")) { mergeThinkingLevelMap(model, { off: null, minimal: null, low: null }); } diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index c28dc1a4..c5bd76e5 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -2990,6 +2990,25 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"anthropic-messages">, + "claude-opus-4-8": { + id: "claude-opus-4-8", + name: "Claude Opus 4.8", + api: "anthropic-messages", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, "claude-sonnet-4": { id: "claude-sonnet-4", name: "Claude Sonnet 4 (latest)", @@ -3485,24 +3504,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 128000, } satisfies Model<"openai-completions">, - "@cf/moonshotai/kimi-k2.5": { - id: "@cf/moonshotai/kimi-k2.5", - name: "Kimi K2.5", - api: "openai-completions", - provider: "cloudflare-workers-ai", - baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", - compat: {"sendSessionAffinityHeaders":true}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.6, - output: 3, - cacheRead: 0.1, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 256000, - } satisfies Model<"openai-completions">, "@cf/moonshotai/kimi-k2.6": { id: "@cf/moonshotai/kimi-k2.6", name: "Kimi K2.6", @@ -6908,7 +6909,7 @@ export const MODELS = { provider: "openai", baseUrl: "https://api.openai.com/v1", reasoning: true, - thinkingLevelMap: {"off":"none","xhigh":"xhigh"}, + thinkingLevelMap: {"off":"none","xhigh":"xhigh","minimal":null}, input: ["text", "image"], cost: { input: 5, @@ -7362,6 +7363,25 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 64000, } satisfies Model<"anthropic-messages">, + "deepseek-v4-flash": { + id: "deepseek-v4-flash", + name: "DeepSeek V4 Flash", + api: "openai-completions", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, + input: ["text"], + cost: { + input: 0.14, + output: 0.28, + cacheRead: 0.03, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 384000, + } satisfies Model<"openai-completions">, "deepseek-v4-flash-free": { id: "deepseek-v4-flash-free", name: "DeepSeek V4 Flash Free", @@ -7825,8 +7845,8 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 1000000, - maxTokens: 128000, + contextWindow: 200000, + maxTokens: 32000, } satisfies Model<"openai-completions">, "minimax-m2.5": { id: "minimax-m2.5", @@ -8833,25 +8853,6 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 131072, } satisfies Model<"openai-completions">, - "deepseek/deepseek-v4-flash:free": { - id: "deepseek/deepseek-v4-flash:free", - name: "DeepSeek: DeepSeek V4 Flash (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - compat: {"requiresReasoningContentOnAssistantMessages":true}, - reasoning: true, - thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh"}, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 384000, - } satisfies Model<"openai-completions">, "deepseek/deepseek-v4-pro": { id: "deepseek/deepseek-v4-pro", name: "DeepSeek: DeepSeek V4 Pro", @@ -9306,9 +9307,9 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.075, - output: 0.625, - cacheRead: 0.015, + input: 0.3, + output: 2.5, + cacheRead: 0.06, cacheWrite: 0, }, contextWindow: 262144, @@ -9552,40 +9553,6 @@ export const MODELS = { contextWindow: 262144, maxTokens: 4096, } satisfies Model<"openai-completions">, - "mistralai/devstral-medium": { - id: "mistralai/devstral-medium", - name: "Mistral: Devstral Medium", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.39999999999999997, - output: 2, - cacheRead: 0.04, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "mistralai/devstral-small": { - id: "mistralai/devstral-small", - name: "Mistral: Devstral Small 1.1", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.09999999999999999, - output: 0.3, - cacheRead: 0.01, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "mistralai/ministral-14b-2512": { id: "mistralai/ministral-14b-2512", name: "Mistral: Ministral 3 14B 2512", @@ -9671,23 +9638,6 @@ export const MODELS = { contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, - "mistralai/mistral-large-2411": { - id: "mistralai/mistral-large-2411", - name: "Mistral Large 2411", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2, - output: 6, - cacheRead: 0.19999999999999998, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "mistralai/mistral-large-2512": { id: "mistralai/mistral-large-2512", name: "Mistral: Mistral Large 3 2512", @@ -9841,23 +9791,6 @@ export const MODELS = { contextWindow: 65536, maxTokens: 4096, } satisfies Model<"openai-completions">, - "mistralai/pixtral-large-2411": { - id: "mistralai/pixtral-large-2411", - name: "Mistral: Pixtral Large 2411", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2, - output: 6, - cacheRead: 0.19999999999999998, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "mistralai/voxtral-small-24b-2507": { id: "mistralai/voxtral-small-24b-2507", name: "Mistral: Voxtral Small 24B 2507", @@ -12201,40 +12134,6 @@ export const MODELS = { contextWindow: 262144, maxTokens: 65536, } satisfies Model<"openai-completions">, - "xiaomi/mimo-v2-omni": { - id: "xiaomi/mimo-v2-omni", - name: "Xiaomi: MiMo-V2-Omni", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.39999999999999997, - output: 2, - cacheRead: 0.08, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "xiaomi/mimo-v2-pro": { - id: "xiaomi/mimo-v2-pro", - name: "Xiaomi: MiMo-V2-Pro", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 1, - output: 3, - cacheRead: 0.19999999999999998, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 131072, - } satisfies Model<"openai-completions">, "xiaomi/mimo-v2.5": { id: "xiaomi/mimo-v2.5", name: "Xiaomi: MiMo-V2.5", @@ -13597,17 +13496,17 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.77, - output: 0.77, - cacheRead: 0, + input: 0.27, + output: 1.12, + cacheRead: 0.135, cacheWrite: 0, }, contextWindow: 163840, - maxTokens: 16384, + maxTokens: 163840, } satisfies Model<"anthropic-messages">, "deepseek/deepseek-v3.1": { id: "deepseek/deepseek-v3.1", - name: "DeepSeek-V3.1", + name: "DeepSeek V3.1", api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", From 654e4529d532ee3dab4d7f5f5aa32e62c39d3356 Mon Sep 17 00:00:00 2001 From: Pavel Soukenik Date: Tue, 26 May 2026 19:33:18 -0700 Subject: [PATCH 123/352] fix: refresh branch in footer on WSL /mnt repos --- .../src/core/footer-data-provider.ts | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/core/footer-data-provider.ts b/packages/coding-agent/src/core/footer-data-provider.ts index b7ea09b8..f15001c2 100644 --- a/packages/coding-agent/src/core/footer-data-provider.ts +++ b/packages/coding-agent/src/core/footer-data-provider.ts @@ -1,5 +1,5 @@ import { type ExecFileException, execFile, spawnSync } from "child_process"; -import { existsSync, type FSWatcher, readFileSync, statSync, unwatchFile, watchFile } from "fs"; +import { existsSync, type FSWatcher, readFileSync, type Stats, statSync, unwatchFile, watchFile } from "fs"; import { dirname, join, resolve } from "path"; import { closeWatcher, FS_WATCH_RETRY_DELAY_MS, watchWithErrorHandler } from "../utils/fs-watch.ts"; @@ -80,6 +80,18 @@ function resolveBranchWithGitAsync(repoDir: string): Promise { }); } +function isWslEnvironment(): boolean { + return process.platform === "linux" && !!(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP); +} + +function isWindowsMountedRepoPath(repoDir: string): boolean { + return /^\/mnt\/[a-z](?:\/|$)/i.test(repoDir); +} + +function shouldPollGitHead(repoDir: string): boolean { + return isWslEnvironment() && isWindowsMountedRepoPath(repoDir); +} + /** * Provides git branch and extension statuses - data not otherwise accessible to extensions. * Token stats, model info available via ctx.sessionManager and ctx.model. @@ -92,6 +104,8 @@ export class FooterDataProvider { private cachedBranch: string | null | undefined = undefined; private gitPaths: GitPaths | null | undefined = undefined; private headWatcher: FSWatcher | null = null; + private headWatchFilePath: string | null = null; + private headWatchFileListener: ((current: Stats, previous: Stats) => void) | null = null; private reftableWatcher: FSWatcher | null = null; private reftableTablesListWatcher: FSWatcher | null = null; private reftableTablesListPath: string | null = null; @@ -255,6 +269,11 @@ export class FooterDataProvider { private clearGitWatchers(): void { closeWatcher(this.headWatcher); this.headWatcher = null; + if (this.headWatchFilePath && this.headWatchFileListener) { + unwatchFile(this.headWatchFilePath, this.headWatchFileListener); + this.headWatchFilePath = null; + this.headWatchFileListener = null; + } closeWatcher(this.reftableWatcher); this.reftableWatcher = null; closeWatcher(this.reftableTablesListWatcher); @@ -289,6 +308,8 @@ export class FooterDataProvider { this.clearGitWatchers(); if (!this.gitPaths) return; + const pollGitHead = shouldPollGitHead(this.gitPaths.repoDir); + // Watch the directory containing HEAD, not HEAD itself. // Git uses atomic writes (write temp, rename over HEAD), which changes the inode. // fs.watch on a file stops working after the inode changes. @@ -301,7 +322,20 @@ export class FooterDataProvider { }, () => this.handleGitWatcherError(), ); - if (!this.headWatcher) { + if (pollGitHead) { + this.headWatchFilePath = this.gitPaths.headPath; + this.headWatchFileListener = (current, previous) => { + if ( + current.mtimeMs !== previous.mtimeMs || + current.ctimeMs !== previous.ctimeMs || + current.size !== previous.size + ) { + this.scheduleRefresh(); + } + }; + watchFile(this.headWatchFilePath, { interval: 1000 }, this.headWatchFileListener); + } + if (!this.headWatcher && !pollGitHead) { return; } From 91a2f8660099f41e7c8c71dbd3ae52e2ab3e81f5 Mon Sep 17 00:00:00 2001 From: Nico Bailon Date: Sat, 30 May 2026 12:48:05 -0700 Subject: [PATCH 124/352] fix(tui): harden overlay focus restoration --- packages/coding-agent/docs/extensions.md | 13 +- packages/coding-agent/docs/tui.md | 11 +- .../examples/extensions/overlay-qa-tests.ts | 185 ++++++++-------- .../test/interactive-mode-status.test.ts | 111 +++++++++- packages/tui/CHANGELOG.md | 8 - packages/tui/README.md | 10 +- packages/tui/src/tui.ts | 183 +++++++++++----- .../tui/test/overlay-non-capturing.test.ts | 197 ++++++++++++++++-- 8 files changed, 542 insertions(+), 176 deletions(-) diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 40781092..a30bbf52 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -2371,7 +2371,7 @@ const result = await ctx.ui.custom( ); ``` -For advanced positioning (anchors, margins, percentages, responsive visibility), pass `overlayOptions`. Use `onHandle` to control visibility programmatically: +For advanced positioning (anchors, margins, percentages, responsive visibility), pass `overlayOptions`. Use `onHandle` to control focus or visibility programmatically: ```typescript const result = await ctx.ui.custom( @@ -2379,12 +2379,19 @@ const result = await ctx.ui.custom( { overlay: true, overlayOptions: { anchor: "top-right", width: "50%", margin: 2 }, - onHandle: (handle) => { /* handle.setHidden(true/false) */ } + onHandle: (handle) => { + handle.focus(); // focus this overlay and bring it to the visual front + // handle.unfocus({ target: editorComponent }); // release input to a specific component + // handle.setHidden(true/false); // toggle visibility + // handle.hide(); // permanently remove + } } ); ``` -See [tui.md](tui.md) for the full `OverlayOptions` API and [overlay-qa-tests.ts](../examples/extensions/overlay-qa-tests.ts) for examples. +A focused visible overlay can reclaim input after temporary non-overlay custom UI closes. If you intentionally want another component to keep input while the overlay stays visible, call `handle.unfocus({ target })`. Passing `{ target: null }` releases the overlay without focusing another component. + +See [tui.md](tui.md) for the full `OverlayOptions` and `OverlayHandle` API and [overlay-qa-tests.ts](../examples/extensions/overlay-qa-tests.ts) for examples. ### Custom Editor diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index afdaf721..c3b31204 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -145,8 +145,11 @@ const result = await ctx.ui.custom( // Responsive: hide on narrow terminals visible: (termWidth, termHeight) => termWidth >= 80, }, - // Get handle for programmatic visibility control + // Get handle for programmatic focus and visibility control onHandle: (handle) => { + // handle.focus() - focus this overlay and bring it to the visual front + // handle.unfocus() - release input to normal fallback + // handle.unfocus({ target }) - release input to a specific component or null // handle.setHidden(true/false) - toggle visibility // handle.hide() - permanently remove }, @@ -154,6 +157,12 @@ const result = await ctx.ui.custom( ); ``` +### Overlay Focus + +A focused visible overlay keeps input ownership across temporary non-overlay UI. If an overlay opens another `ctx.ui.custom()` component without `{ overlay: true }`, that replacement UI receives input while it is active; when it closes, the focused overlay can reclaim input. + +Use `handle.unfocus()` when a visible overlay should stop owning input and let TUI fall back to another visible capturing overlay or the previous focus target. Use `handle.unfocus({ target })` when a specific component should receive input while the overlay stays visible. Passing `{ target: null }` intentionally leaves no focused component until focus is set again. + ### Overlay Lifecycle Overlay components are disposed when closed. Don't reuse references - create fresh instances: diff --git a/packages/coding-agent/examples/extensions/overlay-qa-tests.ts b/packages/coding-agent/examples/extensions/overlay-qa-tests.ts index 696a436b..fd4486d8 100644 --- a/packages/coding-agent/examples/extensions/overlay-qa-tests.ts +++ b/packages/coding-agent/examples/extensions/overlay-qa-tests.ts @@ -21,7 +21,7 @@ import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent"; import type { Component, OverlayAnchor, OverlayHandle, OverlayOptions, TUI } from "@earendil-works/pi-tui"; -import { CURSOR_MARKER, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; +import { Input, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import { spawn } from "child_process"; // Global handle for toggle demo (in real code, use a more elegant pattern) @@ -1031,77 +1031,66 @@ class TimerPanel extends BaseOverlay { // === Focus cycling demo === +type FocusPanelColor = "error" | "success" | "accent"; +type FocusPanelConfig = { label: string; color: FocusPanelColor; options: OverlayOptions }; +type FocusPanelEntry = { panel: FocusPanel; handle: OverlayHandle }; + +const FOCUS_PANEL_CONFIGS = [ + { label: "Alpha", color: "error", options: { row: 2, col: 4, width: 34 } }, + { label: "Beta", color: "success", options: { row: 5, col: 28, width: 34 } }, + { label: "Gamma", color: "accent", options: { row: 8, col: 52, width: 34 } }, +] satisfies FocusPanelConfig[]; + class FocusDemoController extends BaseOverlay { - private tui: TUI; - private panels: FocusPanel[] = []; - private handles: OverlayHandle[] = []; - private done: () => void; + private readonly tui: TUI; + private entries: FocusPanelEntry[] = []; + private readonly done: () => void; + private closed = false; constructor(tui: TUI, theme: Theme, done: () => void) { super(theme); this.tui = tui; this.done = done; - const colors = ["error", "success", "accent"] as const; - const labels = ["Alpha", "Beta", "Gamma"]; - const positions = [ - { row: 2, col: 4 }, - { row: 5, col: 28 }, - { row: 8, col: 52 }, - ]; - for (let i = 0; i < 3; i++) { - const panel = new FocusPanel(theme, labels[i]!, colors[i]!, this); - const handle = this.tui.showOverlay(panel, { - nonCapturing: true, - ...positions[i]!, - width: 34, - }); - panel.handle = handle; - this.panels.push(panel); - this.handles.push(handle); + for (const config of FOCUS_PANEL_CONFIGS) { + const panel = new FocusPanel({ theme, config, controller: this }); + const handle = this.tui.showOverlay(panel, { nonCapturing: true, ...config.options }); + this.entries.push({ panel, handle }); } this.focusFirstOpenPanel(); } focusNext(current: FocusPanel, direction: 1 | -1 = 1): void { - const open = this.openPanelIndexes(); - if (open.length === 0) { - this.close(); - return; - } - - const currentIndex = this.panels.indexOf(current); - const currentOpenPosition = open.indexOf(currentIndex); - const nextOpenPosition = - currentOpenPosition === -1 ? 0 : (currentOpenPosition + direction + open.length) % open.length; - this.handles[open[nextOpenPosition]!]!.focus(); - this.tui.requestRender(); + const openEntries = this.openEntries(); + const currentOpenPosition = openEntries.findIndex((entry) => entry.panel === current); + if (currentOpenPosition === -1) throw new Error(`Panel ${current.label} is not open`); + const nextOpenPosition = (currentOpenPosition + direction + openEntries.length) % openEntries.length; + this.focusEntryAt(openEntries, nextOpenPosition); } dismiss(panel: FocusPanel): void { - const index = this.panels.indexOf(panel); - if (index === -1 || panel.closed) return; + const openEntries = this.openEntries(); + const currentOpenPosition = openEntries.findIndex((candidate) => candidate.panel === panel); + if (currentOpenPosition === -1) return; + const entry = openEntries[currentOpenPosition]; + if (!entry) throw new Error(`Invalid focus panel index ${currentOpenPosition}`); + const remainingEntries = openEntries.filter((candidate) => candidate.panel !== panel); - panel.closed = true; - this.handles[index]?.hide(); - if (this.openPanelIndexes().length === 0) { + entry.panel.closed = true; + entry.handle.hide(); + if (remainingEntries.length === 0) { this.close(); return; } - this.focusNext(panel); + this.focusEntryAt(remainingEntries, currentOpenPosition % remainingEntries.length); } close(): void { - for (let i = 0; i < this.handles.length; i++) { - if (!this.panels[i]?.closed) { - this.panels[i]!.closed = true; - this.handles[i]!.hide(); - } - } - this.handles = []; - this.panels = []; + if (this.closed) return; + this.closed = true; + this.hidePanels(); this.done(); } @@ -1115,7 +1104,7 @@ class FocusDemoController extends BaseOverlay { render(width: number): string[] { const th = this.theme; - const focused = this.panels.find((panel) => panel.handle?.isFocused())?.label ?? "Controller"; + const focused = this.entries.find((entry) => entry.handle.isFocused())?.panel.label ?? "Controller"; return this.box( [ "", @@ -1139,36 +1128,62 @@ class FocusDemoController extends BaseOverlay { } override dispose(): void { - for (const handle of this.handles) handle.hide(); + if (this.closed) return; + this.closed = true; + this.hidePanels(); } private focusFirstOpenPanel(): void { - const firstOpen = this.openPanelIndexes()[0]; - if (firstOpen !== undefined) { - this.handles[firstOpen]!.focus(); + const firstOpen = this.openEntries()[0]; + if (firstOpen) { + firstOpen.handle.focus(); this.tui.requestRender(); } } - private openPanelIndexes(): number[] { - return this.panels.flatMap((panel, index) => (panel.closed ? [] : [index])); + private focusEntryAt(entries: FocusPanelEntry[], index: number): void { + const entry = entries[index]; + if (!entry) throw new Error(`Invalid focus panel index ${index}`); + entry.handle.focus(); + this.tui.requestRender(); + } + + private hidePanels(): void { + for (const entry of this.entries) { + if (!entry.panel.closed) { + entry.panel.closed = true; + entry.handle.hide(); + } + } + this.entries = []; + } + + private openEntries(): FocusPanelEntry[] { + return this.entries.filter((entry) => !entry.panel.closed); } } class FocusPanel extends BaseOverlay { - handle: OverlayHandle | null = null; + focused = false; closed = false; readonly label: string; - private color: "error" | "success" | "accent"; - private controller: FocusDemoController; - private text = ""; - private cursor = 0; + private readonly color: FocusPanelColor; + private readonly controller: FocusDemoController; + private readonly input = new Input(); private inputs: string[] = []; - constructor(theme: Theme, label: string, color: "error" | "success" | "accent", controller: FocusDemoController) { + constructor({ + theme, + config, + controller, + }: { + theme: Theme; + config: FocusPanelConfig; + controller: FocusDemoController; + }) { super(theme); - this.label = label; - this.color = color; + this.label = config.label; + this.color = config.color; this.controller = controller; } @@ -1181,52 +1196,47 @@ class FocusPanel extends BaseOverlay { this.controller.dismiss(this); } else if (matchesKey(data, "ctrl+c")) { this.controller.close(); - } else if (matchesKey(data, "backspace")) { - if (this.cursor > 0) { - this.text = this.text.slice(0, this.cursor - 1) + this.text.slice(this.cursor); - this.cursor--; - } - this.inputs.push("Backspace"); - } else if (matchesKey(data, "left")) { - this.cursor = Math.max(0, this.cursor - 1); - this.inputs.push("←"); - } else if (matchesKey(data, "right")) { - this.cursor = Math.min(this.text.length, this.cursor + 1); - this.inputs.push("→"); } else if (matchesKey(data, "return")) { this.inputs.push("Enter"); } else if (matchesKey(data, "up")) { this.inputs.push("↑"); } else if (matchesKey(data, "down")) { this.inputs.push("↓"); - } else if (data.length === 1 && data.charCodeAt(0) >= 32) { - this.text = this.text.slice(0, this.cursor) + data + this.text.slice(this.cursor); - this.cursor++; - this.inputs.push(JSON.stringify(data)); + } else if (matchesKey(data, "left")) { + this.input.handleInput(data); + this.inputs.push("←"); + } else if (matchesKey(data, "right")) { + this.input.handleInput(data); + this.inputs.push("→"); + } else if (matchesKey(data, "backspace")) { + this.input.handleInput(data); + this.inputs.push("Backspace"); } else { + this.input.handleInput(data); this.inputs.push(JSON.stringify(data)); } } render(width: number): string[] { const th = this.theme; - const focused = this.handle?.isFocused() ?? false; const innerW = Math.max(1, width - 2); - const border = (c: string) => th.fg(focused ? this.color : "dim", c); + const border = (c: string) => th.fg(this.focused ? this.color : "dim", c); const padLine = (s: string) => truncateToWidth(s, innerW, "...", true); const recent = this.inputs.length === 0 ? "(none)" : this.inputs.slice(-6).join(" "); const lines: string[] = []; + this.input.focused = this.focused; + const [inputLine = ""] = this.input.render(Math.max(1, innerW - 8)); lines.push(border(`╭${"─".repeat(innerW)}╮`)); lines.push( border("│") + padLine( - ` ${th.fg(this.color, this.label)} ${focused ? th.fg("success", "FOCUSED") : th.fg("dim", "visible")}`, + ` ${th.fg(this.color, this.label)} ${this.focused ? th.fg("success", "FOCUSED") : th.fg("dim", "visible")}`, ) + border("│"), ); lines.push(border("│") + padLine("") + border("│")); - lines.push(border("│") + padLine(` Input: ${this.renderInput(focused)}`) + border("│")); + lines.push(border("│") + padLine(` Input: ${inputLine}`) + border("│")); lines.push(border("│") + padLine(` Keys: ${recent}`) + border("│")); lines.push(border("│") + padLine(th.fg("dim", " Tab/Shift+Tab focus")) + border("│")); lines.push(border("│") + padLine(th.fg("dim", " Esc/Ctrl+D dismiss")) + border("│")); @@ -1234,15 +1244,6 @@ class FocusPanel extends BaseOverlay { return lines; } - - private renderInput(focused: boolean): string { - if (!focused) return this.text || this.theme.fg("dim", "(empty)"); - - const before = this.text.slice(0, this.cursor); - const cursorChar = this.cursor < this.text.length ? this.text[this.cursor] : " "; - const after = this.text.slice(this.cursor + 1); - return `${before}${CURSOR_MARKER}\x1b[7m${cursorChar}\x1b[27m${after}`; - } } // === Streaming input panel test (/overlay-streaming) === diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index d5a2c159..5f4a8e21 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -1,7 +1,9 @@ import { homedir } from "node:os"; import * as path from "node:path"; -import { type AutocompleteProvider, CombinedAutocompleteProvider, Container } from "@earendil-works/pi-tui"; +import { type AutocompleteProvider, CombinedAutocompleteProvider } from "@earendil-works/pi-tui"; import { beforeAll, describe, expect, test, vi } from "vitest"; +import { type Component, Container, type Focusable, TUI } from "../../tui/src/tui.ts"; +import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts"; import type { AutocompleteProviderFactory } from "../src/core/extensions/types.ts"; import type { SourceInfo } from "../src/core/source-info.ts"; import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; @@ -17,6 +19,41 @@ function renderAll(container: Container, width = 120): string { return container.children.flatMap((child) => child.render(width)).join("\n"); } +class TestFocusableComponent implements Component, Focusable { + focused = false; + inputs: string[] = []; + private readonly label: string; + private text = ""; + + constructor(label: string) { + this.label = label; + } + + handleInput(data: string): void { + this.inputs.push(data); + } + + getText(): string { + return this.text; + } + + setText(text: string): void { + this.text = text; + } + + render(): string[] { + return [this.label]; + } + + invalidate(): void {} +} + +async function flushTui(tui: TUI, terminal: VirtualTerminal): Promise { + tui.requestRender(true); + await Promise.resolve(); + await terminal.waitForRender(); +} + function normalizeRenderedOutput(container: Container, width = 220): string { return renderAll(container, width) .replace(/\u001b\[[0-9;]*m/g, "") @@ -148,6 +185,78 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => { }); }); +describe("InteractiveMode.showExtensionCustom", () => { + beforeAll(() => { + initTheme("dark"); + }); + + test("overlay custom UI reclaims input after non-overlay custom UI closes", async () => { + const terminal = new VirtualTerminal(80, 24); + const ui = new TUI(terminal); + const editorContainer = new Container(); + const editor = new TestFocusableComponent("EDITOR"); + const palette = new TestFocusableComponent("PALETTE"); + const overlay = new TestFocusableComponent("OVERLAY"); + const replacement = new TestFocusableComponent("REPLACEMENT"); + let closeOverlay: (value: string) => void = () => { + throw new Error("closeOverlay was not initialized"); + }; + let closeReplacement: (value: string) => void = () => { + throw new Error("closeReplacement was not initialized"); + }; + const fakeThis = { + editor, + editorContainer, + keybindings: {}, + ui, + }; + const showExtensionCustom = ( + factory: (tui: TUI, theme: unknown, keybindings: unknown, done: (result: T) => void) => Component, + options?: { overlay?: boolean }, + ): Promise => + (InteractiveMode as any).prototype.showExtensionCustom.call(fakeThis, factory, options) as Promise; + + editorContainer.addChild(editor); + ui.addChild(editorContainer); + ui.addChild(palette); + ui.setFocus(palette); + ui.start(); + try { + const overlayPromise = showExtensionCustom( + (_tui, _theme, _keybindings, done) => { + closeOverlay = done; + return overlay; + }, + { overlay: true }, + ); + await flushTui(ui, terminal); + expect(overlay.focused).toBe(true); + + const replacementPromise = showExtensionCustom((_tui, _theme, _keybindings, done) => { + closeReplacement = done; + return replacement; + }); + await flushTui(ui, terminal); + expect(replacement.focused).toBe(true); + + closeReplacement("done"); + await replacementPromise; + await flushTui(ui, terminal); + terminal.sendInput("x"); + await flushTui(ui, terminal); + + expect(overlay.inputs).toEqual(["x"]); + expect(editor.inputs).toEqual([]); + expect(overlay.focused).toBe(true); + + closeOverlay("closed"); + await overlayPromise; + } finally { + ui.stop(); + } + }); +}); + describe("InteractiveMode.createExtensionUIContext addAutocompleteProvider", () => { test("stores wrapper factories and rebuilds autocomplete immediately", () => { const wrapper: AutocompleteProviderFactory = (current) => current; diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 180b7df2..ae628067 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,14 +2,6 @@ ## [Unreleased] -### Added - -- Added `OverlayHandle.unfocus({ target })` for explicitly releasing overlay focus to a chosen component while overlays remain visible. - -### Fixed - -- Fixed focused visible overlays losing input to base components after focus restoration behind the overlay ([#5129](https://github.com/earendil-works/pi/issues/5129)). - ## [0.78.0] - 2026-05-29 ### Fixed diff --git a/packages/tui/README.md b/packages/tui/README.md index f07b785a..d0aae400 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -116,11 +116,17 @@ handle.setHidden(true); // Temporarily hide (can show again) handle.setHidden(false); // Show again after hiding handle.isHidden(); // Check if temporarily hidden handle.focus(); // Focus and bring to visual front -handle.unfocus(); // Release focus to the next visible overlay or previous target +handle.unfocus(); // Release focus to normal fallback handle.unfocus({ target: baseComponent }); // Release this overlay to a specific component -handle.unfocus({ target: null }); // Release this overlay without focusing another component +handle.unfocus({ target: null }); // Release this overlay and leave focus empty handle.isFocused(); // Check if overlay has focus +handle.unfocus(); +// Overlay loses focus; TUI falls back to another visible capturing overlay or the previous focus target. + +handle.unfocus({ target: null }); +// Overlay loses focus; no component receives input until focus is set again. + // A focused visible overlay reclaims keyboard input after temporary replacement UI // releases focus. If you want a specific component to receive input while overlays remain // visible, call handle.unfocus({ target: component }). diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 0d5a7ee6..f746ee68 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -194,25 +194,32 @@ export interface OverlayHandle { isHidden(): boolean; /** Focus this overlay and bring it to the visual front */ focus(): void; - /** Release focus to the next visible overlay or the previous target, or to an explicit target when provided */ + /** Release focus to the next visible capturing overlay or previous target, or to an explicit target when provided */ unfocus(options?: OverlayUnfocusOptions): void; /** Check if this overlay currently has focus */ isFocused(): boolean; } -type ActiveOverlayRestoreFocusState = { status: "eligible" } | { status: "blocked"; blockedBy: Component }; -type OverlayRestoreFocusState = { status: "inactive" } | ActiveOverlayRestoreFocusState; -type RestorableOverlayStackEntry = OverlayStackEntry & { restoreFocus: ActiveOverlayRestoreFocusState }; - type OverlayStackEntry = { component: Component; - options: OverlayOptions | undefined; + options?: OverlayOptions; preFocus: Component | null; hidden: boolean; focusOrder: number; - restoreFocus: OverlayRestoreFocusState; }; +type OverlayBlockedFocusResume = { status: "restore-overlay" } | { status: "focus-target"; target: Component | null }; +type EligibleOverlayFocusRestoreState = { status: "eligible"; overlay: OverlayStackEntry }; +type BlockedOverlayFocusRestoreState = { + status: "blocked"; + overlay: OverlayStackEntry; + blockedBy: Component; + resume: OverlayBlockedFocusResume; +}; +type ActiveOverlayFocusRestoreState = EligibleOverlayFocusRestoreState | BlockedOverlayFocusRestoreState; +type OverlayFocusRestoreState = { status: "inactive" } | ActiveOverlayFocusRestoreState; +type OverlayFocusRestorePolicy = "clear" | "preserve"; + /** * Container - a component that contains other components */ @@ -282,6 +289,7 @@ export class TUI extends Container { // Overlay stack for modal components rendered on top of base content private focusOrderCounter = 0; private overlayStack: OverlayStackEntry[] = []; + private overlayFocusRestore: OverlayFocusRestoreState = { status: "inactive" }; constructor(terminal: Terminal, showHardwareCursor?: boolean) { super(); @@ -322,36 +330,62 @@ export class TUI extends Container { } setFocus(component: Component | null): void { + this.setFocusInternal({ component, overlayFocusRestore: "clear" }); + } + + private setFocusInternal({ + component, + overlayFocusRestore, + }: { + component: Component | null; + overlayFocusRestore: OverlayFocusRestorePolicy; + }): void { const previousFocus = this.focusedComponent; let nextFocus = component; const previousFocusedOverlay = previousFocus ? this.overlayStack.find((entry) => entry.component === previousFocus && this.isOverlayVisible(entry)) : undefined; const nextFocusIsOverlay = nextFocus ? this.overlayStack.some((entry) => entry.component === nextFocus) : false; + const restoreState = this.getVisibleOverlayFocusRestore(); if (nextFocus && !nextFocusIsOverlay) { - const restoreOverlay = this.getRestoreFocusOverlay(); - if ( - restoreOverlay?.restoreFocus.status === "blocked" && - restoreOverlay.restoreFocus.blockedBy === previousFocus - ) { - nextFocus = restoreOverlay.component; + if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) { + if (restoreState.resume.status === "focus-target" || !this.isComponentMounted(restoreState.blockedBy)) { + nextFocus = this.resolveBlockedOverlayFocusResume(restoreState); + } else { + this.overlayFocusRestore = { + status: "blocked", + overlay: restoreState.overlay, + blockedBy: nextFocus, + resume: restoreState.resume, + }; + } } else if ( previousFocusedOverlay && - previousFocusedOverlay.restoreFocus.status !== "inactive" && + restoreState.status !== "inactive" && + restoreState.overlay === previousFocusedOverlay && !this.isOverlayFocusAncestor(previousFocusedOverlay, nextFocus) ) { - previousFocusedOverlay.restoreFocus = { status: "blocked", blockedBy: nextFocus }; + this.overlayFocusRestore = { + status: "blocked", + overlay: previousFocusedOverlay, + blockedBy: nextFocus, + resume: { status: "restore-overlay" }, + }; + } + } else if (nextFocus === null) { + if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) { + nextFocus = this.resolveBlockedOverlayFocusResume(restoreState); + } else if (overlayFocusRestore === "clear") { + this.clearOverlayFocusRestore(); } } - // Clear focused flag on old component if (isFocusable(this.focusedComponent)) { this.focusedComponent.focused = false; } this.focusedComponent = nextFocus; - // Set focused flag on new component if (isFocusable(nextFocus)) { nextFocus.focused = true; } @@ -360,26 +394,33 @@ export class TUI extends Container { ? this.overlayStack.find((entry) => entry.component === nextFocus && this.isOverlayVisible(entry)) : undefined; if (focusedOverlay) { - this.markOverlayRestoreFocusEligible(focusedOverlay); + this.overlayFocusRestore = { status: "eligible", overlay: focusedOverlay }; } } - private markOverlayRestoreFocusEligible(entry: OverlayStackEntry): void { - for (const overlay of this.overlayStack) { - this.clearOverlayRestoreFocus(overlay); + private clearOverlayFocusRestore(): void { + this.overlayFocusRestore = { status: "inactive" }; + } + + private clearOverlayFocusRestoreFor(overlay: OverlayStackEntry): void { + if (this.overlayFocusRestore.status !== "inactive" && this.overlayFocusRestore.overlay === overlay) { + this.clearOverlayFocusRestore(); } - entry.restoreFocus = { status: "eligible" }; } - private clearOverlayRestoreFocus(entry: OverlayStackEntry): void { - entry.restoreFocus = { status: "inactive" }; + private resolveBlockedOverlayFocusResume(restoreState: BlockedOverlayFocusRestoreState): Component | null { + if (restoreState.resume.status === "restore-overlay") return restoreState.overlay.component; + this.clearOverlayFocusRestore(); + return restoreState.resume.target; } - private getRestoreFocusOverlay(): RestorableOverlayStackEntry | undefined { - return this.overlayStack.find( - (overlay): overlay is RestorableOverlayStackEntry => - overlay.restoreFocus.status !== "inactive" && this.isOverlayVisible(overlay), - ); + private getVisibleOverlayFocusRestore(): OverlayFocusRestoreState { + const restoreState = this.overlayFocusRestore; + if (restoreState.status === "inactive") return restoreState; + if (!this.overlayStack.includes(restoreState.overlay) || !this.isOverlayVisible(restoreState.overlay)) { + return { status: "inactive" }; + } + return restoreState; } private isOverlayFocusAncestor(entry: OverlayStackEntry, component: Component): boolean { @@ -393,6 +434,24 @@ export class TUI extends Container { return false; } + private retargetOverlayPreFocus(removed: OverlayStackEntry): void { + for (const overlay of this.overlayStack) { + if (overlay !== removed && overlay.preFocus === removed.component) { + overlay.preFocus = removed.preFocus; + } + } + } + + private isComponentMounted(component: Component): boolean { + return this.children.some((child) => this.containsComponent(child, component)); + } + + private containsComponent(root: Component, target: Component): boolean { + if (root === target) return true; + if (!(root instanceof Container)) return false; + return root.children.some((child) => this.containsComponent(child, target)); + } + /** * Show an overlay component with configurable positioning and sizing. * Returns a handle to control the overlay's visibility. @@ -400,11 +459,10 @@ export class TUI extends Container { showOverlay(component: Component, options?: OverlayOptions): OverlayHandle { const entry: OverlayStackEntry = { component, - options, + ...(options === undefined ? {} : { options }), preFocus: this.focusedComponent, hidden: false, focusOrder: ++this.focusOrderCounter, - restoreFocus: { status: "inactive" }, }; this.overlayStack.push(entry); // Only focus if overlay is actually visible @@ -419,7 +477,8 @@ export class TUI extends Container { hide: () => { const index = this.overlayStack.indexOf(entry); if (index !== -1) { - this.clearOverlayRestoreFocus(entry); + this.clearOverlayFocusRestoreFor(entry); + this.retargetOverlayPreFocus(entry); this.overlayStack.splice(index, 1); // Restore focus if this overlay had focus if (this.focusedComponent === component) { @@ -435,7 +494,7 @@ export class TUI extends Container { entry.hidden = hidden; // Update focus when hiding/showing if (hidden) { - this.clearOverlayRestoreFocus(entry); + this.clearOverlayFocusRestoreFor(entry); // If this overlay had focus, move focus to next visible or preFocus if (this.focusedComponent === component) { const topVisible = this.getTopmostVisibleOverlay(); @@ -459,17 +518,29 @@ export class TUI extends Container { }, unfocus: (unfocusOptions) => { const isFocused = this.focusedComponent === component; - const hasPendingRestore = entry.restoreFocus.status !== "inactive"; - // Nothing to release: we neither hold focus nor have a pending reclaim. + const restoreState = this.overlayFocusRestore; + const hasPendingRestore = restoreState.status !== "inactive" && restoreState.overlay === entry; if (!isFocused && !hasPendingRestore) return; - // True when this overlay is waiting to reclaim focus from the component that - // currently holds it; computed before clearing the (about-to-be-reset) state. - const blockedByFocused = - entry.restoreFocus.status === "blocked" && this.focusedComponent === entry.restoreFocus.blockedBy; - this.clearOverlayRestoreFocus(entry); - // Move focus only if we currently hold it, or the caller named an explicit - // target and we're not mid-reclaim from the focused component. - if (isFocused || (unfocusOptions && !blockedByFocused)) { + if ( + restoreState.status === "blocked" && + restoreState.overlay === entry && + this.focusedComponent === restoreState.blockedBy + ) { + if (unfocusOptions) { + this.overlayFocusRestore = { + status: "blocked", + overlay: entry, + blockedBy: restoreState.blockedBy, + resume: { status: "focus-target", target: unfocusOptions.target }, + }; + } else { + this.clearOverlayFocusRestore(); + } + this.requestRender(); + return; + } + this.clearOverlayFocusRestoreFor(entry); + if (isFocused || unfocusOptions) { const topVisible = this.getTopmostVisibleOverlay(); const fallbackTarget = topVisible && topVisible !== entry ? topVisible.component : entry.preFocus; this.setFocus(unfocusOptions ? unfocusOptions.target : fallbackTarget); @@ -482,9 +553,11 @@ export class TUI extends Container { /** Hide the topmost overlay and restore previous focus. */ hideOverlay(): void { - const overlay = this.overlayStack.pop(); + const overlay = this.overlayStack[this.overlayStack.length - 1]; if (!overlay) return; - this.clearOverlayRestoreFocus(overlay); + this.clearOverlayFocusRestoreFor(overlay); + this.retargetOverlayPreFocus(overlay); + this.overlayStack.pop(); if (this.focusedComponent === overlay.component) { // Find topmost visible overlay, or fall back to preFocus const topVisible = this.getTopmostVisibleOverlay(); @@ -666,20 +739,22 @@ export class TUI extends Container { if (topVisible) { this.setFocus(topVisible.component); } else { - // No visible overlays, restore to preFocus - this.setFocus(focusedOverlay.preFocus); + this.setFocusInternal({ component: focusedOverlay.preFocus, overlayFocusRestore: "preserve" }); } } const focusIsOverlay = this.overlayStack.some((o) => o.component === this.focusedComponent); if (!focusIsOverlay) { - const overlayToRestore = this.getRestoreFocusOverlay(); - if ( - overlayToRestore && - (overlayToRestore.restoreFocus.status === "eligible" || - overlayToRestore.restoreFocus.blockedBy !== this.focusedComponent) - ) { - this.setFocus(overlayToRestore.component); + const restoreState = this.getVisibleOverlayFocusRestore(); + if (restoreState.status === "eligible") { + this.setFocus(restoreState.overlay.component); + } else if (restoreState.status === "blocked" && restoreState.blockedBy !== this.focusedComponent) { + if (restoreState.resume.status === "restore-overlay") { + this.setFocus(restoreState.overlay.component); + } else { + this.clearOverlayFocusRestore(); + this.setFocus(restoreState.resume.target); + } } } diff --git a/packages/tui/test/overlay-non-capturing.test.ts b/packages/tui/test/overlay-non-capturing.test.ts index e6490d4a..64ce6203 100644 --- a/packages/tui/test/overlay-non-capturing.test.ts +++ b/packages/tui/test/overlay-non-capturing.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert"; import { describe, it } from "node:test"; import type { Component, Focusable } from "../src/tui.ts"; -import { TUI } from "../src/tui.ts"; +import { Container, TUI } from "../src/tui.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; class StaticOverlay implements Component { @@ -222,6 +222,35 @@ describe("TUI overlay non-capturing", () => { } }); + it("removed focused child overlay does not become parent overlay fallback", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const child = new FocusableOverlay(["CHILD"]); + const parent = new FocusableOverlay(["PARENT"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const childHandle = tui.showOverlay(child, { nonCapturing: true }); + childHandle.focus(); + const parentHandle = tui.showOverlay(parent); + assert.strictEqual(parent.focused, true); + + childHandle.hide(); + parentHandle.hide(); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + + assert.deepStrictEqual(editor.inputs, ["x"]); + assert.deepStrictEqual(child.inputs, []); + assert.deepStrictEqual(parent.inputs, []); + assert.strictEqual(editor.focused, true); + } finally { + tui.stop(); + } + }); + it("microtask-deferred sub-overlay pattern (showExtensionCustom simulation) restores focus", async () => { const terminal = new VirtualTerminal(80, 24); const tui = new TUI(terminal); @@ -234,18 +263,18 @@ describe("TUI overlay non-capturing", () => { try { // Simulate showExtensionCustom: factory creates timer synchronously, // then .then() pushes controller as a microtask - let timerHandle: ReturnType; + let timerHandle: ReturnType | null = null; let doneFn: () => void = () => { throw new Error("doneFn was not initialized"); }; const overlayPromise = new Promise((resolve) => { doneFn = () => { + if (!timerHandle) throw new Error("timerHandle was not initialized"); timerHandle.hide(); tui.hideOverlay(); resolve(); }; - // Factory runs synchronously: creates timer sub-overlay timerHandle = tui.showOverlay(timer, { nonCapturing: true }); // .then() runs as microtask — same as showExtensionCustom Promise.resolve(controller).then((c) => { @@ -253,8 +282,7 @@ describe("TUI overlay non-capturing", () => { }); }); - // Wait for .then() microtask and renders to settle - await new Promise((r) => setTimeout(r, 50)); + await Promise.resolve(); await renderAndFlush(tui, terminal); assert.strictEqual(controller.focused, true); @@ -391,20 +419,110 @@ describe("TUI overlay non-capturing", () => { } }); + it("blocked replacement can move focus internally before overlay restore", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const base = new Container(); + const editor = new FocusableOverlay(["EDITOR"]); + const firstReplacement = new FocusableOverlay(["FIRST"]); + const secondReplacement = new FocusableOverlay(["SECOND"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + overlay.handleInput = (data: string) => { + overlay.inputs.push(data); + if (data === "b") tui.setFocus(firstReplacement); + }; + firstReplacement.handleInput = (data: string) => { + firstReplacement.inputs.push(data); + if (data === "n") tui.setFocus(secondReplacement); + }; + secondReplacement.handleInput = (data: string) => { + secondReplacement.inputs.push(data); + if (data === "\r") { + base.clear(); + base.addChild(editor); + tui.setFocus(editor); + } + }; + base.addChild(editor); + base.addChild(firstReplacement); + base.addChild(secondReplacement); + tui.addChild(base); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(overlay); + terminal.sendInput("b"); + await renderAndFlush(tui, terminal); + terminal.sendInput("n"); + await renderAndFlush(tui, terminal); + terminal.sendInput("2"); + terminal.sendInput("\r"); + await renderAndFlush(tui, terminal); + + assert.deepStrictEqual(overlay.inputs, ["b"]); + assert.deepStrictEqual(firstReplacement.inputs, ["n"]); + assert.deepStrictEqual(secondReplacement.inputs, ["2", "\r"]); + assert.strictEqual(overlay.focused, true); + } finally { + tui.stop(); + } + }); + + it("removed replacement restores overlay even when overlay preFocus differs from next focus", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const base = new Container(); + const editor = new FocusableOverlay(["EDITOR"]); + const palette = new FocusableOverlay(["PALETTE"]); + const replacement = new FocusableOverlay(["REPLACEMENT"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + overlay.handleInput = (data: string) => { + overlay.inputs.push(data); + if (data === "b") tui.setFocus(replacement); + }; + replacement.handleInput = (data: string) => { + replacement.inputs.push(data); + if (data === "\r") { + base.clear(); + base.addChild(editor); + tui.setFocus(editor); + } + }; + base.addChild(editor); + base.addChild(palette); + base.addChild(replacement); + tui.addChild(base); + tui.setFocus(palette); + tui.start(); + try { + tui.showOverlay(overlay); + terminal.sendInput("b"); + await renderAndFlush(tui, terminal); + terminal.sendInput("\r"); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + + assert.deepStrictEqual(overlay.inputs, ["b", "x"]); + assert.deepStrictEqual(replacement.inputs, ["\r"]); + assert.deepStrictEqual(editor.inputs, []); + assert.strictEqual(overlay.focused, true); + } finally { + tui.stop(); + } + }); + it("unfocus target releases a blocked overlay while replacement remains focused", async () => { const terminal = new VirtualTerminal(80, 24); const tui = new TUI(terminal); - const editor = new FocusableOverlay(["EDITOR"]); + const fallback = new FocusableOverlay(["FALLBACK"]); + const target = new FocusableOverlay(["TARGET"]); const replacement = new FocusableOverlay(["REPLACEMENT"]); const overlay = new FocusableOverlay(["OVERLAY"]); replacement.handleInput = (data: string) => { replacement.inputs.push(data); - if (data === "\r") { - tui.setFocus(editor); - } + if (data === "\r") tui.setFocus(fallback); }; tui.addChild(new EmptyContent()); - tui.setFocus(editor); tui.start(); try { const overlayHandle = tui.showOverlay(overlay); @@ -412,20 +530,21 @@ describe("TUI overlay non-capturing", () => { overlay.inputs.push(data); if (data === "b") { tui.setFocus(replacement); - overlayHandle.unfocus({ target: editor }); + overlayHandle.unfocus({ target }); } }; + terminal.sendInput("b"); await renderAndFlush(tui, terminal); assert.strictEqual(replacement.focused, true); - terminal.sendInput("\r"); terminal.sendInput("x"); await renderAndFlush(tui, terminal); - assert.deepStrictEqual(replacement.inputs, ["\r"]); + assert.deepStrictEqual(overlay.inputs, ["b"]); - assert.deepStrictEqual(editor.inputs, ["x"]); - assert.strictEqual(editor.focused, true); + assert.deepStrictEqual(replacement.inputs, ["\r"]); + assert.deepStrictEqual(fallback.inputs, []); + assert.deepStrictEqual(target.inputs, ["x"]); } finally { tui.stop(); } @@ -541,6 +660,54 @@ describe("TUI overlay non-capturing", () => { } }); + it("setFocus(null) explicitly clears visible overlay restore", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.start(); + try { + tui.showOverlay(overlay); + tui.setFocus(null); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, []); + assert.strictEqual(overlay.focused, false); + } finally { + tui.stop(); + } + }); + + it("blocked replacement setFocus(null) resumes the visible overlay", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const replacement = new FocusableOverlay(["REPLACEMENT"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + replacement.handleInput = (data: string) => { + replacement.inputs.push(data); + if (data === "\r") tui.setFocus(null); + }; + overlay.handleInput = (data: string) => { + overlay.inputs.push(data); + if (data === "b") tui.setFocus(replacement); + }; + tui.addChild(new EmptyContent()); + tui.start(); + try { + tui.showOverlay(overlay); + terminal.sendInput("b"); + await renderAndFlush(tui, terminal); + terminal.sendInput("\r"); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(replacement.inputs, ["\r"]); + assert.deepStrictEqual(overlay.inputs, ["b", "x"]); + assert.strictEqual(overlay.focused, true); + } finally { + tui.stop(); + } + }); + it("temporarily invisible focused overlay falls back without losing restore eligibility", async () => { const terminal = new VirtualTerminal(80, 24); const tui = new TUI(terminal); From ce065efa7ee6010f104ac08571242aeff4a4ee18 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 1 Jun 2026 15:03:47 +0000 Subject: [PATCH 125/352] chore: approve contributor vastxie --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index be423781..925ee8f4 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -227,3 +227,5 @@ stephanmck pr rolfvreijdenberger pr psoukie pr + +vastxie pr From 34719d3f4f40344af0563c74d1429eaf25d38d74 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 1 Jun 2026 18:03:49 +0200 Subject: [PATCH 126/352] Fix Bedrock blank text blocks closes #4975 --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/models.generated.ts | 89 +++++++++++-------- packages/ai/src/providers/amazon-bedrock.ts | 69 +++++++++----- .../ai/test/bedrock-convert-messages.test.ts | 88 +++++++++++++++++- 4 files changed, 187 insertions(+), 60 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 57a25241..98922700 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed Amazon Bedrock requests to replace blank required user/tool-result text with a placeholder and skip blank replay text blocks ([#4975](https://github.com/earendil-works/pi/issues/4975)). - Fixed OpenAI GPT-5.5 generated metadata to omit unsupported minimal thinking ([#5243](https://github.com/earendil-works/pi/issues/5243)). ## [0.78.0] - 2026-05-29 diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index c5bd76e5..e1a2facc 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -394,8 +394,8 @@ export const MODELS = { thinkingLevelMap: {"xhigh":"max"}, input: ["text", "image"], cost: { - input: 5, - output: 25, + input: 5.5, + output: 27.5, cacheRead: 0.5, cacheWrite: 6.25, }, @@ -412,10 +412,10 @@ export const MODELS = { thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], cost: { - input: 5, - output: 25, - cacheRead: 0.5, - cacheWrite: 6.25, + input: 5.5, + output: 27.5, + cacheRead: 0.55, + cacheWrite: 6.875, }, contextWindow: 1000000, maxTokens: 128000, @@ -430,10 +430,10 @@ export const MODELS = { thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], cost: { - input: 5, - output: 25, - cacheRead: 0.5, - cacheWrite: 6.25, + input: 5.5, + output: 27.5, + cacheRead: 0.55, + cacheWrite: 6.875, }, contextWindow: 1000000, maxTokens: 128000, @@ -447,10 +447,10 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, + input: 3.3, + output: 16.5, + cacheRead: 0.33, + cacheWrite: 4.125, }, contextWindow: 200000, maxTokens: 64000, @@ -464,10 +464,10 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, + input: 3.3, + output: 16.5, + cacheRead: 0.33, + cacheWrite: 4.125, }, contextWindow: 1000000, maxTokens: 64000, @@ -2990,25 +2990,6 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"anthropic-messages">, - "claude-opus-4-8": { - id: "claude-opus-4-8", - name: "Claude Opus 4.8", - api: "anthropic-messages", - provider: "cloudflare-ai-gateway", - baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", - compat: {"forceAdaptiveThinking":true}, - reasoning: true, - thinkingLevelMap: {"xhigh":"xhigh"}, - input: ["text", "image"], - cost: { - input: 5, - output: 25, - cacheRead: 0.5, - cacheWrite: 6.25, - }, - contextWindow: 1000000, - maxTokens: 128000, - } satisfies Model<"anthropic-messages">, "claude-sonnet-4": { id: "claude-sonnet-4", name: "Claude Sonnet 4 (latest)", @@ -12066,6 +12047,23 @@ export const MODELS = { contextWindow: 32768, maxTokens: 32768, } satisfies Model<"openai-completions">, + "upstage/solar-pro-3": { + id: "upstage/solar-pro-3", + name: "Upstage: Solar Pro 3", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0.015, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "x-ai/grok-4.20": { id: "x-ai/grok-4.20", name: "xAI: Grok 4.20", @@ -15131,6 +15129,23 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8000, } satisfies Model<"anthropic-messages">, + "stepfun/step-3.5-flash": { + id: "stepfun/step-3.5-flash", + name: "StepFun 3.5 Flash", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.09, + output: 0.3, + cacheRead: 0, + cacheWrite: 0.02, + }, + contextWindow: 262114, + maxTokens: 262114, + } satisfies Model<"anthropic-messages">, "stepfun/step-3.7-flash": { id: "stepfun/step-3.7-flash", name: "Step 3.7 Flash", diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index b5c88bc5..0de0ea6e 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -18,6 +18,7 @@ import { type SystemContentBlock, type ToolChoice, type ToolConfiguration, + type ToolResultContentBlock, ToolResultStatus, } from "@aws-sdk/client-bedrock-runtime"; import { NodeHttpHandler } from "@smithy/node-http-handler"; @@ -28,6 +29,7 @@ import type { AssistantMessage, CacheRetention, Context, + ImageContent, Model, SimpleStreamOptions, StopReason, @@ -86,6 +88,8 @@ export interface BedrockOptions extends StreamOptions { type Block = (TextContent | ThinkingContent | ToolCall) & { index?: number; partialJson?: string }; +const EMPTY_TEXT_PLACEHOLDER = ""; + export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOptions> = ( model: Model<"bedrock-converse-stream">, context: Context, @@ -650,6 +654,29 @@ function normalizeToolCallId(id: string): string { return sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized; } +function createNonBlankTextBlock(text: string): ContentBlock.TextMember | undefined { + const sanitized = sanitizeSurrogates(text); + return sanitized.trim().length === 0 ? undefined : { text: sanitized }; +} + +function createRequiredTextBlock(text: string): ContentBlock.TextMember { + return createNonBlankTextBlock(text) ?? { text: EMPTY_TEXT_PLACEHOLDER }; +} + +function convertToolResultContent(content: (TextContent | ImageContent)[]): ToolResultContentBlock[] { + const result: ToolResultContentBlock[] = []; + for (const c of content) { + if (c.type === "image") { + result.push({ image: createImageBlock(c.mimeType, c.data) }); + } else { + const textBlock = createNonBlankTextBlock(c.text); + if (textBlock) result.push(textBlock); + } + } + if (result.length === 0) result.push({ text: EMPTY_TEXT_PLACEHOLDER }); + return result; +} + function convertMessages( context: Context, model: Model<"bedrock-converse-stream">, @@ -665,13 +692,15 @@ function convertMessages( case "user": { const content: ContentBlock[] = []; if (typeof m.content === "string") { - content.push({ text: sanitizeSurrogates(m.content) }); + content.push(createRequiredTextBlock(m.content)); } else { for (const c of m.content) { switch (c.type) { - case "text": - content.push({ text: sanitizeSurrogates(c.text) }); + case "text": { + const textBlock = createNonBlankTextBlock(c.text); + if (textBlock) content.push(textBlock); break; + } case "image": content.push({ image: createImageBlock(c.mimeType, c.data) }); break; @@ -679,8 +708,8 @@ function convertMessages( continue; } } + if (content.length === 0) content.push({ text: EMPTY_TEXT_PLACEHOLDER }); } - if (content.length === 0) continue; result.push({ role: ConversationRole.USER, content, @@ -696,19 +725,22 @@ function convertMessages( const contentBlocks: ContentBlock[] = []; for (const c of m.content) { switch (c.type) { - case "text": + case "text": { // Skip empty text blocks - if (c.text.trim().length === 0) continue; - contentBlocks.push({ text: sanitizeSurrogates(c.text) }); + const textBlock = createNonBlankTextBlock(c.text); + if (!textBlock) continue; + contentBlocks.push(textBlock); break; + } case "toolCall": contentBlocks.push({ toolUse: { toolUseId: c.id, name: c.name, input: c.arguments }, }); break; - case "thinking": + case "thinking": { // Skip empty thinking blocks - if (c.thinking.trim().length === 0) continue; + const thinking = sanitizeSurrogates(c.thinking); + if (thinking.trim().length === 0) continue; // Only Anthropic models support the signature field in reasoningText. // For other models, we omit the signature to avoid errors like: // "This model doesn't support the reasoningContent.reasoningText.signature field" @@ -717,12 +749,12 @@ function convertMessages( // persisted message lacks a signature, Bedrock rejects the replayed // reasoning block. Fall back to plain text, matching Anthropic. if (!c.thinkingSignature || c.thinkingSignature.trim().length === 0) { - contentBlocks.push({ text: sanitizeSurrogates(c.thinking) }); + contentBlocks.push({ text: thinking }); } else { contentBlocks.push({ reasoningContent: { reasoningText: { - text: sanitizeSurrogates(c.thinking), + text: thinking, signature: c.thinkingSignature, }, }, @@ -731,11 +763,12 @@ function convertMessages( } else { contentBlocks.push({ reasoningContent: { - reasoningText: { text: sanitizeSurrogates(c.thinking) }, + reasoningText: { text: thinking }, }, }); } break; + } default: continue; } @@ -759,11 +792,7 @@ function convertMessages( toolResults.push({ toolResult: { toolUseId: m.toolCallId, - content: m.content.map((c) => - c.type === "image" - ? { image: createImageBlock(c.mimeType, c.data) } - : { text: sanitizeSurrogates(c.text) }, - ), + content: convertToolResultContent(m.content), status: m.isError ? ToolResultStatus.ERROR : ToolResultStatus.SUCCESS, }, }); @@ -775,11 +804,7 @@ function convertMessages( toolResults.push({ toolResult: { toolUseId: nextMsg.toolCallId, - content: nextMsg.content.map((c) => - c.type === "image" - ? { image: createImageBlock(c.mimeType, c.data) } - : { text: sanitizeSurrogates(c.text) }, - ), + content: convertToolResultContent(nextMsg.content), status: nextMsg.isError ? ToolResultStatus.ERROR : ToolResultStatus.SUCCESS, }, }); diff --git a/packages/ai/test/bedrock-convert-messages.test.ts b/packages/ai/test/bedrock-convert-messages.test.ts index d62abd1b..d74dedae 100644 --- a/packages/ai/test/bedrock-convert-messages.test.ts +++ b/packages/ai/test/bedrock-convert-messages.test.ts @@ -117,7 +117,7 @@ describe("bedrock convertMessages skips unknown content types", () => { expect(p.messages[0].content[0]).toEqual({ text: "hello" }); }); - it("skips user messages with only unknown content blocks", async () => { + it("replaces user messages with only unknown content blocks with a placeholder", async () => { const messages: Message[] = [ { role: "user", @@ -128,9 +128,95 @@ describe("bedrock convertMessages skips unknown content types", () => { const payload = await capturePayload({ messages }); expect(payload).toBeDefined(); const p = payload as { messages: Array<{ role: string; content: unknown[] }> }; + expect(p.messages).toHaveLength(1); + expect(p.messages[0].content).toEqual([{ text: "" }]); + }); + + it("replaces blank user string content with a placeholder", async () => { + const payload = await capturePayload({ + messages: [{ role: "user", content: " ", timestamp: Date.now() }], + }); + expect(payload).toBeDefined(); + const p = payload as { messages: Array<{ role: string; content: unknown[] }> }; + expect(p.messages).toHaveLength(1); + expect(p.messages[0].content).toEqual([{ text: "" }]); + }); + + it("filters blank user text blocks when other content remains", async () => { + const payload = await capturePayload({ + messages: [ + { + role: "user", + content: [ + { type: "text", text: "" }, + { type: "text", text: "hello" }, + ], + timestamp: Date.now(), + }, + ], + }); + expect(payload).toBeDefined(); + const p = payload as { messages: Array<{ role: string; content: unknown[] }> }; + expect(p.messages).toHaveLength(1); + expect(p.messages[0].content).toEqual([{ text: "hello" }]); + }); + + it("replaces user content emptied by surrogate sanitization with a placeholder", async () => { + const payload = await capturePayload({ + messages: [{ role: "user", content: String.fromCharCode(0xd83d), timestamp: Date.now() }], + }); + expect(payload).toBeDefined(); + const p = payload as { messages: Array<{ role: string; content: unknown[] }> }; + expect(p.messages).toHaveLength(1); + expect(p.messages[0].content).toEqual([{ text: "" }]); + }); + + it("skips assistant text blocks emptied by surrogate sanitization", async () => { + const messages: Message[] = [ + { + role: "assistant", + content: [{ type: "text", text: String.fromCharCode(0xd83d) }], + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + model: baseModel.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }, + ]; + const payload = await capturePayload({ messages }); + expect(payload).toBeDefined(); + const p = payload as { messages: Array<{ role: string; content: unknown[] }> }; expect(p.messages).toHaveLength(0); }); + it("replaces blank tool result content with a placeholder", async () => { + const messages: Message[] = [ + { + role: "toolResult", + toolCallId: "tool-1", + toolName: "tool", + content: [{ type: "text", text: "" }], + isError: false, + timestamp: Date.now(), + }, + ]; + const payload = await capturePayload({ messages }); + expect(payload).toBeDefined(); + const p = payload as { + messages: Array<{ role: string; content: Array<{ toolResult: { content: unknown[] } }> }>; + }; + expect(p.messages).toHaveLength(1); + expect(p.messages[0].content[0].toolResult.content).toEqual([{ text: "" }]); + }); + it("skips assistant messages with only unknown content blocks", async () => { const messages: Message[] = [ { From e56521e3234131a2c1639a74e2f15fff643acf30 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 1 Jun 2026 18:31:44 +0200 Subject: [PATCH 127/352] Add extension mode context --- packages/coding-agent/CHANGELOG.md | 4 ++ packages/coding-agent/docs/extensions.md | 20 ++++++---- packages/coding-agent/docs/rpc.md | 2 +- .../examples/extensions/custom-header.ts | 2 +- .../examples/extensions/doom-overlay/index.ts | 2 +- .../examples/extensions/handoff.ts | 2 +- .../examples/extensions/interactive-shell.ts | 2 +- .../coding-agent/examples/extensions/qna.ts | 2 +- .../examples/extensions/question.ts | 2 +- .../examples/extensions/questionnaire.ts | 2 +- .../coding-agent/examples/extensions/snake.ts | 2 +- .../examples/extensions/space-invaders.ts | 2 +- .../examples/extensions/summarize.ts | 2 +- .../examples/extensions/tic-tac-toe.ts | 2 +- .../coding-agent/examples/extensions/todo.ts | 2 +- .../coding-agent/examples/extensions/tools.ts | 5 +++ .../coding-agent/src/core/agent-session.ts | 8 +++- .../coding-agent/src/core/extensions/index.ts | 1 + .../src/core/extensions/runner.ts | 9 ++++- .../coding-agent/src/core/extensions/types.ts | 6 ++- .../src/modes/interactive/interactive-mode.ts | 2 + packages/coding-agent/src/modes/print-mode.ts | 1 + .../coding-agent/src/modes/rpc/rpc-mode.ts | 1 + .../test/extensions-runner.test.ts | 39 ++++++++++++++++++- .../test/trigger-compact-extension.test.ts | 1 + 25 files changed, 98 insertions(+), 25 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6013abac..72c008b6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `ctx.mode` to extension contexts so extensions can distinguish TUI, RPC, JSON, and print mode. + ### Fixed - Fixed opening and listing very large JSONL session files by reading session entries line-by-line instead of materializing the full file as one string ([#5231](https://github.com/earendil-works/pi/issues/5231)). diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index a30bbf52..64b1b87b 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -860,9 +860,13 @@ All handlers receive `ctx: ExtensionContext`. UI methods for user interaction. See [Custom UI](#custom-ui) for full details. +### ctx.mode + +Current run mode: `"tui"`, `"rpc"`, `"json"`, or `"print"`. Use `ctx.mode === "tui"` to guard terminal-only features such as `custom()`, component factories, terminal input, and direct TUI rendering. + ### ctx.hasUI -`false` in print mode (`-p`) and JSON mode. `true` in interactive and RPC mode. In RPC mode, dialog methods (`select`, `confirm`, `input`, `editor`) work via the extension UI sub-protocol, and fire-and-forget methods (`notify`, `setStatus`, `setWidget`, `setTitle`, `setEditorText`) emit requests to the client. Some TUI-specific methods are no-ops or return defaults (see [rpc.md](rpc.md#extension-ui-protocol)). +`true` in TUI and RPC modes. `false` in print mode (`-p`) and JSON mode. Use this to guard dialog methods (`select`, `confirm`, `input`, `editor`) and fire-and-forget methods (`notify`, `setStatus`, `setWidget`, `setTitle`, `setEditorText`) that work in both TUI and RPC modes. In RPC mode, some TUI-specific methods are no-ops or return defaults (see [rpc.md](rpc.md#extension-ui-protocol)). ### ctx.cwd @@ -2516,14 +2520,14 @@ const highlighted = highlightCode(code, lang, theme); ## Mode Behavior -| Mode | UI Methods | Notes | -|------|-----------|-------| -| Interactive | Full TUI | Normal operation | -| RPC (`--mode rpc`) | JSON protocol | Host handles UI, see [rpc.md](rpc.md) | -| JSON (`--mode json`) | No-op | Event stream to stdout, see [json.md](json.md) | -| Print (`-p`) | No-op | Extensions run but can't prompt | +| Mode | `ctx.mode` | `ctx.hasUI` | Notes | +|------|------------|-------------|-------| +| Interactive | `"tui"` | `true` | Full TUI with terminal rendering | +| RPC (`--mode rpc`) | `"rpc"` | `true` | Dialogs and notifications via JSON protocol; `custom()` returns `undefined`. See [rpc.md](rpc.md) | +| JSON (`--mode json`) | `"json"` | `false` | Event stream to stdout; UI methods are no-ops | +| Print (`-p`) | `"print"` | `false` | Extensions run but can't prompt | -In non-interactive modes, check `ctx.hasUI` before using UI methods. +Use `ctx.mode === "tui"` before TUI-specific features (`custom()`, component factories, terminal input). Use `ctx.hasUI` before dialog and notification methods that work in both TUI and RPC modes. ## Examples Reference diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index 846162c3..9aa16ffc 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -1003,7 +1003,7 @@ Some `ExtensionUIContext` methods are not supported or degraded in RPC mode beca - `getTheme()` returns `undefined` - `setTheme()` returns `{ success: false, error: "..." }` -Note: `ctx.hasUI` is `true` in RPC mode because the dialog and fire-and-forget methods are functional via the extension UI sub-protocol. +Note: `ctx.mode` is `"rpc"` and `ctx.hasUI` is `true` in RPC mode because the dialog and fire-and-forget methods are functional via the extension UI sub-protocol. Use `ctx.mode === "tui"` to guard TUI-specific features like `custom()` that require a real terminal. ### Extension UI Requests (stdout) diff --git a/packages/coding-agent/examples/extensions/custom-header.ts b/packages/coding-agent/examples/extensions/custom-header.ts index e3aeb656..600a85ce 100644 --- a/packages/coding-agent/examples/extensions/custom-header.ts +++ b/packages/coding-agent/examples/extensions/custom-header.ts @@ -47,7 +47,7 @@ function getPiMascot(theme: Theme): string[] { export default function (pi: ExtensionAPI) { // Set custom header immediately on load (if UI is available) pi.on("session_start", async (_event, ctx) => { - if (ctx.hasUI) { + if (ctx.mode === "tui") { ctx.ui.setHeader((_tui, theme) => { return { render(_width: number): string[] { diff --git a/packages/coding-agent/examples/extensions/doom-overlay/index.ts b/packages/coding-agent/examples/extensions/doom-overlay/index.ts index 9d9dad13..843d52da 100644 --- a/packages/coding-agent/examples/extensions/doom-overlay/index.ts +++ b/packages/coding-agent/examples/extensions/doom-overlay/index.ts @@ -23,7 +23,7 @@ export default function (pi: ExtensionAPI) { description: "Play DOOM as an overlay. Q to pause and exit.", handler: async (args, ctx) => { - if (!ctx.hasUI) { + if (ctx.mode !== "tui") { ctx.ui.notify("DOOM requires interactive mode", "error"); return; } diff --git a/packages/coding-agent/examples/extensions/handoff.ts b/packages/coding-agent/examples/extensions/handoff.ts index 10581dc6..4af6661f 100644 --- a/packages/coding-agent/examples/extensions/handoff.ts +++ b/packages/coding-agent/examples/extensions/handoff.ts @@ -81,7 +81,7 @@ export default function (pi: ExtensionAPI) { pi.registerCommand("handoff", { description: "Transfer context to a new focused session", handler: async (args, ctx) => { - if (!ctx.hasUI) { + if (ctx.mode !== "tui") { ctx.ui.notify("handoff requires interactive mode", "error"); return; } diff --git a/packages/coding-agent/examples/extensions/interactive-shell.ts b/packages/coding-agent/examples/extensions/interactive-shell.ts index 99a7f963..b1dd3746 100644 --- a/packages/coding-agent/examples/extensions/interactive-shell.ts +++ b/packages/coding-agent/examples/extensions/interactive-shell.ts @@ -146,7 +146,7 @@ export default function (pi: ExtensionAPI) { } // No UI available (print mode, RPC, etc.) - if (!ctx.hasUI) { + if (ctx.mode !== "tui") { return { result: { output: "(interactive commands require TUI)", exitCode: 1, cancelled: false, truncated: false }, }; diff --git a/packages/coding-agent/examples/extensions/qna.ts b/packages/coding-agent/examples/extensions/qna.ts index 2675b5d7..70dbef7b 100644 --- a/packages/coding-agent/examples/extensions/qna.ts +++ b/packages/coding-agent/examples/extensions/qna.ts @@ -31,7 +31,7 @@ export default function (pi: ExtensionAPI) { pi.registerCommand("qna", { description: "Extract questions from last assistant message into editor", handler: async (_args, ctx) => { - if (!ctx.hasUI) { + if (ctx.mode !== "tui") { ctx.ui.notify("qna requires interactive mode", "error"); return; } diff --git a/packages/coding-agent/examples/extensions/question.ts b/packages/coding-agent/examples/extensions/question.ts index 7aba1d70..eb1af51a 100644 --- a/packages/coding-agent/examples/extensions/question.ts +++ b/packages/coding-agent/examples/extensions/question.ts @@ -41,7 +41,7 @@ export default function question(pi: ExtensionAPI) { parameters: QuestionParams, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - if (!ctx.hasUI) { + if (ctx.mode !== "tui") { return { content: [{ type: "text", text: "Error: UI not available (running in non-interactive mode)" }], details: { diff --git a/packages/coding-agent/examples/extensions/questionnaire.ts b/packages/coding-agent/examples/extensions/questionnaire.ts index 34fb666c..653bd864 100644 --- a/packages/coding-agent/examples/extensions/questionnaire.ts +++ b/packages/coding-agent/examples/extensions/questionnaire.ts @@ -82,7 +82,7 @@ export default function questionnaire(pi: ExtensionAPI) { parameters: QuestionnaireParams, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - if (!ctx.hasUI) { + if (ctx.mode !== "tui") { return errorResult("Error: UI not available (running in non-interactive mode)"); } if (params.questions.length === 0) { diff --git a/packages/coding-agent/examples/extensions/snake.ts b/packages/coding-agent/examples/extensions/snake.ts index 29995b36..1b58edf9 100644 --- a/packages/coding-agent/examples/extensions/snake.ts +++ b/packages/coding-agent/examples/extensions/snake.ts @@ -311,7 +311,7 @@ export default function (pi: ExtensionAPI) { description: "Play Snake!", handler: async (_args, ctx) => { - if (!ctx.hasUI) { + if (ctx.mode !== "tui") { ctx.ui.notify("Snake requires interactive mode", "error"); return; } diff --git a/packages/coding-agent/examples/extensions/space-invaders.ts b/packages/coding-agent/examples/extensions/space-invaders.ts index c91071b8..306c5ac5 100644 --- a/packages/coding-agent/examples/extensions/space-invaders.ts +++ b/packages/coding-agent/examples/extensions/space-invaders.ts @@ -529,7 +529,7 @@ export default function (pi: ExtensionAPI) { description: "Play Space Invaders!", handler: async (_args, ctx) => { - if (!ctx.hasUI) { + if (ctx.mode !== "tui") { ctx.ui.notify("Space Invaders requires interactive mode", "error"); return; } diff --git a/packages/coding-agent/examples/extensions/summarize.ts b/packages/coding-agent/examples/extensions/summarize.ts index dd30c350..e6480974 100644 --- a/packages/coding-agent/examples/extensions/summarize.ts +++ b/packages/coding-agent/examples/extensions/summarize.ts @@ -115,7 +115,7 @@ const buildSummaryPrompt = (conversationText: string): string => ].join("\n"); const showSummaryUi = async (summary: string, ctx: ExtensionCommandContext) => { - if (!ctx.hasUI) { + if (ctx.mode !== "tui") { return; } diff --git a/packages/coding-agent/examples/extensions/tic-tac-toe.ts b/packages/coding-agent/examples/extensions/tic-tac-toe.ts index 8cbfdeab..8077a57f 100644 --- a/packages/coding-agent/examples/extensions/tic-tac-toe.ts +++ b/packages/coding-agent/examples/extensions/tic-tac-toe.ts @@ -779,7 +779,7 @@ Decide the target cell first, then dump every action for the turn in one go. description: "Play tic-tac-toe against the agent", handler: async (_args, ctx) => { - if (!ctx.hasUI) { + if (ctx.mode !== "tui") { ctx.ui.notify("Tic-tac-toe requires interactive mode", "error"); return; } diff --git a/packages/coding-agent/examples/extensions/todo.ts b/packages/coding-agent/examples/extensions/todo.ts index 89b147c8..67cba742 100644 --- a/packages/coding-agent/examples/extensions/todo.ts +++ b/packages/coding-agent/examples/extensions/todo.ts @@ -284,7 +284,7 @@ export default function (pi: ExtensionAPI) { pi.registerCommand("todos", { description: "Show all todos on the current branch", handler: async (_args, ctx) => { - if (!ctx.hasUI) { + if (ctx.mode !== "tui") { ctx.ui.notify("/todos requires interactive mode", "error"); return; } diff --git a/packages/coding-agent/examples/extensions/tools.ts b/packages/coding-agent/examples/extensions/tools.ts index 5e617ab3..8db496a5 100644 --- a/packages/coding-agent/examples/extensions/tools.ts +++ b/packages/coding-agent/examples/extensions/tools.ts @@ -67,6 +67,11 @@ export default function toolsExtension(pi: ExtensionAPI) { pi.registerCommand("tools", { description: "Enable/disable tools", handler: async (_args, ctx) => { + if (ctx.mode !== "tui") { + ctx.ui.notify("/tools requires TUI mode", "error"); + return; + } + // Refresh tool list allTools = pi.getAllTools(); diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 608a6153..93916496 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -56,6 +56,7 @@ import { type ContextUsage, type ExtensionCommandContextActions, type ExtensionErrorListener, + type ExtensionMode, ExtensionRunner, type ExtensionUIContext, type InputSource, @@ -187,6 +188,7 @@ export interface AgentSessionConfig { export interface ExtensionBindings { uiContext?: ExtensionUIContext; + mode?: ExtensionMode; commandContextActions?: ExtensionCommandContextActions; abortHandler?: () => void; shutdownHandler?: ShutdownHandler; @@ -300,6 +302,7 @@ export class AgentSession { private _baseToolsOverride?: Record; private _sessionStartEvent: SessionStartEvent; private _extensionUIContext?: ExtensionUIContext; + private _extensionMode: ExtensionMode = "print"; private _extensionCommandContextActions?: ExtensionCommandContextActions; private _extensionAbortHandler?: () => void; private _extensionShutdownHandler?: ShutdownHandler; @@ -2064,6 +2067,9 @@ export class AgentSession { if (bindings.uiContext !== undefined) { this._extensionUIContext = bindings.uiContext; } + if (bindings.mode !== undefined) { + this._extensionMode = bindings.mode; + } if (bindings.commandContextActions !== undefined) { this._extensionCommandContextActions = bindings.commandContextActions; } @@ -2136,7 +2142,7 @@ export class AgentSession { } private _applyExtensionBindings(runner: ExtensionRunner): void { - runner.setUIContext(this._extensionUIContext); + runner.setUIContext(this._extensionUIContext, this._extensionMode); runner.bindCommandContext(this._extensionCommandContextActions); this._extensionErrorUnsubscriber?.(); diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index acbc50b1..a25fa37b 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -66,6 +66,7 @@ export type { ExtensionFactory, ExtensionFlag, ExtensionHandler, + ExtensionMode, // Runtime ExtensionRuntime, ExtensionShortcut, diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 751e28a4..ed6f3b23 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -28,6 +28,7 @@ import type { ExtensionError, ExtensionEvent, ExtensionFlag, + ExtensionMode, ExtensionRuntime, ExtensionShortcut, ExtensionUIContext, @@ -225,6 +226,7 @@ export class ExtensionRunner { private extensions: Extension[]; private runtime: ExtensionRuntime; private uiContext: ExtensionUIContext; + private mode: ExtensionMode = "print"; private cwd: string; private sessionManager: SessionManager; private modelRegistry: ModelRegistry; @@ -354,8 +356,9 @@ export class ExtensionRunner { this.reloadHandler = async () => {}; } - setUIContext(uiContext?: ExtensionUIContext): void { + setUIContext(uiContext?: ExtensionUIContext, mode: ExtensionMode = "print"): void { this.uiContext = uiContext ?? noOpUIContext; + this.mode = mode; } getUIContext(): ExtensionUIContext { @@ -578,6 +581,10 @@ export class ExtensionRunner { runner.assertActive(); return runner.uiContext; }, + get mode() { + runner.assertActive(); + return runner.mode; + }, get hasUI() { runner.assertActive(); return runner.hasUI(); diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 7696c62e..db2a8f77 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -295,10 +295,14 @@ export interface CompactOptions { /** * Context passed to extension event handlers. */ +export type ExtensionMode = "tui" | "rpc" | "json" | "print"; + export interface ExtensionContext { /** UI methods for user interaction */ ui: ExtensionUIContext; - /** Whether UI is available (false in print/RPC mode) */ + /** Current run mode. Use "tui" to guard terminal-only UI such as custom components. */ + mode: ExtensionMode; + /** Whether dialog-capable UI is available (true in TUI and RPC modes) */ hasUI: boolean; /** Current working directory */ cwd: string; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 987a62cb..b874495b 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -1508,6 +1508,7 @@ export class InteractiveMode { const uiContext = this.createExtensionUIContext(); await this.session.bindExtensions({ uiContext, + mode: "tui", abortHandler: () => { this.restoreQueuedMessagesToEditor({ abort: true }); }, @@ -1654,6 +1655,7 @@ export class InteractiveMode { // Create a context for shortcut handlers const createContext = (): ExtensionContext => ({ ui: this.createExtensionUIContext(), + mode: "tui", hasUI: true, cwd: this.sessionManager.getCwd(), sessionManager: this.sessionManager, diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index c9553c55..a5ee0235 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -71,6 +71,7 @@ export async function runPrintMode(runtimeHost: AgentSessionRuntime, options: Pr const rebindSession = async (): Promise => { session = runtimeHost.session; await session.bindExtensions({ + mode: mode === "json" ? "json" : "print", commandContextActions: { waitForIdle: () => session.agent.waitForIdle(), newSession: async (newSessionOptions) => runtimeHost.newSession(newSessionOptions), diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index aa09cbb3..9bb1f089 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -317,6 +317,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise session.agent.waitForIdle(), newSession: async (options) => runtimeHost.newSession(options), diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index 2e7b0c01..0432ec39 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -9,7 +9,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.ts"; import { createExtensionRuntime, discoverAndLoadExtensions } from "../src/core/extensions/loader.ts"; import { ExtensionRunner } from "../src/core/extensions/runner.ts"; -import type { ExtensionActions, ExtensionContextActions, ProviderConfig } from "../src/core/extensions/types.ts"; +import type { + ExtensionActions, + ExtensionContextActions, + ExtensionUIContext, + ProviderConfig, +} from "../src/core/extensions/types.ts"; import { KeybindingsManager, type KeyId } from "../src/core/keybindings.ts"; import { ModelRegistry } from "../src/core/model-registry.ts"; import { SessionManager } from "../src/core/session-manager.ts"; @@ -441,6 +446,38 @@ describe("ExtensionRunner", () => { controller.abort(); expect(ctx.signal?.aborted).toBe(true); }); + + it("exposes print mode and hasUI false by default", async () => { + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); + runner.bindCore(extensionActions, extensionContextActions); + + const ctx = runner.createContext(); + expect(ctx.mode).toBe("print"); + expect(ctx.hasUI).toBe(false); + }); + + it("exposes rpc mode with hasUI true when an RPC UI context is provided", async () => { + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); + runner.bindCore(extensionActions, extensionContextActions); + runner.setUIContext({} as ExtensionUIContext, "rpc"); + + const ctx = runner.createContext(); + expect(ctx.mode).toBe("rpc"); + expect(ctx.hasUI).toBe(true); + }); + + it("exposes tui mode with hasUI true when a TUI UI context is provided", async () => { + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); + runner.bindCore(extensionActions, extensionContextActions); + runner.setUIContext({} as ExtensionUIContext, "tui"); + + const ctx = runner.createContext(); + expect(ctx.mode).toBe("tui"); + expect(ctx.hasUI).toBe(true); + }); }); describe("error handling", () => { diff --git a/packages/coding-agent/test/trigger-compact-extension.test.ts b/packages/coding-agent/test/trigger-compact-extension.test.ts index a8bdbb2a..c114fac9 100644 --- a/packages/coding-agent/test/trigger-compact-extension.test.ts +++ b/packages/coding-agent/test/trigger-compact-extension.test.ts @@ -4,6 +4,7 @@ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from ".. function createContext(tokens: number | null, compact = vi.fn()): ExtensionContext { return { + mode: "print", hasUI: false, ui: {} as ExtensionContext["ui"], cwd: process.cwd(), From 8216cca5a772a3ab278672895efff3a0b0d02891 Mon Sep 17 00:00:00 2001 From: Alexey Zaytsev Date: Mon, 1 Jun 2026 15:40:48 -0500 Subject: [PATCH 128/352] Add system prompt options to extension commands --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/extensions.md | 13 ++++++++ .../coding-agent/src/core/agent-session.ts | 1 + .../src/core/extensions/runner.ts | 6 ++++ .../coding-agent/src/core/extensions/types.ts | 4 +++ .../agent-session-model-extension.test.ts | 30 ++++++++++++++++++- 6 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 72c008b6..92726ebc 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Added `ctx.mode` to extension contexts so extensions can distinguish TUI, RPC, JSON, and print mode. +- Added `ctx.getSystemPromptOptions()` for extension commands to inspect the current base system prompt inputs. ### Fixed diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 64b1b87b..a8b453a6 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -982,6 +982,19 @@ pi.on("before_agent_start", (event, ctx) => { Command handlers receive `ExtensionCommandContext`, which extends `ExtensionContext` with session control methods. These are only available in commands because they can deadlock if called from event handlers. +### ctx.getSystemPromptOptions() + +Returns the base inputs Pi currently uses to build the system prompt. + +```typescript +const options = ctx.getSystemPromptOptions(); +const contextPaths = options.contextFiles?.map((file) => file.path) ?? []; +``` + +This has the same shape and mutability as `before_agent_start` `event.systemPromptOptions`: custom prompt, active tools, tool snippets, prompt guidelines, appended system prompt text, cwd, loaded context files, and loaded skills. It may include full context file contents, so treat it as sensitive extension-local data and avoid exposing it through command lists, logs, or autocomplete metadata. + +This reports the current base prompt inputs. It does not include per-turn `before_agent_start` chained system-prompt changes, later `context` event message mutations, or `before_provider_request` payload rewrites. + ### ctx.waitForIdle() Wait for the agent to finish streaming: diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 93916496..556ca20e 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -2264,6 +2264,7 @@ export class AgentSession { })(); }, getSystemPrompt: () => this.systemPrompt, + getSystemPromptOptions: () => this._baseSystemPromptOptions, }, { registerProvider: (name, config) => { diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index ed6f3b23..59412dcb 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -240,6 +240,7 @@ export class ExtensionRunner { private getContextUsageFn: () => ContextUsage | undefined = () => undefined; private compactFn: (options?: CompactOptions) => void = () => {}; private getSystemPromptFn: () => string = () => ""; + private getSystemPromptOptionsFn: () => BuildSystemPromptOptions = () => ({ cwd: this.cwd }); private newSessionHandler: NewSessionHandler = async () => ({ cancelled: false }); private forkHandler: ForkHandler = async () => ({ cancelled: false }); private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false }); @@ -299,6 +300,7 @@ export class ExtensionRunner { this.getContextUsageFn = contextActions.getContextUsage; this.compactFn = contextActions.compact; this.getSystemPromptFn = contextActions.getSystemPrompt; + this.getSystemPromptOptionsFn = contextActions.getSystemPromptOptions ?? (() => ({ cwd: this.cwd })); // Flush provider registrations queued during extension loading for (const { name, config, extensionPath } of this.runtime.pendingProviderRegistrations) { @@ -648,6 +650,10 @@ export class ExtensionRunner { {}, Object.getOwnPropertyDescriptors(this.createContext()), ) as ExtensionCommandContext; + context.getSystemPromptOptions = () => { + this.assertActive(); + return this.getSystemPromptOptionsFn(); + }; context.waitForIdle = () => { this.assertActive(); return this.waitForIdleFn(); diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index db2a8f77..a78a0657 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -335,6 +335,9 @@ export interface ExtensionContext { * Includes session control methods only safe in user-initiated commands. */ export interface ExtensionCommandContext extends ExtensionContext { + /** Get the current base system-prompt construction options. */ + getSystemPromptOptions(): BuildSystemPromptOptions; + /** Wait for the agent to finish streaming */ waitForIdle(): Promise; @@ -1506,6 +1509,7 @@ export interface ExtensionContextActions { getContextUsage: () => ContextUsage | undefined; compact: (options?: CompactOptions) => void; getSystemPrompt: () => string; + getSystemPromptOptions?: () => BuildSystemPromptOptions; } /** diff --git a/packages/coding-agent/test/suite/agent-session-model-extension.test.ts b/packages/coding-agent/test/suite/agent-session-model-extension.test.ts index f6469884..9c15ecf6 100644 --- a/packages/coding-agent/test/suite/agent-session-model-extension.test.ts +++ b/packages/coding-agent/test/suite/agent-session-model-extension.test.ts @@ -2,7 +2,7 @@ import type { AgentTool, ThinkingLevel } from "@earendil-works/pi-agent-core"; import { fauxAssistantMessage, fauxToolCall, type Model } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import { afterEach, describe, expect, it } from "vitest"; -import type { ExtensionAPI } from "../../src/index.ts"; +import type { BuildSystemPromptOptions, ExtensionAPI } from "../../src/index.ts"; import { createHarness, getAssistantTexts, type Harness } from "./harness.ts"; describe("AgentSession model and extension characterization", () => { @@ -260,6 +260,34 @@ describe("AgentSession model and extension characterization", () => { expect(extensionApi).toBeDefined(); }); + it("allows extension commands to inspect live system prompt options", async () => { + const seenOptions: BuildSystemPromptOptions[] = []; + const harness = await createHarness({ + extensionFactories: [ + (pi) => { + pi.registerCommand("inspect-options", { + description: "Inspect system prompt options", + handler: async (_args, ctx) => { + const options = ctx.getSystemPromptOptions(); + seenOptions.push(options); + options.selectedTools?.push("mutated_tool"); + }, + }); + }, + ], + }); + harnesses.push(harness); + + await harness.session.prompt("/inspect-options"); + await harness.session.prompt("/inspect-options"); + + expect(seenOptions).toHaveLength(2); + expect(seenOptions[0]).toBe(seenOptions[1]); + expect(seenOptions[0]?.cwd).toBe(harness.tempDir); + expect(seenOptions[0]?.selectedTools).toContain("read"); + expect(seenOptions[1]?.selectedTools).toContain("mutated_tool"); + }); + it("allows before_agent_start handlers to inject custom messages and modify the system prompt", async () => { const harness = await createHarness({ extensionFactories: [ From f58c1702cf78c1d3c4dcf6aa1204c8e2f04839cf Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 2 Jun 2026 11:20:27 +0200 Subject: [PATCH 129/352] fix: apply httpIdleTimeoutMs to all providers, not just Codex Responses The HTTP timeout setting (httpIdleTimeoutMs) was only used as a fallback for the openai-codex-responses API. For other providers like openai-completions (llama.cpp), the SDK default timeout (10 min) was used instead, ignoring the user's disabled timeout setting. Now httpIdleTimeoutMs applies universally as the default SDK request timeout for all providers that support timeoutMs. Setting HTTP timeout = false (0) correctly disables SDK timeouts across the board. closes #5294 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/core/sdk.ts | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 92726ebc..a8b4ea02 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed +- Fixed HTTP timeout setting not being respected for non-Codex providers (e.g., llama.cpp via OpenAI-compatible API). The `httpIdleTimeoutMs` setting (set via `/settings` HTTP timeout) now applies as the default SDK request timeout for all providers that support it, not just OpenAI Codex Responses. Disabling the timeout (HTTP timeout = false) now correctly disables SDK timeouts for all supported providers ([#5294](https://github.com/earendil-works/pi/issues/5294)). - Fixed opening and listing very large JSONL session files by reading session entries line-by-line instead of materializing the full file as one string ([#5231](https://github.com/earendil-works/pi/issues/5231)). ## [0.78.0] - 2026-05-29 diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index a4401177..417e8a11 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -343,9 +343,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } const providerRetrySettings = settingsManager.getProviderRetrySettings(); const timeoutMs = - options?.timeoutMs ?? - providerRetrySettings.timeoutMs ?? - (model.api === "openai-codex-responses" ? settingsManager.getHttpIdleTimeoutMs() : undefined); + options?.timeoutMs ?? providerRetrySettings.timeoutMs ?? settingsManager.getHttpIdleTimeoutMs(); const websocketConnectTimeoutMs = options?.websocketConnectTimeoutMs ?? settingsManager.getWebSocketConnectTimeoutMs(); const attributionHeaders = getAttributionHeaders(model, settingsManager, options?.sessionId); From 7c531d0518fa141f90fdcc8f92e11136f310930c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 2 Jun 2026 11:26:36 +0200 Subject: [PATCH 130/352] fix: apply httpIdleTimeoutMs to all providers, not just Codex Responses The HTTP timeout setting (httpIdleTimeoutMs) was only used as a fallback for the openai-codex-responses API. For other providers like openai-completions (llama.cpp), the SDK default timeout (10 min) was used instead, ignoring the user's disabled timeout setting. Now httpIdleTimeoutMs applies universally as the default SDK request timeout for all providers that support timeoutMs. Setting HTTP timeout = false (0) correctly disables SDK timeouts across the board by sending max int32 (effectively infinite) instead of 0, since SDKs treat timeout=0 as immediate timeout. closes #5294 --- packages/coding-agent/CHANGELOG.md | 2 +- packages/coding-agent/src/core/sdk.ts | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a8b4ea02..1fcde0b7 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,7 +9,7 @@ ### Fixed -- Fixed HTTP timeout setting not being respected for non-Codex providers (e.g., llama.cpp via OpenAI-compatible API). The `httpIdleTimeoutMs` setting (set via `/settings` HTTP timeout) now applies as the default SDK request timeout for all providers that support it, not just OpenAI Codex Responses. Disabling the timeout (HTTP timeout = false) now correctly disables SDK timeouts for all supported providers ([#5294](https://github.com/earendil-works/pi/issues/5294)). +- Fixed HTTP timeout setting not being respected for non-Codex providers (e.g., llama.cpp via OpenAI-compatible API). The `httpIdleTimeoutMs` setting (set via `/settings` HTTP timeout) now applies as the default SDK request timeout for all providers that support it, not just OpenAI Codex Responses. Disabling the timeout (HTTP timeout = false) now correctly disables SDK timeouts for all supported providers by sending a maximum int32 value (effectively infinite) instead of 0, since SDKs treat timeout=0 as an immediate timeout ([#5294](https://github.com/earendil-works/pi/issues/5294)). - Fixed opening and listing very large JSONL session files by reading session entries line-by-line instead of materializing the full file as one string ([#5231](https://github.com/earendil-works/pi/issues/5231)). ## [0.78.0] - 2026-05-29 diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 417e8a11..7a1c922b 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -342,8 +342,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} throw new Error(auth.error); } const providerRetrySettings = settingsManager.getProviderRetrySettings(); - const timeoutMs = - options?.timeoutMs ?? providerRetrySettings.timeoutMs ?? settingsManager.getHttpIdleTimeoutMs(); + const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs(); + // SDKs treat timeout=0 as 0ms (immediate timeout), not "no timeout". + // Use max int32 to effectively disable the timeout. + const effectiveTimeoutMs = httpIdleTimeoutMs === 0 ? 2147483647 : httpIdleTimeoutMs; + const timeoutMs = options?.timeoutMs ?? providerRetrySettings.timeoutMs ?? effectiveTimeoutMs; const websocketConnectTimeoutMs = options?.websocketConnectTimeoutMs ?? settingsManager.getWebSocketConnectTimeoutMs(); const attributionHeaders = getAttributionHeaders(model, settingsManager, options?.sessionId); From 7c08227019ef7f0438a867426b01be1fb2df122b Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 2 Jun 2026 11:37:32 +0200 Subject: [PATCH 131/352] Fix stale model references in AI tests after model regeneration - Update GitHub Copilot tests to use available models (claude-haiku-4.5, gemini-2.5-pro) - Update OpenRouter tests to use available models (google/gemini-2.5-flash, deepseek/deepseek-chat) - Fix over-broad sed that incorrectly renamed OpenAI Responses gpt-4o test - Remove nvidia.md from repo root - Update packages/ai/CHANGELOG.md --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/models.generated.ts | 491 ++++++++++++------ packages/ai/test/context-overflow.test.ts | 8 +- packages/ai/test/empty.test.ts | 16 +- packages/ai/test/image-tool-result.test.ts | 8 +- packages/ai/test/tokens.test.ts | 4 +- .../ai/test/tool-call-without-result.test.ts | 4 +- packages/ai/test/total-tokens.test.ts | 34 +- packages/ai/test/unicode-surrogate.test.ts | 12 +- 9 files changed, 369 insertions(+), 209 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 98922700..af63cdc4 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -6,6 +6,7 @@ - Fixed Amazon Bedrock requests to replace blank required user/tool-result text with a placeholder and skip blank replay text blocks ([#4975](https://github.com/earendil-works/pi/issues/4975)). - Fixed OpenAI GPT-5.5 generated metadata to omit unsupported minimal thinking ([#5243](https://github.com/earendil-works/pi/issues/5243)). +- Fixed GitHub Copilot and OpenRouter test model references that became stale after model regeneration. ## [0.78.0] - 2026-05-29 diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index e1a2facc..8e83c580 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -3855,7 +3855,7 @@ export const MODELS = { "github-copilot": { "claude-haiku-4.5": { id: "claude-haiku-4.5", - name: "Claude Haiku 4.5", + name: "Claude Haiku 4.5 (latest)", api: "anthropic-messages", provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", @@ -3864,17 +3864,17 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, + input: 1, + output: 5, + cacheRead: 0.1, + cacheWrite: 1.25, }, contextWindow: 200000, maxTokens: 64000, } satisfies Model<"anthropic-messages">, "claude-opus-4.5": { id: "claude-opus-4.5", - name: "Claude Opus 4.5", + name: "Claude Opus 4.5 (latest)", api: "anthropic-messages", provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", @@ -3882,10 +3882,10 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, }, contextWindow: 200000, maxTokens: 32000, @@ -3902,10 +3902,10 @@ export const MODELS = { thinkingLevelMap: {"xhigh":"max"}, input: ["text", "image"], cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, }, contextWindow: 1000000, maxTokens: 32000, @@ -3922,10 +3922,10 @@ export const MODELS = { thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, }, contextWindow: 200000, maxTokens: 32000, @@ -3942,17 +3942,17 @@ export const MODELS = { thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, }, contextWindow: 200000, maxTokens: 64000, } satisfies Model<"anthropic-messages">, - "claude-sonnet-4.5": { - id: "claude-sonnet-4.5", - name: "Claude Sonnet 4.5", + "claude-sonnet-4": { + id: "claude-sonnet-4", + name: "Claude Sonnet 4 (latest)", api: "anthropic-messages", provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", @@ -3961,10 +3961,29 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 216000, + maxTokens: 16000, + } satisfies Model<"anthropic-messages">, + "claude-sonnet-4.5": { + id: "claude-sonnet-4.5", + name: "Claude Sonnet 4.5 (latest)", + api: "anthropic-messages", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsEagerToolInputStreaming":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, }, contextWindow: 200000, maxTokens: 32000, @@ -3980,10 +3999,10 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, }, contextWindow: 1000000, maxTokens: 32000, @@ -3996,12 +4015,12 @@ export const MODELS = { baseUrl: "https://api.individual.githubcopilot.com", headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { - input: 0, - output: 0, - cacheRead: 0, + input: 1.25, + output: 10, + cacheRead: 0.125, cacheWrite: 0, }, contextWindow: 128000, @@ -4009,7 +4028,7 @@ export const MODELS = { } satisfies Model<"openai-completions">, "gemini-3-flash-preview": { id: "gemini-3-flash-preview", - name: "Gemini 3 Flash", + name: "Gemini 3 Flash Preview", api: "openai-completions", provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", @@ -4018,9 +4037,9 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0, - output: 0, - cacheRead: 0, + input: 0.5, + output: 3, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 128000, @@ -4037,9 +4056,9 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0, - output: 0, - cacheRead: 0, + input: 2, + output: 12, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 200000, @@ -4056,9 +4075,9 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0, - output: 0, - cacheRead: 0, + input: 1.5, + output: 9, + cacheRead: 0.15, cacheWrite: 0, }, contextWindow: 200000, @@ -4075,36 +4094,17 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0, - output: 0, - cacheRead: 0, + input: 2, + output: 8, + cacheRead: 0.5, cacheWrite: 0, }, contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, - "gpt-4o": { - id: "gpt-4o", - name: "GPT-4o", - api: "openai-completions", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, - reasoning: false, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "gpt-5-mini": { id: "gpt-5-mini", - name: "GPT-5-mini", + name: "GPT-5 Mini", api: "openai-responses", provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", @@ -4113,9 +4113,9 @@ export const MODELS = { thinkingLevelMap: {"off":null,"minimal":"low"}, input: ["text", "image"], cost: { - input: 0, - output: 0, - cacheRead: 0, + input: 0.25, + output: 2, + cacheRead: 0.025, cacheWrite: 0, }, contextWindow: 264000, @@ -4132,9 +4132,9 @@ export const MODELS = { thinkingLevelMap: {"off":null,"minimal":"low","xhigh":"xhigh"}, input: ["text", "image"], cost: { - input: 0, - output: 0, - cacheRead: 0, + input: 1.75, + output: 14, + cacheRead: 0.175, cacheWrite: 0, }, contextWindow: 400000, @@ -4142,7 +4142,7 @@ export const MODELS = { } satisfies Model<"openai-responses">, "gpt-5.2-codex": { id: "gpt-5.2-codex", - name: "GPT-5.2-Codex", + name: "GPT-5.2 Codex", api: "openai-responses", provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", @@ -4151,9 +4151,9 @@ export const MODELS = { thinkingLevelMap: {"off":null,"minimal":"low","xhigh":"xhigh"}, input: ["text", "image"], cost: { - input: 0, - output: 0, - cacheRead: 0, + input: 1.75, + output: 14, + cacheRead: 0.175, cacheWrite: 0, }, contextWindow: 400000, @@ -4161,7 +4161,7 @@ export const MODELS = { } satisfies Model<"openai-responses">, "gpt-5.3-codex": { id: "gpt-5.3-codex", - name: "GPT-5.3-Codex", + name: "GPT-5.3 Codex", api: "openai-responses", provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", @@ -4170,9 +4170,9 @@ export const MODELS = { thinkingLevelMap: {"off":null,"minimal":"low","xhigh":"xhigh"}, input: ["text", "image"], cost: { - input: 0, - output: 0, - cacheRead: 0, + input: 1.75, + output: 14, + cacheRead: 0.175, cacheWrite: 0, }, contextWindow: 400000, @@ -4189,9 +4189,9 @@ export const MODELS = { thinkingLevelMap: {"off":null,"minimal":"low","xhigh":"xhigh"}, input: ["text", "image"], cost: { - input: 0, - output: 0, - cacheRead: 0, + input: 2.5, + output: 15, + cacheRead: 0.25, cacheWrite: 0, }, contextWindow: 400000, @@ -4199,7 +4199,7 @@ export const MODELS = { } satisfies Model<"openai-responses">, "gpt-5.4-mini": { id: "gpt-5.4-mini", - name: "GPT-5.4 Mini", + name: "GPT-5.4 mini", api: "openai-responses", provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", @@ -4208,9 +4208,28 @@ export const MODELS = { thinkingLevelMap: {"off":null,"minimal":"low","xhigh":"xhigh"}, input: ["text", "image"], cost: { - input: 0, - output: 0, - cacheRead: 0, + input: 0.75, + output: 4.5, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.4-nano": { + id: "gpt-5.4-nano", + name: "GPT-5.4 nano", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":"low","xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 0.2, + output: 1.25, + cacheRead: 0.02, cacheWrite: 0, }, contextWindow: 400000, @@ -4227,32 +4246,32 @@ export const MODELS = { thinkingLevelMap: {"off":null,"minimal":"low","xhigh":"xhigh"}, input: ["text", "image"], cost: { - input: 0, - output: 0, - cacheRead: 0, + input: 5, + output: 30, + cacheRead: 0.5, cacheWrite: 0, }, contextWindow: 400000, maxTokens: 128000, } satisfies Model<"openai-responses">, - "grok-code-fast-1": { - id: "grok-code-fast-1", - name: "Grok Code Fast 1", + "raptor-mini": { + id: "raptor-mini", + name: "Raptor mini", api: "openai-completions", provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, reasoning: true, - input: ["text"], + input: ["text", "image"], cost: { - input: 0, - output: 0, - cacheRead: 0, + input: 0.25, + output: 2, + cacheRead: 0.025, cacheWrite: 0, }, - contextWindow: 128000, - maxTokens: 64000, + contextWindow: 400000, + maxTokens: 128000, } satisfies Model<"openai-completions">, }, "google": { @@ -7863,6 +7882,23 @@ export const MODELS = { contextWindow: 204800, maxTokens: 131072, } satisfies Model<"openai-completions">, + "minimax-m3-free": { + id: "minimax-m3-free", + name: "MiniMax M3 Free", + api: "anthropic-messages", + provider: "opencode", + baseUrl: "https://opencode.ai/zen", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 32000, + } satisfies Model<"anthropic-messages">, "nemotron-3-super-free": { id: "nemotron-3-super-free", name: "Nemotron 3 Super Free", @@ -8092,6 +8128,23 @@ export const MODELS = { contextWindow: 204800, maxTokens: 131072, } satisfies Model<"openai-completions">, + "minimax-m3": { + id: "minimax-m3", + name: "MiniMax M3", + api: "anthropic-messages", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.6, + output: 2.4, + cacheRead: 0.12, + cacheWrite: 0, + }, + contextWindow: 512000, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, "qwen3.6-plus": { id: "qwen3.6-plus", name: "Qwen3.6 Plus", @@ -8688,8 +8741,8 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.2288, - output: 0.9144, + input: 0.20020000000000002, + output: 0.8000999999999999, cacheRead: 0, cacheWrite: 0, }, @@ -8790,13 +8843,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.252, - output: 0.378, - cacheRead: 0.0252, + input: 0.2288, + output: 0.3432, + cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 65536, + maxTokens: 64000, } satisfies Model<"openai-completions">, "deepseek/deepseek-v3.2-exp": { id: "deepseek/deepseek-v3.2-exp", @@ -8870,40 +8923,6 @@ export const MODELS = { contextWindow: 32768, maxTokens: 4096, } satisfies Model<"openai-completions">, - "google/gemini-2.0-flash-001": { - id: "google/gemini-2.0-flash-001", - name: "Google: Gemini 2.0 Flash", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.09999999999999999, - output: 0.39999999999999997, - cacheRead: 0.024999999999999998, - cacheWrite: 0.08333333333333334, - }, - contextWindow: 1048576, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "google/gemini-2.0-flash-lite-001": { - id: "google/gemini-2.0-flash-lite-001", - name: "Google: Gemini 2.0 Flash Lite", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.075, - output: 0.3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 8192, - } satisfies Model<"openai-completions">, "google/gemini-2.5-flash": { id: "google/gemini-2.5-flash", name: "Google: Gemini 2.5 Flash", @@ -9492,13 +9511,30 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.26, + input: 0.27899999999999997, output: 1.2, cacheRead: 0, cacheWrite: 0, }, contextWindow: 204800, - maxTokens: 4096, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "minimax/minimax-m3": { + id: "minimax/minimax-m3", + name: "MiniMax: MiniMax M3", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 512000, } satisfies Model<"openai-completions">, "mistralai/codestral-2508": { id: "mistralai/codestral-2508", @@ -11291,13 +11327,13 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.09, - output: 0.3, + input: 0.0428, + output: 0.1716, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 262144, - maxTokens: 262144, + contextWindow: 131072, + maxTokens: 32000, } satisfies Model<"openai-completions">, "qwen/qwen3-30b-a3b-thinking-2507": { id: "qwen/qwen3-30b-a3b-thinking-2507", @@ -12334,7 +12370,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 202752, - maxTokens: 4096, + maxTokens: 16384, } satisfies Model<"openai-completions">, "z-ai/glm-5-turbo": { id: "z-ai/glm-5-turbo", @@ -12368,7 +12404,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 202752, - maxTokens: 4096, + maxTokens: 131072, } satisfies Model<"openai-completions">, "z-ai/glm-5v-turbo": { id: "z-ai/glm-5v-turbo", @@ -12889,7 +12925,7 @@ export const MODELS = { api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.22, @@ -12974,7 +13010,7 @@ export const MODELS = { api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 1.5, @@ -13008,7 +13044,7 @@ export const MODELS = { api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.5, @@ -13087,6 +13123,40 @@ export const MODELS = { contextWindow: 256000, maxTokens: 65536, } satisfies Model<"anthropic-messages">, + "alibaba/qwen3-next-80b-a3b-instruct": { + id: "alibaba/qwen3-next-80b-a3b-instruct", + name: "Qwen3 Next 80B A3B Instruct", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text"], + cost: { + input: 0.15, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"anthropic-messages">, + "alibaba/qwen3-next-80b-a3b-thinking": { + id: "alibaba/qwen3-next-80b-a3b-thinking", + name: "Qwen3 Next 80B A3B Thinking", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.15, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"anthropic-messages">, "alibaba/qwen3-vl-thinking": { id: "alibaba/qwen3-vl-thinking", name: "Qwen3 VL 235B A22B Thinking", @@ -13189,6 +13259,23 @@ export const MODELS = { contextWindow: 991000, maxTokens: 64000, } satisfies Model<"anthropic-messages">, + "alibaba/qwen3.7-plus": { + id: "alibaba/qwen3.7-plus", + name: "Qwen 3.7 Plus", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.39999999999999997, + output: 1.5999999999999999, + cacheRead: 0.08, + cacheWrite: 0.5, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, "anthropic/claude-3-haiku": { id: "anthropic/claude-3-haiku", name: "Claude 3 Haiku", @@ -13542,8 +13629,8 @@ export const MODELS = { api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", - reasoning: false, - input: ["text"], + reasoning: true, + input: ["text", "image"], cost: { input: 0.28, output: 0.42, @@ -13559,8 +13646,8 @@ export const MODELS = { api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", - reasoning: false, - input: ["text"], + reasoning: true, + input: ["text", "image"], cost: { input: 0.62, output: 1.85, @@ -13577,7 +13664,7 @@ export const MODELS = { provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", reasoning: true, - input: ["text"], + input: ["text", "image"], cost: { input: 0.14, output: 0.28, @@ -13594,7 +13681,7 @@ export const MODELS = { provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", reasoning: true, - input: ["text"], + input: ["text", "image"], cost: { input: 0.435, output: 0.87, @@ -13797,12 +13884,12 @@ export const MODELS = { api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { - input: 0.13, - output: 0.39999999999999997, - cacheRead: 0, + input: 0.15, + output: 0.6, + cacheRead: 0.015, cacheWrite: 0, }, contextWindow: 262144, @@ -14087,7 +14174,7 @@ export const MODELS = { provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", reasoning: true, - input: ["text"], + input: ["text", "image"], cost: { input: 0.6, output: 2.4, @@ -14131,6 +14218,23 @@ export const MODELS = { contextWindow: 204800, maxTokens: 131100, } satisfies Model<"anthropic-messages">, + "minimax/minimax-m3": { + id: "minimax/minimax-m3", + name: "MiniMax M3", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 1000000, + } satisfies Model<"anthropic-messages">, "mistral/codestral": { id: "mistral/codestral", name: "Mistral Codestral", @@ -14267,6 +14371,23 @@ export const MODELS = { contextWindow: 256000, maxTokens: 256000, } satisfies Model<"anthropic-messages">, + "mistral/mistral-nemo": { + id: "mistral/mistral-nemo", + name: "Mistral Nemo 12B", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text"], + cost: { + input: 0.02, + output: 0.04, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, "mistral/mistral-small": { id: "mistral/mistral-small", name: "Mistral Small", @@ -14420,6 +14541,23 @@ export const MODELS = { contextWindow: 262000, maxTokens: 262000, } satisfies Model<"anthropic-messages">, + "nvidia/nemotron-3-super-120b-a12b": { + id: "nvidia/nemotron-3-super-120b-a12b", + name: "NVIDIA Nemotron 3 Super 120B A12B", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.15, + output: 0.65, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 32000, + } satisfies Model<"anthropic-messages">, "nvidia/nemotron-nano-12b-v2-vl": { id: "nvidia/nemotron-nano-12b-v2-vl", name: "Nvidia Nemotron Nano 12B V2 VL", @@ -14597,7 +14735,7 @@ export const MODELS = { provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", reasoning: true, - input: ["text"], + input: ["text", "image"], cost: { input: 1.25, output: 10, @@ -14959,6 +15097,23 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"anthropic-messages">, + "openai/gpt-oss-120b": { + id: "openai/gpt-oss-120b", + name: "GPT OSS 120B", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.35, + output: 0.75, + cacheRead: 0.25, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131000, + } satisfies Model<"anthropic-messages">, "openai/gpt-oss-20b": { id: "openai/gpt-oss-20b", name: "GPT OSS 20B", @@ -15441,7 +15596,7 @@ export const MODELS = { api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 0.6, @@ -15595,7 +15750,7 @@ export const MODELS = { provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", reasoning: true, - input: ["text"], + input: ["text", "image"], cost: { input: 1.4, output: 4.4, diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts index c5594989..9f021a41 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -120,15 +120,15 @@ describe("Context overflow error handling", () => { // ============================================================================= // GitHub Copilot (OAuth) - // Tests both OpenAI and Anthropic models via Copilot + // Tests both Google and Anthropic models via Copilot // ============================================================================= describe("GitHub Copilot (OAuth)", () => { - // OpenAI model via Copilot + // Google model via Copilot it.skipIf(!githubCopilotToken)( - "gpt-4o - should detect overflow via isContextOverflow", + "gemini-2.5-pro - should detect overflow via isContextOverflow", async () => { - const model = getModel("github-copilot", "gpt-4o"); + const model = getModel("github-copilot", "gemini-2.5-pro"); const result = await testContextOverflow(model, githubCopilotToken!); logResult(result); diff --git a/packages/ai/test/empty.test.ts b/packages/ai/test/empty.test.ts index 7df7de2c..a8453dcc 100644 --- a/packages/ai/test/empty.test.ts +++ b/packages/ai/test/empty.test.ts @@ -630,37 +630,37 @@ describe("AI Providers Empty Message Tests", () => { describe("GitHub Copilot Provider Empty Messages", () => { it.skipIf(!githubCopilotToken)( - "gpt-4o - should handle empty content array", + "claude-haiku-4.5 - should handle empty content array", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "gpt-4o"); + const llm = getModel("github-copilot", "claude-haiku-4.5"); await testEmptyMessage(llm, { apiKey: githubCopilotToken }); }, ); it.skipIf(!githubCopilotToken)( - "gpt-4o - should handle empty string content", + "claude-haiku-4.5 - should handle empty string content", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "gpt-4o"); + const llm = getModel("github-copilot", "claude-haiku-4.5"); await testEmptyStringMessage(llm, { apiKey: githubCopilotToken }); }, ); it.skipIf(!githubCopilotToken)( - "gpt-4o - should handle whitespace-only content", + "claude-haiku-4.5 - should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "gpt-4o"); + const llm = getModel("github-copilot", "claude-haiku-4.5"); await testWhitespaceOnlyMessage(llm, { apiKey: githubCopilotToken }); }, ); it.skipIf(!githubCopilotToken)( - "gpt-4o - should handle empty assistant message in conversation", + "claude-haiku-4.5 - should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "gpt-4o"); + const llm = getModel("github-copilot", "claude-haiku-4.5"); await testEmptyAssistantMessage(llm, { apiKey: githubCopilotToken }); }, ); diff --git a/packages/ai/test/image-tool-result.test.ts b/packages/ai/test/image-tool-result.test.ts index 542b2385..a752b7ce 100644 --- a/packages/ai/test/image-tool-result.test.ts +++ b/packages/ai/test/image-tool-result.test.ts @@ -443,19 +443,19 @@ describe("Tool Results with Images", () => { describe("GitHub Copilot Provider", () => { it.skipIf(!githubCopilotToken)( - "gpt-4o - should handle tool result with only image", + "claude-haiku-4.5 - should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "gpt-4o"); + const llm = getModel("github-copilot", "claude-haiku-4.5"); await handleToolWithImageResult(llm, { apiKey: githubCopilotToken }); }, ); it.skipIf(!githubCopilotToken)( - "gpt-4o - should handle tool result with text and image", + "claude-haiku-4.5 - should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "gpt-4o"); + const llm = getModel("github-copilot", "claude-haiku-4.5"); await handleToolWithTextAndImageResult(llm, { apiKey: githubCopilotToken }); }, ); diff --git a/packages/ai/test/tokens.test.ts b/packages/ai/test/tokens.test.ts index df548163..676f5a29 100644 --- a/packages/ai/test/tokens.test.ts +++ b/packages/ai/test/tokens.test.ts @@ -295,10 +295,10 @@ describe("Token Statistics on Abort", () => { describe("GitHub Copilot Provider", () => { it.skipIf(!githubCopilotToken)( - "gpt-4o - should include token stats when aborted mid-stream", + "claude-haiku-4.5 - should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "gpt-4o"); + const llm = getModel("github-copilot", "claude-haiku-4.5"); await testTokensOnAbort(llm, { apiKey: githubCopilotToken }); }, ); diff --git a/packages/ai/test/tool-call-without-result.test.ts b/packages/ai/test/tool-call-without-result.test.ts index 2b1950b9..11b832f3 100644 --- a/packages/ai/test/tool-call-without-result.test.ts +++ b/packages/ai/test/tool-call-without-result.test.ts @@ -297,10 +297,10 @@ describe("Tool Call Without Result Tests", () => { describe("GitHub Copilot Provider", () => { it.skipIf(!githubCopilotToken)( - "gpt-4o - should filter out tool calls without corresponding tool results", + "claude-haiku-4.5 - should filter out tool calls without corresponding tool results", { retry: 3, timeout: 30000 }, async () => { - const model = getModel("github-copilot", "gpt-4o"); + const model = getModel("github-copilot", "claude-haiku-4.5"); await testToolCallWithoutResult(model, { apiKey: githubCopilotToken }); }, ); diff --git a/packages/ai/test/total-tokens.test.ts b/packages/ai/test/total-tokens.test.ts index bd782c2c..972913a7 100644 --- a/packages/ai/test/total-tokens.test.ts +++ b/packages/ai/test/total-tokens.test.ts @@ -178,18 +178,22 @@ describe("totalTokens field", () => { }); describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Responses", () => { - it("gpt-4o - should return totalTokens equal to sum of components", { retry: 3, timeout: 60000 }, async () => { - const llm = getModel("openai", "gpt-4o"); + it( + "claude-haiku-4.5 - should return totalTokens equal to sum of components", + { retry: 3, timeout: 60000 }, + async () => { + const llm = getModel("openai", "gpt-4o"); - console.log(`\nOpenAI Responses / ${llm.id}:`); - const { first, second } = await testTotalTokensWithCache(llm); + console.log(`\nOpenAI Responses / ${llm.id}:`); + const { first, second } = await testTotalTokensWithCache(llm); - logUsage("First request", first); - logUsage("Second request", second); + logUsage("First request", first); + logUsage("Second request", second); - assertTotalTokensEqualsComponents(first); - assertTotalTokensEqualsComponents(second); - }); + assertTotalTokensEqualsComponents(first); + assertTotalTokensEqualsComponents(second); + }, + ); }); describe.skipIf(!hasAzureOpenAICredentials())("Azure OpenAI Responses", () => { @@ -666,10 +670,10 @@ describe("totalTokens field", () => { ); it( - "google/gemini-2.0-flash-001 - should return totalTokens equal to sum of components", + "google/gemini-2.5-flash - should return totalTokens equal to sum of components", { retry: 3, timeout: 60000 }, async () => { - const llm = getModel("openrouter", "google/gemini-2.0-flash-001"); + const llm = getModel("openrouter", "google/gemini-2.5-flash"); console.log(`\nOpenRouter / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm, { apiKey: process.env.OPENROUTER_API_KEY }); @@ -683,10 +687,10 @@ describe("totalTokens field", () => { ); it( - "meta-llama/llama-4-scout - should return totalTokens equal to sum of components", + "deepseek/deepseek-chat - should return totalTokens equal to sum of components", { retry: 3, timeout: 60000 }, async () => { - const llm = getModel("openrouter", "meta-llama/llama-4-scout"); + const llm = getModel("openrouter", "deepseek/deepseek-chat"); console.log(`\nOpenRouter / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm, { apiKey: process.env.OPENROUTER_API_KEY }); @@ -706,10 +710,10 @@ describe("totalTokens field", () => { describe("GitHub Copilot (OAuth)", () => { it.skipIf(!githubCopilotToken)( - "gpt-4o - should return totalTokens equal to sum of components", + "claude-haiku-4.5 - should return totalTokens equal to sum of components", { retry: 3, timeout: 60000 }, async () => { - const llm = getModel("github-copilot", "gpt-4o"); + const llm = getModel("github-copilot", "claude-haiku-4.5"); console.log(`\nGitHub Copilot / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm, { apiKey: githubCopilotToken }); diff --git a/packages/ai/test/unicode-surrogate.test.ts b/packages/ai/test/unicode-surrogate.test.ts index afe48d5c..9cdddefa 100644 --- a/packages/ai/test/unicode-surrogate.test.ts +++ b/packages/ai/test/unicode-surrogate.test.ts @@ -396,28 +396,28 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { describe("GitHub Copilot Provider Unicode Handling", () => { it.skipIf(!githubCopilotToken)( - "gpt-4o - should handle emoji in tool results", + "claude-haiku-4.5 - should handle emoji in tool results", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "gpt-4o"); + const llm = getModel("github-copilot", "claude-haiku-4.5"); await testEmojiInToolResults(llm, { apiKey: githubCopilotToken }); }, ); it.skipIf(!githubCopilotToken)( - "gpt-4o - should handle real-world LinkedIn comment data with emoji", + "claude-haiku-4.5 - should handle real-world LinkedIn comment data with emoji", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "gpt-4o"); + const llm = getModel("github-copilot", "claude-haiku-4.5"); await testRealWorldLinkedInData(llm, { apiKey: githubCopilotToken }); }, ); it.skipIf(!githubCopilotToken)( - "gpt-4o - should handle unpaired high surrogate (0xD83D) in tool results", + "claude-haiku-4.5 - should handle unpaired high surrogate (0xD83D) in tool results", { retry: 3, timeout: 30000 }, async () => { - const llm = getModel("github-copilot", "gpt-4o"); + const llm = getModel("github-copilot", "claude-haiku-4.5"); await testUnpairedHighSurrogate(llm, { apiKey: githubCopilotToken }); }, ); From 4c6c4482b4b1ff59a4114d52eb8331796b6ffaf0 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 2 Jun 2026 12:12:10 +0200 Subject: [PATCH 132/352] Fix SDK embed ENOENT when package.json missing (closes #5226) - Wrap package.json read in try/catch to handle missing file gracefully - Use defaults (APP_NAME=pi, CONFIG_DIR_NAME=.pi, etc.) when package.json not found - Preserves rebranding via piConfig when package.json exists - Only ignores ENOENT; other errors still throw --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/config.ts | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1fcde0b7..85b26f8f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed +- Fixed SDK embedding in bundled Node apps failing with `ENOENT` when `package.json` is not present next to the bundle entrypoint. The package metadata reader now gracefully handles missing `package.json` by using defaults, enabling `createAgentSession()` without requiring package-adjacent files at runtime ([#5226](https://github.com/earendil-works/pi/issues/5226)). - Fixed HTTP timeout setting not being respected for non-Codex providers (e.g., llama.cpp via OpenAI-compatible API). The `httpIdleTimeoutMs` setting (set via `/settings` HTTP timeout) now applies as the default SDK request timeout for all providers that support it, not just OpenAI Codex Responses. Disabling the timeout (HTTP timeout = false) now correctly disables SDK timeouts for all supported providers by sending a maximum int32 value (effectively infinite) instead of 0, since SDKs treat timeout=0 as an immediate timeout ([#5294](https://github.com/earendil-works/pi/issues/5294)). - Fixed opening and listing very large JSONL session files by reading session entries line-by-line instead of materializing the full file as one string ([#5231](https://github.com/earendil-works/pi/issues/5231)). diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 01ec8f8e..65fb220e 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -452,7 +452,13 @@ interface PackageJson { }; } -const pkg = JSON.parse(readFileSync(getPackageJsonPath(), "utf-8")) as PackageJson; +let pkg: PackageJson = {}; +try { + pkg = JSON.parse(readFileSync(getPackageJsonPath(), "utf-8")) as PackageJson; +} catch (e: unknown) { + const err = e as NodeJS.ErrnoException; + if (err.code !== "ENOENT") throw e; +} const piConfigName: string | undefined = pkg.piConfig?.name; export const PACKAGE_NAME: string = pkg.name || "@earendil-works/pi-coding-agent"; From 13898f048fde206d70d54b6307f71f3ed62aa2b6 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 2 Jun 2026 13:54:47 +0200 Subject: [PATCH 133/352] Fix TUI tab width accounting closes #5218 --- packages/tui/CHANGELOG.md | 4 ++++ packages/tui/src/utils.ts | 4 ++++ packages/tui/test/tab-width.test.ts | 23 +++++++++++++++++++++++ 3 files changed, 31 insertions(+) create mode 100644 packages/tui/test/tab-width.test.ts diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index ae628067..c8512cd7 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed tab width accounting in column slicing and overlay compositing so tab-containing output cannot exceed the terminal width ([#5218](https://github.com/earendil-works/pi/issues/5218)). + ## [0.78.0] - 2026-05-29 ### Fixed diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index d613a76d..02c40c79 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -162,6 +162,10 @@ function finalizeTruncatedResult( * check to avoid running the RGI_Emoji regex unnecessarily. */ function graphemeWidth(segment: string): number { + if (segment === "\t") { + return 3; + } + // Zero-width clusters if (zeroWidthRegex.test(segment)) { return 0; diff --git a/packages/tui/test/tab-width.test.ts b/packages/tui/test/tab-width.test.ts new file mode 100644 index 00000000..2797f89f --- /dev/null +++ b/packages/tui/test/tab-width.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { extractSegments, sliceWithWidth, visibleWidth } from "../src/utils.ts"; + +describe("tab width accounting", () => { + it("keeps slice helper widths consistent with visible width", () => { + const text = "out 192M\t.pi/skill-tests/results-ha"; + const slice = sliceWithWidth(text, 0, 10, true); + + assert.strictEqual(slice.text, "out 192M"); + assert.strictEqual(slice.width, 8); + assert.strictEqual(visibleWidth(slice.text), slice.width); + }); + + it("keeps overlay segment widths consistent with visible width", () => { + const text = "out 192M\t.pi/skill-tests/results-ha"; + const segments = extractSegments(text, 10, 13, 10, true); + + assert.strictEqual(segments.before, "out 192M\t"); + assert.strictEqual(segments.beforeWidth, 11); + assert.strictEqual(visibleWidth(segments.before), segments.beforeWidth); + }); +}); From 601480122970ebc3a9b5ce8059f6b20dd8390e78 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 2 Jun 2026 15:09:36 +0200 Subject: [PATCH 134/352] Add NVIDIA NIM provider --- packages/ai/CHANGELOG.md | 4 + packages/ai/README.md | 6 +- packages/ai/scripts/generate-models.ts | 97 +++++ packages/ai/src/env-api-keys.ts | 1 + packages/ai/src/models.generated.ts | 363 ++++++++++++++++++ .../ai/src/providers/openai-completions.ts | 10 +- packages/ai/src/types.ts | 1 + packages/ai/test/stream.test.ts | 24 ++ packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/README.md | 5 +- packages/coding-agent/docs/providers.md | 2 + packages/coding-agent/docs/usage.md | 2 +- packages/coding-agent/src/cli/args.ts | 1 + .../coding-agent/src/core/agent-session.ts | 1 + .../core/compaction/branch-summarization.ts | 31 +- .../coding-agent/src/core/model-resolver.ts | 1 + .../src/core/provider-attribution.ts | 97 +++++ .../src/core/provider-display-names.ts | 1 + packages/coding-agent/src/core/sdk.ts | 52 +-- .../test/sdk-openrouter-attribution.test.ts | 66 +++- 20 files changed, 700 insertions(+), 66 deletions(-) create mode 100644 packages/coding-agent/src/core/provider-attribution.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index af63cdc4..4a616f89 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added NVIDIA NIM as a built-in OpenAI-compatible provider, exposing public NIM models that support tool use. + ### Fixed - Fixed Amazon Bedrock requests to replace blank required user/tool-result text with a placeholder and skip blank replay text blocks ([#4975](https://github.com/earendil-works/pi/issues/4975)). diff --git a/packages/ai/README.md b/packages/ai/README.md index e6624d3b..76a98769 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -54,6 +54,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an - **Azure OpenAI (Responses)** - **OpenAI Codex** (ChatGPT Plus/Pro subscription, requires OAuth, see below) - **DeepSeek** +- **NVIDIA NIM** - **Anthropic** - **Google** - **Vertex AI** (Gemini via Vertex AI) @@ -801,7 +802,7 @@ A **provider** offers models through a specific API. For example: - **Google** models use the `google-generative-ai` API - **OpenAI** models use the `openai-responses` API - **Mistral** models use the `mistral-conversations` API -- **xAI, Cerebras, Groq, Together AI, etc.** models use the `openai-completions` API (OpenAI-compatible) +- **xAI, Cerebras, Groq, NVIDIA NIM, Together AI, etc.** models use the `openai-completions` API (OpenAI-compatible) ### Querying Providers and Models @@ -923,7 +924,7 @@ const ollamaReasoningModel: Model<'openai-completions'> = { ### OpenAI Compatibility Settings -The `openai-completions` API is implemented by many providers with minor differences. By default, the library auto-detects compatibility settings based on `baseUrl` for a small set of known OpenAI-compatible providers (Cerebras, xAI, Chutes, DeepSeek, Together AI, zAi, OpenCode, Cloudflare Workers AI, etc.). For custom proxies or unknown endpoints, you can override these settings via the `compat` field. For `openai-responses` models, the compat field only supports Responses-specific flags. +The `openai-completions` API is implemented by many providers with minor differences. By default, the library auto-detects compatibility settings based on `baseUrl` for a small set of known OpenAI-compatible providers (Cerebras, xAI, Chutes, DeepSeek, NVIDIA NIM, Together AI, zAi, OpenCode, Cloudflare Workers AI, etc.). For custom proxies or unknown endpoints, you can override these settings via the `compat` field. For `openai-responses` models, the compat field only supports Responses-specific flags. ```typescript interface OpenAICompletionsCompat { @@ -1102,6 +1103,7 @@ In Node.js environments, you can set environment variables to avoid passing API | Azure OpenAI | `AZURE_OPENAI_API_KEY` + `AZURE_OPENAI_BASE_URL` (e.g. `https://{resource}.openai.azure.com`) or `AZURE_OPENAI_RESOURCE_NAME`. Supports `*.openai.azure.com` and `*.cognitiveservices.azure.com`; root endpoints auto-normalize to `/openai/v1`. Optional: `AZURE_OPENAI_API_VERSION` (default `v1`), `AZURE_OPENAI_DEPLOYMENT_NAME_MAP`. | | Anthropic | `ANTHROPIC_API_KEY` or `ANTHROPIC_OAUTH_TOKEN` | | DeepSeek | `DEEPSEEK_API_KEY` | +| NVIDIA NIM | `NVIDIA_API_KEY` | | Google | `GEMINI_API_KEY` | | Vertex AI | `GOOGLE_CLOUD_API_KEY` or `GOOGLE_CLOUD_PROJECT` (or `GCLOUD_PROJECT`) + `GOOGLE_CLOUD_LOCATION` + ADC | | Mistral | `MISTRAL_API_KEY` | diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 50df88f2..8fb62d14 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -32,12 +32,17 @@ interface ModelsDevModel { }; modalities?: { input?: string[]; + output?: string[]; }; provider?: { npm?: string; }; } +interface NvidiaNimModelListItem { + id: string; +} + interface AiGatewayModel { id: string; name?: string; @@ -117,6 +122,39 @@ const TOGETHER_TOGGLE_REASONING_LEVEL_MAP = { const AI_GATEWAY_MODELS_URL = "https://ai-gateway.vercel.sh/v1"; const AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh"; +const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1"; +const NVIDIA_HEADERS = { + "NVCF-POLL-SECONDS": "3600", +} as const; +const NVIDIA_OPENAI_COMPAT: OpenAICompletionsCompat = { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + maxTokensField: "max_tokens", + supportsStrictMode: false, + supportsLongCacheRetention: false, +}; +const NVIDIA_NIM_UNSUPPORTED_MODELS = new Set([ + "abacusai/dracarys-llama-3.1-70b-instruct", + "bytedance/seed-oss-36b-instruct", + "deepseek-ai/deepseek-v4-flash", + "deepseek-ai/deepseek-v4-pro", + "google/gemma-2-2b-it", + "google/gemma-3n-e2b-it", + "google/gemma-3n-e4b-it", + "google/gemma-4-31b-it", + "meta/llama-3.2-1b-instruct", + "meta/llama-4-maverick-17b-128e-instruct", + "microsoft/phi-4-mini-instruct", + "minimaxai/minimax-m2.7", + "mistralai/mistral-nemotron", + "nvidia/nemotron-mini-4b-instruct", + "qwen/qwen3-next-80b-a3b-instruct", + "qwen/qwen3.5-122b-a10b", + "qwen/qwen3.5-397b-a17b", + "sarvamai/sarvam-m", + "upstage/solar-10.7b-instruct", +]); const ZAI_TOOL_STREAM_UNSUPPORTED_MODELS = new Set(["glm-4.5", "glm-4.5-air", "glm-4.5-flash", "glm-4.5v"]); const EAGER_TOOL_INPUT_STREAMING_UNSUPPORTED_ANTHROPIC_MODELS = new Set([ "github-copilot:claude-haiku-4.5", @@ -312,6 +350,30 @@ function getBedrockBaseUrl(modelId: string): string { : "https://bedrock-runtime.us-east-1.amazonaws.com"; } +function normalizeNvidiaModelId(modelId: string): string { + return modelId.toLowerCase().replaceAll("_", "."); +} + +async function fetchNvidiaNimModelIds(): Promise> { + try { + console.log("Fetching models from NVIDIA NIM API..."); + const response = await fetch(`${NVIDIA_BASE_URL}/models`); + const data = (await response.json()) as { data?: NvidiaNimModelListItem[] }; + const modelIds = new Map(); + + for (const model of data.data ?? []) { + modelIds.set(model.id, model.id); + modelIds.set(normalizeNvidiaModelId(model.id), model.id); + } + + console.log(`Fetched ${data.data?.length ?? 0} model IDs from NVIDIA NIM`); + return modelIds; + } catch (error) { + console.error("Failed to fetch NVIDIA NIM models:", error); + return new Map(); + } +} + async function fetchOpenRouterModels(): Promise[]> { try { console.log("Fetching models from OpenRouter API..."); @@ -435,6 +497,7 @@ async function loadModelsDevData(): Promise[]> { const data = await response.json(); const models: Model[] = []; + const nvidiaNimModelIds = data.nvidia?.models ? await fetchNvidiaNimModelIds() : new Map(); // Process Amazon Bedrock models if (data["amazon-bedrock"]?.models) { @@ -836,6 +899,40 @@ async function loadModelsDevData(): Promise[]> { } } + // Process NVIDIA NIM models + if (data.nvidia?.models) { + for (const [modelId, model] of Object.entries(data.nvidia.models)) { + const m = model as ModelsDevModel; + if (m.tool_call !== true) continue; + if (!m.modalities?.input?.includes("text")) continue; + if (!m.modalities?.output?.includes("text")) continue; + + const liveModelId = nvidiaNimModelIds.get(modelId) ?? nvidiaNimModelIds.get(normalizeNvidiaModelId(modelId)); + if (!liveModelId) continue; + if (NVIDIA_NIM_UNSUPPORTED_MODELS.has(liveModelId)) continue; + + models.push({ + id: liveModelId, + name: m.name || liveModelId, + api: "openai-completions", + provider: "nvidia", + baseUrl: NVIDIA_BASE_URL, + headers: { ...NVIDIA_HEADERS }, + reasoning: m.reasoning === true, + input: m.modalities.input.includes("image") ? ["text", "image"] : ["text"], + cost: { + input: m.cost?.input || 0, + output: m.cost?.output || 0, + cacheRead: m.cost?.cache_read || 0, + cacheWrite: m.cost?.cache_write || 0, + }, + compat: NVIDIA_OPENAI_COMPAT, + contextWindow: m.limit?.context || 4096, + maxTokens: m.limit?.output || 4096, + }); + } + } + // Process Together AI models const togetherProvider = data.together ?? data.togetherai ?? data["together-ai"]; if (togetherProvider?.models) { diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index a00e8470..6321a03a 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -101,6 +101,7 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined { const envMap: Record = { openai: "OPENAI_API_KEY", "azure-openai-responses": "AZURE_OPENAI_API_KEY", + nvidia: "NVIDIA_API_KEY", deepseek: "DEEPSEEK_API_KEY", google: "GEMINI_API_KEY", "google-vertex": "GOOGLE_CLOUD_API_KEY", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 8e83c580..cd022b79 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -6335,6 +6335,369 @@ export const MODELS = { maxTokens: 262144, } satisfies Model<"openai-completions">, }, + "nvidia": { + "meta/llama-3.1-70b-instruct": { + id: "meta/llama-3.1-70b-instruct", + name: "Llama 3.1 70b Instruct", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "meta/llama-3.1-8b-instruct": { + id: "meta/llama-3.1-8b-instruct", + name: "Llama 3.1 8B Instruct", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 16000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "meta/llama-3.2-11b-vision-instruct": { + id: "meta/llama-3.2-11b-vision-instruct", + name: "Llama 3.2 11b Vision Instruct", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "meta/llama-3.2-90b-vision-instruct": { + id: "meta/llama-3.2-90b-vision-instruct", + name: "Llama-3.2-90B-Vision-Instruct", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "meta/llama-3.3-70b-instruct": { + id: "meta/llama-3.3-70b-instruct", + name: "Llama 3.3 70b Instruct", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/mistral-large-3-675b-instruct-2512": { + id: "mistralai/mistral-large-3-675b-instruct-2512", + name: "Mistral Large 3 675B Instruct 2512", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "mistralai/mistral-small-4-119b-2603": { + id: "mistralai/mistral-small-4-119b-2603", + name: "mistral-small-4-119b-2603", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "moonshotai/kimi-k2.6": { + id: "moonshotai/kimi-k2.6", + name: "Kimi K2.6", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "nvidia/llama-3.3-nemotron-super-49b-v1": { + id: "nvidia/llama-3.3-nemotron-super-49b-v1", + name: "Llama 3.3 Nemotron Super 49B v1", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "nvidia/llama-3.3-nemotron-super-49b-v1.5": { + id: "nvidia/llama-3.3-nemotron-super-49b-v1.5", + name: "Llama 3.3 Nemotron Super 49B v1.5", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "nvidia/nemotron-3-nano-30b-a3b": { + id: "nvidia/nemotron-3-nano-30b-a3b", + name: "nemotron-3-nano-30b-a3b", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning": { + id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", + name: "Nemotron 3 Nano Omni", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "nvidia/nemotron-3-super-120b-a12b": { + id: "nvidia/nemotron-3-super-120b-a12b", + name: "Nemotron 3 Super", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0.2, + output: 0.8, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "nvidia/nvidia-nemotron-nano-9b-v2": { + id: "nvidia/nvidia-nemotron-nano-9b-v2", + name: "nvidia-nemotron-nano-9b-v2", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "openai/gpt-oss-20b": { + id: "openai/gpt-oss-20b", + name: "GPT OSS 20B", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen/qwen3-coder-480b-a35b-instruct": { + id: "qwen/qwen3-coder-480b-a35b-instruct", + name: "Qwen3 Coder 480B A35B Instruct", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 66536, + } satisfies Model<"openai-completions">, + "stepfun-ai/step-3.5-flash": { + id: "stepfun-ai/step-3.5-flash", + name: "Step 3.5 Flash", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "stepfun-ai/step-3.7-flash": { + id: "stepfun-ai/step-3.7-flash", + name: "Step 3.7 Flash", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "z-ai/glm-5.1": { + id: "z-ai/glm-5.1", + name: "GLM-5.1", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + }, "openai": { "gpt-4": { id: "gpt-4", diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index ae06326e..c58cbf46 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -1078,8 +1078,10 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet const isOpenRouter = provider === "openrouter" || baseUrl.includes("openrouter.ai"); const isCloudflareWorkersAI = provider === "cloudflare-workers-ai" || baseUrl.includes("api.cloudflare.com"); const isCloudflareAiGateway = provider === "cloudflare-ai-gateway" || baseUrl.includes("gateway.ai.cloudflare.com"); + const isNvidia = provider === "nvidia" || baseUrl.includes("integrate.api.nvidia.com"); const isNonStandard = + isNvidia || provider === "cerebras" || baseUrl.includes("cerebras.ai") || provider === "xai" || @@ -1094,7 +1096,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet isCloudflareWorkersAI || isCloudflareAiGateway; - const useMaxTokens = baseUrl.includes("chutes.ai") || isMoonshot || isCloudflareAiGateway || isTogether; + const useMaxTokens = baseUrl.includes("chutes.ai") || isMoonshot || isCloudflareAiGateway || isTogether || isNvidia; const isGrok = provider === "xai" || baseUrl.includes("api.x.ai"); const isDeepSeek = provider === "deepseek" || baseUrl.includes("deepseek.com"); @@ -1103,7 +1105,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet return { supportsStore: !isNonStandard, supportsDeveloperRole: !isNonStandard && !isOpenRouter, - supportsReasoningEffort: !isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAiGateway, + supportsReasoningEffort: !isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia, supportsUsageInStreaming: true, maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens", requiresToolResultName: false, @@ -1122,10 +1124,10 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet openRouterRouting: {}, vercelGatewayRouting: {}, zaiToolStream: false, - supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway, + supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia, cacheControlFormat, sendSessionAffinityHeaders: false, - supportsLongCacheRetention: !(isTogether || isCloudflareWorkersAI || isCloudflareAiGateway), + supportsLongCacheRetention: !(isTogether || isCloudflareWorkersAI || isCloudflareAiGateway || isNvidia), }; } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index d0b053ea..7a448772 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -28,6 +28,7 @@ export type KnownProvider = | "openai" | "azure-openai-responses" | "openai-codex" + | "nvidia" | "deepseek" | "github-copilot" | "xai" diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 298104dd..1693f406 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -787,6 +787,30 @@ describe("Generate E2E Tests", () => { }); }); + describe.skipIf(!process.env.NVIDIA_API_KEY)("NVIDIA NIM Provider (Nemotron 3 Super via OpenAI Completions)", () => { + const llm = getModel("nvidia", "nvidia/nemotron-3-super-120b-a12b"); + + it("should complete basic text generation", { retry: 3 }, async () => { + await basicTextGeneration(llm); + }); + + it("should handle tool calling", { retry: 3 }, async () => { + await handleToolCall(llm); + }); + + it("should handle streaming", { retry: 3 }, async () => { + await handleStreaming(llm); + }); + + it("should handle thinking mode", { retry: 3 }, async () => { + await handleThinking(llm, { reasoningEffort: "high" }); + }); + + it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { + await multiTurn(llm, { reasoningEffort: "high" }); + }); + }); + describe.skipIf(!process.env.OPENROUTER_API_KEY)("OpenRouter Provider (glm-4.5v via OpenAI Completions)", () => { const llm = getModel("openrouter", "z-ai/glm-4.5v"); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 85b26f8f..28401d53 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added NVIDIA NIM provider selection, setup documentation, and direct NIM request attribution headers. - Added `ctx.mode` to extension contexts so extensions can distinguish TUI, RPC, JSON, and print mode. - Added `ctx.getSystemPromptOptions()` for extension commands to inspect the current base system prompt inputs. diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 0cf35255..585a93a0 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -113,6 +113,7 @@ For each built-in provider, pi maintains a list of tool-capable models, updated - OpenAI - Azure OpenAI - DeepSeek +- NVIDIA NIM - Google Gemini - Google Vertex - Amazon Bedrock @@ -290,7 +291,7 @@ See [docs/settings.md](docs/settings.md) for all options. Pi has two separate startup features: - **Update check:** fetches `https://pi.dev/api/latest-version` to check whether a newer Pi version exists. Disable it with `PI_SKIP_VERSION_CHECK=1`. Disabling update checks only turns off this check. -- **Install/update telemetry:** after first install or a changelog-detected update, sends an anonymous version ping to `https://pi.dev/api/report-install`. Opt out by setting `enableInstallTelemetry` to `false` in `settings.json`, or by setting `PI_TELEMETRY=0`. This does not disable update checks; Pi may still contact `pi.dev` for the latest version unless update checks are disabled or offline mode is enabled. +- **Install/update telemetry:** after first install or a changelog-detected update, sends an anonymous version ping to `https://pi.dev/api/report-install`. This setting also controls optional provider attribution headers for OpenRouter, Cloudflare, and direct NVIDIA NIM requests. Opt out by setting `enableInstallTelemetry` to `false` in `settings.json`, or by setting `PI_TELEMETRY=0`. This does not disable update checks; Pi may still contact `pi.dev` for the latest version unless update checks are disabled or offline mode is enabled. Use `--offline` or `PI_OFFLINE=1` to disable all startup network operations described here, including update checks, package update checks, and install/update telemetry. @@ -641,7 +642,7 @@ pi --thinking high "Solve this complex problem" | `PI_PACKAGE_DIR` | Override package directory (useful for Nix/Guix where store paths tokenize poorly) | | `PI_OFFLINE` | Disable startup network operations, including update checks, package update checks, and install/update telemetry | | `PI_SKIP_VERSION_CHECK` | Skip the Pi version update check at startup. This prevents the `pi.dev` latest-version request | -| `PI_TELEMETRY` | Override install/update telemetry. Use `1`/`true`/`yes` to enable or `0`/`false`/`no` to disable. This does not disable update checks | +| `PI_TELEMETRY` | Override install/update telemetry and provider attribution headers. Use `1`/`true`/`yes` to enable or `0`/`false`/`no` to disable. This does not disable update checks | | `PI_CACHE_RETENTION` | Set to `long` for extended prompt cache (Anthropic: 1h, OpenAI: 24h) | | `VISUAL`, `EDITOR` | External editor for Ctrl+G | diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index 07b6ba08..e7a77c5a 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -52,6 +52,7 @@ pi | Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` | | OpenAI | `OPENAI_API_KEY` | `openai` | | DeepSeek | `DEEPSEEK_API_KEY` | `deepseek` | +| NVIDIA NIM | `NVIDIA_API_KEY` | `nvidia` | | Google Gemini | `GEMINI_API_KEY` | `google` | | Mistral | `MISTRAL_API_KEY` | `mistral` | | Groq | `GROQ_API_KEY` | `groq` | @@ -86,6 +87,7 @@ Store credentials in `~/.pi/agent/auth.json`: "anthropic": { "type": "api_key", "key": "sk-ant-..." }, "openai": { "type": "api_key", "key": "sk-..." }, "deepseek": { "type": "api_key", "key": "sk-..." }, + "nvidia": { "type": "api_key", "key": "nvapi-..." }, "google": { "type": "api_key", "key": "..." }, "opencode": { "type": "api_key", "key": "..." }, "opencode-go": { "type": "api_key", "key": "..." }, diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 47d08769..2f2a6449 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -275,7 +275,7 @@ pi --exclude-tools ask_question | `PI_PACKAGE_DIR` | Override package directory, useful for Nix/Guix store paths | | `PI_OFFLINE` | Disable startup network operations, including update checks, package update checks, and install/update telemetry | | `PI_SKIP_VERSION_CHECK` | Skip the Pi version update check at startup. This prevents the `pi.dev` latest-version request | -| `PI_TELEMETRY` | Override install/update telemetry: `1`/`true`/`yes` or `0`/`false`/`no`. This does not disable update checks | +| `PI_TELEMETRY` | Override install/update telemetry and provider attribution headers: `1`/`true`/`yes` or `0`/`false`/`no`. This does not disable update checks | | `PI_CACHE_RETENTION` | Set to `long` for extended prompt cache where supported | | `VISUAL`, `EDITOR` | External editor for Ctrl+G | diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 2decae24..8156bd03 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -335,6 +335,7 @@ ${chalk.bold("Environment Variables:")} AZURE_OPENAI_API_VERSION - Azure OpenAI API version (default: v1) AZURE_OPENAI_DEPLOYMENT_NAME_MAP - Azure OpenAI model=deployment map (comma-separated) DEEPSEEK_API_KEY - DeepSeek API key + NVIDIA_API_KEY - NVIDIA NIM API key GEMINI_API_KEY - Google Gemini API key GROQ_API_KEY - Groq API key CEREBRAS_API_KEY - Cerebras API key diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 556ca20e..de76a619 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -2787,6 +2787,7 @@ export class AgentSession { customInstructions, replaceInstructions, reserveTokens: branchSummarySettings.reserveTokens, + streamFn: this.agent.streamFn, }); if (result.aborted) { return { cancelled: true, aborted: true }; diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index 0039bc79..f6ce3d67 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -5,8 +5,8 @@ * a summary of the branch being left so context isn't lost. */ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { Model } from "@earendil-works/pi-ai"; +import type { AgentMessage, StreamFn } from "@earendil-works/pi-agent-core"; +import type { Model, SimpleStreamOptions } from "@earendil-works/pi-ai"; import { completeSimple } from "@earendil-works/pi-ai"; import { convertToLlm, @@ -77,6 +77,8 @@ export interface GenerateBranchSummaryOptions { replaceInstructions?: boolean; /** Tokens reserved for prompt + LLM response (default 16384) */ reserveTokens?: number; + /** Optional session stream function. Used to preserve SDK request behavior without mutating agent state. */ + streamFn?: StreamFn; } // ============================================================================ @@ -284,7 +286,16 @@ export async function generateBranchSummary( entries: SessionEntry[], options: GenerateBranchSummaryOptions, ): Promise { - const { model, apiKey, headers, signal, customInstructions, replaceInstructions, reserveTokens = 16384 } = options; + const { + model, + apiKey, + headers, + signal, + customInstructions, + replaceInstructions, + reserveTokens = 16384, + streamFn, + } = options; // Token budget = context window minus reserved space for prompt + response const contextWindow = model.contextWindow || 128000; @@ -320,12 +331,14 @@ export async function generateBranchSummary( }, ]; - // Call LLM for summarization - const response = await completeSimple( - model, - { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, - { apiKey, headers, signal, maxTokens: 2048 }, - ); + // Call LLM for summarization. Prefer the session stream function so SDK + // request behavior (timeouts, retries, attribution headers) stays consistent + // without running through agent state/events. + const context = { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }; + const requestOptions: SimpleStreamOptions = { apiKey, headers, signal, maxTokens: 2048 }; + const response = streamFn + ? await (await streamFn(model, context, requestOptions)).result() + : await completeSimple(model, context, requestOptions); // Check if aborted or errored if (response.stopReason === "aborted") { diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 87f7354e..e3af076d 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -17,6 +17,7 @@ export const defaultModelPerProvider: Record = { openai: "gpt-5.4", "azure-openai-responses": "gpt-5.4", "openai-codex": "gpt-5.5", + nvidia: "nvidia/nemotron-3-super-120b-a12b", deepseek: "deepseek-v4-pro", google: "gemini-3.1-pro-preview", "google-vertex": "gemini-3.1-pro-preview", diff --git a/packages/coding-agent/src/core/provider-attribution.ts b/packages/coding-agent/src/core/provider-attribution.ts new file mode 100644 index 00000000..91265c92 --- /dev/null +++ b/packages/coding-agent/src/core/provider-attribution.ts @@ -0,0 +1,97 @@ +import type { Api, Model } from "@earendil-works/pi-ai"; +import type { SettingsManager } from "./settings-manager.ts"; +import { isInstallTelemetryEnabled } from "./telemetry.ts"; + +const OPENROUTER_HOST = "openrouter.ai"; +const NVIDIA_NIM_HOST = "integrate.api.nvidia.com"; +const CLOUDFLARE_API_HOST = "api.cloudflare.com"; +const CLOUDFLARE_AI_GATEWAY_HOST = "gateway.ai.cloudflare.com"; +const OPENCODE_HOST = "opencode.ai"; + +function matchesHost(baseUrl: string, expectedHost: string): boolean { + try { + return new URL(baseUrl).hostname === expectedHost; + } catch { + return false; + } +} + +function isOpenRouterModel(model: Model): boolean { + return model.provider === "openrouter" || model.baseUrl.includes(OPENROUTER_HOST); +} + +function isNvidiaNimModel(model: Model): boolean { + return model.provider === "nvidia" || matchesHost(model.baseUrl, NVIDIA_NIM_HOST); +} + +function isCloudflareModel(model: Model): boolean { + return ( + model.provider === "cloudflare-workers-ai" || + model.provider === "cloudflare-ai-gateway" || + matchesHost(model.baseUrl, CLOUDFLARE_API_HOST) || + matchesHost(model.baseUrl, CLOUDFLARE_AI_GATEWAY_HOST) + ); +} + +function getDefaultAttributionHeaders( + model: Model, + settingsManager: SettingsManager, +): Record | undefined { + if (!isInstallTelemetryEnabled(settingsManager)) { + return undefined; + } + + if (isOpenRouterModel(model)) { + return { + "HTTP-Referer": "https://pi.dev", + "X-OpenRouter-Title": "pi", + "X-OpenRouter-Categories": "cli-agent", + }; + } + + if (isNvidiaNimModel(model)) { + return { + "X-BILLING-INVOKE-ORIGIN": "Pi", + }; + } + + if (isCloudflareModel(model)) { + return { + "User-Agent": "pi-coding-agent", + }; + } + + return undefined; +} + +function getSessionHeaders(model: Model, sessionId: string | undefined): Record | undefined { + if (!sessionId) return undefined; + if ( + model.provider !== "opencode" && + model.provider !== "opencode-go" && + !matchesHost(model.baseUrl, OPENCODE_HOST) + ) { + return undefined; + } + return { "x-opencode-session": sessionId, "x-opencode-client": "pi" }; +} + +export function mergeProviderAttributionHeaders( + model: Model, + settingsManager: SettingsManager, + sessionId: string | undefined, + ...headerSources: Array | undefined> +): Record | undefined { + const merged = { + ...getSessionHeaders(model, sessionId), + ...getDefaultAttributionHeaders(model, settingsManager), + }; + + for (const headers of headerSources) { + if (headers) { + Object.assign(merged, headers); + } + } + + return Object.keys(merged).length > 0 ? merged : undefined; +} diff --git a/packages/coding-agent/src/core/provider-display-names.ts b/packages/coding-agent/src/core/provider-display-names.ts index 24f7b3ce..789f3153 100644 --- a/packages/coding-agent/src/core/provider-display-names.ts +++ b/packages/coding-agent/src/core/provider-display-names.ts @@ -17,6 +17,7 @@ export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record = { "minimax-cn": "MiniMax (China)", moonshotai: "Moonshot AI", "moonshotai-cn": "Moonshot AI (China)", + nvidia: "NVIDIA NIM", opencode: "OpenCode Zen", "opencode-go": "OpenCode Go", openai: "OpenAI", diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 7a1c922b..e6db747b 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -11,11 +11,11 @@ import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefi import { convertToLlm } from "./messages.ts"; import { ModelRegistry } from "./model-registry.ts"; import { findInitialModel } from "./model-resolver.ts"; +import { mergeProviderAttributionHeaders } from "./provider-attribution.ts"; import type { ResourceLoader } from "./resource-loader.ts"; import { DefaultResourceLoader } from "./resource-loader.ts"; import { getDefaultSessionDir, SessionManager } from "./session-manager.ts"; import { SettingsManager } from "./settings-manager.ts"; -import { isInstallTelemetryEnabled } from "./telemetry.ts"; import { time } from "./timings.ts"; import { createBashTool, @@ -128,44 +128,6 @@ function getDefaultAgentDir(): string { return getAgentDir(); } -function getAttributionHeaders( - model: Model, - settingsManager: SettingsManager, - sessionId?: string, -): Record | undefined { - if ( - sessionId && - (model.provider === "opencode" || model.provider === "opencode-go" || model.baseUrl.includes("opencode.ai")) - ) { - return { "x-opencode-session": sessionId, "x-opencode-client": "pi" }; - } - - if (!isInstallTelemetryEnabled(settingsManager)) { - return undefined; - } - - if (model.provider === "openrouter" || model.baseUrl.includes("openrouter.ai")) { - return { - "HTTP-Referer": "https://pi.dev", - "X-OpenRouter-Title": "pi", - "X-OpenRouter-Categories": "cli-agent", - }; - } - - if ( - model.provider === "cloudflare-workers-ai" || - model.provider === "cloudflare-ai-gateway" || - model.baseUrl.includes("api.cloudflare.com") || - model.baseUrl.includes("gateway.ai.cloudflare.com") - ) { - return { - "User-Agent": "pi-coding-agent", - }; - } - - return undefined; -} - /** * Create an AgentSession with the specified options. * @@ -349,7 +311,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const timeoutMs = options?.timeoutMs ?? providerRetrySettings.timeoutMs ?? effectiveTimeoutMs; const websocketConnectTimeoutMs = options?.websocketConnectTimeoutMs ?? settingsManager.getWebSocketConnectTimeoutMs(); - const attributionHeaders = getAttributionHeaders(model, settingsManager, options?.sessionId); return streamSimple(model, context, { ...options, apiKey: auth.apiKey, @@ -357,10 +318,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} websocketConnectTimeoutMs, maxRetries: options?.maxRetries ?? providerRetrySettings.maxRetries, maxRetryDelayMs: options?.maxRetryDelayMs ?? providerRetrySettings.maxRetryDelayMs, - headers: - attributionHeaders || auth.headers || options?.headers - ? { ...attributionHeaders, ...auth.headers, ...options?.headers } - : undefined, + headers: mergeProviderAttributionHeaders( + model, + settingsManager, + options?.sessionId, + auth.headers, + options?.headers, + ), }); }, onPayload: async (payload, _model) => { diff --git a/packages/coding-agent/test/sdk-openrouter-attribution.test.ts b/packages/coding-agent/test/sdk-openrouter-attribution.test.ts index c7032103..9f035189 100644 --- a/packages/coding-agent/test/sdk-openrouter-attribution.test.ts +++ b/packages/coding-agent/test/sdk-openrouter-attribution.test.ts @@ -15,14 +15,14 @@ import { createAgentSession } from "../src/core/sdk.ts"; import { SessionManager } from "../src/core/session-manager.ts"; import { SettingsManager } from "../src/core/settings-manager.ts"; -describe("createAgentSession OpenRouter attribution headers", () => { +describe("createAgentSession provider attribution headers", () => { let tempDir: string; let cwd: string; let agentDir: string; let originalTelemetryEnv: string | undefined; beforeEach(() => { - tempDir = join(tmpdir(), `pi-sdk-openrouter-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + tempDir = join(tmpdir(), `pi-sdk-attribution-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); cwd = join(tempDir, "project"); agentDir = join(tempDir, "agent"); mkdirSync(cwd, { recursive: true }); @@ -42,9 +42,9 @@ describe("createAgentSession OpenRouter attribution headers", () => { } }); - function createModel(provider: string, baseUrl: string): Model { + function createModel(provider: string, baseUrl: string, id = `${provider}-test-model`): Model { return { - id: `${provider}-test-model`, + id, name: `${provider} Test Model`, api: "openai-completions", provider, @@ -172,6 +172,14 @@ describe("createAgentSession OpenRouter attribution headers", () => { expect(headers?.["X-OpenRouter-Categories"]).toBe("cli-agent"); }); + it("preserves legacy OpenRouter base URL substring attribution matching", async () => { + const headers = await captureHeaders(createModel("custom-openrouter", "not-a-url-openrouter.ai")); + + expect(headers?.["HTTP-Referer"]).toBe("https://pi.dev"); + expect(headers?.["X-OpenRouter-Title"]).toBe("pi"); + expect(headers?.["X-OpenRouter-Categories"]).toBe("cli-agent"); + }); + it("lets provider and request headers override the defaults", async () => { const headers = await captureHeaders(createModel("openrouter", "https://openrouter.ai/api/v1"), { providerHeaders: { @@ -188,6 +196,56 @@ describe("createAgentSession OpenRouter attribution headers", () => { expect(headers?.["X-OpenRouter-Categories"]).toBe("provider-category"); }); + it("adds default attribution headers for direct NVIDIA NIM endpoints", async () => { + const headers = await captureHeaders(createModel("custom-nim", "https://integrate.api.nvidia.com/v1")); + + expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBe("Pi"); + }); + + it("adds default attribution headers for the NVIDIA provider", async () => { + const headers = await captureHeaders(createModel("nvidia", "https://example.test/v1")); + + expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBe("Pi"); + }); + + it("does not add NVIDIA NIM attribution headers when telemetry is disabled", async () => { + const headers = await captureHeaders(createModel("nvidia", "https://integrate.api.nvidia.com/v1"), { + telemetryEnabled: false, + }); + + expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBeUndefined(); + }); + + it("lets provider and request headers override NVIDIA NIM defaults", async () => { + const headers = await captureHeaders(createModel("nvidia", "https://integrate.api.nvidia.com/v1"), { + providerHeaders: { + "X-BILLING-INVOKE-ORIGIN": "Provider", + }, + requestHeaders: { + "X-BILLING-INVOKE-ORIGIN": "Request", + }, + }); + + expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBe("Request"); + }); + + it("does not add NVIDIA NIM attribution headers for NVIDIA models routed through OpenRouter", async () => { + const headers = await captureHeaders( + createModel("openrouter", "https://openrouter.ai/api/v1", "nvidia/nemotron-3-super-120b-a12b"), + ); + + expect(headers?.["HTTP-Referer"]).toBe("https://pi.dev"); + expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBeUndefined(); + }); + + it("does not add NVIDIA NIM attribution headers for NVIDIA models routed through Vercel AI Gateway", async () => { + const headers = await captureHeaders( + createModel("vercel-ai-gateway", "https://ai-gateway.vercel.sh/v1", "nvidia/nemotron-3-super-120b-a12b"), + ); + + expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBeUndefined(); + }); + it("adds OpenCode session headers", async () => { const headers = await captureHeaders(createModel("opencode", "https://opencode.ai/zen/v1"), { sessionId: "opencode-session", From 6e269edfd0497cc124482e66acde7e62de2509f1 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 2 Jun 2026 15:10:36 +0200 Subject: [PATCH 135/352] Restore NVIDIA Qwen 3.5 122B model --- packages/ai/scripts/generate-models.ts | 1 - packages/ai/src/models.generated.ts | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 8fb62d14..12c926d1 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -150,7 +150,6 @@ const NVIDIA_NIM_UNSUPPORTED_MODELS = new Set([ "mistralai/mistral-nemotron", "nvidia/nemotron-mini-4b-instruct", "qwen/qwen3-next-80b-a3b-instruct", - "qwen/qwen3.5-122b-a10b", "qwen/qwen3.5-397b-a17b", "sarvamai/sarvam-m", "upstage/solar-10.7b-instruct", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index cd022b79..1dacf48d 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -6640,6 +6640,25 @@ export const MODELS = { contextWindow: 262144, maxTokens: 66536, } satisfies Model<"openai-completions">, + "qwen/qwen3.5-122b-a10b": { + id: "qwen/qwen3.5-122b-a10b", + name: "Qwen3.5 122B-A10B", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "stepfun-ai/step-3.5-flash": { id: "stepfun-ai/step-3.5-flash", name: "Step 3.5 Flash", From 0d38e17b6894b689bb09e1cc3c944d82726ab396 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 2 Jun 2026 15:25:27 +0200 Subject: [PATCH 136/352] Fix empty self-rendered tool rows closes #5299 --- packages/coding-agent/CHANGELOG.md | 1 + .../interactive/components/tool-execution.ts | 25 +++++++++++++++ .../test/tool-execution-component.test.ts | 31 +++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 28401d53..a9f3c926 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,6 +13,7 @@ - Fixed SDK embedding in bundled Node apps failing with `ENOENT` when `package.json` is not present next to the bundle entrypoint. The package metadata reader now gracefully handles missing `package.json` by using defaults, enabling `createAgentSession()` without requiring package-adjacent files at runtime ([#5226](https://github.com/earendil-works/pi/issues/5226)). - Fixed HTTP timeout setting not being respected for non-Codex providers (e.g., llama.cpp via OpenAI-compatible API). The `httpIdleTimeoutMs` setting (set via `/settings` HTTP timeout) now applies as the default SDK request timeout for all providers that support it, not just OpenAI Codex Responses. Disabling the timeout (HTTP timeout = false) now correctly disables SDK timeouts for all supported providers by sending a maximum int32 value (effectively infinite) instead of 0, since SDKs treat timeout=0 as an immediate timeout ([#5294](https://github.com/earendil-works/pi/issues/5294)). - Fixed opening and listing very large JSONL session files by reading session entries line-by-line instead of materializing the full file as one string ([#5231](https://github.com/earendil-works/pi/issues/5231)). +- Fixed `renderShell: "self"` tool renderers that emit no component lines leaving a blank chat row ([#5299](https://github.com/earendil-works/pi/issues/5299)). ## [0.78.0] - 2026-05-29 diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index b941dfc8..ad84f441 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -222,6 +222,31 @@ export class ToolExecutionComponent extends Container { if (this.hideComponent) { return []; } + + if (this.hasRendererDefinition() && this.getRenderShell() === "self") { + const contentLines = this.selfRenderContainer.render(width); + if (contentLines.length === 0 && this.imageComponents.length === 0) { + return []; + } + + const lines: string[] = []; + if (contentLines.length > 0) { + lines.push(""); + lines.push(...contentLines); + } + for (let i = 0; i < this.imageComponents.length; i++) { + const spacer = this.imageSpacers[i]; + if (spacer) { + lines.push(...spacer.render(width)); + } + const imageComponent = this.imageComponents[i]; + if (imageComponent) { + lines.push(...imageComponent.render(width)); + } + } + return lines; + } + return super.render(width); } diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index 7a4a813d..bf890959 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -67,6 +67,37 @@ describe("ToolExecutionComponent parity", () => { expect(rendered).toContain("custom result"); }); + test("self-rendered empty tool rows take no layout space", () => { + const toolDefinition: ToolDefinition = { + ...createBaseToolDefinition(), + renderShell: "self", + renderCall: () => new Text("", 0, 0), + renderResult: () => new Text("", 0, 0), + }; + + const component = new ToolExecutionComponent( + "custom_tool", + "tool-empty-self-render", + {}, + {}, + toolDefinition, + createFakeTui(), + process.cwd(), + ); + expect(component.render(120)).toEqual([]); + + component.updateResult( + { + content: [], + details: {}, + isError: false, + }, + false, + ); + + expect(component.render(120)).toEqual([]); + }); + test("uses built-in rendering for built-in overrides without custom renderers", () => { const overrideDefinition: ToolDefinition = { ...createBaseToolDefinition("edit"), From 2125221bfce8e1a6fa02f8db5ff7820890f986a8 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 2 Jun 2026 15:36:52 +0200 Subject: [PATCH 137/352] Fix OpenRouter Kimi reasoning compat closes #5309 --- packages/ai/CHANGELOG.md | 1 + packages/ai/scripts/generate-models.ts | 6 ++- packages/ai/src/models.generated.ts | 4 +- .../ai/src/providers/openai-completions.ts | 4 +- .../openai-completions-tool-choice.test.ts | 37 ++++++++++++++++++- 5 files changed, 47 insertions(+), 5 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 4a616f89..e927bdca 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -10,6 +10,7 @@ - Fixed Amazon Bedrock requests to replace blank required user/tool-result text with a placeholder and skip blank replay text blocks ([#4975](https://github.com/earendil-works/pi/issues/4975)). - Fixed OpenAI GPT-5.5 generated metadata to omit unsupported minimal thinking ([#5243](https://github.com/earendil-works/pi/issues/5243)). +- Fixed OpenRouter Kimi K2.6 thinking replay and preserved developer-role instructions for OpenRouter OpenAI and Anthropic models ([#5309](https://github.com/earendil-works/pi/issues/5309)). - Fixed GitHub Copilot and OpenRouter test model references that became stale after model regeneration. ## [0.78.0] - 2026-05-29 diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 12c926d1..b0f49571 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -1343,7 +1343,11 @@ async function generateModels() { candidate.maxTokens = 4096; } if (candidate.provider === "openrouter" && candidate.id.startsWith("moonshotai/kimi-k2.6")) { - candidate.compat = { ...candidate.compat, supportsDeveloperRole: false }; + candidate.compat = { + ...candidate.compat, + supportsDeveloperRole: false, + requiresReasoningContentOnAssistantMessages: true, + }; } if (candidate.provider === "openrouter" && candidate.id === "z-ai/glm-5") { candidate.cost.input = 0.6; diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 1dacf48d..b3ff24a9 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -10281,7 +10281,7 @@ export const MODELS = { api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - compat: {"supportsDeveloperRole":false}, + compat: {"supportsDeveloperRole":false,"requiresReasoningContentOnAssistantMessages":true}, reasoning: true, input: ["text", "image"], cost: { @@ -10299,7 +10299,7 @@ export const MODELS = { api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - compat: {"supportsDeveloperRole":false}, + compat: {"supportsDeveloperRole":false,"requiresReasoningContentOnAssistantMessages":true}, reasoning: true, input: ["text", "image"], cost: { diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index c58cbf46..ae6d583e 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -1100,11 +1100,13 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet const isGrok = provider === "xai" || baseUrl.includes("api.x.ai"); const isDeepSeek = provider === "deepseek" || baseUrl.includes("deepseek.com"); + const isOpenRouterDeveloperRoleModel = + isOpenRouter && (model.id.startsWith("anthropic/") || model.id.startsWith("openai/")); const cacheControlFormat = provider === "openrouter" && model.id.startsWith("anthropic/") ? "anthropic" : undefined; return { supportsStore: !isNonStandard, - supportsDeveloperRole: !isNonStandard && !isOpenRouter, + supportsDeveloperRole: isOpenRouterDeveloperRoleModel || (!isNonStandard && !isOpenRouter), supportsReasoningEffort: !isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia, supportsUsageInStreaming: true, maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens", diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index 5f40c817..c06ac344 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -817,7 +817,7 @@ describe("openai-completions tool_choice", () => { expect(writeCall).not.toHaveProperty("partialArgs"); }); - it("uses system messages for OpenRouter reasoning model instructions", async () => { + it("uses system messages for non-OpenAI/Anthropic OpenRouter reasoning model instructions", async () => { const model = getModel("openrouter", "deepseek/deepseek-v4-pro")!; let payload: unknown; @@ -839,6 +839,33 @@ describe("openai-completions tool_choice", () => { expect(params.messages?.[0]?.role).toBe("system"); }); + it("keeps developer messages for OpenAI and Anthropic OpenRouter reasoning model instructions", async () => { + for (const model of [ + getModel("openrouter", "openai/gpt-5.2-codex"), + getModel("openrouter", "anthropic/claude-sonnet-4.5"), + ]) { + expect(model).toBeDefined(); + let payload: unknown; + + await streamSimple( + model!, + { + systemPrompt: "Follow instructions.", + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = payload as { messages?: Array<{ role?: string }> }; + expect(params.messages?.[0]?.role).toBe("developer"); + } + }); + it("keeps developer messages for OpenAI reasoning model instructions", async () => { const { compat: _compat, ...baseModel } = getModel("openai", "gpt-5.5")!; const model = { ...baseModel, api: "openai-completions" } as const; @@ -862,6 +889,14 @@ describe("openai-completions tool_choice", () => { expect(params.messages?.[0]?.role).toBe("developer"); }); + it("stores OpenRouter Kimi K2.6 reasoning replay compat in built-in metadata", () => { + for (const modelId of ["moonshotai/kimi-k2.6", "moonshotai/kimi-k2.6:free"] as const) { + const model = getModel("openrouter", modelId)!; + expect(model.compat?.supportsDeveloperRole).toBe(false); + expect(model.compat?.requiresReasoningContentOnAssistantMessages).toBe(true); + } + }); + it("stores Xiaomi MiMo reasoning replay compat in built-in metadata", () => { const providers = ["xiaomi", "xiaomi-token-plan-cn", "xiaomi-token-plan-ams", "xiaomi-token-plan-sgp"] as const; From 276cb1bb89977d83651bae4ea9b00f634c1670ad Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 2 Jun 2026 15:53:37 +0200 Subject: [PATCH 138/352] Fix stale SDK stream options test after httpIdleTimeoutMs universalization --- packages/coding-agent/test/sdk-stream-options.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/test/sdk-stream-options.test.ts b/packages/coding-agent/test/sdk-stream-options.test.ts index e30eff7a..1d565c7e 100644 --- a/packages/coding-agent/test/sdk-stream-options.test.ts +++ b/packages/coding-agent/test/sdk-stream-options.test.ts @@ -119,10 +119,10 @@ describe("createAgentSession stream options", () => { expect(options?.timeoutMs).toBe(1234); }); - it("does not default timeoutMs from httpIdleTimeoutMs for other providers", async () => { + it("defaults timeoutMs from httpIdleTimeoutMs for all providers", async () => { const options = await captureStreamOptions("openai-completions", { httpIdleTimeoutMs: 1234 }); - expect(options?.timeoutMs).toBeUndefined(); + expect(options?.timeoutMs).toBe(1234); }); it("lets request timeoutMs override httpIdleTimeoutMs for OpenAI Codex", async () => { From 7e72ca47c8bff19bcec96a98745ca4a3a7624051 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 2 Jun 2026 16:00:29 +0200 Subject: [PATCH 139/352] Add MiniMax-M3 to direct minimax providers closes #5313 --- packages/ai/CHANGELOG.md | 1 + packages/ai/scripts/generate-models.ts | 12 +------- packages/ai/src/models.generated.ts | 40 ++++++++++++++++++++++++-- 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index e927bdca..3814e2b2 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added MiniMax-M3 model to the `minimax` and `minimax-cn` direct providers, and removed the hardcoded context-window override that was masking models.dev values ([#5313](https://github.com/earendil-works/pi/issues/5313)). - Added NVIDIA NIM as a built-in OpenAI-compatible provider, exposing public NIM models that support tool use. ### Fixed diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index b0f49571..93a664e2 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -1657,17 +1657,7 @@ async function generateModels() { } } - const minimaxDirectSupportedIds = new Set(["MiniMax-M2.7", "MiniMax-M2.7-highspeed"]); - - for (const candidate of allModels) { - if ( - (candidate.provider === "minimax" || candidate.provider === "minimax-cn") && - minimaxDirectSupportedIds.has(candidate.id) - ) { - candidate.contextWindow = 204800; - candidate.maxTokens = 131072; - } - } + const minimaxDirectSupportedIds = new Set(["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M3"]); for (let i = allModels.length - 1; i >= 0; i--) { const candidate = allModels[i]; diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index b3ff24a9..97001d3a 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -5564,6 +5564,23 @@ export const MODELS = { contextWindow: 204800, maxTokens: 131072, } satisfies Model<"anthropic-messages">, + "MiniMax-M3": { + id: "MiniMax-M3", + name: "MiniMax-M3", + api: "anthropic-messages", + provider: "minimax", + baseUrl: "https://api.minimax.io/anthropic", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0.375, + }, + contextWindow: 512000, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, }, "minimax-cn": { "MiniMax-M2.7": { @@ -5600,6 +5617,23 @@ export const MODELS = { contextWindow: 204800, maxTokens: 131072, } satisfies Model<"anthropic-messages">, + "MiniMax-M3": { + id: "MiniMax-M3", + name: "MiniMax-M3", + api: "anthropic-messages", + provider: "minimax-cn", + baseUrl: "https://api.minimaxi.com/anthropic", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0.375, + }, + contextWindow: 512000, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, }, "mistral": { "codestral-latest": { @@ -9689,9 +9723,9 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.3, - output: 2.5, - cacheRead: 0.06, + input: 0.075, + output: 0.625, + cacheRead: 0.015, cacheWrite: 0, }, contextWindow: 262144, From 6cb23f9b5d5b6d1747672f535b167d0d809ac010 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 2 Jun 2026 16:30:14 +0200 Subject: [PATCH 140/352] Fix HTML export URL sanitization --- packages/coding-agent/CHANGELOG.md | 1 + .../src/core/export-html/template.js | 25 ++++++++++++++----- .../coding-agent/test/export-html-xss.test.ts | 15 +++++++---- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a9f3c926..82c509e4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixed +- Fixed stored XSS in HTML session exports by sanitizing Markdown link and image URLs with a scheme allow-list after stripping control characters. - Fixed SDK embedding in bundled Node apps failing with `ENOENT` when `package.json` is not present next to the bundle entrypoint. The package metadata reader now gracefully handles missing `package.json` by using defaults, enabling `createAgentSession()` without requiring package-adjacent files at runtime ([#5226](https://github.com/earendil-works/pi/issues/5226)). - Fixed HTTP timeout setting not being respected for non-Codex providers (e.g., llama.cpp via OpenAI-compatible API). The `httpIdleTimeoutMs` setting (set via `/settings` HTTP timeout) now applies as the default SDK request timeout for all providers that support it, not just OpenAI Codex Responses. Disabling the timeout (HTTP timeout = false) now correctly disables SDK timeouts for all supported providers by sending a maximum int32 value (effectively infinite) instead of 0, since SDKs treat timeout=0 as an immediate timeout ([#5294](https://github.com/earendil-works/pi/issues/5294)). - Fixed opening and listing very large JSONL session files by reading session entries line-by-line instead of materializing the full file as one string ([#5231](https://github.com/earendil-works/pi/issues/5231)). diff --git a/packages/coding-agent/src/core/export-html/template.js b/packages/coding-agent/src/core/export-html/template.js index fe5c6d91..cfdd1612 100644 --- a/packages/coding-agent/src/core/export-html/template.js +++ b/packages/coding-agent/src/core/export-html/template.js @@ -613,6 +613,18 @@ .replace(/'/g, '''); } + function sanitizeMarkdownUrl(value) { + const href = String(value || '').trim().replace(/[\x00-\x1f\x7f]/g, ''); + if (!href) return href; + + const scheme = href.match(/^([A-Za-z][A-Za-z0-9+.-]*):/); + if (scheme && !/^(https?|mailto|tel|ftp)$/i.test(scheme[1])) { + return null; + } + + return href; + } + /** * Truncate string to maxLen chars, append "..." if truncated. */ @@ -1569,10 +1581,11 @@ } }, renderer: { - // Sanitize link URLs to prevent javascript:/vbscript:/data: XSS + // Sanitize link URLs with a scheme allow-list. Browsers strip C0 + // controls from schemes, so strip them before checking and emitting. link(token) { - const href = (token.href || '').trim(); - if (/^\s*(javascript|vbscript|data):/i.test(href)) { + const href = sanitizeMarkdownUrl(token.href); + if (href === null) { return this.parser.parseInline(token.tokens); } let out = ''; return out; }, - // Sanitize image src URLs + // Sanitize image src URLs with the same scheme allow-list. image(token) { - const href = (token.href || '').trim(); - if (/^\s*(javascript|vbscript|data):/i.test(href)) { + const href = sanitizeMarkdownUrl(token.href); + if (href === null) { return escapeHtml(token.text || ''); } let out = '' + escapeHtml(token.text || '') + ' { const templateJs = readFileSync(new URL("../src/core/export-html/template.js", import.meta.url), "utf-8"); - it("overrides the marked link renderer to block javascript: protocol", () => { - // The custom link renderer must check for dangerous protocols + it("overrides the marked link renderer to use scheme allow-list sanitization", () => { expect(templateJs).toMatch(/link\s*\(\s*token\s*\)/); - expect(templateJs).toMatch(/javascript/i); - expect(templateJs).toMatch(/vbscript/i); + expect(templateJs).toMatch(/sanitizeMarkdownUrl\(token\.href\)/); + expect(templateJs).toMatch(/\^\(https\?\|mailto\|tel\|ftp\)/); }); - it("overrides the marked image renderer to block javascript: protocol", () => { + it("overrides the marked image renderer to use scheme allow-list sanitization", () => { expect(templateJs).toMatch(/image\s*\(\s*token\s*\)/); + expect(templateJs).toMatch(/sanitizeMarkdownUrl\(token\.href\)/); + }); + + it("strips C0 controls before checking and emitting markdown URLs", () => { + expect(templateJs).toContain("replace(/[\\x00-\\x1f\\x7f]/g, '')"); + expect(templateJs).not.toMatch(/\^\\s\*\(javascript\|vbscript\|data\):/i); }); it("escapes href attributes in the custom link renderer", () => { From 25a4a8ed1e16c28fa7eceefdff78cbb00e18867e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 2 Jun 2026 16:37:44 +0200 Subject: [PATCH 141/352] Add Ant Ling provider --- packages/ai/CHANGELOG.md | 1 + packages/ai/README.md | 4 +- packages/ai/scripts/generate-models.ts | 63 +++++++++++++ packages/ai/src/env-api-keys.ts | 1 + packages/ai/src/models.generated.ts | 57 ++++++++++++ .../ai/src/providers/openai-completions.ts | 31 +++++-- packages/ai/src/types.ts | 6 +- .../openai-completions-tool-choice.test.ts | 93 ++++++++++++++++++- packages/ai/test/stream.test.ts | 21 +++++ packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/README.md | 1 + packages/coding-agent/docs/providers.md | 2 + packages/coding-agent/src/cli/args.ts | 1 + .../coding-agent/src/core/model-resolver.ts | 1 + .../src/core/provider-display-names.ts | 1 + .../coding-agent/test/model-resolver.test.ts | 3 +- 16 files changed, 275 insertions(+), 12 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 3814e2b2..6e6769be 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added Ant Ling as a built-in OpenAI-compatible provider with Ling 2.6 and Ring 2.6 models. - Added MiniMax-M3 model to the `minimax` and `minimax-cn` direct providers, and removed the hardcoded context-window override that was masking models.dev values ([#5313](https://github.com/earendil-works/pi/issues/5313)). - Added NVIDIA NIM as a built-in OpenAI-compatible provider, exposing public NIM models that support tool use. diff --git a/packages/ai/README.md b/packages/ai/README.md index 76a98769..8f65bc72 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -51,6 +51,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an ## Supported Providers - **OpenAI** +- **Ant Ling** - **Azure OpenAI (Responses)** - **OpenAI Codex** (ChatGPT Plus/Pro subscription, requires OAuth, see below) - **DeepSeek** @@ -939,7 +940,7 @@ interface OpenAICompletionsCompat { requiresAssistantAfterToolResult?: boolean; // Whether tool results must be followed by an assistant message (default: false) requiresThinkingAsText?: boolean; // Whether thinking blocks must be converted to text (default: false) requiresReasoningContentOnAssistantMessages?: boolean; // Whether all replayed assistant messages must include empty reasoning_content when reasoning is enabled (default: auto-detected for DeepSeek) - thinkingFormat?: 'openai' | 'openrouter' | 'deepseek' | 'together' | 'zai' | 'qwen' | 'qwen-chat-template'; // Format for reasoning param: 'openai' uses reasoning_effort, 'openrouter' uses reasoning: { effort }, 'deepseek' uses thinking: { type } plus reasoning_effort when supported, 'together' uses reasoning: { enabled } plus reasoning_effort when supported, 'zai' uses enable_thinking, 'qwen' uses enable_thinking, 'qwen-chat-template' uses chat_template_kwargs.enable_thinking (default: openai) + thinkingFormat?: 'openai' | 'openrouter' | 'deepseek' | 'together' | 'zai' | 'qwen' | 'qwen-chat-template' | 'string-thinking' | 'ant-ling'; // Format for reasoning param: 'openai' uses reasoning_effort, 'openrouter' uses reasoning: { effort }, 'deepseek' uses thinking: { type } plus reasoning_effort when supported, 'together' uses reasoning: { enabled } plus reasoning_effort when supported, 'zai' uses enable_thinking, 'qwen' uses enable_thinking, 'qwen-chat-template' uses chat_template_kwargs.enable_thinking, 'string-thinking' uses top-level thinking, 'ant-ling' uses reasoning: { effort } only for mapped efforts (default: openai) cacheControlFormat?: 'anthropic'; // Anthropic-style cache_control on system prompt, last tool, and last user/assistant text content openRouterRouting?: OpenRouterRouting; // OpenRouter routing preferences (default: {}) vercelGatewayRouting?: VercelGatewayRouting; // Vercel AI Gateway routing preferences (default: {}) @@ -1100,6 +1101,7 @@ In Node.js environments, you can set environment variables to avoid passing API | Provider | Environment Variable(s) | |----------|------------------------| | OpenAI | `OPENAI_API_KEY` | +| Ant Ling | `ANT_LING_API_KEY` | | Azure OpenAI | `AZURE_OPENAI_API_KEY` + `AZURE_OPENAI_BASE_URL` (e.g. `https://{resource}.openai.azure.com`) or `AZURE_OPENAI_RESOURCE_NAME`. Supports `*.openai.azure.com` and `*.cognitiveservices.azure.com`; root endpoints auto-normalize to `/openai/v1`. Optional: `AZURE_OPENAI_API_VERSION` (default `v1`), `AZURE_OPENAI_DEPLOYMENT_NAME_MAP`. | | Anthropic | `ANTHROPIC_API_KEY` or `ANTHROPIC_OAUTH_TOKEN` | | DeepSeek | `DEEPSEEK_API_KEY` | diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 93a664e2..c98f868c 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -169,6 +169,15 @@ const DEEPSEEK_V4_THINKING_LEVEL_MAP = { xhigh: "max", } as const; +const ANT_LING_RING_THINKING_LEVEL_MAP = { + off: null, + minimal: null, + low: null, + medium: null, + high: "high", + xhigh: "xhigh", +} as const; + const OPENAI_RESPONSES_NONE_REASONING_MODELS = new Set([ "gpt-5.1", "gpt-5.2", @@ -330,6 +339,10 @@ function applyThinkingLevelMetadata(model: Model): void { // OpenCode Zen Grok Build reasons by default but rejects explicit reasoningEffort. mergeThinkingLevelMap(model, { off: null, minimal: null, low: null, medium: null }); } + if (model.provider === "ant-ling" && model.reasoning) { + // Ring reasons by default. Only high/xhigh have documented explicit effort controls. + mergeThinkingLevelMap(model, ANT_LING_RING_THINKING_LEVEL_MAP); + } } function getAnthropicMessagesCompat(provider: string, modelId: string): AnthropicMessagesCompat | undefined { @@ -1643,6 +1656,56 @@ async function generateModels() { ]; allModels.push(...deepseekV4Models); + const antLingCompat: OpenAICompletionsCompat = { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + maxTokensField: "max_tokens", + supportsLongCacheRetention: false, + }; + const antLingModels: Model<"openai-completions">[] = [ + { + id: "Ling-2.6-flash", + name: "Ling 2.6 Flash", + api: "openai-completions", + baseUrl: "https://api.ant-ling.com/v1", + provider: "ant-ling", + reasoning: false, + input: ["text"], + cost: { input: 0.01, output: 0.02, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 262144, + maxTokens: 65536, + compat: antLingCompat, + }, + { + id: "Ling-2.6-1T", + name: "Ling 2.6 1T", + api: "openai-completions", + baseUrl: "https://api.ant-ling.com/v1", + provider: "ant-ling", + reasoning: false, + input: ["text"], + cost: { input: 0.06, output: 0.25, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 262144, + maxTokens: 65536, + compat: antLingCompat, + }, + { + id: "Ring-2.6-1T", + name: "Ring 2.6 1T", + api: "openai-completions", + baseUrl: "https://api.ant-ling.com/v1", + provider: "ant-ling", + reasoning: true, + input: ["text"], + cost: { input: 0.06, output: 0.25, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 262144, + maxTokens: 65536, + compat: { ...antLingCompat, thinkingFormat: "ant-ling" }, + }, + ]; + allModels.push(...antLingModels); + for (const candidate of allModels) { if (candidate.api === "openai-completions" && candidate.id.includes("deepseek-v4")) { candidate.compat = { diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index 6321a03a..510f3b38 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -99,6 +99,7 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined { } const envMap: Record = { + "ant-ling": "ANT_LING_API_KEY", openai: "OPENAI_API_KEY", "azure-openai-responses": "AZURE_OPENAI_API_KEY", nvidia: "NVIDIA_API_KEY", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 97001d3a..54acc952 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -1552,6 +1552,63 @@ export const MODELS = { maxTokens: 101376, } satisfies Model<"bedrock-converse-stream">, }, + "ant-ling": { + "Ling-2.6-1T": { + id: "Ling-2.6-1T", + name: "Ling 2.6 1T", + api: "openai-completions", + provider: "ant-ling", + baseUrl: "https://api.ant-ling.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsLongCacheRetention":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0.06, + output: 0.25, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "Ling-2.6-flash": { + id: "Ling-2.6-flash", + name: "Ling 2.6 Flash", + api: "openai-completions", + provider: "ant-ling", + baseUrl: "https://api.ant-ling.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsLongCacheRetention":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0.01, + output: 0.02, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "Ring-2.6-1T": { + id: "Ring-2.6-1T", + name: "Ring 2.6 1T", + api: "openai-completions", + provider: "ant-ling", + baseUrl: "https://api.ant-ling.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsLongCacheRetention":false,"thinkingFormat":"ant-ling"}, + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh"}, + input: ["text"], + cost: { + input: 0.06, + output: 0.25, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + }, "anthropic": { "claude-3-5-haiku-20241022": { id: "claude-3-5-haiku-20241022", diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index ae6d583e..5093d3e8 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -578,6 +578,11 @@ function buildParams( } else if (model.thinkingLevelMap?.off !== null) { openRouterParams.reasoning = { effort: model.thinkingLevelMap?.off ?? "none" }; } + } else if (compat.thinkingFormat === "ant-ling" && model.reasoning && options?.reasoningEffort) { + const effort = model.thinkingLevelMap?.[options.reasoningEffort]; + if (typeof effort === "string") { + (params as typeof params & { reasoning?: { effort: string } }).reasoning = { effort }; + } } else if (compat.thinkingFormat === "together" && model.reasoning) { const togetherParams = params as Omit & { reasoning?: { enabled: boolean }; @@ -1079,6 +1084,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet const isCloudflareWorkersAI = provider === "cloudflare-workers-ai" || baseUrl.includes("api.cloudflare.com"); const isCloudflareAiGateway = provider === "cloudflare-ai-gateway" || baseUrl.includes("gateway.ai.cloudflare.com"); const isNvidia = provider === "nvidia" || baseUrl.includes("integrate.api.nvidia.com"); + const isAntLing = provider === "ant-ling" || baseUrl.includes("api.ant-ling.com"); const isNonStandard = isNvidia || @@ -1094,9 +1100,11 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet provider === "opencode" || baseUrl.includes("opencode.ai") || isCloudflareWorkersAI || - isCloudflareAiGateway; + isCloudflareAiGateway || + isAntLing; - const useMaxTokens = baseUrl.includes("chutes.ai") || isMoonshot || isCloudflareAiGateway || isTogether || isNvidia; + const useMaxTokens = + baseUrl.includes("chutes.ai") || isMoonshot || isCloudflareAiGateway || isTogether || isNvidia || isAntLing; const isGrok = provider === "xai" || baseUrl.includes("api.x.ai"); const isDeepSeek = provider === "deepseek" || baseUrl.includes("deepseek.com"); @@ -1107,7 +1115,8 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet return { supportsStore: !isNonStandard, supportsDeveloperRole: isOpenRouterDeveloperRoleModel || (!isNonStandard && !isOpenRouter), - supportsReasoningEffort: !isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia, + supportsReasoningEffort: + !isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia && !isAntLing, supportsUsageInStreaming: true, maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens", requiresToolResultName: false, @@ -1120,16 +1129,24 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet ? "zai" : isTogether ? "together" - : isOpenRouter - ? "openrouter" - : "openai", + : isAntLing + ? "ant-ling" + : isOpenRouter + ? "openrouter" + : "openai", openRouterRouting: {}, vercelGatewayRouting: {}, zaiToolStream: false, supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia, cacheControlFormat, sendSessionAffinityHeaders: false, - supportsLongCacheRetention: !(isTogether || isCloudflareWorkersAI || isCloudflareAiGateway || isNvidia), + supportsLongCacheRetention: !( + isTogether || + isCloudflareWorkersAI || + isCloudflareAiGateway || + isNvidia || + isAntLing + ), }; } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 7a448772..cbf136a6 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -22,6 +22,7 @@ export type ImagesApi = KnownImagesApi | (string & {}); export type KnownProvider = | "amazon-bedrock" + | "ant-ling" | "anthropic" | "google" | "google-vertex" @@ -390,7 +391,7 @@ export interface OpenAICompletionsCompat { requiresThinkingAsText?: boolean; /** Whether all replayed assistant messages must include an empty reasoning_content field when reasoning is enabled. Default: auto-detected from URL. */ requiresReasoningContentOnAssistantMessages?: boolean; - /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort when supported, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses top-level enable_thinking: boolean, "qwen" uses top-level enable_thinking: boolean, "qwen-chat-template" uses chat_template_kwargs.enable_thinking, and "string-thinking" uses top-level thinking: string. Default: "openai". */ + /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort when supported, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses top-level enable_thinking: boolean, "qwen" uses top-level enable_thinking: boolean, "qwen-chat-template" uses chat_template_kwargs.enable_thinking, "string-thinking" uses top-level thinking: string, and "ant-ling" uses reasoning: { effort } only when the mapped effort is non-null. Default: "openai". */ thinkingFormat?: | "openai" | "openrouter" @@ -399,7 +400,8 @@ export interface OpenAICompletionsCompat { | "zai" | "qwen" | "qwen-chat-template" - | "string-thinking"; + | "string-thinking" + | "ant-ling"; /** OpenRouter-specific routing preferences. Only used when baseUrl points to OpenRouter. */ openRouterRouting?: OpenRouterRouting; /** Vercel AI Gateway routing preferences. Only used when baseUrl points to Vercel AI Gateway. */ diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index c06ac344..be6419d6 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -2,7 +2,7 @@ import { Type } from "typebox"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { getModel } from "../src/models.ts"; import { convertMessages } from "../src/providers/openai-completions.ts"; -import { streamSimple } from "../src/stream.ts"; +import { stream, streamSimple } from "../src/stream.ts"; import type { AssistantMessage, Model, Tool, ToolResultMessage } from "../src/types.ts"; const mockState = vi.hoisted(() => ({ @@ -1295,4 +1295,95 @@ describe("openai-completions tool_choice", () => { expect(params.reasoning).toEqual({ effort: "high" }); expect(params.reasoning_effort).toBeUndefined(); }); + + it("uses Ant Ling compatibility metadata", async () => { + const model = getModel("ant-ling", "Ring-2.6-1T")!; + let payload: unknown; + + expect(model.compat).toMatchObject({ + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + maxTokensField: "max_tokens", + thinkingFormat: "ant-ling", + supportsLongCacheRetention: false, + }); + expect(model.compat?.supportsStrictMode).toBeUndefined(); + expect(model.compat?.requiresReasoningContentOnAssistantMessages).toBeUndefined(); + + await streamSimple( + model, + { + systemPrompt: "Follow instructions.", + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + maxTokens: 123, + reasoning: "high", + cacheRetention: "long", + sessionId: "ant-ling-session", + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = (payload ?? mockState.lastParams) as { + max_tokens?: number; + max_completion_tokens?: number; + messages?: Array<{ role?: string }>; + reasoning?: { effort?: string }; + reasoning_effort?: string; + store?: boolean; + prompt_cache_key?: string; + prompt_cache_retention?: string; + }; + expect(params.max_tokens).toBe(123); + expect(params.max_completion_tokens).toBeUndefined(); + expect(params.messages?.[0]?.role).toBe("system"); + expect(params.reasoning).toEqual({ effort: "high" }); + expect(params.reasoning_effort).toBeUndefined(); + expect(params.store).toBeUndefined(); + expect(params.prompt_cache_key).toBeUndefined(); + expect(params.prompt_cache_retention).toBeUndefined(); + }); + + it("omits Ant Ling reasoning for unmapped direct reasoning efforts and non-reasoning models", async () => { + const ring = getModel("ant-ling", "Ring-2.6-1T")!; + let payload: unknown; + + await stream( + ring, + { + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + reasoningEffort: "medium", + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + expect((payload ?? mockState.lastParams) as { reasoning?: unknown }).not.toHaveProperty("reasoning"); + + const ling = getModel("ant-ling", "Ling-2.6-flash")!; + await streamSimple( + ling, + { + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + reasoning: "high", + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + expect((payload ?? mockState.lastParams) as { reasoning?: unknown }).not.toHaveProperty("reasoning"); + }); }); diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 1693f406..9a70b6c2 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -1169,6 +1169,27 @@ describe("Generate E2E Tests", () => { }, ); + describe.skipIf(!process.env.ANT_LING_API_KEY)("Ant Ling Provider (Ling 2.6 Flash via OpenAI Completions)", () => { + const llm = getModel("ant-ling", "Ling-2.6-flash"); + + it("should complete basic text generation", { retry: 3 }, async () => { + await basicTextGeneration(llm); + }); + + it("should handle tool calling", { retry: 3 }, async () => { + await handleToolCall(llm); + }); + + it("should handle streaming", { retry: 3 }, async () => { + await handleStreaming(llm); + }); + + it("should handle thinking mode", { retry: 3 }, async () => { + const ringModel = getModel("ant-ling", "Ring-2.6-1T"); + await handleThinking(ringModel, { reasoningEffort: "high" }); + }); + }); + // ========================================================================= // OAuth-based providers (credentials from ~/.pi/agent/oauth.json) // Tokens are resolved at module level (see oauthTokens above) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 82c509e4..46eebe64 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added Ant Ling provider selection and setup documentation. - Added NVIDIA NIM provider selection, setup documentation, and direct NIM request attribution headers. - Added `ctx.mode` to extension contexts so extensions can distinguish TUI, RPC, JSON, and print mode. - Added `ctx.getSystemPromptOptions()` for extension commands to inspect the current base system prompt inputs. diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 585a93a0..8ef336c7 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -110,6 +110,7 @@ For each built-in provider, pi maintains a list of tool-capable models, updated **API keys:** - Anthropic +- Ant Ling - OpenAI - Azure OpenAI - DeepSeek diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index e7a77c5a..0ecf439d 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -49,6 +49,7 @@ pi | Provider | Environment Variable | `auth.json` key | |----------|----------------------|------------------| | Anthropic | `ANTHROPIC_API_KEY` | `anthropic` | +| Ant Ling | `ANT_LING_API_KEY` | `ant-ling` | | Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` | | OpenAI | `OPENAI_API_KEY` | `openai` | | DeepSeek | `DEEPSEEK_API_KEY` | `deepseek` | @@ -85,6 +86,7 @@ Store credentials in `~/.pi/agent/auth.json`: ```json { "anthropic": { "type": "api_key", "key": "sk-ant-..." }, + "ant-ling": { "type": "api_key", "key": "..." }, "openai": { "type": "api_key", "key": "sk-..." }, "deepseek": { "type": "api_key", "key": "sk-..." }, "nvidia": { "type": "api_key", "key": "nvapi-..." }, diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 8156bd03..458bd43e 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -328,6 +328,7 @@ ${chalk.bold("Examples:")} ${chalk.bold("Environment Variables:")} ANTHROPIC_API_KEY - Anthropic Claude API key ANTHROPIC_OAUTH_TOKEN - Anthropic OAuth token (alternative to API key) + ANT_LING_API_KEY - Ant Ling API key OPENAI_API_KEY - OpenAI GPT API key AZURE_OPENAI_API_KEY - Azure OpenAI API key AZURE_OPENAI_BASE_URL - Azure OpenAI/Cognitive Services base URL (e.g. https://{resource}.openai.azure.com) diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index e3af076d..a00471c9 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -13,6 +13,7 @@ import type { ModelRegistry } from "./model-registry.ts"; /** Default model IDs for each known provider */ export const defaultModelPerProvider: Record = { "amazon-bedrock": "us.anthropic.claude-opus-4-6-v1", + "ant-ling": "Ring-2.6-1T", anthropic: "claude-opus-4-8", openai: "gpt-5.4", "azure-openai-responses": "gpt-5.4", diff --git a/packages/coding-agent/src/core/provider-display-names.ts b/packages/coding-agent/src/core/provider-display-names.ts index 789f3153..3481e6ae 100644 --- a/packages/coding-agent/src/core/provider-display-names.ts +++ b/packages/coding-agent/src/core/provider-display-names.ts @@ -1,6 +1,7 @@ export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record = { anthropic: "Anthropic", "amazon-bedrock": "Amazon Bedrock", + "ant-ling": "Ant Ling", "azure-openai-responses": "Azure OpenAI Responses", cerebras: "Cerebras", "cloudflare-ai-gateway": "Cloudflare AI Gateway", diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 7339200b..0ac54939 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -378,11 +378,12 @@ describe("default model selection", () => { expect(defaultModelPerProvider["openai-codex"]).toBe("gpt-5.5"); }); - test("zai, minimax, and cerebras defaults track current models", () => { + test("zai, minimax, cerebras, and ant-ling defaults track current models", () => { expect(defaultModelPerProvider.zai).toBe("glm-5.1"); expect(defaultModelPerProvider.minimax).toBe("MiniMax-M2.7"); expect(defaultModelPerProvider["minimax-cn"]).toBe("MiniMax-M2.7"); expect(defaultModelPerProvider.cerebras).toBe("zai-glm-4.7"); + expect(defaultModelPerProvider["ant-ling"]).toBe("Ring-2.6-1T"); }); test("ai-gateway default tracks current model", () => { From e30b1b18d0bf1db233c1a4c8407361cd5837b7bd Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 2 Jun 2026 17:03:40 +0200 Subject: [PATCH 142/352] Added security file --- SECURITY.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..0a249014 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,70 @@ +# Security Policy + +This document should guide you about understanding the security concept behind +Pi and also where the boundaries are. + +In general Pi is a coding agent that runs locally within the security boundary +of the user that is running it. It's the responsibiltiy of the user to monitor +its operations or to contain it within a container, virtual machine or other +Sandbox solution. + +Pi relies on users installing trustworthy extensions and loading trustworthy +skills and only to use pi within trusted repositories. This is because files +like `AGENTS.md` or instructions in comments can be used to prompt inject the +coding agent trivially and this cannot be protected against. + +## Reporting a Vulnerability + +If you believe you found a security vulnerability in pi or another package in +this repository, please report it privately by either: + +- Emailing `security@earendil.com`, or +- Opening a private report through GitHub Security Advisories for this repository + +Please include: + +- A description of the issue and its impact +- Steps to reproduce, proof of concept, or relevant logs +- Affected package, version, commit, or configuration +- Any known mitigations + +Do not open a public issue for security-sensitive reports. We will review +reports and coordinate disclosure as appropriate. + +## Scope + +Security issues in the distributed packages, command-line tools, APIs, and +repository code are in scope as well as earendil operated infrastricture +on `pi.dev`. + +## Out Of Scope + +- Local code execution or sandboxing behavior (the Pi coding agent intentionally does not have a sandbox) +- Behavior of pi extensions or skills installed by the user +- Risks from working in untrusted repositories +- Risks from installing untrusted extensions, skills, packages, or tools +- Isuses caused by non trustworthy MITM proxies +- Public internet exposure of a Pi installation +- Prompt injection attacks +- Exposed secrets that are third-party/user-controlled credentials +- Reports requiring write access to trusted local state/config (`~/.pi`, workspace + files, `AGENTS.md`, skills/extensions config), unless they show how an attacker + gets that write access. +- Issues caused by intentionally weakened user configuration. +- Resource/DOS claims that require trusted local input/config against the pi coding agent. +- Reports about malicious model output. +- User-approved or user-initiated local actions presented as vulnerabilities. + +## Notes for Reporters + +The most useful reports show a current, reproducible security boundary bypass +with demonstrated impact. Reports that only show expected local-agent behavior, +prompt injection, or a malicious trusted extension/skill are not security +vulnerabilities under this model. + +When possible, include the exact affected path, package version or commit SHA, +configuration, and a proof of concept against the latest release or latest +`main`. For dependency reports, include evidence that the shipped dependency is +affected and that the issue is reachable through Pi. For exposed-secret reports, +include evidence that the credential is owned by Earendil or grants access to +Earendil-operated infrastructure or services. From 0462d44f5695b49ef6e27ab3cd225219085a5b2b Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 2 Jun 2026 17:24:41 +0200 Subject: [PATCH 143/352] doc: clarify wording in SECURITY.md --- SECURITY.md | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 0a249014..ddd75e14 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,6 +8,14 @@ of the user that is running it. It's the responsibiltiy of the user to monitor its operations or to contain it within a container, virtual machine or other Sandbox solution. +Pi treats the local user account and files writable by that account as inside +the same trust boundary as the Pi process itself. If an attacker can modify files +under the user's home directory, workspace, shell startup files, environment, or +Pi configuration, they can generally influence Pi or other local developer tools. +Reports that depend on such prior local write access are not security +vulnerabilities unless they demonstrate how Pi grants that write access or crosses +an operating-system privilege boundary. + Pi relies on users installing trustworthy extensions and loading trustworthy skills and only to use pi within trusted repositories. This is because files like `AGENTS.md` or instructions in comments can be used to prompt inject the @@ -47,9 +55,13 @@ on `pi.dev`. - Public internet exposure of a Pi installation - Prompt injection attacks - Exposed secrets that are third-party/user-controlled credentials -- Reports requiring write access to trusted local state/config (`~/.pi`, workspace - files, `AGENTS.md`, skills/extensions config), unless they show how an attacker - gets that write access. +- Reports requiring the ability to create, modify, delete, or replace files, + directories, symlinks, environment variables, shell configuration, or other + user-controlled local state on the target machine. This includes `~/.pi`, + `~/.pi/agent/models.json`, workspace files, `AGENTS.md`, skills, extensions, + extension configuration, dotfiles, and files synchronized through NFS, roaming + profiles, or dotfile managers, unless the report shows how Pi itself grants + that access. - Issues caused by intentionally weakened user configuration. - Resource/DOS claims that require trusted local input/config against the pi coding agent. - Reports about malicious model output. @@ -62,6 +74,11 @@ with demonstrated impact. Reports that only show expected local-agent behavior, prompt injection, or a malicious trusted extension/skill are not security vulnerabilities under this model. +For example, a report showing that malicious contents written to a trusted Pi +configuration file cause Pi to execute commands, load attacker-controlled tools, +send credentials to an attacker-controlled endpoint, or otherwise change behavior +is out of scope. + When possible, include the exact affected path, package version or commit SHA, configuration, and a proof of concept against the latest release or latest `main`. For dependency reports, include evidence that the shipped dependency is From 51df39b9b9246ecc0c914e7c4080815aa9a9e31c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=8E=E6=B0=B4?= Date: Tue, 2 Jun 2026 23:39:56 +0800 Subject: [PATCH 144/352] feat(ai): add ZAI Coding Plan China provider --- packages/ai/README.md | 2 + packages/ai/scripts/generate-models.ts | 61 ++++++------ packages/ai/src/env-api-keys.ts | 1 + packages/ai/src/models.generated.ts | 92 +++++++++++++++++++ .../ai/src/providers/openai-completions.ts | 6 +- packages/ai/src/types.ts | 1 + packages/ai/test/env-api-keys.test.ts | 14 +++ packages/coding-agent/README.md | 1 + packages/coding-agent/docs/providers.md | 1 + packages/coding-agent/src/cli/args.ts | 1 + .../coding-agent/src/core/model-resolver.ts | 1 + .../src/core/provider-display-names.ts | 1 + test.sh | 1 + 13 files changed, 155 insertions(+), 28 deletions(-) diff --git a/packages/ai/README.md b/packages/ai/README.md index 8f65bc72..6be70212 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -67,6 +67,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an - **xAI** - **OpenRouter** - **Vercel AI Gateway** +- **ZAI** (with separate Coding Plan China provider) - **MiniMax** - **Together AI** - **GitHub Copilot** (requires OAuth, see below) @@ -1119,6 +1120,7 @@ In Node.js environments, you can set environment variables to avoid passing API | OpenRouter | `OPENROUTER_API_KEY` | | Vercel AI Gateway | `AI_GATEWAY_API_KEY` | | zAI | `ZAI_API_KEY` | +| ZAI Coding Plan (China) | `ZAI_CODING_CN_API_KEY` | | MiniMax | `MINIMAX_API_KEY` | | OpenCode Zen / OpenCode Go | `OPENCODE_API_KEY` | | Kimi For Coding | `KIMI_API_KEY` | diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index c98f868c..a59911e5 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -788,34 +788,41 @@ async function loadModelsDevData(): Promise[]> { } // Process zAi models - if (data["zai-coding-plan"]?.models) { - for (const [modelId, model] of Object.entries(data["zai-coding-plan"].models)) { - const m = model as ModelsDevModel; - if (m.tool_call !== true) continue; - const supportsImage = m.modalities?.input?.includes("image"); + const zaiCodingPlanVariants = [ + { provider: "zai", baseUrl: "https://api.z.ai/api/coding/paas/v4" }, + { provider: "zai-coding-cn", baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4" }, + ] as const; - models.push({ - id: modelId, - name: m.name || modelId, - api: "openai-completions", - provider: "zai", - baseUrl: "https://api.z.ai/api/coding/paas/v4", - reasoning: m.reasoning === true, - input: supportsImage ? ["text", "image"] : ["text"], - cost: { - input: m.cost?.input || 0, - output: m.cost?.output || 0, - cacheRead: m.cost?.cache_read || 0, - cacheWrite: m.cost?.cache_write || 0, - }, - compat: { - supportsDeveloperRole: false, - thinkingFormat: "zai", - ...(!ZAI_TOOL_STREAM_UNSUPPORTED_MODELS.has(modelId) ? { zaiToolStream: true } : {}), - }, - contextWindow: m.limit?.context || 4096, - maxTokens: m.limit?.output || 4096, - }); + if (data["zai-coding-plan"]?.models) { + for (const { provider, baseUrl } of zaiCodingPlanVariants) { + for (const [modelId, model] of Object.entries(data["zai-coding-plan"].models)) { + const m = model as ModelsDevModel; + if (m.tool_call !== true) continue; + const supportsImage = m.modalities?.input?.includes("image"); + + models.push({ + id: modelId, + name: m.name || modelId, + api: "openai-completions", + provider, + baseUrl, + reasoning: m.reasoning === true, + input: supportsImage ? ["text", "image"] : ["text"], + cost: { + input: m.cost?.input || 0, + output: m.cost?.output || 0, + cacheRead: m.cost?.cache_read || 0, + cacheWrite: m.cost?.cache_write || 0, + }, + compat: { + supportsDeveloperRole: false, + thinkingFormat: "zai", + ...(!ZAI_TOOL_STREAM_UNSUPPORTED_MODELS.has(modelId) ? { zaiToolStream: true } : {}), + }, + contextWindow: m.limit?.context || 4096, + maxTokens: m.limit?.output || 4096, + }); + } } } diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index 510f3b38..291d2288 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -112,6 +112,7 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined { openrouter: "OPENROUTER_API_KEY", "vercel-ai-gateway": "AI_GATEWAY_API_KEY", zai: "ZAI_API_KEY", + "zai-coding-cn": "ZAI_CODING_CN_API_KEY", mistral: "MISTRAL_API_KEY", minimax: "MINIMAX_API_KEY", "minimax-cn": "MINIMAX_CN_API_KEY", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 54acc952..8dd246de 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -16778,4 +16778,96 @@ export const MODELS = { maxTokens: 131072, } satisfies Model<"openai-completions">, }, + "zai-coding-cn": { + "glm-4.5-air": { + id: "glm-4.5-air", + name: "GLM-4.5-Air", + api: "openai-completions", + provider: "zai-coding-cn", + baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", + compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai"}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 98304, + } satisfies Model<"openai-completions">, + "glm-4.7": { + id: "glm-4.7", + name: "GLM-4.7", + api: "openai-completions", + provider: "zai-coding-cn", + baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", + compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","zaiToolStream":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "glm-5-turbo": { + id: "glm-5-turbo", + name: "GLM-5-Turbo", + api: "openai-completions", + provider: "zai-coding-cn", + baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", + compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","zaiToolStream":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "glm-5.1": { + id: "glm-5.1", + name: "GLM-5.1", + api: "openai-completions", + provider: "zai-coding-cn", + baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", + compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","zaiToolStream":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "glm-5v-turbo": { + id: "glm-5v-turbo", + name: "GLM-5V-Turbo", + api: "openai-completions", + provider: "zai-coding-cn", + baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", + compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","zaiToolStream":true}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + }, } as const; diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 5093d3e8..0cfbc234 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -1076,7 +1076,11 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet const provider = model.provider; const baseUrl = model.baseUrl; - const isZai = provider === "zai" || baseUrl.includes("api.z.ai"); + const isZai = + provider === "zai" || + provider === "zai-coding-cn" || + baseUrl.includes("api.z.ai") || + baseUrl.includes("open.bigmodel.cn"); const isTogether = provider === "together" || baseUrl.includes("api.together.ai") || baseUrl.includes("api.together.xyz"); const isMoonshot = provider === "moonshotai" || provider === "moonshotai-cn" || baseUrl.includes("api.moonshot."); diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index cbf136a6..a0564e61 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -38,6 +38,7 @@ export type KnownProvider = | "openrouter" | "vercel-ai-gateway" | "zai" + | "zai-coding-cn" | "mistral" | "minimax" | "minimax-cn" diff --git a/packages/ai/test/env-api-keys.test.ts b/packages/ai/test/env-api-keys.test.ts index 61fec912..b7c86432 100644 --- a/packages/ai/test/env-api-keys.test.ts +++ b/packages/ai/test/env-api-keys.test.ts @@ -4,6 +4,7 @@ import { findEnvKeys, getEnvApiKey } from "../src/env-api-keys.ts"; const originalCopilotGitHubToken = process.env.COPILOT_GITHUB_TOKEN; const originalGhToken = process.env.GH_TOKEN; const originalGitHubToken = process.env.GITHUB_TOKEN; +const originalZaiCodingCnApiKey = process.env.ZAI_CODING_CN_API_KEY; afterEach(() => { if (originalCopilotGitHubToken === undefined) { @@ -23,6 +24,12 @@ afterEach(() => { } else { process.env.GITHUB_TOKEN = originalGitHubToken; } + + if (originalZaiCodingCnApiKey === undefined) { + delete process.env.ZAI_CODING_CN_API_KEY; + } else { + process.env.ZAI_CODING_CN_API_KEY = originalZaiCodingCnApiKey; + } }); describe("environment API keys", () => { @@ -43,4 +50,11 @@ describe("environment API keys", () => { expect(findEnvKeys("github-copilot")).toEqual(["COPILOT_GITHUB_TOKEN"]); expect(getEnvApiKey("github-copilot")).toBe("copilot-token"); }); + + it("resolves ZAI China Coding Plan credentials from ZAI_CODING_CN_API_KEY", () => { + process.env.ZAI_CODING_CN_API_KEY = "zai-coding-cn-token"; + + expect(findEnvKeys("zai-coding-cn")).toEqual(["ZAI_CODING_CN_API_KEY"]); + expect(getEnvApiKey("zai-coding-cn")).toBe("zai-coding-cn-token"); + }); }); diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 8ef336c7..60e8da1a 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -127,6 +127,7 @@ For each built-in provider, pi maintains a list of tool-capable models, updated - OpenRouter - Vercel AI Gateway - ZAI +- ZAI Coding Plan (China) - OpenCode Zen - OpenCode Go - Hugging Face diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index 0ecf439d..185405cd 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -64,6 +64,7 @@ pi | OpenRouter | `OPENROUTER_API_KEY` | `openrouter` | | Vercel AI Gateway | `AI_GATEWAY_API_KEY` | `vercel-ai-gateway` | | ZAI | `ZAI_API_KEY` | `zai` | +| ZAI Coding Plan (China) | `ZAI_CODING_CN_API_KEY` | `zai-coding-cn` | | OpenCode Zen | `OPENCODE_API_KEY` | `opencode` | | OpenCode Go | `OPENCODE_API_KEY` | `opencode-go` | | Hugging Face | `HF_TOKEN` | `huggingface` | diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 458bd43e..959484da 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -346,6 +346,7 @@ ${chalk.bold("Environment Variables:")} OPENROUTER_API_KEY - OpenRouter API key AI_GATEWAY_API_KEY - Vercel AI Gateway API key ZAI_API_KEY - ZAI API key + ZAI_CODING_CN_API_KEY - ZAI Coding Plan API key (China) MISTRAL_API_KEY - Mistral API key MINIMAX_API_KEY - MiniMax API key MOONSHOT_API_KEY - Moonshot AI API key diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index a00471c9..53b8ad11 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -29,6 +29,7 @@ export const defaultModelPerProvider: Record = { groq: "openai/gpt-oss-120b", cerebras: "zai-glm-4.7", zai: "glm-5.1", + "zai-coding-cn": "glm-5.1", mistral: "devstral-medium-latest", minimax: "MiniMax-M2.7", "minimax-cn": "MiniMax-M2.7", diff --git a/packages/coding-agent/src/core/provider-display-names.ts b/packages/coding-agent/src/core/provider-display-names.ts index 3481e6ae..9b0371d2 100644 --- a/packages/coding-agent/src/core/provider-display-names.ts +++ b/packages/coding-agent/src/core/provider-display-names.ts @@ -27,6 +27,7 @@ export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record = { "vercel-ai-gateway": "Vercel AI Gateway", xai: "xAI", zai: "ZAI", + "zai-coding-cn": "ZAI Coding Plan (China)", xiaomi: "Xiaomi MiMo", "xiaomi-token-plan-cn": "Xiaomi MiMo Token Plan (China)", "xiaomi-token-plan-ams": "Xiaomi MiMo Token Plan (Amsterdam)", diff --git a/test.sh b/test.sh index 5f231845..9a553f6f 100755 --- a/test.sh +++ b/test.sh @@ -35,6 +35,7 @@ unset CEREBRAS_API_KEY unset XAI_API_KEY unset OPENROUTER_API_KEY unset ZAI_API_KEY +unset ZAI_CODING_CN_API_KEY unset MISTRAL_API_KEY unset MINIMAX_API_KEY unset MINIMAX_CN_API_KEY From a98e087e5d08ea2a536bf73dbb0aebb87c3ef72e Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 2 Jun 2026 18:20:35 +0200 Subject: [PATCH 145/352] fix(coding-agent): harden git package install paths --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/package-manager.ts | 19 ++++- packages/coding-agent/src/utils/git.ts | 80 +++++++++++++------ .../coding-agent/test/git-ssh-url.test.ts | 13 +++ .../coding-agent/test/package-manager.test.ts | 23 ++++++ 5 files changed, 109 insertions(+), 27 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 46eebe64..5ddda524 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -11,6 +11,7 @@ ### Fixed +- Fixed git package source handling to reject unsafe host/path components and keep managed clone paths inside install roots. - Fixed stored XSS in HTML session exports by sanitizing Markdown link and image URLs with a scheme allow-list after stripping control characters. - Fixed SDK embedding in bundled Node apps failing with `ENOENT` when `package.json` is not present next to the bundle entrypoint. The package metadata reader now gracefully handles missing `package.json` by using defaults, enabling `createAgentSession()` without requiring package-adjacent files at runtime ([#5226](https://github.com/earendil-works/pi/issues/5226)). - Fixed HTTP timeout setting not being respected for non-Codex providers (e.g., llama.cpp via OpenAI-compatible API). The `httpIdleTimeoutMs` setting (set via `/settings` HTTP timeout) now applies as the default SDK request timeout for all providers that support it, not just OpenAI Codex Responses. Disabling the timeout (HTTP timeout = false) now correctly disables SDK timeouts for all supported providers by sending a maximum int32 value (effectively infinite) instead of 0, since SDKs treat timeout=0 as an immediate timeout ([#5294](https://github.com/earendil-works/pi/issues/5294)). diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 1d7dc27d..7c0e96a1 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -1952,10 +1952,11 @@ export class DefaultPackageManager implements PackageManager { if (scope === "temporary") { return this.getTemporaryDir(`git-${source.host}`, source.path); } - if (scope === "project") { - return join(this.cwd, CONFIG_DIR_NAME, "git", source.host, source.path); + const installRoot = this.getGitInstallRoot(scope); + if (!installRoot) { + throw new Error("Missing git install root"); } - return join(this.agentDir, "git", source.host, source.path); + return this.resolveManagedPath(installRoot, source.host, source.path); } private getGitInstallRoot(scope: SourceScope): string | undefined { @@ -1969,11 +1970,21 @@ export class DefaultPackageManager implements PackageManager { } private getTemporaryDir(prefix: string, suffix?: string): string { + const root = this.resolveManagedPath(join(tmpdir(), "pi-extensions"), prefix); const hash = createHash("sha256") .update(`${prefix}-${suffix ?? ""}`) .digest("hex") .slice(0, 8); - return join(tmpdir(), "pi-extensions", prefix, hash, suffix ?? ""); + return this.resolveManagedPath(root, hash, suffix ?? ""); + } + + private resolveManagedPath(root: string, ...parts: string[]): string { + const resolvedRoot = resolve(root); + const resolvedPath = resolve(resolvedRoot, ...parts); + if (resolvedPath !== resolvedRoot && !resolvedPath.startsWith(`${resolvedRoot}${sep}`)) { + throw new Error(`Refusing to use path outside package install root: ${resolvedPath}`); + } + return resolvedPath; } private getBaseDirForScope(scope: SourceScope): string { diff --git a/packages/coding-agent/src/utils/git.ts b/packages/coding-agent/src/utils/git.ts index 9652e401..1314edea 100644 --- a/packages/coding-agent/src/utils/git.ts +++ b/packages/coding-agent/src/utils/git.ts @@ -73,6 +73,56 @@ function splitRef(url: string): { repo: string; ref?: string } { }; } +function decodeForValidation(value: string): string | null { + try { + return decodeURIComponent(value); + } catch { + return null; + } +} + +function hasUnsafeGitInstallPart(value: string, allowSlash: boolean): boolean { + const decoded = decodeForValidation(value); + if (decoded === null) { + return true; + } + const candidates = [value, decoded]; + for (const candidate of candidates) { + if (candidate.includes("\0") || candidate.includes("\\") || candidate.startsWith("/")) { + return true; + } + if (!allowSlash && candidate.includes("/")) { + return true; + } + if (candidate.split("/").includes("..")) { + return true; + } + } + return false; +} + +function buildGitSource(args: { repo: string; host: string; path: string; ref?: string }): GitSource | null { + if (args.path.startsWith("/")) { + return null; + } + const normalizedPath = args.path.replace(/\.git$/, "").replace(/^\/+/, ""); + if (!args.host || !normalizedPath || normalizedPath.split("/").length < 2) { + return null; + } + if (hasUnsafeGitInstallPart(args.host, false) || hasUnsafeGitInstallPart(normalizedPath, true)) { + return null; + } + + return { + type: "git", + repo: args.repo, + host: args.host, + path: normalizedPath, + ref: args.ref, + pinned: Boolean(args.ref), + }; +} + function parseGenericGitUrl(url: string): GitSource | null { const { repo: repoWithoutRef, ref } = splitRef(url); let repo = repoWithoutRef; @@ -109,19 +159,7 @@ function parseGenericGitUrl(url: string): GitSource | null { repo = `https://${repoWithoutRef}`; } - const normalizedPath = path.replace(/\.git$/, "").replace(/^\/+/, ""); - if (!host || !normalizedPath || normalizedPath.split("/").length < 2) { - return null; - } - - return { - type: "git", - repo, - host, - path: normalizedPath, - ref, - pinned: Boolean(ref), - }; + return buildGitSource({ repo, host, path, ref }); } /** @@ -157,14 +195,12 @@ export function parseGitUrl(source: string): GitSource | null { !split.repo.startsWith("ssh://") && !split.repo.startsWith("git://") && !split.repo.startsWith("git@"); - return { - type: "git", + return buildGitSource({ repo: useHttpsPrefix ? `https://${split.repo}` : split.repo, host: info.domain || "", - path: `${info.user}/${info.project}`.replace(/\.git$/, ""), + path: `${info.user}/${info.project}`, ref: info.committish || split.ref || undefined, - pinned: Boolean(info.committish || split.ref), - }; + }); } } @@ -177,14 +213,12 @@ export function parseGitUrl(source: string): GitSource | null { if (split.ref && info.project?.includes("@")) { continue; } - return { - type: "git", + return buildGitSource({ repo: `https://${split.repo}`, host: info.domain || "", - path: `${info.user}/${info.project}`.replace(/\.git$/, ""), + path: `${info.user}/${info.project}`, ref: info.committish || split.ref || undefined, - pinned: Boolean(info.committish || split.ref), - }; + }); } } diff --git a/packages/coding-agent/test/git-ssh-url.test.ts b/packages/coding-agent/test/git-ssh-url.test.ts index 6913b70c..87b8b940 100644 --- a/packages/coding-agent/test/git-ssh-url.test.ts +++ b/packages/coding-agent/test/git-ssh-url.test.ts @@ -62,6 +62,19 @@ describe("Git URL Parsing", () => { }); }); + it("should reject unsafe git install path inputs", () => { + for (const source of [ + "git:git@evil.example:../../victim/repo", + "https://evil.example/..%2F..%2Fvictim/repo", + "https://evil.example/..%2F..%2Fvictim/repo%", + "git:git@evil.example:/absolute/repo", + "git:git@evil.example:user\\repo/name", + "git:git@evil.example:user/repo\0name", + ]) { + expect(parseGitUrl(source)).toBeNull(); + } + }); + describe("unsupported without git: prefix", () => { it("should reject git@host:path without git: prefix", () => { expect(parseGitUrl("git@github.com:user/repo")).toBeNull(); diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index af3bc99d..848b37bd 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -33,6 +33,10 @@ interface PackageManagerInternals { options?: { cwd?: string; timeoutMs?: number; env?: Record }, ): Promise; getLocalGitUpdateTarget(installedPath: string): Promise<{ ref: string; head: string; fetchArgs: string[] }>; + getGitInstallPath( + source: { type: "git"; repo: string; host: string; path: string; pinned: boolean; ref?: string }, + scope: "user" | "project" | "temporary", + ): string; } // Helper to check if a resource is enabled @@ -1138,6 +1142,25 @@ Content`, }); }); + describe("git install paths", () => { + it("should reject paths outside git install roots", () => { + const managerWithInternals = packageManager as unknown as PackageManagerInternals; + const traversalSource = { + type: "git" as const, + repo: "git@evil.example:../../victim/repo", + host: "evil.example", + path: "../../victim/repo", + pinned: false, + }; + + for (const scope of ["user", "project", "temporary"] as const) { + expect(() => managerWithInternals.getGitInstallPath(traversalSource, scope)).toThrow( + "outside package install root", + ); + } + }); + }); + describe("settings source normalization", () => { it("should store global local packages relative to agent settings base", () => { const pkgDir = join(tempDir, "packages", "local-global-pkg"); From ba6e5298df7a1b4a9dc58eaec4e2b3a06270ec0c Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 2 Jun 2026 23:15:46 +0200 Subject: [PATCH 146/352] fix(oauth): harden browser launch handling --- packages/ai/src/utils/oauth/github-copilot.ts | 14 ++- packages/ai/src/utils/oauth/openai-codex.ts | 7 +- packages/ai/test/github-copilot-oauth.test.ts | 91 +++++++++++++++++++ .../interactive/components/login-dialog.ts | 15 +-- .../coding-agent/src/utils/open-browser.ts | 24 +++++ scripts/tool-stats.ts | 4 +- 6 files changed, 138 insertions(+), 17 deletions(-) create mode 100644 packages/coding-agent/src/utils/open-browser.ts diff --git a/packages/ai/src/utils/oauth/github-copilot.ts b/packages/ai/src/utils/oauth/github-copilot.ts index ba371b8f..6a27b9d1 100644 --- a/packages/ai/src/utils/oauth/github-copilot.ts +++ b/packages/ai/src/utils/oauth/github-copilot.ts @@ -132,10 +132,22 @@ async function startDeviceFlow(domain: string): Promise { throw new Error("Invalid device code response fields"); } + // The verification URI is opened in the user's browser and to prevent `open` from + // opening an executable or similar, we force it to be a URL. + let parsedUri: URL; + try { + parsedUri = new URL(verificationUri); + } catch { + throw new Error("Untrusted verification_uri in device code response"); + } + if (parsedUri.protocol !== "https:" && parsedUri.protocol !== "http:") { + throw new Error("Untrusted verification_uri in device code response"); + } + return { device_code: deviceCode, user_code: userCode, - verification_uri: verificationUri, + verification_uri: parsedUri.href, interval, expires_in: expiresIn, }; diff --git a/packages/ai/src/utils/oauth/openai-codex.ts b/packages/ai/src/utils/oauth/openai-codex.ts index 51ea832b..2f769a84 100644 --- a/packages/ai/src/utils/oauth/openai-codex.ts +++ b/packages/ai/src/utils/oauth/openai-codex.ts @@ -28,7 +28,6 @@ import type { OAuthProviderInterface, } from "./types.ts"; -const CALLBACK_HOST = process.env.PI_OAUTH_CALLBACK_HOST || "127.0.0.1"; const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; const AUTH_BASE_URL = "https://auth.openai.com"; const AUTHORIZE_URL = `${AUTH_BASE_URL}/oauth/authorize`; @@ -47,6 +46,10 @@ const JWT_CLAIM_PATH = "https://api.openai.com/auth"; type OAuthToken = { access: string; refresh: string; expires: number }; type TokenOperation = "exchange" | "refresh"; +function getCallbackHost(): string { + return typeof process !== "undefined" ? process.env.PI_OAUTH_CALLBACK_HOST || "127.0.0.1" : "127.0.0.1"; +} + type DeviceAuthInfo = { deviceAuthId: string; userCode: string; @@ -368,7 +371,7 @@ function startLocalOAuthServer(state: string): Promise { return new Promise((resolve) => { server - .listen(1455, CALLBACK_HOST, () => { + .listen(1455, getCallbackHost(), () => { resolve({ close: () => server.close(), cancelWait: () => { diff --git a/packages/ai/test/github-copilot-oauth.test.ts b/packages/ai/test/github-copilot-oauth.test.ts index 324883ec..c94370da 100644 --- a/packages/ai/test/github-copilot-oauth.test.ts +++ b/packages/ai/test/github-copilot-oauth.test.ts @@ -84,6 +84,97 @@ describe("GitHub Copilot OAuth device flow", () => { await loginPromise; }); + it("rejects a non-http(s) verification_uri before it reaches onDeviceCode", async () => { + // A malicious enterprise OAuth server could return a verification_uri that + // the browser launcher would otherwise hand to the OS. Ensure such values + // are rejected at the deserialization boundary. + const fetchMock = vi.fn(async (input: unknown): Promise => { + const url = getUrl(input); + if (url.endsWith("/login/device/code")) { + return jsonResponse({ + device_code: "device-code", + user_code: "ABCD-EFGH", + verification_uri: "$(id>/tmp/pwned)", + interval: 1, + expires_in: 900, + }); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + vi.stubGlobal("fetch", fetchMock); + + const onDeviceCode = vi.fn(); + await expect( + loginGitHubCopilot({ + onDeviceCode, + onPrompt: async () => "", + }), + ).rejects.toThrow(/Untrusted verification_uri/); + expect(onDeviceCode).not.toHaveBeenCalled(); + }); + + it("normalizes verification_uri before it reaches onDeviceCode", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-09T00:00:00Z")); + + const rawVerificationUri = "https://github.com/login/\x1b]8;;evil"; + const normalizedVerificationUri = new URL(rawVerificationUri).href; + expect(normalizedVerificationUri).not.toBe(rawVerificationUri); + + const fetchMock = vi.fn(async (input: unknown): Promise => { + const url = getUrl(input); + + if (url.endsWith("/login/device/code")) { + return jsonResponse({ + device_code: "device-code", + user_code: "ABCD-EFGH", + verification_uri: rawVerificationUri, + interval: 1, + expires_in: 900, + }); + } + + if (url.endsWith("/login/oauth/access_token")) { + return jsonResponse({ access_token: "ghu_refresh_token" }); + } + + if (url.includes("/copilot_internal/v2/token")) { + return jsonResponse({ + token: "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;", + expires_at: 9999999999, + }); + } + + if (url.includes("/models/") && url.endsWith("/policy")) { + return new Response("", { status: 200 }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + vi.stubGlobal("fetch", fetchMock); + + const onDeviceCode = vi.fn(); + const loginPromise = loginGitHubCopilot({ + onDeviceCode, + onPrompt: async () => "", + }); + + await vi.advanceTimersByTimeAsync(0); + + expect(onDeviceCode).toHaveBeenCalledWith({ + userCode: "ABCD-EFGH", + verificationUri: normalizedVerificationUri, + intervalSeconds: 1, + expiresInSeconds: 900, + }); + expect(onDeviceCode).not.toHaveBeenCalledWith(expect.objectContaining({ verificationUri: rawVerificationUri })); + + await vi.advanceTimersByTimeAsync(1000); + await loginPromise; + }); + it("polls immediately and increases the interval after slow_down", async () => { vi.useFakeTimers(); const startTime = new Date("2026-03-09T00:00:00Z"); diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index 3ae9c92d..84d9a2c1 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -1,6 +1,6 @@ import { getOAuthProviders, type OAuthDeviceCodeInfo } from "@earendil-works/pi-ai/oauth"; import { Container, type Focusable, getKeybindings, Input, Spacer, Text, type TUI } from "@earendil-works/pi-tui"; -import { exec } from "child_process"; +import { openBrowser } from "../../../utils/open-browser.ts"; import { theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { keyHint } from "./keybinding-hints.ts"; @@ -101,7 +101,7 @@ export class LoginDialogComponent extends Container implements Focusable { this.contentContainer.addChild(new Text(theme.fg("warning", instructions), 1, 0)); } - this.openUrl(url); + openBrowser(url); this.tui.requestRender(); } @@ -120,19 +120,10 @@ export class LoginDialogComponent extends Container implements Focusable { this.contentContainer.addChild(new Spacer(1)); this.contentContainer.addChild(new Text(theme.fg("warning", `Enter code: ${info.userCode}`), 1, 0)); - this.openUrl(info.verificationUri); + openBrowser(info.verificationUri); this.tui.requestRender(); } - private openUrl(url: string): void { - const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; - try { - exec(`${openCmd} "${url}"`, () => {}); - } catch { - // Ignore browser launch failures. The URL remains visible for manual opening/copying. - } - } - /** * Show input for manual code/URL entry (for callback server providers) */ diff --git a/packages/coding-agent/src/utils/open-browser.ts b/packages/coding-agent/src/utils/open-browser.ts new file mode 100644 index 00000000..435e23f9 --- /dev/null +++ b/packages/coding-agent/src/utils/open-browser.ts @@ -0,0 +1,24 @@ +import { spawn } from "node:child_process"; + +/** + * Open a URL or file in the platform browser/default handler. + * + * This intentionally never invokes a shell. On Windows, do not use + * `cmd /c start`: cmd.exe re-parses metacharacters (&, |, ^, ...) before + * `start` runs, which would make attacker-controlled URLs injectable. + */ +export function openBrowser(target: string): void { + const [cmd, args]: [string, string[]] = + process.platform === "darwin" + ? ["open", [target]] + : process.platform === "win32" + ? ["rundll32", ["url.dll,FileProtocolHandler", target]] + : ["xdg-open", [target]]; + + // spawn reports launcher failures (for example, missing xdg-open) via an + // error event. Browser launch is best-effort: callers still present the target + // to the user, so keep the launcher failure from becoming a process crash. + spawn(cmd, args, { stdio: "ignore", detached: true }) + .on("error", () => {}) + .unref(); +} diff --git a/scripts/tool-stats.ts b/scripts/tool-stats.ts index 78a69418..742e0a83 100755 --- a/scripts/tool-stats.ts +++ b/scripts/tool-stats.ts @@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { spawn } from "node:child_process"; +import { openBrowser } from "../packages/coding-agent/src/utils/open-browser.ts"; interface TextContent { type: "text"; text: string } interface ImageContent { type: "image"; data: string; mimeType?: string } @@ -229,4 +229,4 @@ const html = ` mkdirSync(resolve(output, ".."), { recursive: true }); writeFileSync(output, html); console.log(`Wrote ${output}`); -spawn(process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open", process.platform === "win32" ? ["/c", "start", output] : [output], { detached: true, stdio: "ignore" }).unref(); +openBrowser(output); From 83afcdc24f0f4aa8233d8e5eb7f8d0ca6031ddf7 Mon Sep 17 00:00:00 2001 From: Mattia Cerutti Date: Wed, 3 Jun 2026 00:09:30 +0200 Subject: [PATCH 147/352] fix(ai): remove stale codex models --- packages/ai/scripts/generate-models.ts | 24 ------- packages/ai/src/models.generated.ts | 90 +++++++++++++------------- 2 files changed, 44 insertions(+), 70 deletions(-) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index c98f868c..17d80238 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -1740,30 +1740,6 @@ async function generateModels() { const CODEX_SPARK_CONTEXT = 128000; const CODEX_MAX_TOKENS = 128000; const codexModels: Model<"openai-codex-responses">[] = [ - { - id: "gpt-5.2", - name: "GPT-5.2", - api: "openai-codex-responses", - provider: "openai-codex", - baseUrl: CODEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - contextWindow: CODEX_CONTEXT, - maxTokens: CODEX_MAX_TOKENS, - }, - { - id: "gpt-5.3-codex", - name: "GPT-5.3 Codex", - api: "openai-codex-responses", - provider: "openai-codex", - baseUrl: CODEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - contextWindow: CODEX_CONTEXT, - maxTokens: CODEX_MAX_TOKENS, - }, { id: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 54acc952..7da8c5a9 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -5630,13 +5630,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.3, - output: 1.2, - cacheRead: 0.06, - cacheWrite: 0.375, + input: 0.6, + output: 2.4, + cacheRead: 0.12, + cacheWrite: 0, }, contextWindow: 512000, - maxTokens: 131072, + maxTokens: 128000, } satisfies Model<"anthropic-messages">, }, "minimax-cn": { @@ -5683,13 +5683,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.3, - output: 1.2, - cacheRead: 0.06, - cacheWrite: 0.375, + input: 0.6, + output: 2.4, + cacheRead: 0.12, + cacheWrite: 0, }, contextWindow: 512000, - maxTokens: 131072, + maxTokens: 128000, } satisfies Model<"anthropic-messages">, }, "mistral": { @@ -5727,6 +5727,23 @@ export const MODELS = { contextWindow: 262144, maxTokens: 262144, } satisfies Model<"mistral-conversations">, + "devstral-latest": { + id: "devstral-latest", + name: "Devstral 2", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: false, + input: ["text"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"mistral-conversations">, "devstral-medium-2507": { id: "devstral-medium-2507", name: "Devstral Medium", @@ -6101,6 +6118,23 @@ export const MODELS = { contextWindow: 8000, maxTokens: 8000, } satisfies Model<"mistral-conversations">, + "open-mistral-nemo": { + id: "open-mistral-nemo", + name: "Open Mistral Nemo", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: false, + input: ["text"], + cost: { + input: 0.15, + output: 0.15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"mistral-conversations">, "open-mixtral-8x22b": { id: "open-mixtral-8x22b", name: "Mixtral 8x22B", @@ -7549,42 +7583,6 @@ export const MODELS = { } satisfies Model<"openai-responses">, }, "openai-codex": { - "gpt-5.2": { - id: "gpt-5.2", - name: "GPT-5.2", - api: "openai-codex-responses", - provider: "openai-codex", - baseUrl: "https://chatgpt.com/backend-api", - reasoning: true, - thinkingLevelMap: {"xhigh":"xhigh","minimal":"low"}, - input: ["text", "image"], - cost: { - input: 1.75, - output: 14, - cacheRead: 0.175, - cacheWrite: 0, - }, - contextWindow: 272000, - maxTokens: 128000, - } satisfies Model<"openai-codex-responses">, - "gpt-5.3-codex": { - id: "gpt-5.3-codex", - name: "GPT-5.3 Codex", - api: "openai-codex-responses", - provider: "openai-codex", - baseUrl: "https://chatgpt.com/backend-api", - reasoning: true, - thinkingLevelMap: {"xhigh":"xhigh","minimal":"low"}, - input: ["text", "image"], - cost: { - input: 1.75, - output: 14, - cacheRead: 0.175, - cacheWrite: 0, - }, - contextWindow: 272000, - maxTokens: 128000, - } satisfies Model<"openai-codex-responses">, "gpt-5.3-codex-spark": { id: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark", From 135fb545f99106a4a249274f129b90bc0a77d347 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 3 Jun 2026 00:32:54 +0200 Subject: [PATCH 148/352] fix(coding-agent): set auth file mode on creation --- packages/coding-agent/src/core/auth-storage.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index 7630fc80..9a0564ec 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -45,6 +45,8 @@ type LockResult = { next?: string; }; +const AUTH_FILE_WRITE_OPTIONS = { encoding: "utf-8", mode: 0o600 } as const; + export interface AuthStorageBackend { withLock(fn: (current: string | undefined) => LockResult): T; withLockAsync(fn: (current: string | undefined) => Promise>): Promise; @@ -66,7 +68,7 @@ export class FileAuthStorageBackend implements AuthStorageBackend { private ensureFileExists(): void { if (!existsSync(this.authPath)) { - writeFileSync(this.authPath, "{}", "utf-8"); + writeFileSync(this.authPath, "{}", AUTH_FILE_WRITE_OPTIONS); chmodSync(this.authPath, 0o600); } } @@ -108,7 +110,7 @@ export class FileAuthStorageBackend implements AuthStorageBackend { const current = existsSync(this.authPath) ? readFileSync(this.authPath, "utf-8") : undefined; const { result, next } = fn(current); if (next !== undefined) { - writeFileSync(this.authPath, next, "utf-8"); + writeFileSync(this.authPath, next, AUTH_FILE_WRITE_OPTIONS); chmodSync(this.authPath, 0o600); } return result; @@ -153,7 +155,7 @@ export class FileAuthStorageBackend implements AuthStorageBackend { const { result, next } = await fn(current); throwIfCompromised(); if (next !== undefined) { - writeFileSync(this.authPath, next, "utf-8"); + writeFileSync(this.authPath, next, AUTH_FILE_WRITE_OPTIONS); chmodSync(this.authPath, 0o600); } throwIfCompromised(); From ea3465a8e371a12d0167a06b60f93878e3a3df44 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 3 Jun 2026 00:44:21 +0200 Subject: [PATCH 149/352] fix(coding-agent): move temporary extension cache (#5345) --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/package-manager.ts | 13 ++++++-- packages/coding-agent/test/git-update.test.ts | 27 ++++++++++------ .../coding-agent/test/package-manager.test.ts | 32 ++++++++++++++++++- 4 files changed, 60 insertions(+), 13 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5ddda524..7da2178d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -11,6 +11,7 @@ ### Fixed +- Fixed temporary extension package installs to use a private `~/.pi/agent/tmp/extensions` directory with `0700` permissions instead of `os.tmpdir()/pi-extensions`. - Fixed git package source handling to reject unsafe host/path components and keep managed clone paths inside install roots. - Fixed stored XSS in HTML session exports by sanitizing Markdown link and image URLs with a scheme allow-list after stripping control characters. - Fixed SDK embedding in bundled Node apps failing with `ENOENT` when `package.json` is not present next to the bundle entrypoint. The package metadata reader now gracefully handles missing `package.json` by using defaults, enabling `createAgentSession()` without requiring package-adjacent files at runtime ([#5226](https://github.com/earendil-works/pi/issues/5226)). diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 7c0e96a1..ac35a273 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -1,7 +1,7 @@ import type { ChildProcess, ChildProcessByStdio } from "node:child_process"; import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { homedir, tmpdir } from "node:os"; +import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; function getEnv(): NodeJS.ProcessEnv { if (process.platform !== "linux" || Object.keys(process.env).length > 0) { @@ -206,6 +206,13 @@ function getHomeDir(): string { return process.env.HOME || homedir(); } +export function getExtensionTempFolder(agentDir: string): string { + const tempFolder = join(agentDir, "tmp", "extensions"); + mkdirSync(tempFolder, { recursive: true, mode: 0o700 }); + chmodSync(tempFolder, 0o700); + return tempFolder; +} + function prefixIgnorePattern(line: string, prefix: string): string | null { const trimmed = line.trim(); if (!trimmed) return null; @@ -1970,7 +1977,7 @@ export class DefaultPackageManager implements PackageManager { } private getTemporaryDir(prefix: string, suffix?: string): string { - const root = this.resolveManagedPath(join(tmpdir(), "pi-extensions"), prefix); + const root = this.resolveManagedPath(getExtensionTempFolder(this.agentDir), prefix); const hash = createHash("sha256") .update(`${prefix}-${suffix ?? ""}`) .digest("hex") diff --git a/packages/coding-agent/test/git-update.test.ts b/packages/coding-agent/test/git-update.test.ts index ea8fae4d..1508adcd 100644 --- a/packages/coding-agent/test/git-update.test.ts +++ b/packages/coding-agent/test/git-update.test.ts @@ -7,7 +7,6 @@ */ import { spawnSync } from "node:child_process"; -import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -51,6 +50,20 @@ function getFileContent(repoDir: string, filename: string): string { return readFileSync(join(repoDir, filename), "utf-8"); } +type GitSourceForTest = { + type: "git"; + repo: string; + host: string; + path: string; + pinned: boolean; + ref?: string; +}; + +interface PackageManagerPathInternals { + parseSource(source: string): GitSourceForTest; + getGitInstallPath(source: GitSourceForTest, scope: "temporary"): string; +} + describe("DefaultPackageManager git update", () => { let tempDir: string; let remoteDir: string; // Simulates the "remote" repository @@ -376,10 +389,8 @@ describe("DefaultPackageManager git update", () => { describe("temporary git sources", () => { it("should refresh cached temporary git sources when resolving", async () => { - const gitHost = "github.com"; - const gitPath = "test/extension"; - const hash = createHash("sha256").update(`git-${gitHost}-${gitPath}`).digest("hex").slice(0, 8); - const cachedDir = join(tmpdir(), "pi-extensions", `git-${gitHost}`, hash, gitPath); + const managerWithPaths = packageManager as unknown as PackageManagerPathInternals; + const cachedDir = managerWithPaths.getGitInstallPath(managerWithPaths.parseSource(gitSource), "temporary"); const extensionFile = join(cachedDir, "pi-extensions", "session-breakdown.ts"); rmSync(cachedDir, { recursive: true, force: true }); @@ -423,10 +434,8 @@ describe("DefaultPackageManager git update", () => { }); it("should not refresh pinned temporary git sources", async () => { - const gitHost = "github.com"; - const gitPath = "test/extension"; - const hash = createHash("sha256").update(`git-${gitHost}-${gitPath}`).digest("hex").slice(0, 8); - const cachedDir = join(tmpdir(), "pi-extensions", `git-${gitHost}`, hash, gitPath); + const managerWithPaths = packageManager as unknown as PackageManagerPathInternals; + const cachedDir = managerWithPaths.getGitInstallPath(managerWithPaths.parseSource(gitSource), "temporary"); const extensionFile = join(cachedDir, "pi-extensions", "session-breakdown.ts"); rmSync(cachedDir, { recursive: true, force: true }); diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 848b37bd..cd1f4532 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -1,5 +1,5 @@ import { EventEmitter } from "node:events"; -import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { mkdirSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, relative } from "node:path"; import { PassThrough } from "node:stream"; @@ -33,6 +33,16 @@ interface PackageManagerInternals { options?: { cwd?: string; timeoutMs?: number; env?: Record }, ): Promise; getLocalGitUpdateTarget(installedPath: string): Promise<{ ref: string; head: string; fetchArgs: string[] }>; + parseSource( + source: string, + ): + | { type: "npm"; spec: string; name: string; pinned: boolean } + | { type: "git"; repo: string; host: string; path: string; pinned: boolean; ref?: string } + | { type: "local"; path: string }; + getNpmInstallPath( + source: { type: "npm"; spec: string; name: string; pinned: boolean }, + scope: "user" | "project" | "temporary", + ): string; getGitInstallPath( source: { type: "git"; repo: string; host: string; path: string; pinned: boolean; ref?: string }, scope: "user" | "project" | "temporary", @@ -1161,6 +1171,26 @@ Content`, }); }); + describe("temporary install paths", () => { + it("should place temporary npm packages under the agent temp extension folder", () => { + const managerWithInternals = packageManager as unknown as PackageManagerInternals; + const source = managerWithInternals.parseSource("npm:left-pad"); + if (source.type !== "npm") { + throw new Error("Expected npm source"); + } + + const installPath = managerWithInternals.getNpmInstallPath(source, "temporary"); + const tempRoot = join(agentDir, "tmp", "extensions"); + + expect(pathEndsWith(installPath, "node_modules/left-pad")).toBe(true); + expect(relative(tempRoot, installPath).startsWith("..")).toBe(false); + expect(installPath.startsWith(join(tmpdir(), "pi-extensions"))).toBe(false); + if (process.platform !== "win32") { + expect(statSync(tempRoot).mode & 0o777).toBe(0o700); + } + }); + }); + describe("settings source normalization", () => { it("should store global local packages relative to agent settings base", () => { const pkgDir = join(tempDir, "packages", "local-global-pkg"); From 86314bf38dd284b649e3d7569aa3517f2a25435b Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Wed, 3 Jun 2026 15:53:16 +0200 Subject: [PATCH 150/352] docs: add containerization guide and Gondolin example (#5356) --- README.md | 10 + package-lock.json | 184 +++++- package.json | 3 +- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/README.md | 2 +- .../coding-agent/docs/containerization.md | 111 ++++ packages/coding-agent/docs/docs.json | 4 + packages/coding-agent/docs/extensions.md | 1 + packages/coding-agent/docs/index.md | 1 + .../examples/extensions/README.md | 1 + .../examples/extensions/gondolin/.gitignore | 1 + .../examples/extensions/gondolin/index.ts | 531 ++++++++++++++++++ .../extensions/gondolin/package-lock.json | 185 ++++++ .../examples/extensions/gondolin/package.json | 19 + packages/coding-agent/package.json | 1 + 15 files changed, 1052 insertions(+), 3 deletions(-) create mode 100644 packages/coding-agent/docs/containerization.md create mode 100644 packages/coding-agent/examples/extensions/gondolin/.gitignore create mode 100644 packages/coding-agent/examples/extensions/gondolin/index.ts create mode 100644 packages/coding-agent/examples/extensions/gondolin/package-lock.json create mode 100644 packages/coding-agent/examples/extensions/gondolin/package.json diff --git a/README.md b/README.md index 92ad3d7b..563b6858 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,16 @@ I regularly publish my own `pi-mono` work sessions here: For Slack/chat automation and workflows see [earendil-works/pi-chat](https://github.com/earendil-works/pi-chat). +## Permissions & Containerization + +Pi does not include a built-in permission system for restricting filesystem, process, network, or credential access. By default, it runs with the permissions of the user and process that launched it. + +If you need stronger boundaries, containerize or sandbox Pi. See [packages/coding-agent/docs/containerization.md](packages/coding-agent/docs/containerization.md) for three patterns: + +- **OpenShell**: run the whole `pi` process in a policy-controlled sandbox. +- **Gondolin extension**: keep `pi` and provider auth on the host while routing built-in tools and `!` commands into a local Linux micro-VM. +- **Plain Docker**: run the whole `pi` process in a local container for simple isolation. + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines and [AGENTS.md](AGENTS.md) for project-specific rules (for both humans and agents). diff --git a/package-lock.json b/package-lock.json index 5ddf9899..46b200b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "packages/coding-agent/examples/extensions/with-deps", "packages/coding-agent/examples/extensions/custom-provider-anthropic", "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo", - "packages/coding-agent/examples/extensions/sandbox" + "packages/coding-agent/examples/extensions/sandbox", + "packages/coding-agent/examples/extensions/gondolin" ], "devDependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26", @@ -722,6 +723,78 @@ "node": ">=14.21.3" } }, + "node_modules/@cto.af/wtf8": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@cto.af/wtf8/-/wtf8-0.0.5.tgz", + "integrity": "sha512-LfUFi+Vv4eDzj+XAtR89e3wwjXA/NZjUSwU5NhwbBrLecxPaBYFy3exCuc1j+D4UZeOVdqlsl8G7LmOt18V0tg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@earendil-works/gondolin": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@earendil-works/gondolin/-/gondolin-0.12.0.tgz", + "integrity": "sha512-BXbvzQKb5QmxY5NtthRDONJTu7+IDKbzqWGrJyyNXMP7N681Tx0Q9TK8pK1ba8nUvYQTipNJyGZOsJfYiZll1A==", + "license": "Apache-2.0", + "dependencies": { + "cbor2": "^2.3.0", + "node-forge": "^1.3.3", + "ssh2": "^1.17.0", + "undici": "^6.21.0" + }, + "bin": { + "gondolin": "dist/bin/gondolin.js" + }, + "engines": { + "node": ">=23.6.0" + }, + "optionalDependencies": { + "@earendil-works/gondolin-krun-runner-darwin-arm64": "0.12.0", + "@earendil-works/gondolin-krun-runner-linux-x64": "0.12.0" + } + }, + "node_modules/@earendil-works/gondolin-krun-runner-darwin-arm64": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-darwin-arm64/-/gondolin-krun-runner-darwin-arm64-0.12.0.tgz", + "integrity": "sha512-ftDlusht4PcT7Y3TuPrZIKrCXy3isiBTVMvlXYK0pcud2uXY6uwFTGeunYgP+8ND/60ddb+MImqbfmkcK8B84A==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "gondolin-krun-runner": "bin/gondolin-krun-runner" + } + }, + "node_modules/@earendil-works/gondolin-krun-runner-linux-x64": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-linux-x64/-/gondolin-krun-runner-linux-x64-0.12.0.tgz", + "integrity": "sha512-RRYsgwe2r5ApKmFNy469QgwnyjAHpAs9XANdWpTd9ol4iUYOY3sX7e0xIooAKxd+ktxGI4N/xRWicwGen3D/Ow==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "gondolin-krun-runner": "bin/gondolin-krun-runner" + } + }, + "node_modules/@earendil-works/gondolin/node_modules/undici": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", + "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/@earendil-works/pi-agent-core": { "resolved": "packages/agent", "link": true @@ -2572,6 +2645,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2623,6 +2705,15 @@ ], "license": "MIT" }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -2706,6 +2797,15 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -2731,6 +2831,18 @@ "node": "^18.12.0 || >= 20.9.0" } }, + "node_modules/cbor2": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cbor2/-/cbor2-2.3.0.tgz", + "integrity": "sha512-76WB3hq8BoaGkMkBVJ27fW5LJU+qqDLEpgRNCG/SYKhODWXpVPOTD4UcUto3IEzYLA52nsvbhb0wabhHDn3qXg==", + "license": "MIT", + "dependencies": { + "@cto.af/wtf8": "0.0.5" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -2806,6 +2918,20 @@ "node": ">=18" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3943,6 +4069,13 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nan": { + "version": "2.27.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", + "integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -4034,6 +4167,15 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, "node_modules/npm-run-path": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", @@ -4202,6 +4344,10 @@ "resolved": "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo", "link": true }, + "node_modules/pi-extension-gondolin": { + "resolved": "packages/coding-agent/examples/extensions/gondolin", + "link": true + }, "node_modules/pi-extension-sandbox": { "resolved": "packages/coding-agent/examples/extensions/sandbox", "link": true @@ -4544,6 +4690,12 @@ ], "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", @@ -4696,6 +4848,23 @@ "node": ">=0.10.0" } }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -5116,6 +5285,12 @@ "node": "*" } }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/typebox": { "version": "1.1.38", "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", @@ -6154,6 +6329,13 @@ "name": "pi-extension-custom-provider-gitlab-duo", "version": "0.78.0" }, + "packages/coding-agent/examples/extensions/gondolin": { + "name": "pi-extension-gondolin", + "version": "0.78.0", + "dependencies": { + "@earendil-works/gondolin": "0.12.0" + } + }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", "version": "1.8.0", diff --git a/package.json b/package.json index 072c908a..ed9dbec9 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "packages/coding-agent/examples/extensions/with-deps", "packages/coding-agent/examples/extensions/custom-provider-anthropic", "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo", - "packages/coding-agent/examples/extensions/sandbox" + "packages/coding-agent/examples/extensions/sandbox", + "packages/coding-agent/examples/extensions/gondolin" ], "scripts": { "clean": "npm run clean --workspaces", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7da2178d..a0c74781 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added containerization documentation and a Gondolin extension example for routing built-in tools into a local micro-VM. - Added Ant Ling provider selection and setup documentation. - Added NVIDIA NIM provider selection, setup documentation, and direct NIM request attribution headers. - Added `ctx.mode` to extension contexts so extensions can distinguish TUI, RPC, JSON, and print mode. diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 60e8da1a..cec58d50 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -95,7 +95,7 @@ pi Then just talk to pi. By default, pi gives the model four tools: `read`, `write`, `edit`, and `bash`. The model uses these to fulfill your requests. Add capabilities via [skills](#skills), [prompt templates](#prompt-templates), [extensions](#extensions), or [pi packages](#pi-packages). -**Platform notes:** [Windows](docs/windows.md) | [Termux (Android)](docs/termux.md) | [tmux](docs/tmux.md) | [Terminal setup](docs/terminal-setup.md) | [Shell aliases](docs/shell-aliases.md) +**Platform notes:** [Windows](docs/windows.md) | [Termux (Android)](docs/termux.md) | [tmux](docs/tmux.md) | [Terminal setup](docs/terminal-setup.md) | [Shell aliases](docs/shell-aliases.md) | [Containerization](docs/containerization.md) --- diff --git a/packages/coding-agent/docs/containerization.md b/packages/coding-agent/docs/containerization.md new file mode 100644 index 00000000..a3a96bee --- /dev/null +++ b/packages/coding-agent/docs/containerization.md @@ -0,0 +1,111 @@ +# Containerization + +Pi runs with all permissions by default, but in some cases, you will want to have more control over what directories Pi can write to and which accesses it has. + +There are two general options. You can either +1. run the whole `pi` process inside an isolated environment, or +2. run `pi` on the host and route tool execution into an isolated environment. + +## Choose a pattern + +| Pattern | What is isolated | Best for | Notes | +| --- | --- | --- | --- | +| OpenShell | Whole `pi` process in a policy-controlled sandbox | Local or remote managed sandbox | Requires an OpenShell gateway | +| Gondolin extension | Built-in tools and `!` commands | Local micro-VM isolation while keeping auth on host | See [`examples/extensions/gondolin/`](../examples/extensions/gondolin/). | +| Plain Docker | Whole `pi` process in a local container | Simple local isolation | Provider API keys enter the container. | + +Extensions run wherever the `pi` process runs. If you run host `pi` with a tool-routing extension, other custom extension tools still run on the host unless they also delegate their operations. + +## OpenShell + +Use [NVIDIA OpenShell](https://docs.nvidia.com/openshell/about/overview) when you want a policy-controlled sandbox with filesystem, process, network, credential, and inference controls. +OpenShell can run sandboxes through a local gateway backed by Docker, Podman, or a VM runtime, or through a remote Kubernetes gateway. + +Every sandbox requires an active gateway. +Register and select one before creating a sandbox: + +```bash +openshell gateway add --name +openshell gateway select +``` + +Launch `pi` inside an OpenShell sandbox: + +```bash +openshell sandbox create --name pi-sandbox --from pi -- pi +``` + +In this pattern, the whole `pi` process runs inside the sandbox. +Built-in tools, `!` commands, and extension tools execute inside the OpenShell boundary. + +If the gateway is remote, project files are not bind-mounted from the host, meaning writes in the sandbox are not reflected on your machine. +Clone the repository inside the sandbox or use OpenShell file transfer commands: + +```bash +openshell sandbox upload pi-sandbox ./repo /workspace +openshell sandbox download pi-sandbox /workspace/repo ./repo-out +``` + +OpenShell providers can keep raw model API keys outside the sandbox. +When inference routing is configured, code inside the sandbox can call `https://inference.local`, and the gateway injects the configured provider credentials upstream. +Configure Pi to use the corresponding OpenAI-compatible or Anthropic-compatible endpoint if you want model traffic to use this route. + +## Gondolin + +[Gondolin](https://github.com/earendil-works/gondolin) is a local Linux micro-VM. +Use the [example extension](../examples/extensions/gondolin) when you want `pi` on the host but all built-in tools routed into the VM. + +Setup: + +```bash +cp -R packages/coding-agent/examples/extensions/gondolin ~/.pi/agent/extensions/gondolin +cd ~/.pi/agent/extensions/gondolin +npm install --ignore-scripts +``` + +Run from the project you want mounted: + +```bash +cd /path/to/project +pi -e ~/.pi/agent/extensions/gondolin +``` + +The extension mounts the host cwd at `/workspace` in the VM and overrides `read`, `write`, `edit`, `bash`, `grep`, `find`, and `ls`. +User `!` commands are routed into the VM, as well. +File changes under `/workspace` write through to the host. + +Requirements: Node.js >= 23.6.0 for `@earendil-works/gondolin`, plus QEMU (requires installation through your package manager). + +## Plain Docker + +Run the whole `pi` process in Docker when you want the simplest local container boundary. + +`Dockerfile.pi`: + +```dockerfile +FROM node:24-bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends bash ca-certificates git ripgrep \ + && rm -rf /var/lib/apt/lists/* +RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent + +WORKDIR /workspace +ENTRYPOINT ["pi"] +``` + +Build and run: + +```bash +docker build -t pi-sandbox -f Dockerfile.pi . + +docker run --rm -it \ + -e ANTHROPIC_API_KEY \ + -v "$PWD:/workspace" \ + -v pi-agent-home:/root/.pi/agent \ + pi-sandbox +``` + +The `-v "$PWD:/workspace"` mounts your current directory into the container at /workspace such that reads and writes in `/workspace` inside Docker directly affect your host files, like in the Gondolin example. + +Use a named volume for `/root/.pi/agent` if you want container-local settings and sessions. Mounting your host `~/.pi/agent` exposes host auth and session files to the container. diff --git a/packages/coding-agent/docs/docs.json b/packages/coding-agent/docs/docs.json index f96321d9..f781abc2 100644 --- a/packages/coding-agent/docs/docs.json +++ b/packages/coding-agent/docs/docs.json @@ -19,6 +19,10 @@ "title": "Providers", "path": "providers.md" }, + { + "title": "Containerization", + "path": "containerization.md" + }, { "title": "Settings", "path": "settings.md" diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index a8b453a6..08f60162 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -2606,6 +2606,7 @@ All examples in [examples/extensions/](../examples/extensions/). | `ssh.ts` | SSH remote execution | `registerFlag`, `on("user_bash")`, `on("before_agent_start")`, tool operations | | `interactive-shell.ts` | Persistent shell session | `on("user_bash")` | | `sandbox/` | Sandboxed tool execution | Tool operations | +| `gondolin/` | Route built-in tools and `!` commands into a Gondolin micro-VM | Tool operations, built-in tool overrides, `on("user_bash")` | | `subagent/` | Spawn sub-agents | `registerTool`, `exec` | | **Games** ||| | `snake.ts` | Snake game | `registerCommand`, `ui.custom`, keyboard handling | diff --git a/packages/coding-agent/docs/index.md b/packages/coding-agent/docs/index.md index 75a527d6..2a334e8a 100644 --- a/packages/coding-agent/docs/index.md +++ b/packages/coding-agent/docs/index.md @@ -41,6 +41,7 @@ For the full first-run flow, see [Quickstart](quickstart.md). - [Quickstart](quickstart.md) - install, authenticate, and run a first session. - [Using Pi](usage.md) - interactive mode, slash commands, context files, and CLI reference. - [Providers](providers.md) - subscription and API-key setup for built-in providers. +- [Containerization](containerization.md) - sandbox pi with OpenShell, Gondolin, or Docker. - [Settings](settings.md) - global and project settings. - [Keybindings](keybindings.md) - default shortcuts and custom keybindings. - [Sessions](sessions.md) - session management, branching, and tree navigation. diff --git a/packages/coding-agent/examples/extensions/README.md b/packages/coding-agent/examples/extensions/README.md index fc2049e6..97103347 100644 --- a/packages/coding-agent/examples/extensions/README.md +++ b/packages/coding-agent/examples/extensions/README.md @@ -23,6 +23,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/ | `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, fork) | | `dirty-repo-guard.ts` | Prevents session changes with uncommitted git changes | | `sandbox/` | OS-level sandboxing using `@anthropic-ai/sandbox-runtime` with per-project config | +| `gondolin/` | Route built-in tools and `!` commands into a Gondolin micro-VM | ### Custom Tools diff --git a/packages/coding-agent/examples/extensions/gondolin/.gitignore b/packages/coding-agent/examples/extensions/gondolin/.gitignore new file mode 100644 index 00000000..c2658d7d --- /dev/null +++ b/packages/coding-agent/examples/extensions/gondolin/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/packages/coding-agent/examples/extensions/gondolin/index.ts b/packages/coding-agent/examples/extensions/gondolin/index.ts new file mode 100644 index 00000000..ab050df7 --- /dev/null +++ b/packages/coding-agent/examples/extensions/gondolin/index.ts @@ -0,0 +1,531 @@ +/** + * Gondolin Tool Routing Example + * + * Runs pi's built-in tools inside a local Gondolin micro-VM. The host working + * directory is mounted at /workspace in the guest. File changes under + * /workspace write through to the host; other guest filesystem changes are + * isolated to the VM. + * + * Setup: + * cd packages/coding-agent/examples/extensions/gondolin + * npm install --ignore-scripts + * + * Usage: + * cd /path/to/project + * pi -e /path/to/pi/packages/coding-agent/examples/extensions/gondolin + * + * Requirements: + * - Node.js >= 23.6.0 for @earendil-works/gondolin + * - QEMU installed (for example, `brew install qemu` on macOS) + */ + +import path from "node:path"; +import { RealFSProvider, VM } from "@earendil-works/gondolin"; +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { + type BashOperations, + createBashTool, + createEditTool, + createFindTool, + createGrepTool, + createLsTool, + createReadTool, + createWriteTool, + DEFAULT_MAX_BYTES, + type EditOperations, + type FindOperations, + formatSize, + type GrepToolDetails, + type GrepToolInput, + type LsOperations, + type ReadOperations, + truncateHead, + truncateLine, + type WriteOperations, +} from "@earendil-works/pi-coding-agent"; + +const GUEST_WORKSPACE = "/workspace"; +const DEFAULT_GREP_LIMIT = 100; + +type TextToolResult = { + content: Array<{ type: "text"; text: string }>; + details: TDetails | undefined; +}; + +function stripAtPrefix(value: string): string { + return value.startsWith("@") ? value.slice(1) : value; +} + +function toPosix(value: string): string { + return value.split(path.sep).join(path.posix.sep); +} + +function isInsideHostPath(root: string, value: string): boolean { + const relativePath = path.relative(root, value); + return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); +} + +function hostPathToGuest(localCwd: string, hostPath: string): string { + const relativePath = path.relative(localCwd, hostPath); + if (!isInsideHostPath(localCwd, hostPath)) return toPosix(hostPath); + return relativePath ? path.posix.join(GUEST_WORKSPACE, toPosix(relativePath)) : GUEST_WORKSPACE; +} + +function toGuestPath(localCwd: string, inputPath: string): string { + const trimmed = stripAtPrefix(inputPath.trim()); + if (!trimmed) return GUEST_WORKSPACE; + if (path.isAbsolute(trimmed)) { + if (isInsideHostPath(localCwd, trimmed)) return hostPathToGuest(localCwd, trimmed); + return path.posix.resolve("/", toPosix(trimmed)); + } + return path.posix.resolve(GUEST_WORKSPACE, toPosix(trimmed)); +} + +function createGondolinReadOps(vm: VM, localCwd: string): ReadOperations { + return { + readFile: async (filePath) => vm.fs.readFile(toGuestPath(localCwd, filePath)), + access: async (filePath) => { + await vm.fs.access(toGuestPath(localCwd, filePath)); + }, + detectImageMimeType: async (filePath) => { + const ext = path.posix.extname(toGuestPath(localCwd, filePath)).toLowerCase(); + if (ext === ".png") return "image/png"; + if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg"; + if (ext === ".gif") return "image/gif"; + if (ext === ".webp") return "image/webp"; + return null; + }, + }; +} + +function createGondolinWriteOps(vm: VM, localCwd: string): WriteOperations { + return { + writeFile: async (filePath, content) => { + await vm.fs.writeFile(toGuestPath(localCwd, filePath), content, { encoding: "utf8" }); + }, + mkdir: async (dirPath) => { + await vm.fs.mkdir(toGuestPath(localCwd, dirPath), { recursive: true }); + }, + }; +} + +function createGondolinEditOps(vm: VM, localCwd: string): EditOperations { + const readOps = createGondolinReadOps(vm, localCwd); + const writeOps = createGondolinWriteOps(vm, localCwd); + return { + readFile: readOps.readFile, + writeFile: writeOps.writeFile, + access: readOps.access, + }; +} + +function createGondolinLsOps(vm: VM, localCwd: string): LsOperations { + return { + exists: async (filePath) => { + try { + await vm.fs.access(toGuestPath(localCwd, filePath)); + return true; + } catch { + return false; + } + }, + stat: async (filePath) => vm.fs.stat(toGuestPath(localCwd, filePath)), + readdir: async (dirPath) => vm.fs.listDir(toGuestPath(localCwd, dirPath)), + }; +} + +async function walkGuestFiles( + vm: VM, + root: string, + visit: (guestPath: string, relativePath: string) => Promise, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) throw new Error("Operation aborted"); + const stat = await vm.fs.stat(root, { signal }); + if (!stat.isDirectory()) return visit(root, path.posix.basename(root)); + + const walkDirectory = async (dir: string, relativeDir: string): Promise => { + if (signal?.aborted) throw new Error("Operation aborted"); + const entries = await vm.fs.listDir(dir, { signal }); + for (const entry of entries) { + if (entry === ".git" || entry === "node_modules") continue; + const guestPath = path.posix.join(dir, entry); + const relativePath = relativeDir ? path.posix.join(relativeDir, entry) : entry; + let entryStat: Awaited>; + try { + entryStat = await vm.fs.stat(guestPath, { signal }); + } catch { + continue; + } + if (entryStat.isDirectory()) { + if (!(await walkDirectory(guestPath, relativePath))) return false; + } else if (!(await visit(guestPath, relativePath))) { + return false; + } + } + return true; + }; + + return walkDirectory(root, ""); +} + +function matchesToolGlob(relativePath: string, pattern: string): boolean { + const normalizedPattern = toPosix(pattern); + if (normalizedPattern.includes("/")) { + return ( + path.posix.matchesGlob(relativePath, normalizedPattern) || + path.posix.matchesGlob(relativePath, `**/${normalizedPattern}`) + ); + } + return path.posix.matchesGlob(path.posix.basename(relativePath), normalizedPattern); +} + +function createGondolinFindOps(vm: VM, localCwd: string): FindOperations { + return { + exists: async (filePath) => { + try { + await vm.fs.access(toGuestPath(localCwd, filePath)); + return true; + } catch { + return false; + } + }, + glob: async (pattern, cwd, options) => { + const root = toGuestPath(localCwd, cwd); + const results: string[] = []; + await walkGuestFiles(vm, root, async (guestPath, relativePath) => { + if (results.length >= options.limit) return false; + if (matchesToolGlob(relativePath, pattern)) results.push(guestPath); + return results.length < options.limit; + }); + return results; + }, + }; +} + +function createLineMatcher(pattern: string, literal: boolean | undefined, ignoreCase: boolean | undefined) { + if (literal) { + const needle = ignoreCase ? pattern.toLowerCase() : pattern; + return (line: string) => (ignoreCase ? line.toLowerCase() : line).includes(needle); + } + const regex = new RegExp(pattern, ignoreCase ? "i" : undefined); + return (line: string) => regex.test(line); +} + +function appendGrepBlock(params: { + outputLines: string[]; + lines: string[]; + relativePath: string; + lineIndex: number; + contextLines: number; +}): boolean { + let linesTruncated = false; + const start = params.contextLines > 0 ? Math.max(0, params.lineIndex - params.contextLines) : params.lineIndex; + const end = + params.contextLines > 0 + ? Math.min(params.lines.length - 1, params.lineIndex + params.contextLines) + : params.lineIndex; + + for (let index = start; index <= end; index++) { + const rawLine = params.lines[index] ?? ""; + const { text, wasTruncated } = truncateLine(rawLine.replace(/\r/g, "")); + if (wasTruncated) linesTruncated = true; + const separator = index === params.lineIndex ? ":" : "-"; + params.outputLines.push(`${params.relativePath}${separator}${index + 1}${separator} ${text}`); + } + return linesTruncated; +} + +async function executeGondolinGrep( + vm: VM, + localCwd: string, + params: GrepToolInput, + signal?: AbortSignal, +): Promise> { + const root = toGuestPath(localCwd, params.path ?? "."); + const rootStat = await vm.fs.stat(root, { signal }); + const rootIsDirectory = rootStat.isDirectory(); + const matcher = createLineMatcher(params.pattern, params.literal, params.ignoreCase); + const contextLines = params.context && params.context > 0 ? params.context : 0; + const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT); + const outputLines: string[] = []; + const details: GrepToolDetails = {}; + let matchCount = 0; + let matchLimitReached = false; + let linesTruncated = false; + + await walkGuestFiles( + vm, + root, + async (guestPath, relativePath) => { + if (matchCount >= effectiveLimit) return false; + if (params.glob && !matchesToolGlob(relativePath, params.glob)) return true; + let content: string; + try { + content = await vm.fs.readFile(guestPath, { encoding: "utf8", signal }); + } catch { + return true; + } + const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); + const displayPath = rootIsDirectory ? relativePath : path.posix.basename(guestPath); + for (let index = 0; index < lines.length; index++) { + if (signal?.aborted) throw new Error("Operation aborted"); + if (!matcher(lines[index] ?? "")) continue; + matchCount++; + if (appendGrepBlock({ outputLines, lines, relativePath: displayPath, lineIndex: index, contextLines })) { + linesTruncated = true; + } + if (matchCount >= effectiveLimit) { + matchLimitReached = true; + return false; + } + } + return true; + }, + signal, + ); + + if (matchCount === 0) return { content: [{ type: "text", text: "No matches found" }], details: undefined }; + + const rawOutput = outputLines.join("\n"); + const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); + const notices: string[] = []; + let output = truncation.content; + + if (matchLimitReached) { + details.matchLimitReached = effectiveLimit; + notices.push(`${effectiveLimit} matches limit reached`); + } + if (linesTruncated) { + details.linesTruncated = true; + notices.push("long lines truncated"); + } + if (truncation.truncated) { + details.truncation = truncation; + notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); + } + if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`; + + return { + content: [{ type: "text", text: output }], + details: Object.keys(details).length > 0 ? details : undefined, + }; +} + +function sanitizeEnv(env: NodeJS.ProcessEnv | undefined): Record | undefined { + if (!env) return undefined; + const result: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (typeof value === "string") result[key] = value; + } + return result; +} + +function createGondolinBashOps(vm: VM, localCwd: string, shellPath: string): BashOperations { + return { + exec: async (command, cwd, { onData, signal, timeout, env }) => { + if (signal?.aborted) throw new Error("aborted"); + const guestCwd = toGuestPath(localCwd, cwd); + const controller = new AbortController(); + const onAbort = () => controller.abort(); + signal?.addEventListener("abort", onAbort, { once: true }); + + let timedOut = false; + const timer = + timeout && timeout > 0 + ? setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeout * 1000) + : undefined; + + try { + const proc = vm.exec([shellPath, "-lc", command], { + cwd: guestCwd, + env: sanitizeEnv(env), + signal: controller.signal, + stdout: "pipe", + stderr: "pipe", + }); + for await (const chunk of proc.output()) onData(chunk.data); + const result = await proc; + return { exitCode: result.exitCode }; + } catch (error) { + if (signal?.aborted) throw new Error("aborted"); + if (timedOut) throw new Error(`timeout:${timeout}`); + throw error; + } finally { + if (timer) clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + } + }, + }; +} + +export default function (pi: ExtensionAPI) { + const localCwd = process.cwd(); + const localRead = createReadTool(localCwd); + const localWrite = createWriteTool(localCwd); + const localEdit = createEditTool(localCwd); + const localBash = createBashTool(localCwd); + const localGrep = createGrepTool(localCwd); + const localFind = createFindTool(localCwd); + const localLs = createLsTool(localCwd); + + let vm: VM | undefined; + let vmStarting: Promise | undefined; + let shellPath = "/bin/sh"; + + async function startVm(ctx?: ExtensionContext): Promise { + ctx?.ui.setStatus("gondolin", ctx.ui.theme.fg("accent", `Gondolin: starting ${GUEST_WORKSPACE}`)); + const created = await VM.create({ + sessionLabel: `pi ${path.basename(localCwd)}`, + vfs: { + mounts: { + [GUEST_WORKSPACE]: new RealFSProvider(localCwd), + }, + }, + }); + const bashProbe = await created.exec(["/bin/sh", "-lc", "command -v bash || true"]); + shellPath = bashProbe.stdout.trim() || "/bin/sh"; + vm = created; + ctx?.ui.setStatus( + "gondolin", + ctx.ui.theme.fg("accent", `Gondolin: ${created.id.slice(0, 8)} (${GUEST_WORKSPACE})`), + ); + ctx?.ui.notify(`Gondolin VM ready. ${localCwd} is mounted at ${GUEST_WORKSPACE}.`, "info"); + return created; + } + + async function ensureVm(ctx?: ExtensionContext): Promise { + if (vm) return vm; + if (!vmStarting) { + vmStarting = startVm(ctx).finally(() => { + vmStarting = undefined; + }); + } + return vmStarting; + } + + pi.on("session_start", async (_event, ctx) => { + await ensureVm(ctx); + }); + + pi.on("session_shutdown", async (_event, ctx) => { + const activeVm = vm; + vm = undefined; + vmStarting = undefined; + if (!activeVm) return; + ctx.ui.setStatus("gondolin", ctx.ui.theme.fg("muted", "Gondolin: stopping")); + try { + await activeVm.close(); + } finally { + ctx.ui.setStatus("gondolin", undefined); + } + }); + + pi.registerCommand("gondolin", { + description: "Show Gondolin VM status", + handler: async (_args, ctx) => { + const activeVm = await ensureVm(ctx); + ctx.ui.notify( + [ + `Gondolin VM: ${activeVm.id}`, + `Host workspace: ${localCwd}`, + `Guest workspace: ${GUEST_WORKSPACE}`, + `Shell: ${shellPath}`, + ].join("\n"), + "info", + ); + }, + }); + + pi.registerTool({ + ...localRead, + async execute(id, params, signal, onUpdate, ctx) { + const activeVm = await ensureVm(ctx); + const tool = createReadTool(GUEST_WORKSPACE, { + operations: createGondolinReadOps(activeVm, localCwd), + }); + return tool.execute(id, params, signal, onUpdate); + }, + }); + + pi.registerTool({ + ...localWrite, + async execute(id, params, signal, onUpdate, ctx) { + const activeVm = await ensureVm(ctx); + const tool = createWriteTool(GUEST_WORKSPACE, { + operations: createGondolinWriteOps(activeVm, localCwd), + }); + return tool.execute(id, params, signal, onUpdate); + }, + }); + + pi.registerTool({ + ...localEdit, + async execute(id, params, signal, onUpdate, ctx) { + const activeVm = await ensureVm(ctx); + const tool = createEditTool(GUEST_WORKSPACE, { + operations: createGondolinEditOps(activeVm, localCwd), + }); + return tool.execute(id, params, signal, onUpdate); + }, + }); + + pi.registerTool({ + ...localBash, + async execute(id, params, signal, onUpdate, ctx) { + const activeVm = await ensureVm(ctx); + const tool = createBashTool(GUEST_WORKSPACE, { + operations: createGondolinBashOps(activeVm, localCwd, shellPath), + }); + return tool.execute(id, params, signal, onUpdate); + }, + }); + + pi.registerTool({ + ...localLs, + async execute(id, params, signal, onUpdate, ctx) { + const activeVm = await ensureVm(ctx); + const tool = createLsTool(GUEST_WORKSPACE, { + operations: createGondolinLsOps(activeVm, localCwd), + }); + return tool.execute(id, params, signal, onUpdate); + }, + }); + + pi.registerTool({ + ...localFind, + async execute(id, params, signal, onUpdate, ctx) { + const activeVm = await ensureVm(ctx); + const tool = createFindTool(GUEST_WORKSPACE, { + operations: createGondolinFindOps(activeVm, localCwd), + }); + return tool.execute(id, params, signal, onUpdate); + }, + }); + + pi.registerTool({ + ...localGrep, + async execute(_id, params, signal, _onUpdate, ctx) { + const activeVm = await ensureVm(ctx); + return executeGondolinGrep(activeVm, localCwd, params, signal); + }, + }); + + pi.on("user_bash", async (_event, ctx) => { + const activeVm = await ensureVm(ctx); + return { operations: createGondolinBashOps(activeVm, localCwd, shellPath) }; + }); + + pi.on("before_agent_start", async (event, ctx) => { + await ensureVm(ctx); + const localLine = `Current working directory: ${localCwd}`; + const guestLine = `Current working directory: ${GUEST_WORKSPACE} (Gondolin VM; host workspace mounted from ${localCwd})`; + const systemPrompt = event.systemPrompt.includes(localLine) + ? event.systemPrompt.replace(localLine, guestLine) + : `${event.systemPrompt}\n\n${guestLine}`; + return { systemPrompt }; + }); +} diff --git a/packages/coding-agent/examples/extensions/gondolin/package-lock.json b/packages/coding-agent/examples/extensions/gondolin/package-lock.json new file mode 100644 index 00000000..705ae8a1 --- /dev/null +++ b/packages/coding-agent/examples/extensions/gondolin/package-lock.json @@ -0,0 +1,185 @@ +{ + "name": "pi-extension-gondolin", + "version": "0.78.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pi-extension-gondolin", + "version": "0.78.0", + "dependencies": { + "@earendil-works/gondolin": "0.12.0" + } + }, + "node_modules/@cto.af/wtf8": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@cto.af/wtf8/-/wtf8-0.0.5.tgz", + "integrity": "sha512-LfUFi+Vv4eDzj+XAtR89e3wwjXA/NZjUSwU5NhwbBrLecxPaBYFy3exCuc1j+D4UZeOVdqlsl8G7LmOt18V0tg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@earendil-works/gondolin": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@earendil-works/gondolin/-/gondolin-0.12.0.tgz", + "integrity": "sha512-BXbvzQKb5QmxY5NtthRDONJTu7+IDKbzqWGrJyyNXMP7N681Tx0Q9TK8pK1ba8nUvYQTipNJyGZOsJfYiZll1A==", + "license": "Apache-2.0", + "dependencies": { + "cbor2": "^2.3.0", + "node-forge": "^1.3.3", + "ssh2": "^1.17.0", + "undici": "^6.21.0" + }, + "bin": { + "gondolin": "dist/bin/gondolin.js" + }, + "engines": { + "node": ">=23.6.0" + }, + "optionalDependencies": { + "@earendil-works/gondolin-krun-runner-darwin-arm64": "0.12.0", + "@earendil-works/gondolin-krun-runner-linux-x64": "0.12.0" + } + }, + "node_modules/@earendil-works/gondolin-krun-runner-darwin-arm64": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-darwin-arm64/-/gondolin-krun-runner-darwin-arm64-0.12.0.tgz", + "integrity": "sha512-ftDlusht4PcT7Y3TuPrZIKrCXy3isiBTVMvlXYK0pcud2uXY6uwFTGeunYgP+8ND/60ddb+MImqbfmkcK8B84A==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "gondolin-krun-runner": "bin/gondolin-krun-runner" + } + }, + "node_modules/@earendil-works/gondolin-krun-runner-linux-x64": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-linux-x64/-/gondolin-krun-runner-linux-x64-0.12.0.tgz", + "integrity": "sha512-RRYsgwe2r5ApKmFNy469QgwnyjAHpAs9XANdWpTd9ol4iUYOY3sX7e0xIooAKxd+ktxGI4N/xRWicwGen3D/Ow==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "gondolin-krun-runner": "bin/gondolin-krun-runner" + } + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/cbor2": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cbor2/-/cbor2-2.3.0.tgz", + "integrity": "sha512-76WB3hq8BoaGkMkBVJ27fW5LJU+qqDLEpgRNCG/SYKhODWXpVPOTD4UcUto3IEzYLA52nsvbhb0wabhHDn3qXg==", + "license": "MIT", + "dependencies": { + "@cto.af/wtf8": "0.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/nan": { + "version": "2.27.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", + "integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==", + "license": "MIT", + "optional": true + }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, + "node_modules/undici": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", + "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + } + } +} diff --git a/packages/coding-agent/examples/extensions/gondolin/package.json b/packages/coding-agent/examples/extensions/gondolin/package.json new file mode 100644 index 00000000..58aa8a0b --- /dev/null +++ b/packages/coding-agent/examples/extensions/gondolin/package.json @@ -0,0 +1,19 @@ +{ + "name": "pi-extension-gondolin", + "private": true, + "version": "0.78.0", + "type": "module", + "scripts": { + "clean": "echo 'nothing to clean'", + "build": "echo 'nothing to build'", + "check": "echo 'nothing to check'" + }, + "pi": { + "extensions": [ + "./index.ts" + ] + }, + "dependencies": { + "@earendil-works/gondolin": "0.12.0" + } +} diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index b9fdb289..28550f97 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -25,6 +25,7 @@ "dist", "docs", "examples", + "containerization.md", "CHANGELOG.md", "npm-shrinkwrap.json" ], From dc7b547f628475676acfd00cb0f54df05d42acaf Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Wed, 3 Jun 2026 15:55:33 +0200 Subject: [PATCH 151/352] docs(coding-agent): remove containerization platform note --- packages/coding-agent/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index cec58d50..60e8da1a 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -95,7 +95,7 @@ pi Then just talk to pi. By default, pi gives the model four tools: `read`, `write`, `edit`, and `bash`. The model uses these to fulfill your requests. Add capabilities via [skills](#skills), [prompt templates](#prompt-templates), [extensions](#extensions), or [pi packages](#pi-packages). -**Platform notes:** [Windows](docs/windows.md) | [Termux (Android)](docs/termux.md) | [tmux](docs/tmux.md) | [Terminal setup](docs/terminal-setup.md) | [Shell aliases](docs/shell-aliases.md) | [Containerization](docs/containerization.md) +**Platform notes:** [Windows](docs/windows.md) | [Termux (Android)](docs/termux.md) | [tmux](docs/tmux.md) | [Terminal setup](docs/terminal-setup.md) | [Shell aliases](docs/shell-aliases.md) --- From e9a9322199ab58038f69c830bbe440552e0ae8c1 Mon Sep 17 00:00:00 2001 From: Sviatoslav Abakumov Date: Wed, 3 Jun 2026 22:36:09 +0400 Subject: [PATCH 152/352] fix(coding-agent): add a space between the skill and user messages --- packages/coding-agent/src/modes/interactive/interactive-mode.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index b874495b..5ed40caf 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -3106,6 +3106,7 @@ export class InteractiveMode { this.chatContainer.addChild(component); // Render user message separately if present if (skillBlock.userMessage) { + this.chatContainer.addChild(new Spacer(1)); const userComponent = new UserMessageComponent( skillBlock.userMessage, this.getMarkdownThemeWithSettings(), From e4d6f45efe234e9609ab7535552c4ece262a3e9b Mon Sep 17 00:00:00 2001 From: Alexey Zaytsev Date: Wed, 3 Jun 2026 23:15:15 -0500 Subject: [PATCH 153/352] docs: document commit message format --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 729cb09e..d653442f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,7 @@ Committing: - Stage explicit paths (`git add `); never `git add -A` / `git add .`. - Before committing, run `git status` and verify you are only staging your files. - `packages/ai/src/models.generated.ts` may always be included alongside your files. +- Message format: `{feat,fix,docs}[(ai,tui,agent,coding-agent)]: (optionally multiple lines)`. Message is informative and concise. Never run (destroys other agents' work or bypasses checks): From f9ce0bf0e789f245929aed279692dea470ddcf06 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 4 Jun 2026 18:17:53 +0200 Subject: [PATCH 154/352] Update generated model lists --- packages/ai/src/image-models.generated.ts | 15 +++ packages/ai/src/models.generated.ts | 134 ++++++++++++++++------ 2 files changed, 116 insertions(+), 33 deletions(-) diff --git a/packages/ai/src/image-models.generated.ts b/packages/ai/src/image-models.generated.ts index f5305b11..3402e613 100644 --- a/packages/ai/src/image-models.generated.ts +++ b/packages/ai/src/image-models.generated.ts @@ -125,6 +125,21 @@ export const IMAGE_MODELS = { cacheWrite: 0, }, } satisfies ImagesModel<"openrouter-images">, + "microsoft/mai-image-2.5": { + id: "microsoft/mai-image-2.5", + name: "Microsoft: MAI-Image-2.5", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + output: ["image"], + cost: { + input: 5, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, "openai/gpt-5-image": { id: "openai/gpt-5-image", name: "OpenAI: GPT-5 Image", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index cf50f63e..32966490 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -3869,8 +3869,8 @@ export const MODELS = { cacheRead: 0.1, cacheWrite: 0, }, - contextWindow: 128000, - maxTokens: 8192, + contextWindow: 262144, + maxTokens: 65536, } satisfies Model<"anthropic-messages">, "accounts/fireworks/routers/glm-5p1-fast": { id: "accounts/fireworks/routers/glm-5p1-fast", @@ -8370,9 +8370,9 @@ export const MODELS = { contextWindow: 200000, maxTokens: 32000, } satisfies Model<"anthropic-messages">, - "nemotron-3-super-free": { - id: "nemotron-3-super-free", - name: "Nemotron 3 Super Free", + "nemotron-3-ultra-free": { + id: "nemotron-3-ultra-free", + name: "Nemotron 3 Ultra Free", api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", @@ -8384,7 +8384,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 204800, + contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"openai-completions">, "qwen3.5-plus": { @@ -8651,6 +8651,23 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 65536, } satisfies Model<"anthropic-messages">, + "qwen3.7-plus": { + id: "qwen3.7-plus", + name: "Qwen3.7 Plus", + api: "anthropic-messages", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.4, + output: 1.6, + cacheRead: 0.04, + cacheWrite: 0.5, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"anthropic-messages">, }, "openrouter": { "ai21/jamba-large-1.7": { @@ -10519,6 +10536,40 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 262144, } satisfies Model<"openai-completions">, + "nvidia/nemotron-3-ultra-550b-a55b": { + id: "nvidia/nemotron-3-ultra-550b-a55b", + name: "NVIDIA: Nemotron 3 Ultra", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.5, + output: 2.5, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "nvidia/nemotron-3-ultra-550b-a55b:free": { + id: "nvidia/nemotron-3-ultra-550b-a55b:free", + name: "NVIDIA: Nemotron 3 Ultra (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "nvidia/nemotron-nano-12b-v2-vl:free": { id: "nvidia/nemotron-nano-12b-v2-vl:free", name: "NVIDIA: Nemotron Nano 12B 2 VL (free)", @@ -10638,23 +10689,6 @@ export const MODELS = { contextWindow: 8191, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-4-0314": { - id: "openai/gpt-4-0314", - name: "OpenAI: GPT-4 (older v0314)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 30, - output: 60, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8191, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-4-1106-preview": { id: "openai/gpt-4-1106-preview", name: "OpenAI: GPT-4 Turbo (older v1106)", @@ -11798,8 +11832,8 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.0428, - output: 0.1716, + input: 0.04815, + output: 0.19305, cacheRead: 0, cacheWrite: 0, }, @@ -12401,6 +12435,23 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 65536, } satisfies Model<"openai-completions">, + "qwen/qwen3.7-plus": { + id: "qwen/qwen3.7-plus", + name: "Qwen: Qwen3.7 Plus", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.39999999999999997, + output: 1.5999999999999999, + cacheRead: 0.08, + cacheWrite: 0.5, + }, + contextWindow: 1000000, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "rekaai/reka-edge": { id: "rekaai/reka-edge", name: "Reka Edge", @@ -12841,7 +12892,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 202752, - maxTokens: 16384, + maxTokens: 4096, } satisfies Model<"openai-completions">, "z-ai/glm-5-turbo": { id: "z-ai/glm-5-turbo", @@ -12875,7 +12926,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 202752, - maxTokens: 131072, + maxTokens: 4096, } satisfies Model<"openai-completions">, "z-ai/glm-5v-turbo": { id: "z-ai/glm-5v-turbo", @@ -13448,7 +13499,7 @@ export const MODELS = { provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", reasoning: true, - input: ["text", "image"], + input: ["text"], cost: { input: 1.3, output: 7.8, @@ -13720,7 +13771,7 @@ export const MODELS = { provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", reasoning: true, - input: ["text", "image"], + input: ["text"], cost: { input: 1.25, output: 3.75, @@ -14152,7 +14203,7 @@ export const MODELS = { provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", reasoning: true, - input: ["text", "image"], + input: ["text"], cost: { input: 0.435, output: 0.87, @@ -14645,7 +14696,7 @@ export const MODELS = { provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", reasoning: true, - input: ["text", "image"], + input: ["text"], cost: { input: 0.6, output: 2.4, @@ -14662,7 +14713,7 @@ export const MODELS = { provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", reasoning: true, - input: ["text", "image"], + input: ["text"], cost: { input: 0.3, output: 1.2, @@ -14679,7 +14730,7 @@ export const MODELS = { provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", reasoning: true, - input: ["text", "image"], + input: ["text"], cost: { input: 0.6, output: 2.4, @@ -15029,6 +15080,23 @@ export const MODELS = { contextWindow: 256000, maxTokens: 32000, } satisfies Model<"anthropic-messages">, + "nvidia/nemotron-3-ultra-550b-a55b": { + id: "nvidia/nemotron-3-ultra-550b-a55b", + name: "Nemotron 3 Ultra", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.5, + output: 2.5, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65000, + } satisfies Model<"anthropic-messages">, "nvidia/nemotron-nano-12b-v2-vl": { id: "nvidia/nemotron-nano-12b-v2-vl", name: "Nvidia Nemotron Nano 12B V2 VL", From e0c2813a2ae8588051b2fb16c50b74d978bdfd71 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 4 Jun 2026 18:22:44 +0200 Subject: [PATCH 155/352] Audit unreleased changelog entries --- packages/ai/CHANGELOG.md | 4 +++- packages/coding-agent/CHANGELOG.md | 17 ++++++++++++++++- packages/tui/CHANGELOG.md | 1 + 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 6e6769be..327aadd5 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -11,9 +11,11 @@ ### Fixed - Fixed Amazon Bedrock requests to replace blank required user/tool-result text with a placeholder and skip blank replay text blocks ([#4975](https://github.com/earendil-works/pi/issues/4975)). +- Fixed Anthropic Claude Opus 4.7+ requests to suppress deprecated temperature parameters ([#5251](https://github.com/earendil-works/pi/pull/5251) by [@yzhg1983](https://github.com/yzhg1983)). - Fixed OpenAI GPT-5.5 generated metadata to omit unsupported minimal thinking ([#5243](https://github.com/earendil-works/pi/issues/5243)). - Fixed OpenRouter Kimi K2.6 thinking replay and preserved developer-role instructions for OpenRouter OpenAI and Anthropic models ([#5309](https://github.com/earendil-works/pi/issues/5309)). -- Fixed GitHub Copilot and OpenRouter test model references that became stale after model regeneration. +- Fixed OpenRouter reasoning instruction requests to preserve the system role when required ([#5221](https://github.com/earendil-works/pi/pull/5221) by [@PriNova](https://github.com/PriNova)). +- Restored the NVIDIA Qwen 3.5 122B NIM model. ## [0.78.0] - 2026-05-29 diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a0c74781..6fb09504 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,13 +2,19 @@ ## [Unreleased] +### New Features + +- **More built-in provider coverage** - Added Ant Ling and NVIDIA NIM provider setup, plus MiniMax-M3 support for the direct MiniMax providers. See [Providers](docs/providers.md). +- **Richer extension context** - Extensions can use `ctx.mode` and `ctx.getSystemPromptOptions()` to adapt behavior across TUI, RPC, JSON, and print modes and inspect base system prompt inputs. See [Extensions](docs/extensions.md). + ### Added - Added containerization documentation and a Gondolin extension example for routing built-in tools into a local micro-VM. - Added Ant Ling provider selection and setup documentation. +- Added MiniMax-M3 model support inherited from `@earendil-works/pi-ai` for the `minimax` and `minimax-cn` direct providers ([#5313](https://github.com/earendil-works/pi/issues/5313)). - Added NVIDIA NIM provider selection, setup documentation, and direct NIM request attribution headers. - Added `ctx.mode` to extension contexts so extensions can distinguish TUI, RPC, JSON, and print mode. -- Added `ctx.getSystemPromptOptions()` for extension commands to inspect the current base system prompt inputs. +- Added `ctx.getSystemPromptOptions()` for extension commands to inspect the current base system prompt inputs ([#5306](https://github.com/earendil-works/pi/pull/5306) by [@xl0](https://github.com/xl0)). ### Fixed @@ -17,8 +23,17 @@ - Fixed stored XSS in HTML session exports by sanitizing Markdown link and image URLs with a scheme allow-list after stripping control characters. - Fixed SDK embedding in bundled Node apps failing with `ENOENT` when `package.json` is not present next to the bundle entrypoint. The package metadata reader now gracefully handles missing `package.json` by using defaults, enabling `createAgentSession()` without requiring package-adjacent files at runtime ([#5226](https://github.com/earendil-works/pi/issues/5226)). - Fixed HTTP timeout setting not being respected for non-Codex providers (e.g., llama.cpp via OpenAI-compatible API). The `httpIdleTimeoutMs` setting (set via `/settings` HTTP timeout) now applies as the default SDK request timeout for all providers that support it, not just OpenAI Codex Responses. Disabling the timeout (HTTP timeout = false) now correctly disables SDK timeouts for all supported providers by sending a maximum int32 value (effectively infinite) instead of 0, since SDKs treat timeout=0 as an immediate timeout ([#5294](https://github.com/earendil-works/pi/issues/5294)). +- Fixed inherited Amazon Bedrock requests to replace blank required user/tool-result text with a placeholder and skip blank replay text blocks ([#4975](https://github.com/earendil-works/pi/issues/4975)). +- Fixed inherited Anthropic Claude Opus 4.7+ requests to suppress deprecated temperature parameters ([#5251](https://github.com/earendil-works/pi/pull/5251) by [@yzhg1983](https://github.com/yzhg1983)). +- Fixed inherited OpenAI GPT-5.5 generated metadata to omit unsupported minimal thinking ([#5243](https://github.com/earendil-works/pi/issues/5243)). +- Fixed inherited OpenRouter Kimi K2.6 thinking replay and developer-role instruction handling ([#5309](https://github.com/earendil-works/pi/issues/5309)). +- Fixed inherited OpenRouter reasoning instruction requests to preserve the system role when required ([#5221](https://github.com/earendil-works/pi/pull/5221) by [@PriNova](https://github.com/PriNova)). +- Fixed inherited overlay focus restoration so non-capturing overlays remain interactive after UI rerenders and explicit focus release ([#5235](https://github.com/earendil-works/pi/pull/5235) by [@nicobailon](https://github.com/nicobailon)). +- Fixed inherited tab width accounting in column slicing and overlay compositing so tab-containing output cannot exceed the terminal width ([#5218](https://github.com/earendil-works/pi/issues/5218)). - Fixed opening and listing very large JSONL session files by reading session entries line-by-line instead of materializing the full file as one string ([#5231](https://github.com/earendil-works/pi/issues/5231)). +- Fixed the footer branch display in WSL `/mnt/...` repositories to refresh after branch changes ([#5264](https://github.com/earendil-works/pi/pull/5264) by [@psoukie](https://github.com/psoukie)). - Fixed `renderShell: "self"` tool renderers that emit no component lines leaving a blank chat row ([#5299](https://github.com/earendil-works/pi/issues/5299)). +- Restored inherited NVIDIA Qwen 3.5 122B NIM model support. ## [0.78.0] - 2026-05-29 diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index c8512cd7..17af5d8a 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed overlay focus restoration so non-capturing overlays remain interactive after UI rerenders and explicit focus release ([#5235](https://github.com/earendil-works/pi/pull/5235) by [@nicobailon](https://github.com/nicobailon)). - Fixed tab width accounting in column slicing and overlay compositing so tab-containing output cannot exceed the terminal width ([#5218](https://github.com/earendil-works/pi/issues/5218)). ## [0.78.0] - 2026-05-29 From 4f7d756df8cbb6cff552c2dce661ece220b78e28 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 4 Jun 2026 18:27:18 +0200 Subject: [PATCH 156/352] Exclude optional Gondolin example from root check --- tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 4dfbf3d2..903f80f3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,5 +21,5 @@ } }, "include": ["packages/*/src/**/*", "packages/*/test/**/*", "packages/coding-agent/examples/**/*"], - "exclude": ["**/dist/**"] + "exclude": ["**/dist/**", "packages/coding-agent/examples/extensions/gondolin/**"] } From 1d33a8eb7ab9cf3a15c03c0280bea4d2e99785e4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 4 Jun 2026 18:31:11 +0200 Subject: [PATCH 157/352] Update generated image model list From 592c34c05643d115d6eed08a6f615999651cfaa3 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 4 Jun 2026 18:31:33 +0200 Subject: [PATCH 158/352] Release v0.78.1 --- package-lock.json | 50 +++++-------------- packages/agent/CHANGELOG.md | 2 +- packages/agent/package.json | 4 +- packages/ai/CHANGELOG.md | 2 +- packages/ai/package.json | 2 +- packages/coding-agent/CHANGELOG.md | 2 +- .../package-lock.json | 4 +- .../custom-provider-anthropic/package.json | 2 +- .../custom-provider-gitlab-duo/package.json | 2 +- .../extensions/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 +- packages/coding-agent/npm-shrinkwrap.json | 24 ++++----- packages/coding-agent/package.json | 8 +-- packages/tui/CHANGELOG.md | 2 +- packages/tui/package.json | 2 +- 19 files changed, 50 insertions(+), 74 deletions(-) diff --git a/package-lock.json b/package-lock.json index 46b200b0..65e743a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6170,10 +6170,10 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.78.0", + "version": "0.78.1", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.78.0", + "@earendil-works/pi-ai": "^0.78.1", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -6188,30 +6188,6 @@ "node": ">=22.19.0" } }, - "packages/agent/node_modules/@earendil-works/pi-ai": { - "version": "0.77.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.77.0.tgz", - "integrity": "sha512-H21BrQDPf3ydaeBmS5maNDHxUGFMiKBF/n3WnE+OTWloIZSayeL+/NVEgG3aKQw8fZL6HAMYAGpUIVJgFuKtnw==", - "license": "MIT", - "dependencies": { - "@anthropic-ai/sdk": "0.91.1", - "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.1", - "@smithy/node-http-handler": "4.7.3", - "http-proxy-agent": "7.0.2", - "https-proxy-agent": "7.0.6", - "openai": "6.26.0", - "partial-json": "0.1.7", - "typebox": "1.1.38" - }, - "bin": { - "pi-ai": "dist/cli.js" - }, - "engines": { - "node": ">=22.19.0" - } - }, "packages/agent/node_modules/@types/node": { "version": "24.12.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", @@ -6231,7 +6207,7 @@ }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.78.0", + "version": "0.78.1", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -6276,12 +6252,12 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "0.78.0", + "version": "0.78.1", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.78.0", - "@earendil-works/pi-ai": "^0.78.0", - "@earendil-works/pi-tui": "^0.78.0", + "@earendil-works/pi-agent-core": "^0.78.1", + "@earendil-works/pi-ai": "^0.78.1", + "@earendil-works/pi-tui": "^0.78.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -6320,32 +6296,32 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.78.0", + "version": "0.78.1", "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.78.0" + "version": "0.78.1" }, "packages/coding-agent/examples/extensions/gondolin": { "name": "pi-extension-gondolin", - "version": "0.78.0", + "version": "0.78.1", "dependencies": { "@earendil-works/gondolin": "0.12.0" } }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.8.0", + "version": "1.8.1", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.78.0", + "version": "0.78.1", "dependencies": { "ms": "2.1.3" }, @@ -6381,7 +6357,7 @@ }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.78.0", + "version": "0.78.1", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index fd816423..e5b3305a 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.78.1] - 2026-06-04 ## [0.78.0] - 2026-05-29 diff --git a/packages/agent/package.json b/packages/agent/package.json index 2c6e36f0..fc4a5478 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "0.78.0", + "version": "0.78.1", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -29,7 +29,7 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "^0.78.0", + "@earendil-works/pi-ai": "^0.78.1", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 327aadd5..1345a596 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.78.1] - 2026-06-04 ### Added diff --git a/packages/ai/package.json b/packages/ai/package.json index 555d073f..e62c370d 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.78.0", + "version": "0.78.1", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6fb09504..55d425fd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.78.1] - 2026-06-04 ### 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 de32c5aa..71342f77 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.78.0", + "version": "0.78.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "0.78.0", + "version": "0.78.1", "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 b33f5156..40e82831 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.78.0", + "version": "0.78.1", "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 17cde4b7..df981282 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.78.0", + "version": "0.78.1", "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 705ae8a1..94a45117 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.78.0", + "version": "0.78.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-gondolin", - "version": "0.78.0", + "version": "0.78.1", "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 58aa8a0b..c6c68530 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.78.0", + "version": "0.78.1", "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 e6b78ad9..493ce950 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.8.0", + "version": "1.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.8.0", + "version": "1.8.1", "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 da17fff9..7614a82d 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.8.0", + "version": "1.8.1", "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 9037b8a8..1c16ed45 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.78.0", + "version": "0.78.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "0.78.0", + "version": "0.78.1", "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 00ef86f9..03fc01e7 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.78.0", + "version": "0.78.1", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 39bb597b..c52c882b 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -1,17 +1,17 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.78.0", + "version": "0.78.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent", - "version": "0.78.0", + "version": "0.78.1", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.78.0", - "@earendil-works/pi-ai": "^0.78.0", - "@earendil-works/pi-tui": "^0.78.0", + "@earendil-works/pi-agent-core": "^0.78.1", + "@earendil-works/pi-ai": "^0.78.1", + "@earendil-works/pi-tui": "^0.78.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -473,11 +473,11 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.78.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.78.0.tgz", + "version": "0.78.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.78.1.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.78.0", + "@earendil-works/pi-ai": "^0.78.1", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -487,8 +487,8 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.78.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.78.0.tgz", + "version": "0.78.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.78.1.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -510,8 +510,8 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.78.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.78.0.tgz", + "version": "0.78.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.78.1.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 28550f97..f7635819 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.78.0", + "version": "0.78.1", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -40,9 +40,9 @@ "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" }, "dependencies": { - "@earendil-works/pi-agent-core": "^0.78.0", - "@earendil-works/pi-ai": "^0.78.0", - "@earendil-works/pi-tui": "^0.78.0", + "@earendil-works/pi-agent-core": "^0.78.1", + "@earendil-works/pi-ai": "^0.78.1", + "@earendil-works/pi-tui": "^0.78.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 17af5d8a..fdab0d18 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.78.1] - 2026-06-04 ### Fixed diff --git a/packages/tui/package.json b/packages/tui/package.json index ab65ebe9..224132e2 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-tui", - "version": "0.78.0", + "version": "0.78.1", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", From ca66adfe60186f8d810ab88b8c7a52d4bee986c7 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 4 Jun 2026 18:31:35 +0200 Subject: [PATCH 159/352] Add [Unreleased] section for next cycle --- packages/agent/CHANGELOG.md | 2 ++ packages/ai/CHANGELOG.md | 2 ++ packages/coding-agent/CHANGELOG.md | 2 ++ packages/tui/CHANGELOG.md | 2 ++ 4 files changed, 8 insertions(+) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index e5b3305a..667e61de 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.78.1] - 2026-06-04 ## [0.78.0] - 2026-05-29 diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 1345a596..a65d0e83 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.78.1] - 2026-06-04 ### Added diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 55d425fd..3cb168ec 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.78.1] - 2026-06-04 ### New Features diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index fdab0d18..2069c7eb 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.78.1] - 2026-06-04 ### Fixed From c45787411e3447a59350a3f97403fd6b9f21c100 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 4 Jun 2026 23:44:25 +0200 Subject: [PATCH 160/352] Fix tool expand hint punctuation styling closes #5359 --- packages/coding-agent/CHANGELOG.md | 4 ++++ packages/coding-agent/src/core/tools/bash.ts | 2 +- packages/coding-agent/src/core/tools/find.ts | 2 +- packages/coding-agent/src/core/tools/grep.ts | 2 +- packages/coding-agent/src/core/tools/ls.ts | 2 +- packages/coding-agent/src/core/tools/read.ts | 2 +- packages/coding-agent/src/core/tools/write.ts | 2 +- .../src/modes/interactive/components/bash-execution.ts | 6 ++++-- 8 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3cb168ec..119c29b8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed built-in tool expand hints to style closing parentheses consistently ([#5359](https://github.com/earendil-works/pi/issues/5359)). + ## [0.78.1] - 2026-06-04 ### New Features diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index efe3a5af..d2bfacb9 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -228,7 +228,7 @@ function rebuildBashResultRenderComponent( if (state.cachedSkipped && state.cachedSkipped > 0) { const hint = theme.fg("muted", `... (${state.cachedSkipped} earlier lines,`) + - ` ${keyHint("app.tools.expand", "to expand")})`; + ` ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`; return ["", truncateToWidth(hint, width, "..."), ...(state.cachedLines ?? [])]; } return ["", ...(state.cachedLines ?? [])]; diff --git a/packages/coding-agent/src/core/tools/find.ts b/packages/coding-agent/src/core/tools/find.ts index 550dacef..6f852f61 100644 --- a/packages/coding-agent/src/core/tools/find.ts +++ b/packages/coding-agent/src/core/tools/find.ts @@ -91,7 +91,7 @@ function formatFindResult( const remaining = lines.length - maxLines; text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`; if (remaining > 0) { - text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`; + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`; } } diff --git a/packages/coding-agent/src/core/tools/grep.ts b/packages/coding-agent/src/core/tools/grep.ts index 1c4286c7..e4ed36d1 100644 --- a/packages/coding-agent/src/core/tools/grep.ts +++ b/packages/coding-agent/src/core/tools/grep.ts @@ -103,7 +103,7 @@ function formatGrepResult( const remaining = lines.length - maxLines; text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`; if (remaining > 0) { - text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`; + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`; } } diff --git a/packages/coding-agent/src/core/tools/ls.ts b/packages/coding-agent/src/core/tools/ls.ts index 5c78c625..8a689e8d 100644 --- a/packages/coding-agent/src/core/tools/ls.ts +++ b/packages/coding-agent/src/core/tools/ls.ts @@ -77,7 +77,7 @@ function formatLsResult( const remaining = lines.length - maxLines; text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`; if (remaining > 0) { - text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`; + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`; } } diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index 2d0dd838..52e87b1d 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -184,7 +184,7 @@ function formatReadResult( const remaining = lines.length - maxLines; let text = `\n${displayLines.map((line) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line)))).join("\n")}`; if (remaining > 0) { - text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`; + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`; } const truncation = result.details?.truncation; diff --git a/packages/coding-agent/src/core/tools/write.ts b/packages/coding-agent/src/core/tools/write.ts index a1b755ad..12668e61 100644 --- a/packages/coding-agent/src/core/tools/write.ts +++ b/packages/coding-agent/src/core/tools/write.ts @@ -154,7 +154,7 @@ function formatWriteCall( const remaining = lines.length - maxLines; text += `\n\n${displayLines.map((line) => (lang ? line : theme.fg("toolOutput", replaceTabs(line)))).join("\n")}`; if (remaining > 0) { - text += `${theme.fg("muted", `\n... (${remaining} more lines, ${totalLines} total,`)} ${keyHint("app.tools.expand", "to expand")})`; + text += `${theme.fg("muted", `\n... (${remaining} more lines, ${totalLines} total,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`; } } diff --git a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts index e53f5a5e..b46a9d0b 100644 --- a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts @@ -176,10 +176,12 @@ export class BashExecutionComponent extends Container { // Show how many lines are hidden (collapsed preview) if (hiddenLineCount > 0) { if (this.expanded) { - statusParts.push(`(${keyHint("app.tools.expand", "to collapse")})`); + statusParts.push( + `${theme.fg("muted", "(")}${keyHint("app.tools.expand", "to collapse")}${theme.fg("muted", ")")}`, + ); } else { statusParts.push( - `${theme.fg("muted", `... ${hiddenLineCount} more lines`)} (${keyHint("app.tools.expand", "to expand")})`, + `${theme.fg("muted", `... ${hiddenLineCount} more lines (`)}${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`, ); } } From c52c22b39e7f4b7f29e027a52bb034ac26764cc2 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 4 Jun 2026 23:47:06 +0200 Subject: [PATCH 161/352] Document issue view gh fields --- .pi/prompts/is.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.pi/prompts/is.md b/.pi/prompts/is.md index ccdb27e1..b0319f63 100644 --- a/.pi/prompts/is.md +++ b/.pi/prompts/is.md @@ -7,7 +7,10 @@ Analyze GitHub issue(s): $ARGUMENTS For each issue: 1. Add the `inprogress` label to the issue via GitHub CLI and assign the issue to the local `gh` user before analysis starts. If either action fails, report that explicitly and continue. -2. Read the issue in full, including all comments and linked issues/PRs. +2. Read the issue in full, including all comments and linked issues/PRs. Use fields supported by GitHub CLI, for example: + ```sh + gh issue view --json title,body,comments,labels,assignees,state,url,author,createdAt,updatedAt,closedByPullRequestsReferences + ``` 3. Do not trust analysis written in the issue. Independently verify behavior and derive your own analysis from the code and execution path. 4. **For bugs**: From b9bfa7ed46ef19f48991473eae202be768623702 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 4 Jun 2026 23:49:46 +0200 Subject: [PATCH 162/352] Fix OpenRouter routing compat for custom providers closes #5347 --- packages/ai/CHANGELOG.md | 4 ++++ packages/ai/src/providers/openai-completions.ts | 2 +- packages/ai/src/types.ts | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index a65d0e83..7f3b72d0 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed OpenRouter routing preferences on OpenAI-compatible custom providers to send `compat.openRouterRouting` even when `baseUrl` does not point directly at OpenRouter ([#5347](https://github.com/earendil-works/pi/issues/5347)). + ## [0.78.1] - 2026-06-04 ### Added diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 0cfbc234..9fb3a357 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -610,7 +610,7 @@ function buildParams( } // OpenRouter provider routing preferences - if (model.baseUrl.includes("openrouter.ai") && model.compat?.openRouterRouting) { + if (model.compat?.openRouterRouting) { (params as any).provider = model.compat.openRouterRouting; } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index a0564e61..ed36c7ec 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -403,7 +403,7 @@ export interface OpenAICompletionsCompat { | "qwen-chat-template" | "string-thinking" | "ant-ling"; - /** OpenRouter-specific routing preferences. Only used when baseUrl points to OpenRouter. */ + /** OpenRouter-compatible routing preferences sent as the `provider` request field. */ openRouterRouting?: OpenRouterRouting; /** Vercel AI Gateway routing preferences. Only used when baseUrl points to Vercel AI Gateway. */ vercelGatewayRouting?: VercelGatewayRouting; From db594d3a597ae30e6c1f752b260d3f3faacdb9c5 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 5 Jun 2026 10:01:11 +0200 Subject: [PATCH 163/352] feat(coding-agent): show cache hit rate in footer --- packages/coding-agent/CHANGELOG.md | 4 ++++ packages/coding-agent/README.md | 2 +- .../src/modes/interactive/components/footer.ts | 9 +++++++++ .../coding-agent/test/footer-width.test.ts | 18 ++++++++++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 119c29b8..6cd0b184 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added the latest prompt cache hit rate to the interactive footer. + ### Fixed - Fixed built-in tool expand hints to style closing parentheses consistently ([#5359](https://github.com/earendil-works/pi/issues/5359)). diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 60e8da1a..e86d8c62 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -155,7 +155,7 @@ The interface from top to bottom: - **Startup header** - Shows shortcuts (`/hotkeys` for all), loaded AGENTS.md files, prompt templates, skills, and extensions - **Messages** - Your messages, assistant responses, tool calls and results, notifications, errors, and extension UI - **Editor** - Where you type; border color indicates thinking level -- **Footer** - Working directory, session name, total token/cache usage, cost, context usage, current model +- **Footer** - Working directory, session name, total token/cache usage (`↑` input, `↓` output, `R` cache read, `W` cache write, `CH` latest cache hit rate), cost, context usage, current model The editor can be temporarily replaced by other UI, like built-in `/settings` or custom UI from extensions (e.g., a Q&A tool that lets the user answer model questions in a structured format). [Extensions](#extensions) can also replace the editor, add widgets above/below it, a status line, custom footer, or overlays. diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts index 47ff58ae..f108ae5e 100644 --- a/packages/coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -88,6 +88,7 @@ export class FooterComponent implements Component { let totalCacheRead = 0; let totalCacheWrite = 0; let totalCost = 0; + let latestCacheHitRate: number | undefined; for (const entry of this.session.sessionManager.getEntries()) { if (entry.type === "message" && entry.message.role === "assistant") { @@ -96,6 +97,11 @@ export class FooterComponent implements Component { totalCacheRead += entry.message.usage.cacheRead; totalCacheWrite += entry.message.usage.cacheWrite; totalCost += entry.message.usage.cost.total; + + const latestPromptTokens = + entry.message.usage.input + entry.message.usage.cacheRead + entry.message.usage.cacheWrite; + latestCacheHitRate = + latestPromptTokens > 0 ? (entry.message.usage.cacheRead / latestPromptTokens) * 100 : undefined; } } @@ -127,6 +133,9 @@ export class FooterComponent implements Component { if (totalOutput) statsParts.push(`↓${formatTokens(totalOutput)}`); if (totalCacheRead) statsParts.push(`R${formatTokens(totalCacheRead)}`); if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`); + if ((totalCacheRead > 0 || totalCacheWrite > 0) && latestCacheHitRate !== undefined) { + statsParts.push(`CH${latestCacheHitRate.toFixed(1)}%`); + } // Show cost with "(sub)" indicator if using OAuth subscription const usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false; diff --git a/packages/coding-agent/test/footer-width.test.ts b/packages/coding-agent/test/footer-width.test.ts index d5586d7d..51df1eda 100644 --- a/packages/coding-agent/test/footer-width.test.ts +++ b/packages/coding-agent/test/footer-width.test.ts @@ -4,6 +4,7 @@ import type { AgentSession } from "../src/core/agent-session.ts"; import type { ReadonlyFooterDataProvider } from "../src/core/footer-data-provider.ts"; import { FooterComponent, formatCwdForFooter } from "../src/modes/interactive/components/footer.ts"; import { initTheme } from "../src/modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../src/utils/ansi.ts"; type AssistantUsage = { input: number; @@ -123,4 +124,21 @@ describe("FooterComponent width handling", () => { expect(visibleWidth(line)).toBeLessThanOrEqual(width); } }); + + it("shows the latest cache hit rate when cache usage is present", () => { + const session = createSession({ + sessionName: "", + usage: { + input: 100, + output: 10, + cacheRead: 50, + cacheWrite: 50, + cost: { total: 0.001 }, + }, + }); + const footer = new FooterComponent(session, createFooterData(1)); + + const statsLine = stripAnsi(footer.render(120)[1]); + expect(statsLine).toContain("CH25.0%"); + }); }); From 89a92207f1c9303d53d822fd9b0ac21578834cb4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 5 Jun 2026 10:51:20 +0200 Subject: [PATCH 164/352] feat(coding-agent): add project trust gating --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/README.md | 21 ++- packages/coding-agent/docs/extensions.md | 2 +- packages/coding-agent/docs/packages.md | 4 +- .../coding-agent/docs/prompt-templates.md | 2 +- packages/coding-agent/docs/settings.md | 10 ++ packages/coding-agent/docs/skills.md | 2 +- packages/coding-agent/docs/themes.md | 2 +- packages/coding-agent/docs/usage.md | 18 ++- packages/coding-agent/src/cli/args.ts | 13 +- .../coding-agent/src/core/package-manager.ts | 83 ++++++---- .../coding-agent/src/core/resource-loader.ts | 45 +++--- .../coding-agent/src/core/settings-manager.ts | 114 +++++++++---- .../coding-agent/src/core/slash-commands.ts | 1 + .../coding-agent/src/core/trust-manager.ts | 153 ++++++++++++++++++ packages/coding-agent/src/index.ts | 2 + packages/coding-agent/src/main.ts | 95 ++++++++++- .../src/modes/interactive/components/index.ts | 1 + .../interactive/components/trust-selector.ts | 118 ++++++++++++++ .../src/modes/interactive/interactive-mode.ts | 53 ++++++ .../coding-agent/src/package-manager-cli.ts | 67 +++++++- packages/coding-agent/test/args.test.ts | 22 +++ .../test/package-command-paths.test.ts | 100 +++++++++++- .../coding-agent/test/resource-loader.test.ts | 46 ++++++ .../test/settings-manager.test.ts | 38 +++++ .../test/stdout-cleanliness.test.ts | 20 ++- .../coding-agent/test/trust-manager.test.ts | 60 +++++++ .../coding-agent/test/trust-selector.test.ts | 48 ++++++ 28 files changed, 1029 insertions(+), 112 deletions(-) create mode 100644 packages/coding-agent/src/core/trust-manager.ts create mode 100644 packages/coding-agent/src/modes/interactive/components/trust-selector.ts create mode 100644 packages/coding-agent/test/trust-manager.test.ts create mode 100644 packages/coding-agent/test/trust-selector.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6cd0b184..8d84d266 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added project trust gating for project-local settings, resources, instructions, and packages ([#5332](https://github.com/earendil-works/pi/pull/5332)). - Added the latest prompt cache hit rate to the interactive footer. ### Fixed diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index e86d8c62..ad6fb655 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -186,6 +186,7 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist | `/name ` | Set session display name | | `/session` | Show session info (file, ID, messages, tokens, cost) | | `/tree` | Jump to any point in the session and continue from there | +| `/trust` | Save project trust decision for future sessions (restart required) | | `/fork` | Create a new session from a previous user message | | `/clone` | Duplicate the current active branch into a new session | | `/compact [prompt]` | Manually compact context, optional custom instructions | @@ -288,6 +289,16 @@ Use `/settings` to modify common options, or edit JSON files directly: See [docs/settings.md](docs/settings.md) for all options. +### Project Trust + +On interactive startup, pi asks before trusting a project folder that contains project-local inputs and has no saved decision in `~/.pi/agent/trust.json`. Trusting a project allows pi to read project instructions (`AGENTS.md`/`CLAUDE.md`), load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. + +Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without a saved trust decision, they ignore project-local inputs unless `--approve`/`-a` is passed. Use `--no-approve`/`-na` to ignore project-local inputs for one run even when the project is trusted. + +`pi config` assumes project trust for that command so you can view and change project resource settings before starting a session. It does not save a trust decision; starting a session in that folder still prompts. Pass `--no-approve` to hide project-local inputs in `pi config`. + +Use `/trust` in interactive mode to save a project trust decision for future sessions. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect. + ### Telemetry and update checks Pi has two separate startup features: @@ -303,10 +314,10 @@ Use `--offline` or `PI_OFFLINE=1` to disable all startup network operations desc Pi loads `AGENTS.md` (or `CLAUDE.md`) at startup from: - `~/.pi/agent/AGENTS.md` (global) -- Parent directories (walking up from cwd) -- Current directory +- Parent directories (walking up from cwd, only when the project is trusted) +- Current directory (only when the project is trusted) -Use for project instructions, conventions, common commands. All matching files are concatenated. +Use for project instructions (`AGENTS.md`/`CLAUDE.md`), conventions, common commands. All matching files are concatenated. Disable context file loading with `--no-context-files` (or `-nc`). @@ -512,6 +523,8 @@ pi list # List installed packages pi config # Enable/disable package resources ``` +Project package commands accept `--approve`/`--no-approve` to trust or ignore project-local package settings for one command. + ### Modes | Flag | Description | @@ -585,6 +598,8 @@ Combine `--no-*` with explicit flags to load exactly what you need, ignoring set | `--system-prompt ` | Replace default prompt (context files and skills still appended) | | `--append-system-prompt ` | Append to system prompt | | `--verbose` | Force verbose startup | +| `-a`, `--approve` | Trust project-local files for this run | +| `-na`, `--no-approve` | Ignore project-local files for this run | | `-h`, `--help` | Show help | | `-v`, `--version` | Show version | diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 08f60162..0d63ceb6 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -109,7 +109,7 @@ pi -e ./my-extension.ts > **Security:** Extensions run with your full system permissions and can execute arbitrary code. Only install from sources you trust. -Extensions are auto-discovered from: +Extensions are auto-discovered from trusted locations. Project-local `.pi/extensions` entries load only after the project is trusted. | Location | Scope | |----------|-------| diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index 33527191..1469ea98 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -38,7 +38,9 @@ pi update --extension npm:@foo/bar These commands manage pi packages, not the pi CLI installation. 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. +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. + +Project package commands read project settings only when the project is trusted. Use `--approve` to trust project-local files for one command, or `--no-approve` to ignore them for one command. To try a package without installing it, use `--extension` or `-e`. This installs to a temporary directory for the current run only: diff --git a/packages/coding-agent/docs/prompt-templates.md b/packages/coding-agent/docs/prompt-templates.md index 056d2c9a..00450858 100644 --- a/packages/coding-agent/docs/prompt-templates.md +++ b/packages/coding-agent/docs/prompt-templates.md @@ -9,7 +9,7 @@ Prompt templates are Markdown snippets that expand into full prompts. Type `/nam Pi loads prompt templates from: - Global: `~/.pi/agent/prompts/*.md` -- Project: `.pi/prompts/*.md` +- Project: `.pi/prompts/*.md` (only after the project is trusted) - Packages: `prompts/` directories or `pi.prompts` entries in `package.json` - Settings: `prompts` array with files or directories - CLI: `--prompt-template ` (repeatable) diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 47aecc4a..df2d0bd6 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -9,6 +9,16 @@ Pi uses JSON settings files with project settings overriding global settings. Edit directly or use `/settings` for common options. +## Project Trust + +On interactive startup, pi asks before trusting a project folder that contains project-local inputs and has no saved decision in `~/.pi/agent/trust.json`. Trusting a project allows pi to read project instructions (`AGENTS.md`/`CLAUDE.md`), load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. + +Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without a saved trust decision, they ignore project-local inputs unless `--approve`/`-a` is passed. Use `--no-approve`/`-na` to ignore project-local inputs for one run even when the project is trusted. + +`pi config` assumes project trust for that command so you can view and change project resource settings before starting a session. It does not save a trust decision; starting a session in that folder still prompts. Pass `--no-approve` to hide project-local inputs in `pi config`. + +Use `/trust` in interactive mode to save a project trust decision for future sessions. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect. + ## All Settings ### Model & Thinking diff --git a/packages/coding-agent/docs/skills.md b/packages/coding-agent/docs/skills.md index cb96dd97..d3ffeea8 100644 --- a/packages/coding-agent/docs/skills.md +++ b/packages/coding-agent/docs/skills.md @@ -26,7 +26,7 @@ Pi loads skills from: - Global: - `~/.pi/agent/skills/` - `~/.agents/skills/` -- Project: +- Project (only after the project is trusted): - `.pi/skills/` - `.agents/skills/` in `cwd` and ancestor directories (up to git repo root, or filesystem root when not in a repo) - Packages: `skills/` directories or `pi.skills` entries in `package.json` diff --git a/packages/coding-agent/docs/themes.md b/packages/coding-agent/docs/themes.md index 8762943d..c18e954b 100644 --- a/packages/coding-agent/docs/themes.md +++ b/packages/coding-agent/docs/themes.md @@ -20,7 +20,7 @@ Pi loads themes from: - Built-in: `dark`, `light` - Global: `~/.pi/agent/themes/*.json` -- Project: `.pi/themes/*.json` +- Project: `.pi/themes/*.json` (only after the project is trusted) - Packages: `themes/` directories or `pi.themes` entries in `package.json` - Settings: `themes` array with files or directories - CLI: `--theme ` (repeatable) diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 2f2a6449..ad9b80e1 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -96,8 +96,8 @@ See [Sessions](sessions.md) and [Compaction](compaction.md) for details. Pi loads `AGENTS.md` or `CLAUDE.md` at startup from: - `~/.pi/agent/AGENTS.md` for global instructions -- parent directories, walking up from the current working directory -- the current directory +- parent directories, walking up from the current working directory when the project is trusted +- the current directory when the project is trusted Use context files for project conventions, commands, safety rules, and preferences. Disable loading with `--no-context-files` or `-nc`. @@ -110,6 +110,16 @@ Replace the default system prompt with: Append to the default prompt without replacing it with `APPEND_SYSTEM.md` in either location. +### Project Trust + +On interactive startup, pi asks before trusting a project folder that contains project-local inputs and has no saved decision in `~/.pi/agent/trust.json`. Trusting a project allows pi to read project instructions (`AGENTS.md`/`CLAUDE.md`), load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. + +Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without a saved trust decision, they ignore project-local inputs unless `--approve`/`-a` is passed. Use `--no-approve`/`-na` to ignore project-local inputs for one run even when the project is trusted. + +`pi config` assumes project trust for that command so you can view and change project resource settings before starting a session. It does not save a trust decision; starting a session in that folder still prompts. Pass `--no-approve` to hide project-local inputs in `pi config`. + +Use `/trust` in interactive mode to save a project trust decision for future sessions. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect. + ## Exporting and Sharing Sessions Use `/export [file]` to write a session to HTML. @@ -138,7 +148,7 @@ pi list # List installed packages pi config # Enable/disable package resources ``` -These commands manage pi packages, not the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). +These commands manage pi packages, not the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). Project package commands accept `--approve`/`--no-approve` to trust or ignore project-local package settings for one command. See [Pi Packages](packages.md) for package sources and security notes. @@ -219,6 +229,8 @@ pi --no-extensions -e ./my-extension.ts | `--system-prompt ` | Replace default prompt; context files and skills are still appended | | `--append-system-prompt ` | Append to system prompt | | `--verbose` | Force verbose startup | +| `-a`, `--approve` | Trust project-local files for this run | +| `-na`, `--no-approve` | Ignore project-local files for this run | | `-h`, `--help` | Show help | | `-v`, `--version` | Show version | diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 959484da..0258c0c1 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -46,6 +46,7 @@ export interface Args { listModels?: string | true; offline?: boolean; verbose?: boolean; + projectTrustOverride?: boolean; messages: string[]; fileArgs: string[]; /** Unknown flags (potentially extension flags) - map of flag name to value */ @@ -176,6 +177,10 @@ export function parseArgs(args: string[]): Args { } } else if (arg === "--verbose") { result.verbose = true; + } else if (arg === "--approve" || arg === "-a") { + result.projectTrustOverride = true; + } else if (arg === "--no-approve" || arg === "-na") { + result.projectTrustOverride = false; } else if (arg === "--offline") { result.offline = true; } else if (arg.startsWith("@")) { @@ -225,8 +230,10 @@ ${chalk.bold("Commands:")} ${APP_NAME} remove [-l] Remove extension source from settings ${APP_NAME} uninstall [-l] Alias for remove ${APP_NAME} update [source|self|pi] Update pi and installed extensions - ${APP_NAME} list List installed extensions from settings - ${APP_NAME} config Open TUI to enable/disable package resources + ${APP_NAME} list [--approve|--no-approve] + List installed extensions from settings + ${APP_NAME} config [--no-approve] + Open TUI to enable/disable package resources ${APP_NAME} --help Show help for install/remove/uninstall/update/list ${chalk.bold("Options:")} @@ -266,6 +273,8 @@ ${chalk.bold("Options:")} --export Export session file to HTML and exit --list-models [search] List available models (with optional fuzzy search) --verbose Force verbose startup (overrides quietStartup setting) + --approve, -a Trust project-local files for this run + --no-approve, -na Ignore project-local files for this run --offline Disable startup network operations (same as PI_OFFLINE=1) --help, -h Show this help --version, -v Show version number diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index ac35a273..5120c9ad 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -963,6 +963,7 @@ export class DefaultPackageManager implements PackageManager { async install(source: string, options?: { local?: boolean }): Promise { const parsed = this.parseSource(source); const scope: SourceScope = options?.local ? "project" : "user"; + this.assertProjectTrustedForScope(scope); await this.withProgress("install", source, `Installing ${source}...`, async () => { if (parsed.type === "npm") { await this.installNpm(parsed, scope, false); @@ -991,6 +992,7 @@ export class DefaultPackageManager implements PackageManager { async remove(source: string, options?: { local?: boolean }): Promise { const parsed = this.parseSource(source); const scope: SourceScope = options?.local ? "project" : "user"; + this.assertProjectTrustedForScope(scope); await this.withProgress("remove", source, `Removing ${source}...`, async () => { if (parsed.type === "npm") { await this.uninstallNpm(parsed, scope); @@ -1673,6 +1675,12 @@ export class DefaultPackageManager implements PackageManager { return { name, version }; } + private assertProjectTrustedForScope(scope: SourceScope): void { + if (scope === "project" && !this.settingsManager.isProjectTrusted()) { + throw new Error("Project is not trusted; refusing to access project package storage"); + } + } + private getNpmCommand(): { command: string; args: string[] } { const configuredCommand = this.settingsManager.getNpmCommand(); if (!configuredCommand || configuredCommand.length === 0) { @@ -1893,6 +1901,7 @@ export class DefaultPackageManager implements PackageManager { return this.getTemporaryDir("npm"); } if (scope === "project") { + this.assertProjectTrustedForScope(scope); return join(this.cwd, CONFIG_DIR_NAME, "npm"); } return join(this.agentDir, "npm"); @@ -1933,6 +1942,7 @@ export class DefaultPackageManager implements PackageManager { return join(this.getTemporaryDir("npm"), "node_modules", source.name); } if (scope === "project") { + this.assertProjectTrustedForScope(scope); return join(this.cwd, CONFIG_DIR_NAME, "npm", "node_modules", source.name); } return join(this.agentDir, "npm", "node_modules", source.name); @@ -1971,6 +1981,7 @@ export class DefaultPackageManager implements PackageManager { return undefined; } if (scope === "project") { + this.assertProjectTrustedForScope(scope); return join(this.cwd, CONFIG_DIR_NAME, "git"); } return join(this.agentDir, "git"); @@ -1996,6 +2007,7 @@ export class DefaultPackageManager implements PackageManager { private getBaseDirForScope(scope: SourceScope): string { if (scope === "project") { + this.assertProjectTrustedForScope(scope); return join(this.cwd, CONFIG_DIR_NAME); } if (scope === "user") { @@ -2257,9 +2269,10 @@ export class DefaultPackageManager implements PackageManager { themes: join(projectBaseDir, "themes"), }; const userAgentsSkillsDir = join(getHomeDir(), ".agents", "skills"); - const projectAgentsSkillDirs = collectAncestorAgentsSkillDirs(this.cwd).filter( - (dir) => resolve(dir) !== resolve(userAgentsSkillsDir), - ); + const projectTrusted = this.settingsManager.isProjectTrusted(); + const projectAgentsSkillDirs = projectTrusted + ? collectAncestorAgentsSkillDirs(this.cwd).filter((dir) => resolve(dir) !== resolve(userAgentsSkillsDir)) + : []; const addResources = ( resourceType: ResourceType, @@ -2275,23 +2288,25 @@ export class DefaultPackageManager implements PackageManager { } }; - // Project extensions from .pi/ - addResources( - "extensions", - collectAutoExtensionEntries(projectDirs.extensions), - projectMetadata, - projectOverrides.extensions, - projectBaseDir, - ); + if (projectTrusted) { + // Project extensions from .pi/ + addResources( + "extensions", + collectAutoExtensionEntries(projectDirs.extensions), + projectMetadata, + projectOverrides.extensions, + projectBaseDir, + ); - // Project skills from .pi/ - addResources( - "skills", - collectAutoSkillEntries(projectDirs.skills, "pi"), - projectMetadata, - projectOverrides.skills, - projectBaseDir, - ); + // Project skills from .pi/ + addResources( + "skills", + collectAutoSkillEntries(projectDirs.skills, "pi"), + projectMetadata, + projectOverrides.skills, + projectBaseDir, + ); + } // Project skills from .agents/ (each with its own baseDir) for (const agentsSkillsDir of projectAgentsSkillDirs) { @@ -2309,20 +2324,22 @@ export class DefaultPackageManager implements PackageManager { ); } - addResources( - "prompts", - collectAutoPromptEntries(projectDirs.prompts), - projectMetadata, - projectOverrides.prompts, - projectBaseDir, - ); - addResources( - "themes", - collectAutoThemeEntries(projectDirs.themes), - projectMetadata, - projectOverrides.themes, - projectBaseDir, - ); + if (projectTrusted) { + addResources( + "prompts", + collectAutoPromptEntries(projectDirs.prompts), + projectMetadata, + projectOverrides.prompts, + projectBaseDir, + ); + addResources( + "themes", + collectAutoThemeEntries(projectDirs.themes), + projectMetadata, + projectOverrides.themes, + projectBaseDir, + ); + } // User extensions from ~/.pi/agent/ addResources( diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index eb043cd5..1221366a 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -75,6 +75,7 @@ function loadContextFileFromDir(dir: string): { path: string; content: string } export function loadProjectContextFiles(options: { cwd: string; agentDir: string; + projectTrusted?: boolean; }): Array<{ path: string; content: string }> { const resolvedCwd = resolvePath(options.cwd); const resolvedAgentDir = resolvePath(options.agentDir); @@ -88,27 +89,29 @@ export function loadProjectContextFiles(options: { seenPaths.add(globalContext.path); } - const ancestorContextFiles: Array<{ path: string; content: string }> = []; + if (options.projectTrusted !== false) { + const ancestorContextFiles: Array<{ path: string; content: string }> = []; - let currentDir = resolvedCwd; - const root = resolve("/"); + let currentDir = resolvedCwd; + const root = resolve("/"); - while (true) { - const contextFile = loadContextFileFromDir(currentDir); - if (contextFile && !seenPaths.has(contextFile.path)) { - ancestorContextFiles.unshift(contextFile); - seenPaths.add(contextFile.path); + while (true) { + const contextFile = loadContextFileFromDir(currentDir); + if (contextFile && !seenPaths.has(contextFile.path)) { + ancestorContextFiles.unshift(contextFile); + seenPaths.add(contextFile.path); + } + + if (currentDir === root) break; + + const parentDir = resolve(currentDir, ".."); + if (parentDir === currentDir) break; + currentDir = parentDir; } - if (currentDir === root) break; - - const parentDir = resolve(currentDir, ".."); - if (parentDir === currentDir) break; - currentDir = parentDir; + contextFiles.push(...ancestorContextFiles); } - contextFiles.push(...ancestorContextFiles); - return contextFiles; } @@ -466,7 +469,13 @@ export class DefaultResourceLoader implements ResourceLoader { } const agentsFiles = { - agentsFiles: this.noContextFiles ? [] : loadProjectContextFiles({ cwd: this.cwd, agentDir: this.agentDir }), + agentsFiles: this.noContextFiles + ? [] + : loadProjectContextFiles({ + cwd: this.cwd, + agentDir: this.agentDir, + projectTrusted: this.settingsManager.isProjectTrusted(), + }), }; const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles; this.agentsFiles = resolvedAgentsFiles.agentsFiles; @@ -852,7 +861,7 @@ export class DefaultResourceLoader implements ResourceLoader { private discoverSystemPromptFile(): string | undefined { const projectPath = join(this.cwd, CONFIG_DIR_NAME, "SYSTEM.md"); - if (existsSync(projectPath)) { + if (this.settingsManager.isProjectTrusted() && existsSync(projectPath)) { return projectPath; } @@ -866,7 +875,7 @@ export class DefaultResourceLoader implements ResourceLoader { private discoverAppendSystemPromptFile(): string | undefined { const projectPath = join(this.cwd, CONFIG_DIR_NAME, "APPEND_SYSTEM.md"); - if (existsSync(projectPath)) { + if (this.settingsManager.isProjectTrusted() && existsSync(projectPath)) { return projectPath; } diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 910b68aa..2ef32d6d 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -159,6 +159,10 @@ function parseTimeoutSetting(value: unknown, settingName: string): number | unde export type SettingsScope = "global" | "project"; +export interface SettingsManagerCreateOptions { + projectTrusted?: boolean; +} + export interface SettingsStorage { withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void; } @@ -259,6 +263,7 @@ export class SettingsManager { private globalSettings: Settings; private projectSettings: Settings; private settings: Settings; + private projectTrusted: boolean; private modifiedFields = new Set(); // Track global fields modified during session private modifiedNestedFields = new Map>(); // Track global nested field modifications private modifiedProjectFields = new Set(); // Track project fields modified during session @@ -275,10 +280,12 @@ export class SettingsManager { globalLoadError: Error | null = null, projectLoadError: Error | null = null, initialErrors: SettingsError[] = [], + projectTrusted = true, ) { this.storage = storage; this.globalSettings = initialGlobal; this.projectSettings = initialProject; + this.projectTrusted = projectTrusted; this.globalSettingsLoadError = globalLoadError; this.projectSettingsLoadError = projectLoadError; this.errors = [...initialErrors]; @@ -286,15 +293,20 @@ export class SettingsManager { } /** Create a SettingsManager that loads from files */ - static create(cwd: string, agentDir: string = getAgentDir()): SettingsManager { + static create( + cwd: string, + agentDir: string = getAgentDir(), + options: SettingsManagerCreateOptions = {}, + ): SettingsManager { const storage = new FileSettingsStorage(cwd, agentDir); - return SettingsManager.fromStorage(storage); + return SettingsManager.fromStorage(storage, options); } /** Create a SettingsManager from an arbitrary storage backend */ - static fromStorage(storage: SettingsStorage): SettingsManager { + static fromStorage(storage: SettingsStorage, options: SettingsManagerCreateOptions = {}): SettingsManager { + const projectTrusted = options.projectTrusted ?? true; const globalLoad = SettingsManager.tryLoadFromStorage(storage, "global"); - const projectLoad = SettingsManager.tryLoadFromStorage(storage, "project"); + const projectLoad = SettingsManager.tryLoadFromStorage(storage, "project", projectTrusted); const initialErrors: SettingsError[] = []; if (globalLoad.error) { initialErrors.push({ scope: "global", error: globalLoad.error }); @@ -310,6 +322,7 @@ export class SettingsManager { globalLoad.error, projectLoad.error, initialErrors, + projectTrusted, ); } @@ -321,7 +334,11 @@ export class SettingsManager { return SettingsManager.fromStorage(storage); } - private static loadFromStorage(storage: SettingsStorage, scope: SettingsScope): Settings { + private static loadFromStorage(storage: SettingsStorage, scope: SettingsScope, projectTrusted = true): Settings { + if (scope === "project" && !projectTrusted) { + return {}; + } + let content: string | undefined; storage.withLock(scope, (current) => { content = current; @@ -338,9 +355,10 @@ export class SettingsManager { private static tryLoadFromStorage( storage: SettingsStorage, scope: SettingsScope, + projectTrusted = true, ): { settings: Settings; error: Error | null } { try { - return { settings: SettingsManager.loadFromStorage(storage, scope), error: null }; + return { settings: SettingsManager.loadFromStorage(storage, scope, projectTrusted), error: null }; } catch (error) { return { settings: {}, error: error as Error }; } @@ -416,6 +434,35 @@ export class SettingsManager { return structuredClone(this.projectSettings); } + isProjectTrusted(): boolean { + return this.projectTrusted; + } + + setProjectTrusted(trusted: boolean): void { + if (this.projectTrusted === trusted) { + return; + } + + this.projectTrusted = trusted; + this.modifiedProjectFields.clear(); + this.modifiedProjectNestedFields.clear(); + + if (!trusted) { + this.projectSettings = {}; + this.projectSettingsLoadError = null; + this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); + return; + } + + const projectLoad = SettingsManager.tryLoadFromStorage(this.storage, "project", trusted); + this.projectSettings = projectLoad.settings; + this.projectSettingsLoadError = projectLoad.error; + if (projectLoad.error) { + this.recordError("project", projectLoad.error); + } + this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); + } + async reload(): Promise { await this.writeQueue; const globalLoad = SettingsManager.tryLoadFromStorage(this.storage, "global"); @@ -432,7 +479,7 @@ export class SettingsManager { this.modifiedProjectFields.clear(); this.modifiedProjectNestedFields.clear(); - const projectLoad = SettingsManager.tryLoadFromStorage(this.storage, "project"); + const projectLoad = SettingsManager.tryLoadFromStorage(this.storage, "project", this.projectTrusted); if (!projectLoad.error) { this.projectSettings = projectLoad.settings; this.projectSettingsLoadError = null; @@ -471,6 +518,12 @@ export class SettingsManager { } } + private assertProjectTrustedForWrite(): void { + if (!this.projectTrusted) { + throw new Error("Project is not trusted; refusing to write project settings"); + } + } + private recordError(scope: SettingsScope, error: unknown): void { const normalizedError = error instanceof Error ? error : new Error(String(error)); this.errors.push({ scope, error: normalizedError }); @@ -490,6 +543,9 @@ export class SettingsManager { private enqueueWrite(scope: SettingsScope, task: () => void): void { this.writeQueue = this.writeQueue .then(() => { + if (scope === "project") { + this.assertProjectTrustedForWrite(); + } task(); this.clearModifiedScope(scope); }) @@ -554,6 +610,7 @@ export class SettingsManager { } private saveProjectSettings(settings: Settings): void { + this.assertProjectTrustedForWrite(); this.projectSettings = structuredClone(settings); this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); @@ -569,6 +626,14 @@ export class SettingsManager { }); } + private updateProjectSettings(field: keyof Settings, update: (settings: Settings) => void): void { + this.assertProjectTrustedForWrite(); + const projectSettings = structuredClone(this.projectSettings); + update(projectSettings); + this.markProjectModified(field); + this.saveProjectSettings(projectSettings); + } + async flush(): Promise { await this.writeQueue; } @@ -839,10 +904,9 @@ export class SettingsManager { } setProjectPackages(packages: PackageSource[]): void { - const projectSettings = structuredClone(this.projectSettings); - projectSettings.packages = packages; - this.markProjectModified("packages"); - this.saveProjectSettings(projectSettings); + this.updateProjectSettings("packages", (settings) => { + settings.packages = packages; + }); } getExtensionPaths(): string[] { @@ -856,10 +920,9 @@ export class SettingsManager { } setProjectExtensionPaths(paths: string[]): void { - const projectSettings = structuredClone(this.projectSettings); - projectSettings.extensions = paths; - this.markProjectModified("extensions"); - this.saveProjectSettings(projectSettings); + this.updateProjectSettings("extensions", (settings) => { + settings.extensions = paths; + }); } getSkillPaths(): string[] { @@ -873,10 +936,9 @@ export class SettingsManager { } setProjectSkillPaths(paths: string[]): void { - const projectSettings = structuredClone(this.projectSettings); - projectSettings.skills = paths; - this.markProjectModified("skills"); - this.saveProjectSettings(projectSettings); + this.updateProjectSettings("skills", (settings) => { + settings.skills = paths; + }); } getPromptTemplatePaths(): string[] { @@ -890,10 +952,9 @@ export class SettingsManager { } setProjectPromptTemplatePaths(paths: string[]): void { - const projectSettings = structuredClone(this.projectSettings); - projectSettings.prompts = paths; - this.markProjectModified("prompts"); - this.saveProjectSettings(projectSettings); + this.updateProjectSettings("prompts", (settings) => { + settings.prompts = paths; + }); } getThemePaths(): string[] { @@ -907,10 +968,9 @@ export class SettingsManager { } setProjectThemePaths(paths: string[]): void { - const projectSettings = structuredClone(this.projectSettings); - projectSettings.themes = paths; - this.markProjectModified("themes"); - this.saveProjectSettings(projectSettings); + this.updateProjectSettings("themes", (settings) => { + settings.themes = paths; + }); } getEnableSkillCommands(): boolean { diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index e52892c8..77dd79aa 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -30,6 +30,7 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "fork", description: "Create a new fork from a previous user message" }, { name: "clone", description: "Duplicate the current session at the current position" }, { name: "tree", description: "Navigate session tree (switch branches)" }, + { name: "trust", description: "Save project trust decision for future sessions" }, { name: "login", description: "Configure provider authentication" }, { name: "logout", description: "Remove provider authentication" }, { name: "new", description: "Start a new session" }, diff --git a/packages/coding-agent/src/core/trust-manager.ts b/packages/coding-agent/src/core/trust-manager.ts new file mode 100644 index 00000000..59abcc92 --- /dev/null +++ b/packages/coding-agent/src/core/trust-manager.ts @@ -0,0 +1,153 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import lockfile from "proper-lockfile"; +import { CONFIG_DIR_NAME } from "../config.ts"; +import { canonicalizePath, resolvePath } from "../utils/paths.ts"; + +export type ProjectTrustDecision = boolean | null; + +type TrustFile = Record; + +const CONTEXT_FILE_NAMES = ["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]; + +function normalizeCwd(cwd: string): string { + return canonicalizePath(resolvePath(cwd)); +} + +function readTrustFile(path: string): TrustFile { + if (!existsSync(path)) { + return {}; + } + + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(path, "utf-8")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to read trust store ${path}: ${message}`); + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error(`Invalid trust store ${path}: expected an object`); + } + + const data: TrustFile = {}; + for (const [key, value] of Object.entries(parsed)) { + if (value !== true && value !== false && value !== null) { + throw new Error(`Invalid trust store ${path}: value for ${JSON.stringify(key)} must be true, false, or null`); + } + data[key] = value; + } + return data; +} + +function writeTrustFile(path: string, data: TrustFile): void { + const sorted: TrustFile = {}; + for (const key of Object.keys(data).sort()) { + const value = data[key]; + if (value === true || value === false || value === null) { + sorted[key] = value; + } + } + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(sorted, null, 2)}\n`, "utf-8"); +} + +function acquireTrustLockSync(path: string): () => void { + const trustDir = dirname(path); + mkdirSync(trustDir, { recursive: true }); + const maxAttempts = 10; + const delayMs = 20; + let lastError: unknown; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return lockfile.lockSync(trustDir, { realpath: false, lockfilePath: `${path}.lock` }); + } catch (error) { + const code = + typeof error === "object" && error !== null && "code" in error + ? String((error as { code?: unknown }).code) + : undefined; + if (code !== "ELOCKED" || attempt === maxAttempts) { + throw error; + } + lastError = error; + const start = Date.now(); + while (Date.now() - start < delayMs) { + // Sleep synchronously to avoid changing trust store callers to async. + } + } + } + + if (lastError instanceof Error) { + throw lastError; + } + throw new Error("Failed to acquire trust store lock"); +} + +function withTrustFileLock(path: string, fn: () => T): T { + const release = acquireTrustLockSync(path); + try { + return fn(); + } finally { + release(); + } +} + +export function hasProjectTrustInputs(cwd: string): boolean { + let currentDir = resolvePath(cwd); + if (existsSync(join(currentDir, CONFIG_DIR_NAME))) { + return true; + } + + const root = resolve("/"); + while (true) { + for (const filename of CONTEXT_FILE_NAMES) { + if (existsSync(join(currentDir, filename))) { + return true; + } + } + if (existsSync(join(currentDir, ".agents", "skills"))) { + return true; + } + + if (currentDir === root) { + return false; + } + + const parentDir = resolve(currentDir, ".."); + if (parentDir === currentDir) { + return false; + } + currentDir = parentDir; + } +} + +export class ProjectTrustStore { + private trustPath: string; + + constructor(agentDir: string) { + this.trustPath = join(resolvePath(agentDir), "trust.json"); + } + + get(cwd: string): ProjectTrustDecision { + return withTrustFileLock(this.trustPath, () => { + const data = readTrustFile(this.trustPath); + const value = data[normalizeCwd(cwd)]; + return value === true || value === false ? value : null; + }); + } + + set(cwd: string, decision: ProjectTrustDecision): void { + withTrustFileLock(this.trustPath, () => { + const data = readTrustFile(this.trustPath); + const key = normalizeCwd(cwd); + if (decision === null) { + delete data[key]; + } else { + data[key] = decision; + } + writeTrustFile(this.trustPath, data); + }); + } +} diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 0cdfc726..1f9b3a19 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -219,6 +219,7 @@ export { type PackageSource, type RetrySettings, SettingsManager, + type SettingsManagerCreateOptions, } from "./core/settings-manager.ts"; // Skills export { @@ -281,6 +282,7 @@ export { type WriteToolOptions, withFileMutationQueue, } from "./core/tools/index.ts"; +export { hasProjectTrustInputs, type ProjectTrustDecision, ProjectTrustStore } from "./core/trust-manager.ts"; // Main entry point export { type MainOptions, main } from "./main.ts"; // Run modes for programmatic SDK usage diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 353e4815..a3fbb719 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -40,6 +40,7 @@ import { import { assertValidSessionId, SessionManager } from "./core/session-manager.ts"; import { SettingsManager } from "./core/settings-manager.ts"; import { printTimings, resetTimings, time } from "./core/timings.ts"; +import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts"; import { runMigrations, showDeprecationWarnings } from "./migrations.ts"; import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts"; import { ExtensionSelectorComponent } from "./modes/interactive/components/extension-selector.ts"; @@ -436,10 +437,11 @@ function resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | u return paths?.map((value) => (isLocalPath(value) ? resolvePath(value, cwd) : value)); } -async function promptForMissingSessionCwd( - issue: SessionCwdIssue, +async function showStartupSelector( settingsManager: SettingsManager, -): Promise { + title: string, + options: Array<{ label: string; value: T }>, +): Promise { initTheme(settingsManager.getTheme()); setKeybindings(KeybindingsManager.create()); @@ -448,7 +450,7 @@ async function promptForMissingSessionCwd( ui.setClearOnShrink(settingsManager.getClearOnShrink()); let settled = false; - const finish = (result: string | undefined) => { + const finish = (result: T | undefined) => { if (settled) { return; } @@ -458,9 +460,9 @@ async function promptForMissingSessionCwd( }; const selector = new ExtensionSelectorComponent( - formatMissingSessionCwdPrompt(issue), - ["Continue", "Cancel"], - (option) => finish(option === "Continue" ? issue.fallbackCwd : undefined), + title, + options.map((option) => option.label), + (option) => finish(options.find((entry) => entry.label === option)?.value), () => finish(undefined), { tui: ui }, ); @@ -470,6 +472,69 @@ async function promptForMissingSessionCwd( }); } +async function promptForMissingSessionCwd( + issue: SessionCwdIssue, + settingsManager: SettingsManager, +): Promise { + return showStartupSelector(settingsManager, formatMissingSessionCwdPrompt(issue), [ + { label: "Continue", value: issue.fallbackCwd }, + { label: "Cancel", value: undefined }, + ]); +} + +interface ProjectTrustPromptResult { + trusted: boolean; + remember: boolean; +} + +async function promptForProjectTrust( + cwd: string, + settingsManager: SettingsManager, +): Promise { + return showStartupSelector( + settingsManager, + `Trust project folder?\n${cwd}\n\nThis allows pi to read project instructions (AGENTS.md/CLAUDE.md), load .pi settings and resources, install missing project packages, and execute project extensions.`, + [ + { label: "Trust", value: { trusted: true, remember: true } }, + { label: "Trust (this session only)", value: { trusted: true, remember: false } }, + { label: "Do not trust", value: { trusted: false, remember: true } }, + { label: "Do not trust (this session only)", value: { trusted: false, remember: false } }, + ], + ); +} + +async function resolveProjectTrusted(options: { + cwd: string; + trustStore: ProjectTrustStore; + trustOverride?: boolean; + appMode: AppMode; + settingsManagerForPrompt: SettingsManager; +}): Promise { + if (options.trustOverride !== undefined) { + return options.trustOverride; + } + if (!hasProjectTrustInputs(options.cwd)) { + return true; + } + + const decision = options.trustStore.get(options.cwd); + if (decision !== null) { + return decision; + } + if (options.appMode !== "interactive") { + return false; + } + + const selected = await promptForProjectTrust(options.cwd, options.settingsManagerForPrompt); + if (selected !== undefined) { + if (selected.remember) { + options.trustStore.set(options.cwd, selected.trusted); + } + return selected.trusted; + } + return false; +} + export interface MainOptions { extensionFactories?: ExtensionFactory[]; } @@ -581,6 +646,16 @@ export async function main(args: string[], options?: MainOptions) { } time("createSessionManager"); + const trustStore = new ProjectTrustStore(agentDir); + const trustPromptMode: AppMode = parsed.help || parsed.listModels !== undefined ? "print" : appMode; + const projectTrustedForSession = await resolveProjectTrusted({ + cwd: sessionManager.getCwd(), + trustStore, + trustOverride: parsed.projectTrustOverride, + appMode: trustPromptMode, + settingsManagerForPrompt: startupSettingsManager, + }); + const resolvedExtensionPaths = resolveCliPaths(cwd, parsed.extensions); const resolvedSkillPaths = resolveCliPaths(cwd, parsed.skills); const resolvedPromptTemplatePaths = resolveCliPaths(cwd, parsed.promptTemplates); @@ -592,10 +667,16 @@ export async function main(args: string[], options?: MainOptions) { sessionManager, sessionStartEvent, }) => { + const projectTrusted = + cwd === sessionManager.getCwd() + ? projectTrustedForSession + : (parsed.projectTrustOverride ?? (!hasProjectTrustInputs(cwd) || trustStore.get(cwd) === true)); + const runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted }); const services = await createAgentSessionServices({ cwd, agentDir, authStorage, + settingsManager: runtimeSettingsManager, extensionFlagValues: parsed.unknownFlags, resourceLoaderOptions: { additionalExtensionPaths: resolvedExtensionPaths, diff --git a/packages/coding-agent/src/modes/interactive/components/index.ts b/packages/coding-agent/src/modes/interactive/components/index.ts index d9b5d773..fa47d642 100644 --- a/packages/coding-agent/src/modes/interactive/components/index.ts +++ b/packages/coding-agent/src/modes/interactive/components/index.ts @@ -27,6 +27,7 @@ export { ThemeSelectorComponent } from "./theme-selector.ts"; export { ThinkingSelectorComponent } from "./thinking-selector.ts"; export { ToolExecutionComponent, type ToolExecutionOptions } from "./tool-execution.ts"; export { TreeSelectorComponent } from "./tree-selector.ts"; +export { TrustSelectorComponent } from "./trust-selector.ts"; export { UserMessageComponent } from "./user-message.ts"; export { UserMessageSelectorComponent } from "./user-message-selector.ts"; export { truncateToVisualLines, type VisualTruncateResult } from "./visual-truncate.ts"; diff --git a/packages/coding-agent/src/modes/interactive/components/trust-selector.ts b/packages/coding-agent/src/modes/interactive/components/trust-selector.ts new file mode 100644 index 00000000..b3664768 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/trust-selector.ts @@ -0,0 +1,118 @@ +import { Container, getKeybindings, Spacer, Text } from "@earendil-works/pi-tui"; +import type { ProjectTrustDecision } from "../../../core/trust-manager.ts"; +import { theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyHint, rawKeyHint } from "./keybinding-hints.ts"; + +interface TrustOption { + label: string; + trusted: boolean; +} + +export interface TrustSelectorOptions { + cwd: string; + savedDecision: ProjectTrustDecision; + projectTrusted: boolean; + onSelect: (trusted: boolean) => void; + onCancel: () => void; +} + +const TRUST_OPTIONS: TrustOption[] = [ + { label: "Trust", trusted: true }, + { label: "Do not trust", trusted: false }, +]; + +function formatDecision(decision: ProjectTrustDecision): string { + if (decision === true) { + return "trusted"; + } + if (decision === false) { + return "untrusted"; + } + return "none"; +} + +export class TrustSelectorComponent extends Container { + private selectedIndex: number; + private readonly listContainer: Container; + private readonly savedDecision: ProjectTrustDecision; + private readonly onSelectCallback: (trusted: boolean) => void; + private readonly onCancelCallback: () => void; + + constructor(options: TrustSelectorOptions) { + super(); + + this.savedDecision = options.savedDecision; + this.selectedIndex = Math.max( + 0, + TRUST_OPTIONS.findIndex((option) => option.trusted === options.savedDecision), + ); + this.onSelectCallback = options.onSelect; + this.onCancelCallback = options.onCancel; + + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("accent", theme.bold("Project trust")), 1, 0)); + this.addChild(new Text(theme.fg("muted", options.cwd), 1, 0)); + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("muted", `Saved decision: ${formatDecision(options.savedDecision)}`), 1, 0)); + this.addChild( + new Text(theme.fg("muted", `Current session: ${options.projectTrusted ? "trusted" : "untrusted"}`), 1, 0), + ); + this.addChild(new Spacer(1)); + + this.listContainer = new Container(); + this.addChild(this.listContainer); + this.addChild(new Spacer(1)); + this.addChild( + new Text( + rawKeyHint("↑↓", "navigate") + + " " + + keyHint("tui.select.confirm", "save") + + " " + + keyHint("tui.select.cancel", "cancel"), + 1, + 0, + ), + ); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + + this.updateList(); + } + + private updateList(): void { + this.listContainer.clear(); + for (let i = 0; i < TRUST_OPTIONS.length; i++) { + const option = TRUST_OPTIONS[i]; + if (!option) { + continue; + } + + const isSelected = i === this.selectedIndex; + const isCurrent = option.trusted === this.savedDecision; + const checkmark = isCurrent ? theme.fg("success", " ✓") : ""; + const prefix = isSelected ? theme.fg("accent", "→ ") : " "; + const label = isSelected ? theme.fg("accent", option.label) : theme.fg("text", option.label); + this.listContainer.addChild(new Text(`${prefix}${label}${checkmark}`, 1, 0)); + } + } + + handleInput(keyData: string): void { + const kb = getKeybindings(); + if (kb.matches(keyData, "tui.select.up") || keyData === "k") { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + this.updateList(); + } else if (kb.matches(keyData, "tui.select.down") || keyData === "j") { + this.selectedIndex = Math.min(TRUST_OPTIONS.length - 1, this.selectedIndex + 1); + this.updateList(); + } else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") { + const selected = TRUST_OPTIONS[this.selectedIndex]; + if (selected) { + this.onSelectCallback(selected.trusted); + } + } else if (kb.matches(keyData, "tui.select.cancel")) { + this.onCancelCallback(); + } + } +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 5ed40caf..5e0ecaf9 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -85,6 +85,7 @@ import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts"; import type { SourceInfo } from "../../core/source-info.ts"; import { isInstallTelemetryEnabled } from "../../core/telemetry.ts"; import type { TruncationResult } from "../../core/tools/truncate.ts"; +import { hasProjectTrustInputs, ProjectTrustStore } from "../../core/trust-manager.ts"; import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.ts"; import { copyToClipboard } from "../../utils/clipboard.ts"; import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts"; @@ -120,6 +121,7 @@ import { SettingsSelectorComponent } from "./components/settings-selector.ts"; import { SkillInvocationMessageComponent } from "./components/skill-invocation-message.ts"; import { ToolExecutionComponent } from "./components/tool-execution.ts"; import { TreeSelectorComponent } from "./components/tree-selector.ts"; +import { TrustSelectorComponent } from "./components/trust-selector.ts"; import { UserMessageComponent } from "./components/user-message.ts"; import { UserMessageSelectorComponent } from "./components/user-message-selector.ts"; import { @@ -2564,6 +2566,11 @@ export class InteractiveMode { this.editor.setText(""); return; } + if (text === "/trust") { + this.showTrustSelector(); + this.editor.setText(""); + return; + } if (text === "/login") { this.showOAuthSelector("login"); this.editor.setText(""); @@ -3226,6 +3233,7 @@ export class InteractiveMode { updateFooter: true, populateHistory: true, }); + this.renderProjectTrustWarningIfNeeded(); // Show compaction info if session was compacted const allEntries = this.sessionManager.getEntries(); @@ -3236,6 +3244,26 @@ export class InteractiveMode { } } + private renderProjectTrustWarningIfNeeded(): void { + if (this.settingsManager.isProjectTrusted() || !hasProjectTrustInputs(this.sessionManager.getCwd())) { + return; + } + + if (this.chatContainer.children.length > 0) { + this.chatContainer.addChild(new Spacer(1)); + } + this.chatContainer.addChild( + new Text( + theme.fg( + "warning", + "This project is not trusted. Project instructions (AGENTS.md/CLAUDE.md), .pi resources, and project packages are ignored. Use /trust to save a trust decision, then restart pi.", + ), + 1, + 0, + ), + ); + } + async getUserInput(): Promise { const queuedInput = this.pendingUserInputs.shift(); if (queuedInput !== undefined) { @@ -4135,6 +4163,31 @@ export class InteractiveMode { } } + private showTrustSelector(): void { + const cwd = this.sessionManager.getCwd(); + const trustStore = new ProjectTrustStore(this.runtimeHost.services.agentDir); + const savedDecision = trustStore.get(cwd); + this.showSelector((done) => { + const selector = new TrustSelectorComponent({ + cwd, + savedDecision, + projectTrusted: this.settingsManager.isProjectTrusted(), + onSelect: (trusted) => { + trustStore.set(cwd, trusted); + done(); + this.showStatus( + `Saved trust decision: ${trusted ? "trusted" : "untrusted"}. Restart pi for this to take effect.`, + ); + }, + onCancel: () => { + done(); + this.ui.requestRender(); + }, + }); + return { component: selector, focus: selector }; + }); + } + private showModelSelector(initialSearchInput?: string): void { this.showSelector((done) => { const selector = new ModelSelectorComponent( diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index f61d4ff2..ea90d318 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -14,6 +14,7 @@ import { } from "./config.ts"; import { DefaultPackageManager } from "./core/package-manager.ts"; import { SettingsManager } from "./core/settings-manager.ts"; +import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts"; import { spawnProcess } from "./utils/child-process.ts"; import { getLatestPiRelease, isNewerPackageVersion } from "./utils/version-check.ts"; import { @@ -48,6 +49,7 @@ interface PackageCommandOptions { updateTarget?: UpdateTarget; local: boolean; force: boolean; + projectTrustOverride?: boolean; help: boolean; invalidOption?: string; invalidArgument?: string; @@ -68,13 +70,13 @@ function reportSettingsErrors(settingsManager: SettingsManager, context: string) function getPackageCommandUsage(command: PackageCommand): string { switch (command) { case "install": - return `${APP_NAME} install [-l]`; + return `${APP_NAME} install [-l] [--approve|--no-approve]`; case "remove": - return `${APP_NAME} remove [-l]`; + return `${APP_NAME} remove [-l] [--approve|--no-approve]`; case "update": - return `${APP_NAME} update [source|self|pi] [--self] [--extensions] [--extension ] [--force]`; + return `${APP_NAME} update [source|self|pi] [--self] [--extensions] [--extension ] [--approve|--no-approve] [--force]`; case "list": - return `${APP_NAME} list`; + return `${APP_NAME} list [--approve|--no-approve]`; } } @@ -87,7 +89,9 @@ function printPackageCommandHelp(command: PackageCommand): void { Install a package and add it to settings. Options: - -l, --local Install project-locally (.pi/settings.json) + -l, --local Install project-locally (.pi/settings.json) + -a, --approve Trust project-local files for this command + -na, --no-approve Ignore project-local files for this command Examples: ${APP_NAME} install npm:@foo/bar @@ -107,7 +111,9 @@ Remove a package and its source from settings. Alias: ${APP_NAME} uninstall [-l] Options: - -l, --local Remove from project settings (.pi/settings.json) + -l, --local Remove from project settings (.pi/settings.json) + -a, --approve Trust project-local files for this command + -na, --no-approve Ignore project-local files for this command Examples: ${APP_NAME} remove npm:@foo/bar @@ -125,6 +131,8 @@ Options: --self Update pi only --extensions Update installed packages only --extension Update one package only + -a, --approve Trust project-local files for this command + -na, --no-approve Ignore project-local files for this command --force Reinstall pi even if the current version is latest Short forms: @@ -139,6 +147,10 @@ Short forms: ${getPackageCommandUsage("list")} List installed packages from user and project settings. + +Options: + -a, --approve Trust project-local files for this command + -na, --no-approve Ignore project-local files for this command `); return; } @@ -158,6 +170,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined let local = false; let force = false; + let projectTrustOverride: boolean | undefined; let help = false; let invalidOption: string | undefined; let invalidArgument: string | undefined; @@ -202,6 +215,16 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined continue; } + if (arg === "--approve" || arg === "-a") { + projectTrustOverride = true; + continue; + } + + if (arg === "--no-approve" || arg === "-na") { + projectTrustOverride = false; + continue; + } + if (arg === "--force") { if (command === "update") { force = true; @@ -280,6 +303,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined updateTarget, local, force, + projectTrustOverride, help, invalidOption, invalidArgument, @@ -389,6 +413,25 @@ function prepareWindowsNpmSelfUpdate(): void { quarantineWindowsNativeDependencies(packageDir); } +function parseProjectTrustOverride(args: readonly string[]): boolean | undefined { + let trustOverride: boolean | undefined; + for (const arg of args) { + if (arg === "--approve" || arg === "-a") { + trustOverride = true; + } else if (arg === "--no-approve" || arg === "-na") { + trustOverride = false; + } + } + return trustOverride; +} + +function resolveProjectTrusted(cwd: string, agentDir: string, trustOverride: boolean | undefined): boolean { + if (trustOverride !== undefined) { + return trustOverride; + } + return !hasProjectTrustInputs(cwd) || new ProjectTrustStore(agentDir).get(cwd) === true; +} + export async function handleConfigCommand(args: string[]): Promise { if (args[0] !== "config") { return false; @@ -396,7 +439,8 @@ export async function handleConfigCommand(args: string[]): Promise { const cwd = process.cwd(); const agentDir = getAgentDir(); - const settingsManager = SettingsManager.create(cwd, agentDir); + const projectTrusted = parseProjectTrustOverride(args) ?? true; + const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted }); reportSettingsErrors(settingsManager, "config command"); const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager }); const resolvedPaths = await packageManager.resolve(); @@ -460,7 +504,14 @@ export async function handlePackageCommand(args: string[]): Promise { const cwd = process.cwd(); const agentDir = getAgentDir(); - const settingsManager = SettingsManager.create(cwd, agentDir); + const writesProjectPackageConfig = (options.command === "install" || options.command === "remove") && options.local; + const projectTrusted = resolveProjectTrusted(cwd, agentDir, options.projectTrustOverride); + if (!projectTrusted && writesProjectPackageConfig) { + console.error(chalk.red("Project is not trusted. Use --approve to modify local package config.")); + process.exitCode = 1; + return true; + } + const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted }); reportSettingsErrors(settingsManager, "package command"); const selfUpdateNpmCommand = settingsManager.getGlobalSettings().npmCommand; diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index 78fd832c..8591974d 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -293,6 +293,28 @@ describe("parseArgs", () => { }); }); + describe("project approval flags", () => { + test("parses --approve", () => { + const result = parseArgs(["--approve"]); + expect(result.projectTrustOverride).toBe(true); + }); + + test("parses -a shorthand", () => { + const result = parseArgs(["-a"]); + expect(result.projectTrustOverride).toBe(true); + }); + + test("parses --no-approve", () => { + const result = parseArgs(["--no-approve"]); + expect(result.projectTrustOverride).toBe(false); + }); + + test("parses -na shorthand", () => { + const result = parseArgs(["-na"]); + expect(result.projectTrustOverride).toBe(false); + }); + }); + describe("--verbose flag", () => { test("parses --verbose flag", () => { const result = parseArgs(["--verbose"]); diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index 5e4a1a48..1fc587c8 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ENV_AGENT_DIR, PACKAGE_NAME, VERSION } from "../src/config.ts"; +import { ProjectTrustStore } from "../src/core/trust-manager.ts"; import { main } from "../src/main.ts"; describe("package commands", () => { @@ -85,6 +86,103 @@ describe("package commands", () => { expect(removedSettings.packages ?? []).toHaveLength(0); }); + it("skips untrusted project package settings", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect(main(["list"])).resolves.toBeUndefined(); + + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stdout).toContain("No packages installed."); + expect(stdout).not.toContain("Project packages:"); + } finally { + logSpy.mockRestore(); + } + }); + + it("uses remembered project trust for list", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); + new ProjectTrustStore(agentDir).set(projectDir, true); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect(main(["list"])).resolves.toBeUndefined(); + + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stdout).toContain("Project packages:"); + expect(stdout).toContain("npm:@project/pkg"); + expect(stdout).not.toContain("No packages installed."); + expect(process.exitCode).toBeUndefined(); + } finally { + logSpy.mockRestore(); + } + }); + + it("overrides remembered trust for list with --no-approve", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); + new ProjectTrustStore(agentDir).set(projectDir, true); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect(main(["list", "--no-approve"])).resolves.toBeUndefined(); + + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stdout).toContain("No packages installed."); + expect(stdout).not.toContain("Project packages:"); + expect(process.exitCode).toBeUndefined(); + } finally { + logSpy.mockRestore(); + } + }); + + it("approves project trust for list with --approve", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect(main(["list", "--approve"])).resolves.toBeUndefined(); + + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stdout).toContain("Project packages:"); + expect(stdout).toContain("npm:@project/pkg"); + expect(stdout).not.toContain("No packages installed."); + expect(process.exitCode).toBeUndefined(); + } finally { + logSpy.mockRestore(); + } + }); + + it("blocks local package changes when project is untrusted", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect(main(["install", "-l", "./local-package"])).resolves.toBeUndefined(); + + const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stderr).toContain("Project is not trusted. Use --approve to modify local package config."); + expect(process.exitCode).toBe(1); + } finally { + errorSpy.mockRestore(); + } + }); + + it("allows local package install to initialize fresh project settings", async () => { + await main(["install", "-l", packageDir]); + + const settingsPath = join(projectDir, ".pi", "settings.json"); + const settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as { packages?: string[] }; + expect(settings.packages?.length).toBe(1); + const stored = settings.packages?.[0] ?? ""; + expect(realpathSync(join(projectDir, ".pi", stored))).toBe(realpathSync(packageDir)); + expect(process.exitCode).toBeUndefined(); + }); + it("shows install subcommand help", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); @@ -111,7 +209,7 @@ describe("package commands", () => { const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); expect(stderr).toContain('Unknown option --unknown for "install".'); - expect(stderr).toContain('Use "pi --help" or "pi install [-l]".'); + expect(stderr).toContain('Use "pi --help" or "pi install [-l] [--approve|--no-approve]".'); expect(process.exitCode).toBe(1); } finally { errorSpy.mockRestore(); diff --git a/packages/coding-agent/test/resource-loader.test.ts b/packages/coding-agent/test/resource-loader.test.ts index 693f05c9..88dd5895 100644 --- a/packages/coding-agent/test/resource-loader.test.ts +++ b/packages/coding-agent/test/resource-loader.test.ts @@ -329,6 +329,52 @@ Content`, expect(loader.getSystemPrompt()).toBe("You are a helpful assistant."); }); + it("should skip project resources when project is not trusted", async () => { + const piDir = join(cwd, ".pi"); + const extensionsDir = join(piDir, "extensions"); + const skillDir = join(piDir, "skills", "project-skill"); + const promptsDir = join(piDir, "prompts"); + const themesDir = join(piDir, "themes"); + mkdirSync(extensionsDir, { recursive: true }); + mkdirSync(skillDir, { recursive: true }); + mkdirSync(promptsDir, { recursive: true }); + mkdirSync(themesDir, { recursive: true }); + writeFileSync(join(piDir, "SYSTEM.md"), "Project system prompt."); + writeFileSync(join(agentDir, "SYSTEM.md"), "Global system prompt."); + writeFileSync(join(agentDir, "AGENTS.md"), "Global instructions"); + writeFileSync(join(cwd, "AGENTS.md"), "Project instructions"); + writeFileSync(join(extensionsDir, "project.ts"), `throw new Error("should not load");`); + writeFileSync( + join(skillDir, "SKILL.md"), + `--- +name: project-skill +description: Project skill +--- +Project skill content`, + ); + writeFileSync(join(promptsDir, "project.md"), "Project prompt"); + const themeData = JSON.parse( + readFileSync(join(process.cwd(), "src", "modes", "interactive", "theme", "dark.json"), "utf-8"), + ) as { name: string }; + themeData.name = "project-theme"; + writeFileSync(join(themesDir, "project.json"), JSON.stringify(themeData, null, 2)); + const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false }); + + const loader = new DefaultResourceLoader({ cwd, agentDir, settingsManager }); + await loader.reload(); + + expect(loader.getSystemPrompt()).toBe("Global system prompt."); + expect(loader.getAgentsFiles().agentsFiles.some((file) => file.path === join(agentDir, "AGENTS.md"))).toBe( + true, + ); + expect(loader.getAgentsFiles().agentsFiles.some((file) => file.path === join(cwd, "AGENTS.md"))).toBe(false); + expect(loader.getExtensions().extensions).toHaveLength(0); + expect(loader.getExtensions().errors).toEqual([]); + expect(loader.getSkills().skills.some((skill) => skill.name === "project-skill")).toBe(false); + expect(loader.getPrompts().prompts.some((prompt) => prompt.name === "project")).toBe(false); + expect(loader.getThemes().themes.some((theme) => theme.name === "project-theme")).toBe(false); + }); + it("should discover APPEND_SYSTEM.md", async () => { const piDir = join(cwd, ".pi"); mkdirSync(piDir, { recursive: true }); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index a503e3cc..b28d086a 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -214,6 +214,44 @@ describe("SettingsManager", () => { }); }); + describe("project trust", () => { + it("should skip project settings when project is not trusted", () => { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ theme: "global" })); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ theme: "project" })); + + const manager = SettingsManager.create(projectDir, agentDir, { projectTrusted: false }); + + expect(manager.isProjectTrusted()).toBe(false); + expect(manager.getTheme()).toBe("global"); + expect(manager.getProjectSettings()).toEqual({}); + }); + + it("should reload project settings after trust changes to true", () => { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ theme: "global" })); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ theme: "project" })); + const manager = SettingsManager.create(projectDir, agentDir, { projectTrusted: false }); + + manager.setProjectTrusted(true); + + expect(manager.isProjectTrusted()).toBe(true); + expect(manager.getTheme()).toBe("project"); + }); + + it("should fail project settings writes when project is not trusted", async () => { + const projectSettingsPath = join(projectDir, ".pi", "settings.json"); + writeFileSync(projectSettingsPath, JSON.stringify({ packages: ["npm:existing"] })); + const manager = SettingsManager.create(projectDir, agentDir, { projectTrusted: false }); + + expect(() => manager.setProjectPackages(["npm:new"])).toThrow( + "Project is not trusted; refusing to write project settings", + ); + await manager.flush(); + + expect(manager.getProjectSettings()).toEqual({}); + expect(JSON.parse(readFileSync(projectSettingsPath, "utf-8"))).toEqual({ packages: ["npm:existing"] }); + }); + }); + describe("project settings directory creation", () => { it("should not create .pi folder when only reading project settings", () => { // Create agent dir with global settings, but NO .pi folder in project diff --git a/packages/coding-agent/test/stdout-cleanliness.test.ts b/packages/coding-agent/test/stdout-cleanliness.test.ts index f6c426a1..057db06a 100644 --- a/packages/coding-agent/test/stdout-cleanliness.test.ts +++ b/packages/coding-agent/test/stdout-cleanliness.test.ts @@ -80,8 +80,8 @@ async function runCli(args: string[]): Promise<{ stdout: string; stderr: string; } describe("stdout cleanliness in non-interactive modes", () => { - it("keeps stdout empty for --mode json --help while routing startup chatter to stderr", async () => { - const result = await runCli(["--mode", "json", "--help"]); + it("keeps stdout empty for --mode json --help while routing trusted startup chatter to stderr", async () => { + const result = await runCli(["--mode", "json", "--help", "--approve"]); expect(result.code).toBe(0); expect(result.stdout).toBe(""); @@ -90,13 +90,23 @@ describe("stdout cleanliness in non-interactive modes", () => { expect(result.stderr).toContain("Usage:"); }); - it("keeps stdout empty for -p --help while routing startup chatter to stderr", async () => { + it("keeps stdout empty for -p --help while routing trusted startup chatter to stderr", async () => { + const result = await runCli(["-p", "--help", "--approve"]); + + expect(result.code).toBe(0); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("changed 1 package in 471ms"); + expect(result.stderr).toContain("found 0 vulnerabilities"); + expect(result.stderr).toContain("Usage:"); + }); + + it("ignores untrusted project package installs for help", async () => { const result = await runCli(["-p", "--help"]); expect(result.code).toBe(0); expect(result.stdout).toBe(""); - expect(result.stderr).toContain("changed 1 package in 471ms"); - expect(result.stderr).toContain("found 0 vulnerabilities"); + expect(result.stderr).not.toContain("changed 1 package in 471ms"); + expect(result.stderr).not.toContain("found 0 vulnerabilities"); expect(result.stderr).toContain("Usage:"); }); }); diff --git a/packages/coding-agent/test/trust-manager.test.ts b/packages/coding-agent/test/trust-manager.test.ts new file mode 100644 index 00000000..3bb01634 --- /dev/null +++ b/packages/coding-agent/test/trust-manager.test.ts @@ -0,0 +1,60 @@ +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { hasProjectTrustInputs, ProjectTrustStore } from "../src/core/trust-manager.ts"; + +describe("ProjectTrustStore", () => { + let tempDir: string; + let agentDir: string; + let cwd: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `trust-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + agentDir = join(tempDir, "agent"); + cwd = join(tempDir, "project"); + mkdirSync(agentDir, { recursive: true }); + mkdirSync(cwd, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("stores decisions per cwd", () => { + const store = new ProjectTrustStore(agentDir); + + expect(store.get(cwd)).toBeNull(); + store.set(cwd, true); + expect(store.get(cwd)).toBe(true); + store.set(cwd, false); + expect(store.get(cwd)).toBe(false); + store.set(cwd, null); + expect(store.get(cwd)).toBeNull(); + }); + + it("fails loudly without overwriting malformed trust stores", () => { + const trustPath = join(agentDir, "trust.json"); + writeFileSync(trustPath, "{not json", "utf-8"); + const store = new ProjectTrustStore(agentDir); + + expect(() => store.get(cwd)).toThrow(/Failed to read trust store/); + expect(() => store.set(cwd, true)).toThrow(/Failed to read trust store/); + expect(readFileSync(trustPath, "utf-8")).toBe("{not json"); + }); + + it("detects project trust inputs", () => { + expect(hasProjectTrustInputs(cwd)).toBe(false); + + mkdirSync(join(cwd, ".pi"), { recursive: true }); + expect(hasProjectTrustInputs(cwd)).toBe(true); + rmSync(join(cwd, ".pi"), { recursive: true, force: true }); + + writeFileSync(join(cwd, "AGENTS.md"), "Project instructions"); + expect(hasProjectTrustInputs(cwd)).toBe(true); + rmSync(join(cwd, "AGENTS.md"), { force: true }); + + mkdirSync(join(cwd, ".agents", "skills"), { recursive: true }); + expect(hasProjectTrustInputs(cwd)).toBe(true); + }); +}); diff --git a/packages/coding-agent/test/trust-selector.test.ts b/packages/coding-agent/test/trust-selector.test.ts new file mode 100644 index 00000000..65c73c21 --- /dev/null +++ b/packages/coding-agent/test/trust-selector.test.ts @@ -0,0 +1,48 @@ +import { setKeybindings } from "@earendil-works/pi-tui"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { KeybindingsManager } from "../src/core/keybindings.ts"; +import { TrustSelectorComponent } from "../src/modes/interactive/components/trust-selector.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../src/utils/ansi.ts"; + +describe("TrustSelectorComponent", () => { + beforeAll(() => { + initTheme("dark"); + }); + + beforeEach(() => { + setKeybindings(new KeybindingsManager()); + }); + + it("marks the saved trusted decision", () => { + const selector = new TrustSelectorComponent({ + cwd: "/project", + savedDecision: true, + projectTrusted: true, + onSelect: () => {}, + onCancel: () => {}, + }); + + const output = stripAnsi(selector.render(120).join("\n")); + + expect(output).toContain("Saved decision: trusted"); + expect(output).toContain("Current session: trusted"); + expect(output).toContain("Trust ✓"); + expect(output).not.toContain("Do not trust ✓"); + }); + + it("selects a trust decision", () => { + const onSelect = vi.fn(); + const selector = new TrustSelectorComponent({ + cwd: "/project", + savedDecision: null, + projectTrusted: false, + onSelect, + onCancel: () => {}, + }); + + selector.handleInput("\n"); + + expect(onSelect).toHaveBeenCalledWith(true); + }); +}); From ff3e9df5f5b32368c20b0ef553a6834b3dee9350 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 6 Jun 2026 22:32:35 +0200 Subject: [PATCH 165/352] fix(coding-agent): make trust input traversal portable --- packages/coding-agent/src/core/trust-manager.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/src/core/trust-manager.ts b/packages/coding-agent/src/core/trust-manager.ts index 59abcc92..e8e59323 100644 --- a/packages/coding-agent/src/core/trust-manager.ts +++ b/packages/coding-agent/src/core/trust-manager.ts @@ -1,5 +1,5 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; +import { dirname, join } from "node:path"; import lockfile from "proper-lockfile"; import { CONFIG_DIR_NAME } from "../config.ts"; import { canonicalizePath, resolvePath } from "../utils/paths.ts"; @@ -95,12 +95,11 @@ function withTrustFileLock(path: string, fn: () => T): T { } export function hasProjectTrustInputs(cwd: string): boolean { - let currentDir = resolvePath(cwd); + let currentDir = canonicalizePath(resolvePath(cwd)); if (existsSync(join(currentDir, CONFIG_DIR_NAME))) { return true; } - const root = resolve("/"); while (true) { for (const filename of CONTEXT_FILE_NAMES) { if (existsSync(join(currentDir, filename))) { @@ -111,11 +110,7 @@ export function hasProjectTrustInputs(cwd: string): boolean { return true; } - if (currentDir === root) { - return false; - } - - const parentDir = resolve(currentDir, ".."); + const parentDir = dirname(currentDir); if (parentDir === currentDir) { return false; } From 98697d2d7a0d3706e4f3084cf4e17025da094a18 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 7 Jun 2026 08:29:37 +0000 Subject: [PATCH 166/352] chore: approve contributor ItsumoSeito --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 925ee8f4..9c9fc5e3 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -229,3 +229,5 @@ rolfvreijdenberger pr psoukie pr vastxie pr + +ItsumoSeito pr From e34b3b38030ff355fc5dade5557a5e584da1c328 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 7 Jun 2026 10:34:42 +0200 Subject: [PATCH 167/352] docs(coding-agent): update tmux requirement docs closes #5432 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/tmux.md | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8d84d266..4649345b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed +- Fixed tmux setup documentation to require tmux 3.5 for `extended-keys-format csi-u` and document the tmux 3.2-3.4 fallback ([#5432](https://github.com/earendil-works/pi/issues/5432)). - Fixed built-in tool expand hints to style closing parentheses consistently ([#5359](https://github.com/earendil-works/pi/issues/5359)). ## [0.78.1] - 2026-06-04 diff --git a/packages/coding-agent/docs/tmux.md b/packages/coding-agent/docs/tmux.md index efe6ab57..0083b5f1 100644 --- a/packages/coding-agent/docs/tmux.md +++ b/packages/coding-agent/docs/tmux.md @@ -18,7 +18,7 @@ tmux kill-server tmux ``` -Pi requests extended key reporting automatically when Kitty keyboard protocol is not available. With `extended-keys-format csi-u`, tmux forwards modified keys in CSI-u format, which is the most reliable configuration. +Pi requests extended key reporting automatically when Kitty keyboard protocol is not available. With `extended-keys-format csi-u`, tmux forwards modified keys in CSI-u format, which is the most reliable configuration. The `extended-keys-format` option requires tmux 3.5 or later. ## Why `csi-u` Is Recommended @@ -57,5 +57,7 @@ This affects the default keybindings (`Enter` to submit, `Shift+Enter` for newli ## Requirements -- tmux 3.2 or later (run `tmux -V` to check) +- tmux 3.5 or later for `extended-keys-format csi-u` (run `tmux -V` to check) - A terminal emulator that supports extended keys (Ghostty, Kitty, iTerm2, WezTerm, Windows Terminal) + +With tmux 3.2 through 3.4, omit `extended-keys-format csi-u`; Pi still supports tmux's default xterm `modifyOtherKeys` format. From 22ac2713a3022368130484969a41e4ea074ed30e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 7 Jun 2026 10:56:01 +0200 Subject: [PATCH 168/352] feat(coding-agent): export RPC extension UI types closes #5455 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/index.ts | 2 ++ packages/coding-agent/src/modes/index.ts | 8 +++++++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4649345b..707281c0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,7 @@ - Added project trust gating for project-local settings, resources, instructions, and packages ([#5332](https://github.com/earendil-works/pi/pull/5332)). - Added the latest prompt cache hit rate to the interactive footer. +- Exported RPC extension UI request and response types from the public API ([#5455](https://github.com/earendil-works/pi/issues/5455)). ### Fixed diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 1f9b3a19..d4cf91d3 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -295,6 +295,8 @@ export { type RpcClientOptions, type RpcCommand, type RpcEventListener, + type RpcExtensionUIRequest, + type RpcExtensionUIResponse, type RpcResponse, type RpcSessionState, runPrintMode, diff --git a/packages/coding-agent/src/modes/index.ts b/packages/coding-agent/src/modes/index.ts index 02cab782..732aee55 100644 --- a/packages/coding-agent/src/modes/index.ts +++ b/packages/coding-agent/src/modes/index.ts @@ -6,4 +6,10 @@ export { InteractiveMode, type InteractiveModeOptions } from "./interactive/inte export { type PrintModeOptions, runPrintMode } from "./print-mode.ts"; export { type ModelInfo, RpcClient, type RpcClientOptions, type RpcEventListener } from "./rpc/rpc-client.ts"; export { runRpcMode } from "./rpc/rpc-mode.ts"; -export type { RpcCommand, RpcResponse, RpcSessionState } from "./rpc/rpc-types.ts"; +export type { + RpcCommand, + RpcExtensionUIRequest, + RpcExtensionUIResponse, + RpcResponse, + RpcSessionState, +} from "./rpc/rpc-types.ts"; From 59d70256684fd3d15c66e84587315e8a2b81245f Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 7 Jun 2026 10:56:09 +0200 Subject: [PATCH 169/352] fix(tui): position cursor for prompt history navigation closes #5454 --- packages/tui/CHANGELOG.md | 4 ++ packages/tui/src/components/editor.ts | 8 +-- packages/tui/test/editor.test.ts | 72 ++++++++++++--------------- 3 files changed, 41 insertions(+), 43 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 2069c7eb..dd487722 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed prompt history navigation to place the cursor at the start when browsing upward and at the end when browsing downward, so repeated Up/Down traverses multiline prompts immediately ([#5454](https://github.com/earendil-works/pi/issues/5454)). + ## [0.78.1] - 2026-06-04 ### Fixed diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index ddbd98ee..382c88ae 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -390,16 +390,16 @@ export class Editor implements Component, Focusable { // Returned to "current" state - clear editor this.setTextInternal(""); } else { - this.setTextInternal(this.history[this.historyIndex] || ""); + this.setTextInternal(this.history[this.historyIndex] || "", direction === -1 ? "start" : "end"); } } /** Internal setText that doesn't reset history state - used by navigateHistory */ - private setTextInternal(text: string): void { + private setTextInternal(text: string, cursorPlacement: "start" | "end" = "end"): void { const lines = text.split("\n"); this.state.lines = lines.length === 0 ? [""] : lines; - this.state.cursorLine = this.state.lines.length - 1; - this.setCursorCol(this.state.lines[this.state.cursorLine]?.length || 0); + this.state.cursorLine = cursorPlacement === "start" ? 0 : this.state.lines.length - 1; + this.setCursorCol(cursorPlacement === "start" ? 0 : this.state.lines[this.state.cursorLine]?.length || 0); // Reset scroll - render() will adjust to show cursor this.scrollOffset = 0; diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 2d43bc24..938f9646 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -122,7 +122,7 @@ describe("Editor component", () => { editor.handleInput("\x1b[A"); // Up - shows "old prompt" editor.handleInput("x"); // Type a character - exits history mode - assert.strictEqual(editor.getText(), "old promptx"); + assert.strictEqual(editor.getText(), "xold prompt"); }); it("exits history mode on setText", () => { @@ -222,61 +222,55 @@ describe("Editor component", () => { assert.strictEqual(editor.getText(), "prompt 5"); }); - it("allows cursor movement within multi-line history entry with Down", () => { - const editor = new Editor(createTestTUI(), defaultEditorTheme); - - editor.addToHistory("line1\nline2\nline3"); - - // Browse to the multi-line entry - editor.handleInput("\x1b[A"); // Up - shows entry, cursor at end of line3 - assert.strictEqual(editor.getText(), "line1\nline2\nline3"); - - // Down should exit history since cursor is on last line - editor.handleInput("\x1b[B"); // Down - assert.strictEqual(editor.getText(), ""); // Exited to empty - }); - - it("allows cursor movement within multi-line history entry with Up", () => { + it("places cursor at start after browsing history upward", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.addToHistory("older entry"); editor.addToHistory("line1\nline2\nline3"); - // Browse to the multi-line entry - editor.handleInput("\x1b[A"); // Up - shows multi-line, cursor at end of line3 + editor.handleInput("\x1b[A"); // Up - shows multi-line entry at start + assert.strictEqual(editor.getText(), "line1\nline2\nline3"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); - // Up should move cursor within the entry (not on first line yet) - editor.handleInput("\x1b[A"); // Up - cursor moves to line2 - assert.strictEqual(editor.getText(), "line1\nline2\nline3"); // Still same entry - - editor.handleInput("\x1b[A"); // Up - cursor moves to line1 (now on first visual line) - assert.strictEqual(editor.getText(), "line1\nline2\nline3"); // Still same entry - - // Now Up should navigate to older history entry - editor.handleInput("\x1b[A"); // Up - navigate to older + editor.handleInput("\x1b[A"); // Up again - immediately navigates to older entry assert.strictEqual(editor.getText(), "older entry"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); }); - it("navigates from multi-line entry back to newer via Down after cursor movement", () => { + it("places cursor at end after browsing history downward", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.addToHistory("older entry"); + editor.addToHistory("line1\nline2\nline3"); + editor.addToHistory("newer entry"); + + editor.handleInput("\x1b[A"); // newer entry + editor.handleInput("\x1b[A"); // multi-line entry + editor.handleInput("\x1b[A"); // older entry + + editor.handleInput("\x1b[B"); // Down - shows multi-line entry at end + assert.strictEqual(editor.getText(), "line1\nline2\nline3"); + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 5 }); + + editor.handleInput("\x1b[B"); // Down again - immediately navigates to newer entry + assert.strictEqual(editor.getText(), "newer entry"); + }); + + it("allows opposite-direction cursor movement within multi-line history entry", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.addToHistory("line1\nline2\nline3"); - // Browse to entry and move cursor up - editor.handleInput("\x1b[A"); // Up - shows entry, cursor at end - editor.handleInput("\x1b[A"); // Up - cursor to line2 - editor.handleInput("\x1b[A"); // Up - cursor to line1 + editor.handleInput("\x1b[A"); // Up - shows entry at start + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); - // Now Down should move cursor down within the entry - editor.handleInput("\x1b[B"); // Down - cursor to line2 + editor.handleInput("\x1b[B"); // Down - cursor moves to line2 assert.strictEqual(editor.getText(), "line1\nline2\nline3"); + assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 0 }); - editor.handleInput("\x1b[B"); // Down - cursor to line3 + editor.handleInput("\x1b[A"); // Up - cursor moves back to line1 assert.strictEqual(editor.getText(), "line1\nline2\nline3"); - - // Now on last line, Down should exit history - editor.handleInput("\x1b[B"); // Down - exit to empty - assert.strictEqual(editor.getText(), ""); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); }); }); From b8f6f660e73a9e728ace09239b877eb97cdc34fb Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 7 Jun 2026 11:07:36 +0200 Subject: [PATCH 170/352] fix(ai): honor OpenAI Responses developer role compat closes #5456 --- packages/ai/CHANGELOG.md | 1 + packages/ai/README.md | 6 ++++-- packages/ai/src/providers/openai-responses-shared.ts | 3 ++- packages/ai/src/providers/openai-responses.ts | 1 + packages/ai/src/types.ts | 2 ++ packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/core/model-registry.ts | 1 + 7 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 7f3b72d0..b72f4718 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed OpenAI Responses custom providers to honor `compat.supportsDeveloperRole: false` for reasoning models ([#5456](https://github.com/earendil-works/pi/issues/5456)). - Fixed OpenRouter routing preferences on OpenAI-compatible custom providers to send `compat.openRouterRouting` even when `baseUrl` does not point directly at OpenRouter ([#5347](https://github.com/earendil-works/pi/issues/5347)). ## [0.78.1] - 2026-06-04 diff --git a/packages/ai/README.md b/packages/ai/README.md index 6be70212..4190fcb8 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -926,7 +926,7 @@ const ollamaReasoningModel: Model<'openai-completions'> = { ### OpenAI Compatibility Settings -The `openai-completions` API is implemented by many providers with minor differences. By default, the library auto-detects compatibility settings based on `baseUrl` for a small set of known OpenAI-compatible providers (Cerebras, xAI, Chutes, DeepSeek, NVIDIA NIM, Together AI, zAi, OpenCode, Cloudflare Workers AI, etc.). For custom proxies or unknown endpoints, you can override these settings via the `compat` field. For `openai-responses` models, the compat field only supports Responses-specific flags. +The `openai-completions` API is implemented by many providers with minor differences. By default, the library auto-detects compatibility settings based on `baseUrl` for a small set of known OpenAI-compatible providers (Cerebras, xAI, Chutes, DeepSeek, NVIDIA NIM, Together AI, zAi, OpenCode, Cloudflare Workers AI, etc.). For custom proxies or unknown endpoints, you can override these settings via the `compat` field. For `openai-responses` models, the compat field supports Responses-specific flags. ```typescript interface OpenAICompletionsCompat { @@ -948,7 +948,9 @@ interface OpenAICompletionsCompat { } interface OpenAIResponsesCompat { - // Reserved for future use + supportsDeveloperRole?: boolean; // Whether provider supports `developer` role vs `system` (default: true) + sendSessionIdHeader?: boolean; // Whether to send `session_id` from `sessionId` when caching is enabled (default: true) + supportsLongCacheRetention?: boolean; // Whether provider supports `prompt_cache_retention: "24h"` (default: true) } ``` diff --git a/packages/ai/src/providers/openai-responses-shared.ts b/packages/ai/src/providers/openai-responses-shared.ts index 11a977d5..c006c284 100644 --- a/packages/ai/src/providers/openai-responses-shared.ts +++ b/packages/ai/src/providers/openai-responses-shared.ts @@ -124,7 +124,8 @@ export function convertResponsesMessages( const includeSystemPrompt = options?.includeSystemPrompt ?? true; if (includeSystemPrompt && context.systemPrompt) { - const role = model.reasoning ? "developer" : "system"; + const compat = model.compat as { supportsDeveloperRole?: boolean } | undefined; + const role = model.reasoning && compat?.supportsDeveloperRole !== false ? "developer" : "system"; messages.push({ role, content: sanitizeSurrogates(context.systemPrompt), diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 4b35a24c..9afeff29 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -39,6 +39,7 @@ function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention function getCompat(model: Model<"openai-responses">): Required { return { + supportsDeveloperRole: model.compat?.supportsDeveloperRole ?? true, sendSessionIdHeader: model.compat?.sendSessionIdHeader ?? true, supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true, }; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index ed36c7ec..802b8b39 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -421,6 +421,8 @@ export interface OpenAICompletionsCompat { /** Compatibility settings for OpenAI Responses APIs. */ export interface OpenAIResponsesCompat { + /** Whether the provider supports the `developer` role (vs `system`). Default: true. */ + supportsDeveloperRole?: boolean; /** Whether to send the OpenAI `session_id` cache-affinity header from `options.sessionId` when caching is enabled. Default: true. */ sendSessionIdHeader?: boolean; /** Whether the provider supports `prompt_cache_retention: "24h"`. Default: true. */ diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 707281c0..c0d45312 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixed +- Fixed `models.json` schema support for OpenAI Responses `compat.supportsDeveloperRole` ([#5456](https://github.com/earendil-works/pi/issues/5456)). - Fixed tmux setup documentation to require tmux 3.5 for `extended-keys-format csi-u` and document the tmux 3.2-3.4 fallback ([#5432](https://github.com/earendil-works/pi/issues/5432)). - Fixed built-in tool expand hints to style closing parentheses consistently ([#5359](https://github.com/earendil-works/pi/issues/5359)). diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index 455ab71f..00c81576 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -127,6 +127,7 @@ const OpenAICompletionsCompatSchema = Type.Object({ }); const OpenAIResponsesCompatSchema = Type.Object({ + supportsDeveloperRole: Type.Optional(Type.Boolean()), sendSessionIdHeader: Type.Optional(Type.Boolean()), supportsLongCacheRetention: Type.Optional(Type.Boolean()), }); From 8c6c8a4ef3a9f6d1c079c1f63757e55a3d565261 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 7 Jun 2026 11:19:10 +0200 Subject: [PATCH 171/352] fix(agent): neutralize compaction summarization prompt closes #5401 --- packages/agent/CHANGELOG.md | 4 ++++ packages/agent/src/harness/compaction/compaction.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 667e61de..bdfa5474 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed the compaction summarization system prompt to use neutral AI assistant wording for non-coding agents ([#5401](https://github.com/earendil-works/pi/issues/5401)). + ## [0.78.1] - 2026-06-04 ## [0.78.0] - 2026-05-29 diff --git a/packages/agent/src/harness/compaction/compaction.ts b/packages/agent/src/harness/compaction/compaction.ts index e3a9f114..dba753d7 100644 --- a/packages/agent/src/harness/compaction/compaction.ts +++ b/packages/agent/src/harness/compaction/compaction.ts @@ -376,7 +376,7 @@ export function findCutPoint( }; } -export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified. +export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified. Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`; From eb43bd449369dfac3bb9ec3e93658b049e80fbb3 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 7 Jun 2026 11:25:15 +0200 Subject: [PATCH 172/352] fix(coding-agent): export package asset path helpers closes #5415 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/sdk.md | 5 +++++ packages/coding-agent/src/index.ts | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c0d45312..696f1e02 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -7,6 +7,7 @@ - Added project trust gating for project-local settings, resources, instructions, and packages ([#5332](https://github.com/earendil-works/pi/pull/5332)). - Added the latest prompt cache hit rate to the interactive footer. - Exported RPC extension UI request and response types from the public API ([#5455](https://github.com/earendil-works/pi/issues/5455)). +- Exported coding-agent package asset path helpers from the public API ([#5415](https://github.com/earendil-works/pi/issues/5415)). ### Fixed diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index 2be5f531..c6b2a75d 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -1112,6 +1112,11 @@ createEventBus // Helpers defineTool +getAgentDir +getPackageDir +getReadmePath +getDocsPath +getExamplesPath // Session management SessionManager diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index d4cf91d3..7ddbe5da 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -3,7 +3,7 @@ export { type Args, parseArgs } from "./cli/args.ts"; // Config paths -export { getAgentDir, VERSION } from "./config.ts"; +export { getAgentDir, getDocsPath, getExamplesPath, getPackageDir, getReadmePath, VERSION } from "./config.ts"; export { AgentSession, type AgentSessionConfig, From 72fd9113567cbed441b34f0f02529faab92d5727 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 7 Jun 2026 11:28:27 +0200 Subject: [PATCH 173/352] fix(coding-agent): neutralize compaction summarization prompt closes #5401 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/core/compaction/utils.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 696f1e02..d3328fce 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -11,6 +11,7 @@ ### Fixed +- Fixed the compaction summarization system prompt to use neutral AI assistant wording for non-coding agents ([#5401](https://github.com/earendil-works/pi/issues/5401)). - Fixed `models.json` schema support for OpenAI Responses `compat.supportsDeveloperRole` ([#5456](https://github.com/earendil-works/pi/issues/5456)). - Fixed tmux setup documentation to require tmux 3.5 for `extended-keys-format csi-u` and document the tmux 3.2-3.4 fallback ([#5432](https://github.com/earendil-works/pi/issues/5432)). - Fixed built-in tool expand hints to style closing parentheses consistently ([#5359](https://github.com/earendil-works/pi/issues/5359)). diff --git a/packages/coding-agent/src/core/compaction/utils.ts b/packages/coding-agent/src/core/compaction/utils.ts index 64218b2a..6cfc1622 100644 --- a/packages/coding-agent/src/core/compaction/utils.ts +++ b/packages/coding-agent/src/core/compaction/utils.ts @@ -165,6 +165,6 @@ export function serializeConversation(messages: Message[]): string { // Summarization System Prompt // ============================================================================ -export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified. +export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified. Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`; From 130ae577acd76ea2f040f2e357b0f3e730a53883 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 7 Jun 2026 17:04:19 +0200 Subject: [PATCH 174/352] fix(tui,coding-agent): make keyboard protocol fallback response-driven closes #5188 --- packages/coding-agent/docs/terminal-setup.md | 38 +++++- packages/tui/CHANGELOG.md | 1 + packages/tui/src/terminal.ts | 116 ++++++------------- packages/tui/test/key-tester.ts | 38 ++++-- packages/tui/test/terminal.test.ts | 91 +++++++++------ 5 files changed, 156 insertions(+), 128 deletions(-) diff --git a/packages/coding-agent/docs/terminal-setup.md b/packages/coding-agent/docs/terminal-setup.md index cccf683f..ff1638b5 100644 --- a/packages/coding-agent/docs/terminal-setup.md +++ b/packages/coding-agent/docs/terminal-setup.md @@ -40,7 +40,7 @@ If you want `Shift+Enter` to keep working in tmux via that remap, add `ctrl+j` t ## WezTerm -Create `~/.wezterm.lua`: +WezTerm usually works out of the box for `Shift+Enter` via xterm modifyOtherKeys. To use the Kitty keyboard protocol explicitly, create `~/.wezterm.lua`: ```lua local wezterm = require 'wezterm' @@ -49,16 +49,50 @@ config.enable_kitty_keyboard = true return config ``` +On macOS, WezTerm binds `Option+Enter` to fullscreen by default. To use `Option+Enter` for pi follow-up queueing, add this key override: + +```lua +local wezterm = require 'wezterm' +local config = wezterm.config_builder() +config.keys = { + { + key = 'Enter', + mods = 'ALT', + action = wezterm.action.SendString('\x1b[13;3u'), + }, +} +return config +``` + +If you already have a `config.keys` table, add the entry to it. + On WSL, WezTerm may require a visible hardware cursor for IME candidate window positioning. If CJK IME candidates do not follow the text cursor, set `PI_HARDWARE_CURSOR=1` before running pi or set `showHardwareCursor` to `true` in settings. +## Alacritty + +Alacritty usually works out of the box for `Shift+Enter`. On macOS, `Option+Enter` may arrive as plain `Enter`. To use `Option+Enter` for pi follow-up queueing, add to `~/.config/alacritty/alacritty.toml`: + +```toml +[[keyboard.bindings]] +key = "Enter" +mods = "Alt" +chars = "\u001b[13;3u" +``` + +Restart Alacritty after changing the config. + ## VS Code (Integrated Terminal) +VS Code 1.109.5 and newer enable Kitty keyboard protocol in the integrated terminal by default, so `Shift+Enter` should work out of the box. + +VS Code versions older than 1.109.5 need an explicit terminal keybinding for `Shift+Enter`. + `keybindings.json` locations: - macOS: `~/Library/Application Support/Code/User/keybindings.json` - Linux: `~/.config/Code/User/keybindings.json` - Windows: `%APPDATA%\\Code\\User\\keybindings.json` -Add to `keybindings.json` to enable `Shift+Enter` for multi-line input: +Add to `keybindings.json`: ```json { diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index dd487722..3682b529 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed prompt history navigation to place the cursor at the start when browsing upward and at the end when browsing downward, so repeated Up/Down traverses multiline prompts immediately ([#5454](https://github.com/earendil-works/pi/issues/5454)). +- Fixed intermittent Shift+Enter handling by making Kitty keyboard protocol fallback response-driven instead of timeout-driven ([#5188](https://github.com/earendil-works/pi/issues/5188)). ## [0.78.1] - 2026-06-04 diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 563537d7..2542286a 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -13,7 +13,6 @@ const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07"; const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07"; const APPLE_TERMINAL_SHIFT_ENTER_SEQUENCE = "\x1b[13;2u"; const DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS = 7; -const KITTY_KEYBOARD_PROTOCOL_FALLBACK_TIMEOUT_MS = 150; const KEYBOARD_PROTOCOL_RESPONSE_FRAGMENT_TIMEOUT_MS = 150; const KITTY_KEYBOARD_PROTOCOL_QUERY = `\x1b[>${DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS}u\x1b[?u\x1b[c`; @@ -34,8 +33,8 @@ export function parseKeyboardProtocolNegotiationSequence( return undefined; } -function isKeyboardProtocolNegotiationSequencePrefix(sequence: string, allowBareEscapePrefix: boolean): boolean { - return (allowBareEscapePrefix && sequence === "\x1b") || sequence === "\x1b[" || /^\x1b\[\?[\d;]*$/.test(sequence); +function isKeyboardProtocolNegotiationSequencePrefix(sequence: string): boolean { + return sequence === "\x1b[" || /^\x1b\[\?[\d;]*$/.test(sequence); } export function isAppleTerminalSession(): boolean { @@ -104,10 +103,7 @@ export class ProcessTerminal implements Terminal { private _kittyProtocolActive = false; private _modifyOtherKeysActive = false; private keyboardProtocolPushed = false; - private keyboardProtocolNegotiationPending = false; - private keyboardProtocolLateResponsePending = false; private keyboardProtocolNegotiationBuffer = ""; - private keyboardProtocolFallbackTimer?: ReturnType; private keyboardProtocolBufferFlushTimer?: ReturnType; private stdinBuffer?: StdinBuffer; private stdinDataHandler?: (data: string) => void; @@ -131,6 +127,10 @@ export class ProcessTerminal implements Terminal { return this._kittyProtocolActive; } + get modifyOtherKeysActive(): boolean { + return this._modifyOtherKeysActive; + } + start(onInput: (data: string) => void, onResize: () => void): void { this.inputHandler = onInput; this.resizeHandler = onResize; @@ -161,8 +161,7 @@ export class ProcessTerminal implements Terminal { // since that resets console mode flags. this.enableWindowsVTInput(); - // Query and enable Kitty keyboard protocol - // The query handler intercepts input temporarily, then installs the user's handler + // Query Kitty keyboard protocol and fall back to modifyOtherKeys when DA confirms no Kitty response. // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ this.queryAndEnableKittyProtocol(); } @@ -180,25 +179,13 @@ export class ProcessTerminal implements Terminal { // Forward individual sequences to the input handler this.stdinBuffer.on("data", (sequence) => { - if (this.keyboardProtocolNegotiationPending) { - const negotiationSequence = this.readKeyboardProtocolNegotiationSequence(sequence, true); - if (negotiationSequence === "pending") { - return; // Wait for the rest of a split negotiation response. - } - if (this.handleKeyboardProtocolNegotiationSequence(negotiationSequence)) { - return; - } + const negotiationSequence = this.readKeyboardProtocolNegotiationSequence(sequence); + if (negotiationSequence === "pending") { + this.scheduleKeyboardProtocolNegotiationBufferFlush(); + return; // Wait briefly for the rest of a split Kitty response. } - - if (this.keyboardProtocolLateResponsePending) { - const negotiationSequence = this.readKeyboardProtocolNegotiationSequence(sequence, false); - if (negotiationSequence === "pending") { - this.scheduleKeyboardProtocolNegotiationBufferFlush(); - return; // Wait for the rest of a split late negotiation response. - } - if (this.handleKeyboardProtocolNegotiationSequence(negotiationSequence)) { - return; - } + if (this.handleKeyboardProtocolNegotiationSequence(negotiationSequence)) { + return; } this.forwardInputSequence(sequence); @@ -222,9 +209,8 @@ export class ProcessTerminal implements Terminal { * * Kitty's progressive enhancement detection requires requesting the desired * flags before querying them. The trailing DA query is a sentinel supported by - * terminals that do not know Kitty keyboard protocol. A short timeout remains - * as a backup for terminals, PTYs, and SSH sessions that delay, split, or drop - * the DA response. + * terminals that do not know Kitty keyboard protocol; receiving DA before a + * Kitty response enables modifyOtherKeys fallback without a startup timeout. * * The requested flags are: * - 1 = disambiguate escape codes @@ -235,50 +221,36 @@ export class ProcessTerminal implements Terminal { this.setupStdinBuffer(); process.stdin.on("data", this.stdinDataHandler!); this.keyboardProtocolPushed = true; - this.keyboardProtocolNegotiationPending = true; - this.keyboardProtocolLateResponsePending = false; this.clearKeyboardProtocolNegotiationBuffer(); process.stdout.write(KITTY_KEYBOARD_PROTOCOL_QUERY); - this.keyboardProtocolFallbackTimer = setTimeout(() => { - this.keyboardProtocolFallbackTimer = undefined; - this.keyboardProtocolNegotiationPending = false; - this.keyboardProtocolLateResponsePending = true; - if (this.keyboardProtocolNegotiationBuffer === "\x1b") { - this.flushKeyboardProtocolNegotiationBufferAsInput(); - } else { - this.scheduleKeyboardProtocolNegotiationBufferFlush(); - } - this.enableModifyOtherKeys(); - }, KITTY_KEYBOARD_PROTOCOL_FALLBACK_TIMEOUT_MS); } private handleKeyboardProtocolNegotiationSequence( negotiationSequence: KeyboardProtocolNegotiationSequence | undefined, ): boolean { if (!negotiationSequence) return false; + this.clearKeyboardProtocolNegotiationBuffer(); if (negotiationSequence.type === "kitty-flags") { - if (negotiationSequence.flags !== 0 && !this._kittyProtocolActive) { - this._kittyProtocolActive = true; - setKittyProtocolActive(true); - this.keyboardProtocolNegotiationPending = false; - this.keyboardProtocolLateResponsePending = true; - this.clearKeyboardProtocolNegotiationBuffer(); - this.clearKeyboardProtocolFallbackTimer(); + if (negotiationSequence.flags !== 0) { + this.disableModifyOtherKeys(); + if (!this._kittyProtocolActive) { + this._kittyProtocolActive = true; + setKittyProtocolActive(true); + } + } else { + this.enableModifyOtherKeys(); } return true; } - this.keyboardProtocolNegotiationPending = false; - this.keyboardProtocolLateResponsePending = true; - this.clearKeyboardProtocolNegotiationBuffer(); - this.clearKeyboardProtocolFallbackTimer(); - this.enableModifyOtherKeys(); + if (!this._kittyProtocolActive) { + this.enableModifyOtherKeys(); + } return true; } private readKeyboardProtocolNegotiationSequence( sequence: string, - allowBareEscapePrefix: boolean, ): KeyboardProtocolNegotiationSequence | "pending" | undefined { if (this.keyboardProtocolNegotiationBuffer) { const bufferedSequence = this.keyboardProtocolNegotiationBuffer + sequence; @@ -287,7 +259,7 @@ export class ProcessTerminal implements Terminal { this.clearKeyboardProtocolNegotiationBuffer(); return negotiationSequence; } - if (isKeyboardProtocolNegotiationSequencePrefix(bufferedSequence, allowBareEscapePrefix)) { + if (isKeyboardProtocolNegotiationSequencePrefix(bufferedSequence)) { this.setKeyboardProtocolNegotiationBuffer(bufferedSequence); return "pending"; } @@ -296,7 +268,7 @@ export class ProcessTerminal implements Terminal { const negotiationSequence = parseKeyboardProtocolNegotiationSequence(sequence); if (negotiationSequence) return negotiationSequence; - if (isKeyboardProtocolNegotiationSequencePrefix(sequence, allowBareEscapePrefix)) { + if (isKeyboardProtocolNegotiationSequencePrefix(sequence)) { this.setKeyboardProtocolNegotiationBuffer(sequence); return "pending"; } @@ -351,10 +323,10 @@ export class ProcessTerminal implements Terminal { this._modifyOtherKeysActive = true; } - private clearKeyboardProtocolFallbackTimer(): void { - if (!this.keyboardProtocolFallbackTimer) return; - clearTimeout(this.keyboardProtocolFallbackTimer); - this.keyboardProtocolFallbackTimer = undefined; + private disableModifyOtherKeys(): void { + if (!this._modifyOtherKeysActive) return; + process.stdout.write("\x1b[>4;0m"); + this._modifyOtherKeysActive = false; } /** @@ -394,11 +366,8 @@ export class ProcessTerminal implements Terminal { } async drainInput(maxMs = 1000, idleMs = 50): Promise { - const shouldDisableKittyProtocol = - this.keyboardProtocolPushed || this._kittyProtocolActive || this.keyboardProtocolNegotiationPending; - this.keyboardProtocolLateResponsePending = false; + const shouldDisableKittyProtocol = this.keyboardProtocolPushed || this._kittyProtocolActive; this.clearKeyboardProtocolNegotiationBuffer(); - this.clearKeyboardProtocolFallbackTimer(); if (shouldDisableKittyProtocol) { // Disable Kitty keyboard protocol first so any late key releases // do not generate new Kitty escape sequences. @@ -407,11 +376,7 @@ export class ProcessTerminal implements Terminal { this._kittyProtocolActive = false; setKittyProtocolActive(false); } - this.keyboardProtocolNegotiationPending = false; - if (this._modifyOtherKeysActive) { - process.stdout.write("\x1b[>4;0m"); - this._modifyOtherKeysActive = false; - } + this.disableModifyOtherKeys(); const previousHandler = this.inputHandler; this.inputHandler = undefined; @@ -446,11 +411,8 @@ export class ProcessTerminal implements Terminal { // Disable bracketed paste mode process.stdout.write("\x1b[?2004l"); - const shouldDisableKittyProtocol = - this.keyboardProtocolPushed || this._kittyProtocolActive || this.keyboardProtocolNegotiationPending; - this.keyboardProtocolLateResponsePending = false; + const shouldDisableKittyProtocol = this.keyboardProtocolPushed || this._kittyProtocolActive; this.clearKeyboardProtocolNegotiationBuffer(); - this.clearKeyboardProtocolFallbackTimer(); // Disable Kitty keyboard protocol if not already done by drainInput() if (shouldDisableKittyProtocol) { @@ -459,11 +421,7 @@ export class ProcessTerminal implements Terminal { this._kittyProtocolActive = false; setKittyProtocolActive(false); } - this.keyboardProtocolNegotiationPending = false; - if (this._modifyOtherKeysActive) { - process.stdout.write("\x1b[>4;0m"); - this._modifyOtherKeysActive = false; - } + this.disableModifyOtherKeys(); // Clean up StdinBuffer if (this.stdinBuffer) { diff --git a/packages/tui/test/key-tester.ts b/packages/tui/test/key-tester.ts index 650b98cb..dce060d4 100755 --- a/packages/tui/test/key-tester.ts +++ b/packages/tui/test/key-tester.ts @@ -2,6 +2,7 @@ import { matchesKey } from "../src/keys.ts"; import { ProcessTerminal } from "../src/terminal.ts"; import { type Component, TUI } from "../src/tui.ts"; +import { truncateToWidth } from "../src/utils.ts"; /** * Simple key code logger component @@ -10,9 +11,11 @@ class KeyLogger implements Component { private log: string[] = []; private maxLines = 20; private tui: TUI; + private terminal: ProcessTerminal; - constructor(tui: TUI) { + constructor(tui: TUI, terminal: ProcessTerminal) { this.tui = tui; + this.terminal = terminal; } handleInput(data: string): void { @@ -52,18 +55,29 @@ class KeyLogger implements Component { // No cached state to invalidate currently } + private protocolName(): string { + if (this.terminal.kittyProtocolActive) return "kitty"; + if (this.terminal.modifyOtherKeysActive) return "modifyOtherKeys"; + return "legacy"; + } + + private fit(line: string, width: number): string { + return truncateToWidth(line, width).padEnd(width); + } + render(width: number): string[] { const lines: string[] = []; // Title lines.push("=".repeat(width)); - lines.push("Key Code Tester - Press keys to see their codes (Ctrl+C to exit)".padEnd(width)); + lines.push(this.fit("Key Code Tester - Press keys to see their codes (Ctrl+C to exit)", width)); + lines.push(this.fit(`Protocol: ${this.protocolName()}`, width)); lines.push("=".repeat(width)); lines.push(""); // Log entries for (const entry of this.log) { - lines.push(entry.padEnd(width)); + lines.push(this.fit(entry, width)); } // Fill remaining space @@ -74,12 +88,12 @@ class KeyLogger implements Component { // Footer lines.push("=".repeat(width)); - lines.push("Test these:".padEnd(width)); - lines.push(" - Shift + Enter (should show: \\x1b[13;2u with Kitty protocol)".padEnd(width)); - lines.push(" - Alt/Option + Enter".padEnd(width)); - lines.push(" - Option/Alt + Backspace".padEnd(width)); - lines.push(" - Cmd/Ctrl + Backspace".padEnd(width)); - lines.push(" - Regular Backspace".padEnd(width)); + lines.push(this.fit("Test these:", width)); + lines.push(this.fit(" - Shift + Enter (should show: \\x1b[13;2u with Kitty protocol)", width)); + lines.push(this.fit(" - Alt/Option + Enter", width)); + lines.push(this.fit(" - Option/Alt + Backspace", width)); + lines.push(this.fit(" - Cmd/Ctrl + Backspace", width)); + lines.push(this.fit(" - Regular Backspace", width)); lines.push("=".repeat(width)); return lines; @@ -89,7 +103,7 @@ class KeyLogger implements Component { // Set up TUI const terminal = new ProcessTerminal(); const tui = new TUI(terminal); -const logger = new KeyLogger(tui); +const logger = new KeyLogger(tui, terminal); tui.addChild(logger); tui.setFocus(logger); @@ -103,3 +117,7 @@ process.on("SIGINT", () => { // Start the TUI tui.start(); + +// Protocol negotiation completes asynchronously after the first render. +// Refresh briefly/continuously so the displayed protocol state is not stale. +setInterval(() => tui.requestRender(), 100); diff --git a/packages/tui/test/terminal.test.ts b/packages/tui/test/terminal.test.ts index 73328142..a356bff1 100644 --- a/packages/tui/test/terminal.test.ts +++ b/packages/tui/test/terminal.test.ts @@ -82,58 +82,80 @@ describe("ProcessTerminal Kitty keyboard protocol negotiation", () => { }; } - it("activates Kitty mode for non-zero negotiated flags", () => { - mock.timers.enable({ apis: ["setTimeout"] }); + it("queries Kitty mode before enabling modifyOtherKeys fallback", () => { const harness = setupNegotiation(); try { - harness.send("\x1b[?1u"); - mock.timers.tick(150); - assert.equal(harness.writes[0], "\x1b[>7u\x1b[?u\x1b[c"); assert.equal(harness.writes.includes("\x1b[>4;2m"), false); + assert.equal(harness.terminal.kittyProtocolActive, false); + } finally { + harness.cleanup(); + } + }); + + it("activates Kitty mode for non-zero negotiated flags", () => { + const harness = setupNegotiation(); + try { + harness.send("\x1b[?7u"); + assert.equal(harness.getInput(), undefined); assert.equal(harness.terminal.kittyProtocolActive, true); + assert.equal(harness.writes.includes("\x1b[>4;2m"), false); + assert.equal(harness.writes.includes("\x1b[>4;0m"), false); harness.cleanup(); assert.equal(harness.writes.filter((write) => write === "\x1b[4;0m"), false); } finally { harness.cleanup(); - mock.timers.reset(); } }); - it("falls back to modifyOtherKeys for unsupported or silent terminals", () => { - const unsupported = setupNegotiation(); + it("falls back to modifyOtherKeys for zero Kitty flags", () => { + const harness = setupNegotiation(); try { - unsupported.send("\x1b[?62;4;52c"); + harness.send("\x1b[?0u"); - assert.equal(unsupported.writes[0], "\x1b[>7u\x1b[?u\x1b[c"); - assert.equal(unsupported.writes.includes("\x1b[>4;2m"), true); - assert.equal(unsupported.getInput(), undefined); - assert.equal(unsupported.terminal.kittyProtocolActive, false); + assert.equal(harness.getInput(), undefined); + assert.equal(harness.terminal.kittyProtocolActive, false); + assert.equal(harness.writes.filter((write) => write === "\x1b[>4;2m").length, 1); + + harness.cleanup(); + assert.equal(harness.writes.filter((write) => write === "\x1b[>4;0m").length, 1); } finally { - unsupported.cleanup(); - } - - mock.timers.enable({ apis: ["setTimeout"] }); - const silent = setupNegotiation(); - try { - mock.timers.tick(150); - - assert.equal(silent.writes[0], "\x1b[>7u\x1b[?u\x1b[c"); - assert.equal(silent.writes.includes("\x1b[>4;2m"), true); - assert.equal(silent.terminal.kittyProtocolActive, false); - } finally { - silent.cleanup(); - mock.timers.reset(); + harness.cleanup(); } }); - it("tracks late split Kitty confirmation after fallback", () => { + it("falls back to modifyOtherKeys for device attributes without Kitty flags", () => { + const harness = setupNegotiation(); + try { + harness.send("\x1b[?62;4;52c"); + + assert.equal(harness.getInput(), undefined); + assert.equal(harness.terminal.kittyProtocolActive, false); + assert.equal(harness.writes.filter((write) => write === "\x1b[>4;2m").length, 1); + } finally { + harness.cleanup(); + } + }); + + it("forwards normal input while waiting for Kitty response", () => { + const harness = setupNegotiation(); + try { + harness.send("a"); + + assert.equal(harness.getInput(), "a"); + assert.equal(harness.terminal.kittyProtocolActive, false); + } finally { + harness.cleanup(); + } + }); + + it("tracks split Kitty confirmation", () => { mock.timers.enable({ apis: ["setTimeout"] }); const harness = setupNegotiation(); try { - mock.timers.tick(150); harness.send("\x1b[?7"); mock.timers.tick(10); @@ -141,26 +163,21 @@ describe("ProcessTerminal Kitty keyboard protocol negotiation", () => { harness.send("u"); - assert.equal(harness.writes.includes("\x1b[>4;2m"), true); assert.equal(harness.terminal.kittyProtocolActive, true); - - harness.cleanup(); - assert.equal(harness.writes.filter((write) => write === "\x1b[ write === "\x1b[>4;0m").length, 1); + assert.equal(harness.writes.includes("\x1b[>4;2m"), false); } finally { harness.cleanup(); mock.timers.reset(); } }); - it("replays buffered CSI-prefix input after fallback", () => { + it("replays buffered CSI-prefix input when it is not a Kitty response", () => { mock.timers.enable({ apis: ["setTimeout"] }); const harness = setupNegotiation(); try { harness.send("\x1b["); - mock.timers.tick(150); + mock.timers.tick(10); - assert.equal(harness.writes.includes("\x1b[>4;2m"), true); assert.equal(harness.getInput(), undefined); mock.timers.tick(150); From 38f18be44727e669eb0a6e2eb8edb51b0232d83c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 8 Jun 2026 10:08:01 +0200 Subject: [PATCH 175/352] fix(coding-agent): persist implicit project trust on reload --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/trust-manager.ts | 6 ++- packages/coding-agent/src/main.ts | 6 ++- .../src/modes/interactive/interactive-mode.ts | 39 ++++++++++++++++++- .../coding-agent/test/trust-manager.test.ts | 4 +- 5 files changed, 51 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d3328fce..afd84aa9 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -11,6 +11,7 @@ ### Fixed +- Fixed `/reload` to persist project trust when an implicitly trusted session creates a project `.pi` directory. - Fixed the compaction summarization system prompt to use neutral AI assistant wording for non-coding agents ([#5401](https://github.com/earendil-works/pi/issues/5401)). - Fixed `models.json` schema support for OpenAI Responses `compat.supportsDeveloperRole` ([#5456](https://github.com/earendil-works/pi/issues/5456)). - Fixed tmux setup documentation to require tmux 3.5 for `extended-keys-format csi-u` and document the tmux 3.2-3.4 fallback ([#5432](https://github.com/earendil-works/pi/issues/5432)). diff --git a/packages/coding-agent/src/core/trust-manager.ts b/packages/coding-agent/src/core/trust-manager.ts index e8e59323..c86c8518 100644 --- a/packages/coding-agent/src/core/trust-manager.ts +++ b/packages/coding-agent/src/core/trust-manager.ts @@ -94,9 +94,13 @@ function withTrustFileLock(path: string, fn: () => T): T { } } +export function hasProjectConfigDir(cwd: string): boolean { + return existsSync(join(canonicalizePath(resolvePath(cwd)), CONFIG_DIR_NAME)); +} + export function hasProjectTrustInputs(cwd: string): boolean { let currentDir = canonicalizePath(resolvePath(cwd)); - if (existsSync(join(currentDir, CONFIG_DIR_NAME))) { + if (hasProjectConfigDir(currentDir)) { return true; } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index a3fbb719..99846761 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -647,9 +647,12 @@ export async function main(args: string[], options?: MainOptions) { time("createSessionManager"); const trustStore = new ProjectTrustStore(agentDir); + const sessionCwd = sessionManager.getCwd(); + const autoTrustOnReloadCwd = + parsed.projectTrustOverride === undefined && !hasProjectTrustInputs(sessionCwd) ? sessionCwd : undefined; const trustPromptMode: AppMode = parsed.help || parsed.listModels !== undefined ? "print" : appMode; const projectTrustedForSession = await resolveProjectTrusted({ - cwd: sessionManager.getCwd(), + cwd: sessionCwd, trustStore, trustOverride: parsed.projectTrustOverride, appMode: trustPromptMode, @@ -827,6 +830,7 @@ export async function main(args: string[], options?: MainOptions) { const interactiveMode = new InteractiveMode(runtime, { migratedProviders, modelFallbackMessage, + autoTrustOnReloadCwd, initialMessage, initialImages, initialMessages: parsed.messages, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 5e0ecaf9..c0e22282 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -85,7 +85,7 @@ import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts"; import type { SourceInfo } from "../../core/source-info.ts"; import { isInstallTelemetryEnabled } from "../../core/telemetry.ts"; import type { TruncationResult } from "../../core/tools/truncate.ts"; -import { hasProjectTrustInputs, ProjectTrustStore } from "../../core/trust-manager.ts"; +import { hasProjectConfigDir, hasProjectTrustInputs, ProjectTrustStore } from "../../core/trust-manager.ts"; import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.ts"; import { copyToClipboard } from "../../utils/clipboard.ts"; import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts"; @@ -249,6 +249,8 @@ export interface InteractiveModeOptions { migratedProviders?: string[]; /** Warning message if session model couldn't be restored */ modelFallbackMessage?: string; + /** Cwd to trust after reload if it gained a .pi directory during this implicitly trusted session. */ + autoTrustOnReloadCwd?: string; /** Initial message to send on startup (can include @file content) */ initialMessage?: string; /** Images to attach to the initial message */ @@ -367,6 +369,7 @@ export class InteractiveMode { private customHeader: (Component & { dispose?(): void }) | undefined = undefined; private options: InteractiveModeOptions; + private autoTrustOnReloadCwd: string | undefined; // Convenience accessors private get session(): AgentSession { @@ -385,6 +388,7 @@ export class InteractiveMode { constructor(runtimeHost: AgentSessionRuntime, options: InteractiveModeOptions = {}) { this.runtimeHost = runtimeHost; this.options = options; + this.autoTrustOnReloadCwd = options.autoTrustOnReloadCwd; this.runtimeHost.setBeforeSessionInvalidate(() => { this.resetExtensionUI(); }); @@ -4163,6 +4167,32 @@ export class InteractiveMode { } } + private maybeSaveImplicitProjectTrustAfterReload(): boolean { + const cwd = this.sessionManager.getCwd(); + if (this.autoTrustOnReloadCwd !== cwd) { + return false; + } + if (!this.settingsManager.isProjectTrusted() || !hasProjectConfigDir(cwd)) { + return false; + } + + const trustStore = new ProjectTrustStore(this.runtimeHost.services.agentDir); + try { + if (trustStore.get(cwd) !== null) { + this.autoTrustOnReloadCwd = undefined; + return false; + } + trustStore.set(cwd, true); + this.autoTrustOnReloadCwd = undefined; + return true; + } catch (error) { + this.showWarning( + `Could not save project trust after reload: ${error instanceof Error ? error.message : String(error)}`, + ); + return false; + } + } + private showTrustSelector(): void { const cwd = this.sessionManager.getCwd(); const trustStore = new ProjectTrustStore(this.runtimeHost.services.agentDir); @@ -5047,11 +5077,16 @@ export class InteractiveMode { force: false, showDiagnosticsWhenQuiet: true, }); + const savedImplicitProjectTrust = this.maybeSaveImplicitProjectTrustAfterReload(); const modelsJsonError = this.session.modelRegistry.getError(); if (modelsJsonError) { this.showError(`models.json error: ${modelsJsonError}`); } - this.showStatus("Reloaded keybindings, extensions, skills, prompts, themes"); + this.showStatus( + savedImplicitProjectTrust + ? "Reloaded keybindings, extensions, skills, prompts, themes; saved project trust" + : "Reloaded keybindings, extensions, skills, prompts, themes", + ); } catch (error) { dismissReloadBox(previousEditor as Component); this.showError(`Reload failed: ${error instanceof Error ? error.message : String(error)}`); diff --git a/packages/coding-agent/test/trust-manager.test.ts b/packages/coding-agent/test/trust-manager.test.ts index 3bb01634..d91dde49 100644 --- a/packages/coding-agent/test/trust-manager.test.ts +++ b/packages/coding-agent/test/trust-manager.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { hasProjectTrustInputs, ProjectTrustStore } from "../src/core/trust-manager.ts"; +import { hasProjectConfigDir, hasProjectTrustInputs, ProjectTrustStore } from "../src/core/trust-manager.ts"; describe("ProjectTrustStore", () => { let tempDir: string; @@ -44,9 +44,11 @@ describe("ProjectTrustStore", () => { }); it("detects project trust inputs", () => { + expect(hasProjectConfigDir(cwd)).toBe(false); expect(hasProjectTrustInputs(cwd)).toBe(false); mkdirSync(join(cwd, ".pi"), { recursive: true }); + expect(hasProjectConfigDir(cwd)).toBe(true); expect(hasProjectTrustInputs(cwd)).toBe(true); rmSync(join(cwd, ".pi"), { recursive: true, force: true }); From 9d007fe642599bdd54d2ec7eaef3cfbcc2dfc6be Mon Sep 17 00:00:00 2001 From: Roman Galeev Date: Mon, 8 Jun 2026 10:57:50 +0200 Subject: [PATCH 176/352] fix(tui): re-query autocomplete picker on cursor movement moveCursor() repositioned the cursor but never called updateAutocomplete() or cancelAutocomplete(), unlike insertCharacter()/handleBackspace(). So an open autocomplete picker went stale when the cursor moved: e.g. typing `/cmd ` (argument menu open) then arrowing Left back into the command name left the argument list showing against a `/cmd` prefix, and a Tab there concatenated the stale suggestion onto the partial command name (e.g. `/swarepo`). Sync the picker at the end of moveCursor(): when a picker is open, re-query via updateAutocomplete(), which refreshes the list for the new cursor position or closes it when nothing matches. Adds a regression test in test/editor.test.ts (verified to fail without the fix). Fixes earendil-works/pi#5496. --- packages/tui/src/components/editor.ts | 12 ++++++ packages/tui/test/editor.test.ts | 59 +++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 382c88ae..f485218c 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -1742,6 +1742,18 @@ export class Editor implements Component, Focusable { } } } + + // Keep an open autocomplete picker in sync with the new cursor + // position: cursor movement changes the text before the cursor, so a + // picker computed for the old position is stale. Re-query so it + // refreshes — or closes when the new position yields no suggestions — + // mirroring insertCharacter()/handleBackspace(). Without this, arrowing + // left from `/cmd ` back into the command name leaves the argument + // picker showing against a `/cmd` prefix (and a Tab there would + // concatenate the stale suggestion onto the partial command name). + if (this.autocompleteState) { + this.updateAutocomplete(); + } } /** diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 938f9646..9f92643a 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -2250,6 +2250,65 @@ describe("Editor component", () => { assert.strictEqual(editor.isShowingAutocomplete(), true); }); + it("re-queries the autocomplete picker when the cursor moves back into the command name", async () => { + // Regression for earendil-works/pi#5496: arrowing left out of a slash + // command's argument region must re-query the picker, not leave the + // stale argument list showing. Before the fix, moveCursor() never + // called updateAutocomplete(), so `/cmd ` (argument menu) + Left kept + // displaying the arguments against a `/cmd` prefix — and a Tab there + // would concatenate the stale suggestion onto the partial command name. + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + const mockProvider: AutocompleteProvider = { + getSuggestions: async (lines, _cursorLine, cursorCol) => { + const before = (lines[0] || "").slice(0, cursorCol); + if (!before.startsWith("/")) return null; + // Past the command name (a space before the cursor): offer arguments. + if (before.includes(" ")) { + return { + items: [ + { value: "repo", label: "repo" }, + { value: "message", label: "message" }, + { value: "help", label: "help" }, + ], + prefix: before.slice(before.indexOf(" ") + 1), + }; + } + // Inside the command name: offer the command name only. + return { items: [{ value: "cmd", label: "cmd" }], prefix: before }; + }, + applyCompletion, + }; + + editor.setAutocompleteProvider(mockProvider); + + // Type `/cmd ` so the picker ends up showing the argument list. + for (const ch of "/cmd ") { + editor.handleInput(ch); + await flushAutocomplete(); + } + assert.strictEqual(editor.getText(), "/cmd "); + assert.strictEqual(editor.isShowingAutocomplete(), true); + const atArg = editor + .render(80) + .map((l) => stripVTControlCharacters(l)) + .join("\n"); + assert.ok(atArg.includes("repo"), "argument menu should be visible at `/cmd `"); + + // Arrow Left back into the command name (`/cmd`). + editor.handleInput("\x1b[D"); + await flushAutocomplete(); + + // The picker must have re-queried: the stale argument items are gone + // (replaced by the command-name suggestion, or the picker closed). + const afterMove = editor + .render(80) + .map((l) => stripVTControlCharacters(l)) + .join("\n"); + assert.ok(!afterMove.includes("repo"), "stale argument menu must not survive the cursor move"); + assert.ok(!afterMove.includes("message"), "stale argument menu must not survive the cursor move"); + }); + it("debounces # autocomplete while typing", async () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); let suggestionCalls = 0; From f4f72d4ed59c6bb7252fa936e09c2e121f491d1e Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 8 Jun 2026 12:16:04 +0200 Subject: [PATCH 177/352] docs(agent): add security advisory prompt --- .pi/prompts/sa.md | 163 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 .pi/prompts/sa.md diff --git a/.pi/prompts/sa.md b/.pi/prompts/sa.md new file mode 100644 index 00000000..e7025c07 --- /dev/null +++ b/.pi/prompts/sa.md @@ -0,0 +1,163 @@ +--- +description: Update a GitHub security advisory for publication +argument-hint: "" +--- +Update a GitHub security advisory for publication: $ARGUMENTS + +Use `gh` for all GitHub operations. Do not publish the advisory, change its state, or request a CVE unless the user explicitly agrees or the draft markdown explicitly says `request_cve: true`. + +GitHub does not expose repository security advisory comments/discussion through the documented REST OpenAPI schema or public GraphQL schema. A 404 from guessed API endpoints such as `api.github.com/repos/.../security-advisories//comments`, `.../timeline`, or `.../events` is expected and is not, by itself, an auth failure. Do not use a browser session, browser cookies, or cookie extraction to fetch advisory comments. Instead, clearly tell the user that advisory comments were not included and that they can paste any relevant comments if they want them considered. + +## Input handling + +- If `$ARGUMENTS` is a GitHub security advisory URL, start the investigation and drafting workflow. +- If `$ARGUMENTS` is a path to an existing markdown draft, read it and apply that draft to the advisory. +- In a follow-up message after this prompt, if the user says "update", "apply", "looks good", or similar, treat it as approval to apply the previously written temp markdown draft. Re-read the file from disk before updating GitHub. +- If applying a draft and there is no known draft path, ask the user for the markdown file path. + +## Initial advisory workflow + +1. Parse the advisory URL into `owner`, `repo`, and `GHSA` id. +2. Fetch the advisory with: + ```sh + gh api repos///security-advisories/ + ``` + Record the advisory's original severity, CVSS vector, and CVSS score exactly as returned before proposing changes. +3. Do not fetch advisory comments/discussion unless the user pasted them into the conversation: + - Inspect the advisory JSON for references, credits, linked issues/PRs, and any discussion fields. + - Do not rely on invented API endpoints such as `/comments`, `/timeline`, or `/events`; they commonly return 404 because GitHub does not expose draft advisory comments through the public API. + - Do not use a browser session, browser cookies, or cookie extraction to fetch comments. + - Explicitly tell the user: `Advisory comments were not included because GitHub does not expose them through the public API. Paste any relevant comments if you want them considered.` + - If the user pasted comments, read and consider them. + - Never pretend comments were read. +4. Investigate independently: + - Read the advisory text, metadata, affected package(s), version ranges, CVSS, CWE, references, and linked issues/PRs/commits. + - Inspect relevant code history, releases, changelogs, package metadata, and tags. + - Determine whether the vulnerability is already fixed. + - If fixed, identify the patched version(s) and the correct affected version range. + - Do not trust the reporter's analysis without verification. +5. Discuss CVSS with the user before drafting the final update: + - Propose a CVSS vector, score, and severity. + - Explain the controversial metrics briefly. + - Ask the user to confirm or adjust it. +6. Ask whether a CVE should be requested from GitHub for this advisory. +7. Draft a publication-ready advisory markdown file under `/tmp`, for example `/tmp/sa-.md`. Include both the original CVSS from the advisory and the proposed/confirmed updated CVSS. +8. Tell the user: + - the path to the temp markdown file + - the original advisory URL + - that they can edit the file and then say "update" or provide the path + +## Draft markdown format + +The draft file must contain YAML frontmatter followed by the advisory body. Include all fields needed to update GitHub and to decide whether to request a CVE. + +```markdown +--- +advisory_url: https://github.com///security/advisories/ +owner: +repo: +ghsa_id: +summary: +original_severity: +original_cvss_vector: +original_cvss_score: +severity: +cvss_vector: +cvss_score: +cwe_ids: + - CWE-... +vulnerabilities: + - package: + ecosystem: npm + name: + vulnerable_version_range: + patched_versions: +request_cve: false +--- + +# + + + +## Info + + + +## Impact + + + +## Affected versions + +- Affected: `` +- Patched: `` + +## The solution + + + +## Recommendations + + + +## Workarounds + + + +## Timeline + +- YYYY-MM-DD: Report received +- YYYY-MM-DD: Fix committed +- YYYY-MM-DD: Fixed version released +- YYYY-MM-DD: Advisory published + +## Credits + + + +## References + +- +``` + +Use the curl advisory style as inspiration: clear sections, direct language, affected/fixed version facts, recommendations, timeline, and credits. Do not include a PoC. + +## Applying a draft to GitHub + +When the user approves with "update"/similar or provides a markdown path: + +1. Re-read the markdown file from disk. Never rely on the previously generated content in memory. +2. Parse the YAML frontmatter and body. +3. Build a JSON payload in a temporary file. Map fields as follows: + - `summary` from frontmatter + - `description` from the markdown body after frontmatter + - `severity` from frontmatter if present + - `cvss_vector_string` from `cvss_vector` + - `cwe_ids` from frontmatter + - `vulnerabilities` from frontmatter + - Do not send `original_severity`, `original_cvss_vector`, or `original_cvss_score`; those fields are retained only for audit context. +4. Update the advisory with: + ```sh + gh api -X PATCH repos///security-advisories/ --input /tmp/.json + ``` +5. If and only if the markdown frontmatter has `request_cve: true`, request a CVE with: + ```sh + gh api -X POST repos///security-advisories//cve + ``` + Treat "already requested" or "already assigned" as non-fatal and report it. +6. Report what was updated: + - advisory URL + - summary + - affected range + - patched versions + - original CVSS vector/score/severity + - updated CVSS vector/score/severity + - whether CVE was requested + +## Safety rules + +- Do not include PoC material in the final advisory body. +- Do not request a CVE unless `request_cve: true` is present in the markdown file. +- Do not publish the advisory or change its state unless the user explicitly asks. +- Do not fetch advisory comments through browser sessions or cookies. State that comments were not included and invite the user to paste relevant comments if they want them considered. +- If there is uncertainty in affected ranges, patched versions, CVSS, or CVE request status, ask the user before applying. From dce3e28516bb6bee1c9802bcea379fa32171381d Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 8 Jun 2026 12:34:15 +0200 Subject: [PATCH 178/352] fix: show security advisories in prompt widget --- .pi/extensions/prompt-url-widget.ts | 178 ++++++++++++++++++++++------ 1 file changed, 145 insertions(+), 33 deletions(-) diff --git a/.pi/extensions/prompt-url-widget.ts b/.pi/extensions/prompt-url-widget.ts index 835fce10..85ef6537 100644 --- a/.pi/extensions/prompt-url-widget.ts +++ b/.pi/extensions/prompt-url-widget.ts @@ -1,43 +1,154 @@ +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { resolve } from "node:path"; import { DynamicBorder, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Container, Text } from "@earendil-works/pi-tui"; const PR_PROMPT_PATTERN = /^\s*You are given one or more GitHub PR URLs:\s*(\S+)/im; const ISSUE_PROMPT_PATTERN = /^\s*Analyze GitHub issue\(s\):\s*(\S+)/im; +const ADVISORY_PROMPT_PATTERN = /^\s*Update a GitHub security advisory for publication:\s*(\S+)/im; type PromptMatch = { - kind: "pr" | "issue"; - url: string; + kind: "pr" | "issue" | "advisory"; + target: string; }; type GhMetadata = { title?: string; + detail?: string; + displayUrl?: string; author?: { login?: string; name?: string | null; }; }; +type GitHubAdvisoryMetadata = { + ghsa_id?: string; + summary?: string; + severity?: string; + state?: string; + html_url?: string; + cve_id?: string | null; +}; + +type AdvisoryRef = { + owner: string; + repo: string; + ghsaId: string; + url: string; +}; + function extractPromptMatch(prompt: string): PromptMatch | undefined { const prMatch = prompt.match(PR_PROMPT_PATTERN); if (prMatch?.[1]) { - return { kind: "pr", url: prMatch[1].trim() }; + return { kind: "pr", target: prMatch[1].trim() }; } const issueMatch = prompt.match(ISSUE_PROMPT_PATTERN); if (issueMatch?.[1]) { - return { kind: "issue", url: issueMatch[1].trim() }; + return { kind: "issue", target: issueMatch[1].trim() }; + } + + const advisoryMatch = prompt.match(ADVISORY_PROMPT_PATTERN); + if (advisoryMatch?.[1]) { + return { kind: "advisory", target: advisoryMatch[1].trim() }; } return undefined; } +function getPromptLabel(kind: PromptMatch["kind"]): string { + if (kind === "pr") return "PR"; + if (kind === "issue") return "Issue"; + return "Advisory"; +} + +function parseAdvisoryUrl(value: string): AdvisoryRef | undefined { + const match = value.match( + /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/security\/advisories\/(GHSA-[A-Za-z0-9-]+)(?:[/?#].*)?$/i, + ); + if (!match?.[1] || !match[2] || !match[3]) return undefined; + return { + owner: match[1], + repo: match[2], + ghsaId: match[3], + url: `https://github.com/${match[1]}/${match[2]}/security/advisories/${match[3]}`, + }; +} + +function unquoteYamlValue(value: string): string { + const trimmed = value.trim(); + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +function resolveDraftPath(cwd: string, target: string): string { + if (target === "~") return homedir(); + if (target.startsWith("~/")) return resolve(homedir(), target.slice(2)); + return resolve(cwd, target); +} + +async function readAdvisoryRefFromDraft(cwd: string, target: string): Promise { + try { + const content = await readFile(resolveDraftPath(cwd, target), "utf8"); + const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + const body = frontmatter?.[1] ?? content; + const urlMatch = body.match(/^advisory_url:\s*(.+)$/m); + if (!urlMatch?.[1]) return undefined; + return parseAdvisoryUrl(unquoteYamlValue(urlMatch[1])); + } catch { + return undefined; + } +} + +function formatAdvisoryDetail(advisory: GitHubAdvisoryMetadata): string | undefined { + const parts = [advisory.ghsa_id, advisory.cve_id ?? undefined, advisory.severity, advisory.state] + .map((part) => part?.trim()) + .filter((part): part is string => part !== undefined && part.length > 0); + return parts.length > 0 ? parts.join(" · ") : undefined; +} + +async function fetchAdvisoryMetadata(pi: ExtensionAPI, cwd: string, target: string): Promise { + const advisoryRef = parseAdvisoryUrl(target) ?? (await readAdvisoryRefFromDraft(cwd, target)); + if (!advisoryRef) return undefined; + + try { + const result = await pi.exec("gh", [ + "api", + `repos/${advisoryRef.owner}/${advisoryRef.repo}/security-advisories/${advisoryRef.ghsaId}`, + ]); + if (result.code !== 0 || !result.stdout) return { displayUrl: advisoryRef.url }; + const advisory = JSON.parse(result.stdout) as GitHubAdvisoryMetadata; + return { + title: advisory.summary, + detail: formatAdvisoryDetail(advisory), + displayUrl: advisory.html_url ?? advisoryRef.url, + }; + } catch { + return { displayUrl: advisoryRef.url }; + } +} + async function fetchGhMetadata( pi: ExtensionAPI, kind: PromptMatch["kind"], - url: string, + target: string, + cwd: string, ): Promise { + if (kind === "advisory") { + return fetchAdvisoryMetadata(pi, cwd, target); + } + const args = - kind === "pr" ? ["pr", "view", url, "--json", "title,author"] : ["issue", "view", url, "--json", "title,author"]; + kind === "pr" + ? ["pr", "view", target, "--json", "title,author"] + : ["issue", "view", target, "--json", "title,author"]; try { const result = await pi.exec("gh", args); @@ -59,14 +170,18 @@ function formatAuthor(author?: GhMetadata["author"]): string | undefined { } export default function promptUrlWidgetExtension(pi: ExtensionAPI) { - const setWidget = (ctx: ExtensionContext, match: PromptMatch, title?: string, authorText?: string) => { + const setWidget = (ctx: ExtensionContext, match: PromptMatch, metadata?: GhMetadata) => { ctx.ui.setWidget("prompt-url", (_tui, thm) => { - const titleText = title ? thm.fg("accent", title) : thm.fg("accent", match.url); - const authorLine = authorText ? thm.fg("muted", authorText) : undefined; - const urlLine = thm.fg("dim", match.url); + const displayTarget = metadata?.displayUrl ?? match.target; + const titleText = metadata?.title + ? thm.fg("accent", metadata.title) + : thm.fg("accent", displayTarget); + const detailText = metadata?.detail ?? formatAuthor(metadata?.author); + const detailLine = detailText ? thm.fg("muted", detailText) : undefined; + const urlLine = thm.fg("dim", displayTarget); const lines = [titleText]; - if (authorLine) lines.push(authorLine); + if (detailLine) lines.push(detailLine); lines.push(urlLine); const container = new Container(); @@ -76,21 +191,32 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) { }); }; - const applySessionName = (ctx: ExtensionContext, match: PromptMatch, title?: string) => { - const label = match.kind === "pr" ? "PR" : "Issue"; - const trimmedTitle = title?.trim(); - const fallbackName = `${label}: ${match.url}`; - const desiredName = trimmedTitle ? `${label}: ${trimmedTitle} (${match.url})` : fallbackName; + const applySessionName = (ctx: ExtensionContext, match: PromptMatch, metadata?: GhMetadata) => { + const label = getPromptLabel(match.kind); + const displayTarget = metadata?.displayUrl ?? match.target; + const trimmedTitle = metadata?.title?.trim(); + const fallbackName = `${label}: ${match.target}`; + const desiredFallbackName = `${label}: ${displayTarget}`; + const desiredName = trimmedTitle ? `${label}: ${trimmedTitle} (${displayTarget})` : desiredFallbackName; const currentName = pi.getSessionName()?.trim(); if (!currentName) { pi.setSessionName(desiredName); return; } - if (currentName === match.url || currentName === fallbackName) { + if (currentName === match.target || currentName === fallbackName || currentName === desiredFallbackName) { pi.setSessionName(desiredName); } }; + const updatePromptContext = (ctx: ExtensionContext, match: PromptMatch) => { + setWidget(ctx, match); + applySessionName(ctx, match); + void fetchGhMetadata(pi, match.kind, match.target, ctx.cwd).then((meta) => { + setWidget(ctx, match, meta); + applySessionName(ctx, match, meta); + }); + }; + pi.on("before_agent_start", async (event, ctx) => { if (!ctx.hasUI) return; const match = extractPromptMatch(event.prompt); @@ -98,14 +224,7 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) { return; } - setWidget(ctx, match); - applySessionName(ctx, match); - void fetchGhMetadata(pi, match.kind, match.url).then((meta) => { - const title = meta?.title?.trim(); - const authorText = formatAuthor(meta?.author); - setWidget(ctx, match, title, authorText); - applySessionName(ctx, match, title); - }); + updatePromptContext(ctx, match); }); pi.on("session_switch", async (_event, ctx) => { @@ -142,14 +261,7 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) { return; } - setWidget(ctx, match); - applySessionName(ctx, match); - void fetchGhMetadata(pi, match.kind, match.url).then((meta) => { - const title = meta?.title?.trim(); - const authorText = formatAuthor(meta?.author); - setWidget(ctx, match, title, authorText); - applySessionName(ctx, match, title); - }); + updatePromptContext(ctx, match); }; pi.on("session_start", async (_event, ctx) => { From 718215bd95b6fc6fa251580d27ea8aab857de390 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 8 Jun 2026 12:58:49 +0200 Subject: [PATCH 179/352] feat(coding-agent): add extension project trust decisions --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/README.md | 2 + packages/coding-agent/docs/extensions.md | 20 ++ .../examples/extensions/README.md | 1 + .../examples/extensions/project-trust.ts | 59 +++++ .../src/core/agent-session-runtime.ts | 15 +- .../src/core/agent-session-services.ts | 10 +- .../coding-agent/src/core/extensions/index.ts | 4 + .../src/core/extensions/loader.ts | 13 +- .../src/core/extensions/runner.ts | 34 +++ .../coding-agent/src/core/extensions/types.ts | 26 +- .../coding-agent/src/core/resource-loader.ts | 177 +++++++++---- packages/coding-agent/src/index.ts | 4 + packages/coding-agent/src/main.ts | 232 +++++++++++++++--- .../src/modes/interactive/interactive-mode.ts | 18 ++ .../coding-agent/test/resource-loader.test.ts | 47 ++++ packages/tui/src/tui.ts | 10 +- packages/tui/test/tui-shrink.test.ts | 44 ++++ 18 files changed, 631 insertions(+), 86 deletions(-) create mode 100644 packages/coding-agent/examples/extensions/project-trust.ts create mode 100644 packages/tui/test/tui-shrink.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index afd84aa9..5acf40df 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added a `project_trust` extension event so global and CLI extensions can decide project trust during startup and runtime cwd switches. - Added project trust gating for project-local settings, resources, instructions, and packages ([#5332](https://github.com/earendil-works/pi/pull/5332)). - Added the latest prompt cache hit rate to the interactive footer. - Exported RPC extension UI request and response types from the public API ([#5455](https://github.com/earendil-works/pi/issues/5455)). diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index ad6fb655..202b6f3e 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -293,6 +293,8 @@ See [docs/settings.md](docs/settings.md) for all options. On interactive startup, pi asks before trusting a project folder that contains project-local inputs and has no saved decision in `~/.pi/agent/trust.json`. Trusting a project allows pi to read project instructions (`AGENTS.md`/`CLAUDE.md`), load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. +Before the trust decision, pi loads only user/global extensions and CLI `-e` extensions so they can handle the `project_trust` event. Project-local extensions, project package-managed extensions, project settings, and project instructions are loaded only after the project is trusted. This split also applies when switching to a session from a different cwd whose trust has not been resolved in the current process. + Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without a saved trust decision, they ignore project-local inputs unless `--approve`/`-a` is passed. Use `--no-approve`/`-na` to ignore project-local inputs for one run even when the project is trusted. `pi config` assumes project trust for that command so you can view and change project resource settings before starting a session. It does not save a trust decision; starting a session in that folder still prompts. Pass `--no-approve` to hide project-local inputs in `pi config`. diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 0d63ceb6..53152ca0 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -270,6 +270,7 @@ Run `npm install` in the extension directory, then imports from `node_modules/` ``` pi starts │ + ├─► project_trust (user/global and CLI extensions only, before project resources load) ├─► session_start { reason: "startup" } └─► resources_discover { reason: "startup" } │ @@ -334,6 +335,24 @@ exit (Ctrl+C, Ctrl+D, SIGHUP, SIGTERM) └─► session_shutdown ``` +### Startup Events + +#### project_trust + +Fired before pi decides whether to trust a project with trust inputs (`.pi`, `AGENTS.md`/`CLAUDE.md`, or `.agents/skills`). It runs during startup and when session replacement (for example `/resume`) enters a cwd whose trust has not been resolved in the current process. Only user/global extensions and CLI `-e` extensions participate; project-local extensions are not loaded until after trust is resolved. + +```typescript +pi.on("project_trust", async (event, ctx) => { + // event.cwd - current working directory + // ctx has a limited trust context: cwd, mode, hasUI, and select/confirm/input/notify UI helpers + if (await ctx.ui.confirm("Trust project?", event.cwd)) { + return { trusted: true, remember: true }; + } +}); +``` + +A `project_trust` handler must return `{ trusted }`; if a user/global or CLI extension registers this handler, it owns the decision. The first returned decision wins and suppresses the built-in trust prompt. Use `remember: true` to persist the decision; otherwise it applies only to the current process. Check `ctx.hasUI` before prompting. If no extension handles `project_trust`, normal trust resolution continues, including the built-in trust prompt when UI is available. + ### Resource Events #### resources_discover @@ -2567,6 +2586,7 @@ All examples in [examples/extensions/](../examples/extensions/). | `shutdown-command.ts` | Graceful shutdown command | `registerCommand`, `shutdown()` | | **Events & Gates** ||| | `permission-gate.ts` | Block dangerous commands | `on("tool_call")`, `ui.confirm` | +| `project-trust.ts` | Decide project trust from a user/global or CLI extension | `on("project_trust")`, trust UI, required trust result | | `protected-paths.ts` | Block writes to specific paths | `on("tool_call")` | | `confirm-destructive.ts` | Confirm session changes | `on("session_before_switch")`, `on("session_before_fork")` | | `dirty-repo-guard.ts` | Warn on dirty git repo | `on("session_before_*")`, `exec` | diff --git a/packages/coding-agent/examples/extensions/README.md b/packages/coding-agent/examples/extensions/README.md index 97103347..d4f4f2cc 100644 --- a/packages/coding-agent/examples/extensions/README.md +++ b/packages/coding-agent/examples/extensions/README.md @@ -19,6 +19,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/ | Extension | Description | |-----------|-------------| | `permission-gate.ts` | Prompts for confirmation before dangerous bash commands (rm -rf, sudo, etc.) | +| `project-trust.ts` | Demonstrates the `project_trust` event for user/global and CLI extensions | | `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) | | `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, fork) | | `dirty-repo-guard.ts` | Prevents session changes with uncommitted git changes | diff --git a/packages/coding-agent/examples/extensions/project-trust.ts b/packages/coding-agent/examples/extensions/project-trust.ts new file mode 100644 index 00000000..66020a54 --- /dev/null +++ b/packages/coding-agent/examples/extensions/project-trust.ts @@ -0,0 +1,59 @@ +/** + * Project Trust Extension + * + * Demonstrates the project_trust event. Install globally or pass via -e: + * + * mkdir -p ~/.pi/agent/extensions + * cp packages/coding-agent/examples/extensions/project-trust.ts ~/.pi/agent/extensions/ + * + * Or: + * + * pi -e packages/coding-agent/examples/extensions/project-trust.ts + * + * Try it in a project containing .pi, AGENTS.md/CLAUDE.md, or .agents/skills. + */ + +import type { ExtensionAPI, ProjectTrustEventResult } from "@earendil-works/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + let loadCount = 0; + loadCount++; + + // Multiple handlers in one extension are allowed. The first handler that returns + // { trusted } wins and suppresses the built-in trust prompt. A project_trust + // handler must return a decision. + pi.on("project_trust", async (event, ctx): Promise => { + ctx.ui.notify(`project_trust fired for ${event.cwd} (mode: ${ctx.mode}, load: ${loadCount})`, "info"); + + if (!ctx.hasUI) { + return { trusted: false }; + } + + const choice = await ctx.ui.select(`Project trust for:\n${event.cwd}`, [ + "Trust and remember", + "Trust with note and remember", + "Trust this session", + "Do not trust this session", + ]); + + if (choice === "Trust with note and remember") { + const note = await ctx.ui.input("Project trust note", "Optional note for this demo"); + ctx.ui.notify(note ? `Recorded demo note: ${note}` : "No demo note entered", "info"); + return { trusted: true, remember: true }; + } + if (choice === "Trust and remember") { + return { trusted: true, remember: true }; + } + if (choice === "Trust this session") { + return { trusted: true }; + } + if (choice === "Do not trust this session") { + return { trusted: false }; + } + return { trusted: false }; + }); + + pi.on("session_start", (_event, ctx) => { + ctx.ui.notify(`project-trust example loaded after trust resolution in ${ctx.cwd}`, "info"); + }); +} diff --git a/packages/coding-agent/src/core/agent-session-runtime.ts b/packages/coding-agent/src/core/agent-session-runtime.ts index baab11b0..be855ea4 100644 --- a/packages/coding-agent/src/core/agent-session-runtime.ts +++ b/packages/coding-agent/src/core/agent-session-runtime.ts @@ -3,7 +3,12 @@ import { basename, join, resolve } from "node:path"; import { resolvePath } from "../utils/paths.ts"; import type { AgentSession } from "./agent-session.ts"; import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agent-session-services.ts"; -import type { ReplacedSessionContext, SessionShutdownEvent, SessionStartEvent } from "./extensions/index.ts"; +import type { + ProjectTrustContext, + ReplacedSessionContext, + SessionShutdownEvent, + SessionStartEvent, +} from "./extensions/index.ts"; import { emitSessionShutdownEvent } from "./extensions/runner.ts"; import type { CreateAgentSessionResult } from "./sdk.ts"; import { assertSessionCwdExists } from "./session-cwd.ts"; @@ -32,6 +37,7 @@ export type CreateAgentSessionRuntimeFactory = (options: { agentDir: string; sessionManager: SessionManager; sessionStartEvent?: SessionStartEvent; + projectTrustContext?: ProjectTrustContext; }) => Promise; /** @@ -186,7 +192,11 @@ export class AgentSessionRuntime { async switchSession( sessionPath: string, - options?: { cwdOverride?: string; withSession?: (ctx: ReplacedSessionContext) => Promise }, + options?: { + cwdOverride?: string; + withSession?: (ctx: ReplacedSessionContext) => Promise; + projectTrustContextFactory?: (cwd: string) => ProjectTrustContext; + }, ): Promise<{ cancelled: boolean }> { const beforeResult = await this.emitBeforeSwitch("resume", sessionPath); if (beforeResult.cancelled) { @@ -203,6 +213,7 @@ export class AgentSessionRuntime { agentDir: this.services.agentDir, sessionManager, sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile }, + projectTrustContext: options?.projectTrustContextFactory?.(sessionManager.getCwd()), }), ); await this.finishSessionReplacement(options?.withSession); diff --git a/packages/coding-agent/src/core/agent-session-services.ts b/packages/coding-agent/src/core/agent-session-services.ts index d66adc13..3a6035cb 100644 --- a/packages/coding-agent/src/core/agent-session-services.ts +++ b/packages/coding-agent/src/core/agent-session-services.ts @@ -6,7 +6,12 @@ import { resolvePath } from "../utils/paths.ts"; import { AuthStorage } from "./auth-storage.ts"; import type { SessionStartEvent, ToolDefinition } from "./extensions/index.ts"; import { ModelRegistry } from "./model-registry.ts"; -import { DefaultResourceLoader, type DefaultResourceLoaderOptions, type ResourceLoader } from "./resource-loader.ts"; +import { + DefaultResourceLoader, + type DefaultResourceLoaderOptions, + type ResourceLoader, + type ResourceLoaderReloadOptions, +} from "./resource-loader.ts"; import { type CreateAgentSessionOptions, type CreateAgentSessionResult, createAgentSession } from "./sdk.ts"; import type { SessionManager } from "./session-manager.ts"; import { SettingsManager } from "./settings-manager.ts"; @@ -38,6 +43,7 @@ export interface CreateAgentSessionServicesOptions { modelRegistry?: ModelRegistry; extensionFlagValues?: Map; resourceLoaderOptions?: Omit; + resourceLoaderReloadOptions?: ResourceLoaderReloadOptions; } /** @@ -142,7 +148,7 @@ export async function createAgentSessionServices( agentDir, settingsManager, }); - await resourceLoader.reload(); + await resourceLoader.reload(options.resourceLoaderReloadOptions); const diagnostics: AgentSessionRuntimeDiagnostic[] = []; const extensionsResult = resourceLoader.getExtensions(); diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index a25fa37b..a20887ed 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -98,6 +98,10 @@ export type { MessageUpdateEvent, ModelSelectEvent, ModelSelectSource, + ProjectTrustContext, + ProjectTrustEvent, + ProjectTrustEventResult, + ProjectTrustHandler, // Provider Registration ProviderConfig, ProviderModelConfig, diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index 6a7d8abb..081d2d11 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -410,15 +410,20 @@ export async function loadExtensionFromFactory( /** * Load extensions from paths. */ -export async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise { +export async function loadExtensions( + paths: string[], + cwd: string, + eventBus?: EventBus, + runtime?: ExtensionRuntime, +): Promise { const extensions: Extension[] = []; const errors: Array<{ path: string; error: string }> = []; const resolvedCwd = resolvePath(cwd); const resolvedEventBus = eventBus ?? createEventBus(); - const runtime = createExtensionRuntime(); + const resolvedRuntime = runtime ?? createExtensionRuntime(); for (const extPath of paths) { - const { extension, error } = await loadExtension(extPath, resolvedCwd, resolvedEventBus, runtime); + const { extension, error } = await loadExtension(extPath, resolvedCwd, resolvedEventBus, resolvedRuntime); if (error) { errors.push({ path: extPath, error }); @@ -433,7 +438,7 @@ export async function loadExtensions(paths: string[], cwd: string, eventBus?: Ev return { extensions, errors, - runtime, + runtime: resolvedRuntime, }; } diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 59412dcb..db4ba0be 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -35,9 +35,13 @@ import type { InputEvent, InputEventResult, InputSource, + LoadExtensionsResult, MessageEndEvent, MessageEndEventResult, MessageRenderer, + ProjectTrustContext, + ProjectTrustEvent, + ProjectTrustEventResult, ProviderConfig, RegisteredCommand, RegisteredTool, @@ -116,6 +120,7 @@ interface BeforeAgentStartCombinedResult { type RunnerEmitEvent = Exclude< ExtensionEvent, | ToolCallEvent + | ProjectTrustEvent | ToolResultEvent | UserBashEvent | ContextEvent @@ -189,6 +194,35 @@ export async function emitSessionShutdownEvent( return false; } +export async function emitProjectTrustEvent( + extensionsResult: LoadExtensionsResult, + event: ProjectTrustEvent, + ctx: ProjectTrustContext, +): Promise<{ result?: ProjectTrustEventResult; errors: ExtensionError[] }> { + const errors: ExtensionError[] = []; + for (const ext of extensionsResult.extensions) { + // A single extension may register multiple handlers for the same event. + // The first project_trust handler that returns a decision wins. + const handlers = ext.handlers.get("project_trust"); + if (!handlers || handlers.length === 0) continue; + + for (const handler of handlers) { + try { + const handlerResult = (await handler(event, ctx)) as ProjectTrustEventResult; + return { result: handlerResult, errors }; + } catch (error) { + errors.push({ + extensionPath: ext.path, + event: event.type, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + } + } + } + return { errors }; +} + const noOpUIContext: ExtensionUIContext = { select: async () => undefined, confirm: async () => false, diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index a78a0657..9fd3ffdf 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -495,9 +495,31 @@ export function defineTool; +} + +export type ProjectTrustHandler = ( + event: ProjectTrustEvent, + ctx: ProjectTrustContext, +) => Promise | ProjectTrustEventResult; + /** Fired after session_start to allow extensions to provide additional resource paths. */ export interface ResourcesDiscoverEvent { type: "resources_discover"; @@ -957,6 +979,7 @@ export function isToolCallEventType(toolName: string, event: ToolCallEvent): boo /** Union of all event types */ export type ExtensionEvent = + | ProjectTrustEvent | ResourcesDiscoverEvent | SessionEvent | ContextEvent @@ -1095,6 +1118,7 @@ export interface ExtensionAPI { // Event Subscription // ========================================================================= + on(event: "project_trust", handler: ProjectTrustHandler): void; on(event: "resources_discover", handler: ExtensionHandler): void; on(event: "session_start", handler: ExtensionHandler): void; on( diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index 1221366a..394679bb 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -11,7 +11,7 @@ import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts"; import { createEventBus, type EventBus } from "./event-bus.ts"; import { createExtensionRuntime, loadExtensionFromFactory, loadExtensions } from "./extensions/loader.ts"; import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.ts"; -import { DefaultPackageManager, type PathMetadata } from "./package-manager.ts"; +import { DefaultPackageManager, type PathMetadata, type ResolvedResource } from "./package-manager.ts"; import type { PromptTemplate } from "./prompt-templates.ts"; import { loadPromptTemplates } from "./prompt-templates.ts"; import { SettingsManager } from "./settings-manager.ts"; @@ -25,6 +25,10 @@ export interface ResourceExtensionPaths { themePaths?: Array<{ path: string; metadata: PathMetadata }>; } +export interface ResourceLoaderReloadOptions { + resolveProjectTrust?: (input: { extensionsResult: LoadExtensionsResult }) => Promise; +} + export interface ResourceLoader { getExtensions(): LoadExtensionsResult; getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] }; @@ -34,7 +38,7 @@ export interface ResourceLoader { getSystemPrompt(): string | undefined; getAppendSystemPrompt(): string[]; extendResources(paths: ResourceExtensionPaths): void; - reload(): Promise; + reload(options?: ResourceLoaderReloadOptions): Promise; } function resolvePromptInput(input: string | undefined, description: string): string | undefined { @@ -321,7 +325,19 @@ export class DefaultResourceLoader implements ResourceLoader { } } - async reload(): Promise { + async reload(options?: ResourceLoaderReloadOptions): Promise { + let preTrustExtensions: LoadExtensionsResult | undefined; + if (options?.resolveProjectTrust) { + // Force untrusted project settings for the bootstrap pass. This keeps project-local + // extensions/packages out while still loading user/global and temporary CLI extensions. + this.settingsManager.setProjectTrusted(false); + await this.settingsManager.reload(); + preTrustExtensions = await this.loadCurrentExtensionSet({ includeInlineFactories: true }); + const projectTrusted = await options.resolveProjectTrust({ extensionsResult: preTrustExtensions }); + this.settingsManager.setProjectTrusted(projectTrusted); + } + + // reload() preserves SettingsManager.projectTrusted and reloads settings for that trust state. await this.settingsManager.reload(); const resolvedPaths = await this.packageManager.resolve(); const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, { @@ -334,9 +350,7 @@ export class DefaultResourceLoader implements ResourceLoader { this.extensionThemeSourceInfos = new Map(); // Helper to extract enabled paths and store metadata - const getEnabledResources = ( - resources: Array<{ path: string; enabled: boolean; metadata: PathMetadata }>, - ): Array<{ path: string; enabled: boolean; metadata: PathMetadata }> => { + const getEnabledResources = (resources: ResolvedResource[]): ResolvedResource[] => { for (const r of resources) { if (!metadataByPath.has(r.path)) { metadataByPath.set(r.path, r.metadata); @@ -345,37 +359,14 @@ export class DefaultResourceLoader implements ResourceLoader { return resources.filter((r) => r.enabled); }; - const getEnabledPaths = ( - resources: Array<{ path: string; enabled: boolean; metadata: PathMetadata }>, - ): string[] => getEnabledResources(resources).map((r) => r.path); + const getEnabledPaths = (resources: ResolvedResource[]): string[] => + getEnabledResources(resources).map((r) => r.path); const enabledExtensions = getEnabledPaths(resolvedPaths.extensions); const enabledSkillResources = getEnabledResources(resolvedPaths.skills); const enabledPrompts = getEnabledPaths(resolvedPaths.prompts); const enabledThemes = getEnabledPaths(resolvedPaths.themes); - const mapSkillPath = (resource: { path: string; metadata: PathMetadata }): string => { - if (resource.metadata.source !== "auto" && resource.metadata.origin !== "package") { - return resource.path; - } - try { - const stats = statSync(resource.path); - if (!stats.isDirectory()) { - return resource.path; - } - } catch { - return resource.path; - } - const skillFile = join(resource.path, "SKILL.md"); - if (existsSync(skillFile)) { - if (!metadataByPath.has(skillFile)) { - metadataByPath.set(skillFile, resource.metadata); - } - return skillFile; - } - return resource.path; - }; - - const enabledSkills = enabledSkillResources.map(mapSkillPath); + const enabledSkills = enabledSkillResources.map((resource) => this.mapSkillPath(resource, metadataByPath)); // Add CLI paths metadata for (const r of cliExtensionPaths.extensions) { @@ -398,18 +389,7 @@ export class DefaultResourceLoader implements ResourceLoader { ? cliEnabledExtensions : this.mergePaths(cliEnabledExtensions, enabledExtensions); - const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus); - const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime); - extensionsResult.extensions.push(...inlineExtensions.extensions); - extensionsResult.errors.push(...inlineExtensions.errors); - - // Detect extension conflicts (tools, commands, flags with same names from different extensions) - // Keep all extensions loaded. Conflicts are reported as diagnostics, and precedence is handled by load order. - const conflicts = this.detectExtensionConflicts(extensionsResult.extensions); - for (const conflict of conflicts) { - extensionsResult.errors.push({ path: conflict.path, error: conflict.message }); - } - + const extensionsResult = await this.loadFinalExtensionSet(extensionPaths, preTrustExtensions); for (const p of this.additionalExtensionPaths) { if (isLocalPath(p)) { const resolved = this.resolveResourcePath(p); @@ -497,6 +477,115 @@ export class DefaultResourceLoader implements ResourceLoader { : baseAppend; } + private async loadCurrentExtensionSet(options: { includeInlineFactories: boolean }): Promise { + const resolvedPaths = await this.packageManager.resolve(); + const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, { + temporary: true, + }); + const enabledExtensions = resolvedPaths.extensions.filter((r) => r.enabled).map((r) => r.path); + const cliEnabledExtensions = cliExtensionPaths.extensions.filter((r) => r.enabled).map((r) => r.path); + const extensionPaths = this.noExtensions + ? cliEnabledExtensions + : this.mergePaths(cliEnabledExtensions, enabledExtensions); + const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus); + if (!options.includeInlineFactories) { + return extensionsResult; + } + + const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime); + extensionsResult.extensions.push(...inlineExtensions.extensions); + extensionsResult.errors.push(...inlineExtensions.errors); + return extensionsResult; + } + + private resolveExtensionLoadPath(path: string): string { + return resolvePath(path, this.cwd, { normalizeUnicodeSpaces: true }); + } + + private async loadFinalExtensionSet( + extensionPaths: string[], + preTrustExtensions: LoadExtensionsResult | undefined, + ): Promise { + if (!preTrustExtensions) { + const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus); + const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime); + extensionsResult.extensions.push(...inlineExtensions.extensions); + extensionsResult.errors.push(...inlineExtensions.errors); + this.addExtensionConflictDiagnostics(extensionsResult); + return extensionsResult; + } + + const preloadedByPath = new Map( + preTrustExtensions.extensions + .filter((extension) => !extension.path.startsWith(" [extension.resolvedPath, extension]), + ); + const failedPreloadPaths = new Set( + preTrustExtensions.errors.map((error) => this.resolveExtensionLoadPath(error.path)), + ); + const remainingPaths = extensionPaths.filter((path) => { + const resolvedPath = this.resolveExtensionLoadPath(path); + return !preloadedByPath.has(resolvedPath) && !failedPreloadPaths.has(resolvedPath); + }); + const remainingExtensions = await loadExtensions( + remainingPaths, + this.cwd, + this.eventBus, + preTrustExtensions.runtime, + ); + const loadedByPath = new Map(preloadedByPath); + for (const extension of remainingExtensions.extensions) { + loadedByPath.set(extension.resolvedPath, extension); + } + + const inlineExtensions = preTrustExtensions.extensions.filter((extension) => + extension.path.startsWith(" loadedByPath.get(this.resolveExtensionLoadPath(path))) + .filter((extension): extension is Extension => extension !== undefined); + orderedExtensions.push(...inlineExtensions); + + const extensionsResult: LoadExtensionsResult = { + extensions: orderedExtensions, + errors: [...preTrustExtensions.errors, ...remainingExtensions.errors], + runtime: preTrustExtensions.runtime, + }; + this.addExtensionConflictDiagnostics(extensionsResult); + return extensionsResult; + } + + private addExtensionConflictDiagnostics(extensionsResult: LoadExtensionsResult): void { + // Detect extension conflicts (tools, commands, flags with same names from different extensions) + // Keep all extensions loaded. Conflicts are reported as diagnostics, and precedence is handled by load order. + const conflicts = this.detectExtensionConflicts(extensionsResult.extensions); + for (const conflict of conflicts) { + extensionsResult.errors.push({ path: conflict.path, error: conflict.message }); + } + } + + private mapSkillPath(resource: ResolvedResource, metadataByPath: Map): string { + if (resource.metadata.source !== "auto" && resource.metadata.origin !== "package") { + return resource.path; + } + try { + const stats = statSync(resource.path); + if (!stats.isDirectory()) { + return resource.path; + } + } catch { + return resource.path; + } + const skillFile = join(resource.path, "SKILL.md"); + if (existsSync(skillFile)) { + if (!metadataByPath.has(skillFile)) { + metadataByPath.set(skillFile, resource.metadata); + } + return skillFile; + } + return resource.path; + } + private normalizeExtensionPaths( entries: Array<{ path: string; metadata: PathMetadata }>, ): Array<{ path: string; metadata: PathMetadata }> { diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 7ddbe5da..677c030b 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -98,6 +98,10 @@ export type { LsToolCallEvent, MessageRenderer, MessageRenderOptions, + ProjectTrustContext, + ProjectTrustEvent, + ProjectTrustEventResult, + ProjectTrustHandler, ProviderConfig, ProviderModelConfig, ReadToolCallEvent, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 99846761..4387af6d 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -24,7 +24,8 @@ import { import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts"; import { AuthStorage } from "./core/auth-storage.ts"; import { exportFromFile } from "./core/export-html/index.ts"; -import type { ExtensionFactory } from "./core/extensions/types.ts"; +import { emitProjectTrustEvent } from "./core/extensions/runner.ts"; +import type { ExtensionFactory, LoadExtensionsResult, ProjectTrustContext } from "./core/extensions/types.ts"; import { configureHttpDispatcher } from "./core/http-dispatcher.ts"; import { KeybindingsManager } from "./core/keybindings.ts"; import type { ModelRegistry } from "./core/model-registry.ts"; @@ -43,6 +44,7 @@ import { printTimings, resetTimings, time } from "./core/timings.ts"; import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts"; import { runMigrations, showDeprecationWarnings } from "./migrations.ts"; import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts"; +import { ExtensionInputComponent } from "./modes/interactive/components/extension-input.ts"; import { ExtensionSelectorComponent } from "./modes/interactive/components/extension-selector.ts"; import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts"; import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts"; @@ -437,24 +439,35 @@ function resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | u return paths?.map((value) => (isLocalPath(value) ? resolvePath(value, cwd) : value)); } +function createStartupTui(settingsManager: SettingsManager): TUI { + initTheme(settingsManager.getTheme()); + setKeybindings(KeybindingsManager.create()); + const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor()); + ui.setClearOnShrink(settingsManager.getClearOnShrink()); + return ui; +} + +async function clearStartupTui(ui: TUI): Promise { + ui.clear(); + ui.requestRender(); + await new Promise((resolve) => setTimeout(resolve, 25)); +} + async function showStartupSelector( settingsManager: SettingsManager, title: string, options: Array<{ label: string; value: T }>, ): Promise { - initTheme(settingsManager.getTheme()); - setKeybindings(KeybindingsManager.create()); - return new Promise((resolve) => { - const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor()); - ui.setClearOnShrink(settingsManager.getClearOnShrink()); + const ui = createStartupTui(settingsManager); let settled = false; - const finish = (result: T | undefined) => { + const finish = async (result: T | undefined) => { if (settled) { return; } settled = true; + await clearStartupTui(ui); ui.stop(); resolve(result); }; @@ -462,8 +475,8 @@ async function showStartupSelector( const selector = new ExtensionSelectorComponent( title, options.map((option) => option.label), - (option) => finish(options.find((entry) => entry.label === option)?.value), - () => finish(undefined), + (option) => void finish(options.find((entry) => entry.label === option)?.value), + () => void finish(undefined), { tui: ui }, ); ui.addChild(selector); @@ -472,6 +485,41 @@ async function showStartupSelector( }); } +async function showStartupInput( + settingsManager: SettingsManager, + title: string, + placeholder?: string, +): Promise { + return new Promise((resolve) => { + const ui = createStartupTui(settingsManager); + + let settled = false; + const finish = async (result: string | undefined) => { + if (settled) { + return; + } + settled = true; + input.dispose(); + await clearStartupTui(ui); + ui.stop(); + resolve(result); + }; + + const input = new ExtensionInputComponent( + title, + placeholder, + (value) => void finish(value), + () => void finish(undefined), + { + tui: ui, + }, + ); + ui.addChild(input); + ui.setFocus(input); + ui.start(); + }); +} + async function promptForMissingSessionCwd( issue: SessionCwdIssue, settingsManager: SettingsManager, @@ -487,20 +535,90 @@ interface ProjectTrustPromptResult { remember: boolean; } +const PROJECT_TRUST_PROMPT_OPTIONS: Array<{ label: string; value: ProjectTrustPromptResult }> = [ + { label: "Trust", value: { trusted: true, remember: true } }, + { label: "Trust (this session only)", value: { trusted: true, remember: false } }, + { label: "Do not trust", value: { trusted: false, remember: true } }, + { label: "Do not trust (this session only)", value: { trusted: false, remember: false } }, +]; + +function formatProjectTrustPrompt(cwd: string): string { + return `Trust project folder?\n${cwd}\n\nThis allows pi to read project instructions (AGENTS.md/CLAUDE.md), load .pi settings and resources, install missing project packages, and execute project extensions.`; +} + async function promptForProjectTrust( cwd: string, settingsManager: SettingsManager, ): Promise { - return showStartupSelector( - settingsManager, - `Trust project folder?\n${cwd}\n\nThis allows pi to read project instructions (AGENTS.md/CLAUDE.md), load .pi settings and resources, install missing project packages, and execute project extensions.`, - [ - { label: "Trust", value: { trusted: true, remember: true } }, - { label: "Trust (this session only)", value: { trusted: true, remember: false } }, - { label: "Do not trust", value: { trusted: false, remember: true } }, - { label: "Do not trust (this session only)", value: { trusted: false, remember: false } }, - ], + return showStartupSelector(settingsManager, formatProjectTrustPrompt(cwd), PROJECT_TRUST_PROMPT_OPTIONS); +} + +async function promptForProjectTrustWithContext( + cwd: string, + ctx: ProjectTrustContext, +): Promise { + const selected = await ctx.ui.select( + formatProjectTrustPrompt(cwd), + PROJECT_TRUST_PROMPT_OPTIONS.map((option) => option.label), ); + return PROJECT_TRUST_PROMPT_OPTIONS.find((option) => option.label === selected)?.value; +} + +function createProjectTrustContext(options: { + cwd: string; + mode: AppMode; + settingsManager: SettingsManager; + hasUI: boolean; +}): ProjectTrustContext { + return { + cwd: options.cwd, + mode: options.mode === "interactive" ? "tui" : options.mode, + hasUI: options.hasUI, + ui: { + select: async (title, selectOptions) => { + if (!options.hasUI) { + return undefined; + } + if (options.mode !== "interactive") { + return undefined; + } + return showStartupSelector( + options.settingsManager, + title, + selectOptions.map((option) => ({ label: option, value: option })), + ); + }, + confirm: async (title, message) => { + if (!options.hasUI) { + return false; + } + if (options.mode !== "interactive") { + return false; + } + return ( + (await showStartupSelector(options.settingsManager, `${title}\n${message}`, [ + { label: "Yes", value: true }, + { label: "No", value: false }, + ])) ?? false + ); + }, + input: async (title, placeholder) => { + if (!options.hasUI) { + return undefined; + } + if (options.mode !== "interactive") { + return undefined; + } + return showStartupInput(options.settingsManager, title, placeholder); + }, + notify: (message, type = "info") => { + if (options.mode !== "interactive") { + const color = type === "error" ? chalk.red : type === "warning" ? chalk.yellow : chalk.cyan; + console.error(color(message)); + } + }, + }, + }; } async function resolveProjectTrusted(options: { @@ -509,6 +627,9 @@ async function resolveProjectTrusted(options: { trustOverride?: boolean; appMode: AppMode; settingsManagerForPrompt: SettingsManager; + extensionsResult?: LoadExtensionsResult; + projectTrustContext?: ProjectTrustContext; + onExtensionError?: (message: string) => void; }): Promise { if (options.trustOverride !== undefined) { return options.trustOverride; @@ -517,10 +638,37 @@ async function resolveProjectTrusted(options: { return true; } + if (options.extensionsResult && options.projectTrustContext) { + const { result, errors } = await emitProjectTrustEvent( + options.extensionsResult, + { type: "project_trust", cwd: options.cwd }, + options.projectTrustContext, + ); + for (const error of errors) { + options.onExtensionError?.(`Extension "${error.extensionPath}" project_trust error: ${error.error}`); + } + if (result) { + if (result.remember === true) { + options.trustStore.set(options.cwd, result.trusted); + } + return result.trusted; + } + } + const decision = options.trustStore.get(options.cwd); if (decision !== null) { return decision; } + if (options.projectTrustContext?.hasUI) { + const selected = await promptForProjectTrustWithContext(options.cwd, options.projectTrustContext); + if (selected !== undefined) { + if (selected.remember) { + options.trustStore.set(options.cwd, selected.trusted); + } + return selected.trusted; + } + return false; + } if (options.appMode !== "interactive") { return false; } @@ -651,13 +799,7 @@ export async function main(args: string[], options?: MainOptions) { const autoTrustOnReloadCwd = parsed.projectTrustOverride === undefined && !hasProjectTrustInputs(sessionCwd) ? sessionCwd : undefined; const trustPromptMode: AppMode = parsed.help || parsed.listModels !== undefined ? "print" : appMode; - const projectTrustedForSession = await resolveProjectTrusted({ - cwd: sessionCwd, - trustStore, - trustOverride: parsed.projectTrustOverride, - appMode: trustPromptMode, - settingsManagerForPrompt: startupSettingsManager, - }); + const projectTrustByCwd = new Map(); const resolvedExtensionPaths = resolveCliPaths(cwd, parsed.extensions); const resolvedSkillPaths = resolveCliPaths(cwd, parsed.skills); @@ -669,11 +811,17 @@ export async function main(args: string[], options?: MainOptions) { agentDir, sessionManager, sessionStartEvent, + projectTrustContext, }) => { - const projectTrusted = - cwd === sessionManager.getCwd() - ? projectTrustedForSession - : (parsed.projectTrustOverride ?? (!hasProjectTrustInputs(cwd) || trustStore.get(cwd) === true)); + const isInitialRuntime = sessionStartEvent === undefined; + const projectTrustDiagnostics: AgentSessionRuntimeDiagnostic[] = []; + const cachedProjectTrust = projectTrustByCwd.get(cwd); + const hasTrustInputs = hasProjectTrustInputs(cwd); + const shouldResolveProjectTrust = + parsed.projectTrustOverride === undefined && cachedProjectTrust === undefined && hasTrustInputs; + const projectTrusted = shouldResolveProjectTrust + ? false + : (cachedProjectTrust ?? parsed.projectTrustOverride ?? (!hasTrustInputs || trustStore.get(cwd) === true)); const runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted }); const services = await createAgentSessionServices({ cwd, @@ -681,6 +829,31 @@ export async function main(args: string[], options?: MainOptions) { authStorage, settingsManager: runtimeSettingsManager, extensionFlagValues: parsed.unknownFlags, + resourceLoaderReloadOptions: shouldResolveProjectTrust + ? { + resolveProjectTrust: async ({ extensionsResult }) => { + const trusted = await resolveProjectTrusted({ + cwd, + trustStore, + trustOverride: parsed.projectTrustOverride, + appMode: isInitialRuntime ? trustPromptMode : "print", + settingsManagerForPrompt: startupSettingsManager, + extensionsResult, + projectTrustContext: + projectTrustContext ?? + createProjectTrustContext({ + cwd, + mode: isInitialRuntime ? trustPromptMode : appMode, + settingsManager: startupSettingsManager, + hasUI: isInitialRuntime && trustPromptMode === "interactive", + }), + onExtensionError: (message) => projectTrustDiagnostics.push({ type: "warning", message }), + }); + projectTrustByCwd.set(cwd, trusted); + return trusted; + }, + } + : undefined, resourceLoaderOptions: { additionalExtensionPaths: resolvedExtensionPaths, additionalSkillPaths: resolvedSkillPaths, @@ -698,6 +871,7 @@ export async function main(args: string[], options?: MainOptions) { }); const { settingsManager, modelRegistry, resourceLoader } = services; const diagnostics: AgentSessionRuntimeDiagnostic[] = [ + ...projectTrustDiagnostics, ...services.diagnostics, ...collectSettingsDiagnostics(settingsManager, "runtime creation"), ...resourceLoader.getExtensions().errors.map(({ path, error }) => ({ diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index c0e22282..f90cffab 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -70,6 +70,7 @@ import type { ExtensionUIContext, ExtensionUIDialogOptions, ExtensionWidgetOptions, + ProjectTrustContext, } from "../../core/extensions/index.ts"; import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.ts"; import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/http-dispatcher.ts"; @@ -1993,6 +1994,21 @@ export class InteractiveMode { /** * Create the ExtensionUIContext for extensions. */ + private createProjectTrustContext(cwd: string): ProjectTrustContext { + const ui = this.createExtensionUIContext(); + return { + cwd, + mode: "tui", + hasUI: true, + ui: { + select: ui.select, + confirm: ui.confirm, + input: ui.input, + notify: ui.notify, + }, + }; + } + private createExtensionUIContext(): ExtensionUIContext { return { select: (title, options, opts) => this.showExtensionSelector(title, options, opts), @@ -4569,6 +4585,7 @@ export class InteractiveMode { try { const result = await this.runtimeHost.switchSession(sessionPath, { withSession: options?.withSession, + projectTrustContextFactory: (cwd) => this.createProjectTrustContext(cwd), }); if (result.cancelled) { return result; @@ -4586,6 +4603,7 @@ export class InteractiveMode { const result = await this.runtimeHost.switchSession(sessionPath, { cwdOverride: selectedCwd, withSession: options?.withSession, + projectTrustContextFactory: (cwd) => this.createProjectTrustContext(cwd), }); if (result.cancelled) { return result; diff --git a/packages/coding-agent/test/resource-loader.test.ts b/packages/coding-agent/test/resource-loader.test.ts index 88dd5895..779f6064 100644 --- a/packages/coding-agent/test/resource-loader.test.ts +++ b/packages/coding-agent/test/resource-loader.test.ts @@ -187,6 +187,53 @@ Project skill`, expect(extensionsResult.extensions[0].path).toBe(join(cwd, ".pi", "extensions", "shared.ts")); }); + it("should load user extensions before trust and reuse them after trust resolves", async () => { + const userExtDir = join(agentDir, "extensions"); + const projectExtDir = join(cwd, ".pi", "extensions"); + mkdirSync(userExtDir, { recursive: true }); + mkdirSync(projectExtDir, { recursive: true }); + const loadCountKey = `__piTrustPreloadCount_${Date.now()}_${Math.random().toString(36).slice(2)}`; + const globalState = globalThis as typeof globalThis & Record; + + writeFileSync( + join(userExtDir, "user.ts"), + `globalThis[${JSON.stringify(loadCountKey)}] = (globalThis[${JSON.stringify(loadCountKey)}] ?? 0) + 1; +export default function(pi) { + pi.on("project_trust", () => ({ trusted: true })); + pi.registerCommand("user-trust", { + description: "user trust", + handler: async () => {}, + }); +}`, + ); + writeFileSync( + join(projectExtDir, "project.ts"), + `export default function(pi) { + pi.registerCommand("project-trusted", { + description: "project trusted", + handler: async () => {}, + }); +}`, + ); + + const loader = new DefaultResourceLoader({ cwd, agentDir }); + await loader.reload({ + resolveProjectTrust: async ({ extensionsResult }) => { + expect(extensionsResult.extensions.map((extension) => extension.path)).toEqual([ + join(userExtDir, "user.ts"), + ]); + return true; + }, + }); + + const extensionsResult = loader.getExtensions(); + expect(extensionsResult.extensions.map((extension) => extension.path)).toEqual([ + join(cwd, ".pi", "extensions", "project.ts"), + join(userExtDir, "user.ts"), + ]); + expect(globalState[loadCountKey]).toBe(1); + }); + it("should keep both extensions loaded when command names collide", async () => { const userExtDir = join(agentDir, "extensions"); const projectExtDir = join(cwd, ".pi", "extensions"); diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index f746ee68..9f6dda4b 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1282,15 +1282,17 @@ export class TUI extends Container { fullRender(true); return; } - if (extraLines > 0) { - buffer += "\x1b[1B"; + const clearStartOffset = newLines.length === 0 ? 0 : 1; + if (extraLines > 0 && clearStartOffset > 0) { + buffer += `\x1b[${clearStartOffset}B`; } for (let i = 0; i < extraLines; i++) { buffer += "\r\x1b[2K"; if (i < extraLines - 1) buffer += "\x1b[1B"; } - if (extraLines > 0) { - buffer += `\x1b[${extraLines}A`; + const moveBack = Math.max(0, extraLines - 1 + clearStartOffset); + if (moveBack > 0) { + buffer += `\x1b[${moveBack}A`; } buffer += "\x1b[?2026l"; this.terminal.write(buffer); diff --git a/packages/tui/test/tui-shrink.test.ts b/packages/tui/test/tui-shrink.test.ts new file mode 100644 index 00000000..13fa5c4b --- /dev/null +++ b/packages/tui/test/tui-shrink.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { type Component, TUI } from "../src/tui.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; + +class Lines implements Component { + private lines: string[]; + + constructor(lines: string[]) { + this.lines = lines; + } + + render(): string[] { + return this.lines; + } + + invalidate(): void {} +} + +describe("TUI shrinking content", () => { + it("clears all rendered lines when content shrinks to zero", async () => { + const terminal = new VirtualTerminal(40, 10); + const tui = new TUI(terminal); + const content = new Lines(["first", "second", "third"]); + tui.addChild(content); + tui.start(); + await terminal.waitForRender(); + + assert.ok(terminal.getViewport().some((line) => line.includes("first"))); + assert.ok(terminal.getViewport().some((line) => line.includes("second"))); + assert.ok(terminal.getViewport().some((line) => line.includes("third"))); + + tui.clear(); + tui.requestRender(); + await terminal.waitForRender(); + + const viewport = terminal.getViewport(); + assert.ok(!viewport.some((line) => line.includes("first")), "first line should be cleared"); + assert.ok(!viewport.some((line) => line.includes("second")), "second line should be cleared"); + assert.ok(!viewport.some((line) => line.includes("third")), "third line should be cleared"); + + tui.stop(); + }); +}); From 085a08582ff669ee8c196ee45d649775b2c500a4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 8 Jun 2026 13:27:03 +0200 Subject: [PATCH 180/352] fix(coding-agent): remove stale hooks export --- packages/coding-agent/package.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index f7635819..d53608ab 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -15,10 +15,6 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" - }, - "./hooks": { - "types": "./dist/core/hooks/index.d.ts", - "import": "./dist/core/hooks/index.js" } }, "files": [ From d8aef0feff2bf0ce6aa7f5769da2ed210c14ab74 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 8 Jun 2026 13:38:29 +0200 Subject: [PATCH 181/352] feat(coding-agent): allow project trust extensions to defer --- packages/coding-agent/CHANGELOG.md | 2 +- packages/coding-agent/docs/extensions.md | 7 +-- .../examples/extensions/project-trust.ts | 21 +++++---- .../coding-agent/src/core/extensions/index.ts | 1 + .../src/core/extensions/runner.ts | 5 ++- .../coding-agent/src/core/extensions/types.ts | 4 +- packages/coding-agent/src/index.ts | 1 + packages/coding-agent/src/main.ts | 5 ++- .../test/extensions-runner.test.ts | 43 ++++++++++++++++++- .../coding-agent/test/resource-loader.test.ts | 2 +- 10 files changed, 72 insertions(+), 19 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5acf40df..83b6fbc6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Added a `project_trust` extension event so global and CLI extensions can decide project trust during startup and runtime cwd switches. +- Added a `project_trust` extension event so global and CLI extensions can decide or defer project trust during startup and runtime cwd switches. - Added project trust gating for project-local settings, resources, instructions, and packages ([#5332](https://github.com/earendil-works/pi/pull/5332)). - Added the latest prompt cache hit rate to the interactive footer. - Exported RPC extension UI request and response types from the public API ([#5455](https://github.com/earendil-works/pi/issues/5455)). diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 53152ca0..601d378e 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -346,12 +346,13 @@ pi.on("project_trust", async (event, ctx) => { // event.cwd - current working directory // ctx has a limited trust context: cwd, mode, hasUI, and select/confirm/input/notify UI helpers if (await ctx.ui.confirm("Trust project?", event.cwd)) { - return { trusted: true, remember: true }; + return { trusted: "yes", remember: true }; } + return { trusted: "undecided" }; }); ``` -A `project_trust` handler must return `{ trusted }`; if a user/global or CLI extension registers this handler, it owns the decision. The first returned decision wins and suppresses the built-in trust prompt. Use `remember: true` to persist the decision; otherwise it applies only to the current process. Check `ctx.hasUI` before prompting. If no extension handles `project_trust`, normal trust resolution continues, including the built-in trust prompt when UI is available. +A `project_trust` handler must return `{ trusted: "yes" | "no" | "undecided" }`. A user/global or CLI extension that returns `"yes"` or `"no"` owns the decision; the first yes/no decision wins and suppresses the built-in trust prompt. Use `remember: true` to persist a yes/no decision; otherwise it applies only to the current process. Return `"undecided"` to let later handlers or the built-in trust flow decide. Check `ctx.hasUI` before prompting. If no handler returns yes/no, normal trust resolution continues, including the built-in trust prompt when UI is available. ### Resource Events @@ -2586,7 +2587,7 @@ All examples in [examples/extensions/](../examples/extensions/). | `shutdown-command.ts` | Graceful shutdown command | `registerCommand`, `shutdown()` | | **Events & Gates** ||| | `permission-gate.ts` | Block dangerous commands | `on("tool_call")`, `ui.confirm` | -| `project-trust.ts` | Decide project trust from a user/global or CLI extension | `on("project_trust")`, trust UI, required trust result | +| `project-trust.ts` | Decide or defer project trust from a user/global or CLI extension | `on("project_trust")`, trust UI, required trust result | | `protected-paths.ts` | Block writes to specific paths | `on("tool_call")` | | `confirm-destructive.ts` | Confirm session changes | `on("session_before_switch")`, `on("session_before_fork")` | | `dirty-repo-guard.ts` | Warn on dirty git repo | `on("session_before_*")`, `exec` | diff --git a/packages/coding-agent/examples/extensions/project-trust.ts b/packages/coding-agent/examples/extensions/project-trust.ts index 66020a54..6234bc24 100644 --- a/packages/coding-agent/examples/extensions/project-trust.ts +++ b/packages/coding-agent/examples/extensions/project-trust.ts @@ -20,13 +20,14 @@ export default function (pi: ExtensionAPI) { loadCount++; // Multiple handlers in one extension are allowed. The first handler that returns - // { trusted } wins and suppresses the built-in trust prompt. A project_trust - // handler must return a decision. + // { trusted: "yes" } or { trusted: "no" } wins and suppresses the built-in + // trust prompt. Return { trusted: "undecided" } to let another handler or the + // built-in flow decide. pi.on("project_trust", async (event, ctx): Promise => { ctx.ui.notify(`project_trust fired for ${event.cwd} (mode: ${ctx.mode}, load: ${loadCount})`, "info"); if (!ctx.hasUI) { - return { trusted: false }; + return { trusted: "undecided" }; } const choice = await ctx.ui.select(`Project trust for:\n${event.cwd}`, [ @@ -34,23 +35,27 @@ export default function (pi: ExtensionAPI) { "Trust with note and remember", "Trust this session", "Do not trust this session", + "Let built-in prompt decide", ]); if (choice === "Trust with note and remember") { const note = await ctx.ui.input("Project trust note", "Optional note for this demo"); ctx.ui.notify(note ? `Recorded demo note: ${note}` : "No demo note entered", "info"); - return { trusted: true, remember: true }; + return { trusted: "yes", remember: true }; } if (choice === "Trust and remember") { - return { trusted: true, remember: true }; + return { trusted: "yes", remember: true }; } if (choice === "Trust this session") { - return { trusted: true }; + return { trusted: "yes" }; } if (choice === "Do not trust this session") { - return { trusted: false }; + return { trusted: "no" }; } - return { trusted: false }; + if (choice === "Let built-in prompt decide") { + return { trusted: "undecided" }; + } + return { trusted: "undecided" }; }); pi.on("session_start", (_event, ctx) => { diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index a20887ed..3ed7a662 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -100,6 +100,7 @@ export type { ModelSelectSource, ProjectTrustContext, ProjectTrustEvent, + ProjectTrustEventDecision, ProjectTrustEventResult, ProjectTrustHandler, // Provider Registration diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index db4ba0be..3030b65d 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -202,13 +202,16 @@ export async function emitProjectTrustEvent( const errors: ExtensionError[] = []; for (const ext of extensionsResult.extensions) { // A single extension may register multiple handlers for the same event. - // The first project_trust handler that returns a decision wins. + // The first project_trust handler that returns yes/no wins; undecided falls through. const handlers = ext.handlers.get("project_trust"); if (!handlers || handlers.length === 0) continue; for (const handler of handlers) { try { const handlerResult = (await handler(event, ctx)) as ProjectTrustEventResult; + if (handlerResult.trusted === "undecided") { + continue; + } return { result: handlerResult, errors }; } catch (error) { errors.push({ diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 9fd3ffdf..7575d8af 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -503,8 +503,10 @@ export interface ProjectTrustEvent { cwd: string; } +export type ProjectTrustEventDecision = "yes" | "no" | "undecided"; + export interface ProjectTrustEventResult { - trusted: boolean; + trusted: ProjectTrustEventDecision; remember?: boolean; } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 677c030b..398f5430 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -100,6 +100,7 @@ export type { MessageRenderOptions, ProjectTrustContext, ProjectTrustEvent, + ProjectTrustEventDecision, ProjectTrustEventResult, ProjectTrustHandler, ProviderConfig, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 4387af6d..335d814a 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -648,10 +648,11 @@ async function resolveProjectTrusted(options: { options.onExtensionError?.(`Extension "${error.extensionPath}" project_trust error: ${error.error}`); } if (result) { + const trusted = result.trusted === "yes"; if (result.remember === true) { - options.trustStore.set(options.cwd, result.trusted); + options.trustStore.set(options.cwd, trusted); } - return result.trusted; + return trusted; } } diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index 0432ec39..f4939367 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -7,8 +7,8 @@ import * as os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.ts"; -import { createExtensionRuntime, discoverAndLoadExtensions } from "../src/core/extensions/loader.ts"; -import { ExtensionRunner } from "../src/core/extensions/runner.ts"; +import { createExtensionRuntime, discoverAndLoadExtensions, loadExtensions } from "../src/core/extensions/loader.ts"; +import { ExtensionRunner, emitProjectTrustEvent } from "../src/core/extensions/runner.ts"; import type { ExtensionActions, ExtensionContextActions, @@ -85,6 +85,45 @@ describe("ExtensionRunner", () => { getSystemPrompt: () => "", }; + describe("project_trust", () => { + it("continues past undecided handlers and returns the first yes/no decision", async () => { + const undecidedPath = path.join(extensionsDir, "undecided.ts"); + const decidedPath = path.join(extensionsDir, "decided.ts"); + fs.writeFileSync( + undecidedPath, + `export default function(pi) { + pi.on("project_trust", () => ({ trusted: "undecided", remember: true })); +}`, + ); + fs.writeFileSync( + decidedPath, + `export default function(pi) { + pi.on("project_trust", () => ({ trusted: "no", remember: true })); +}`, + ); + + const extensionsResult = await loadExtensions([undecidedPath, decidedPath], tempDir); + const result = await emitProjectTrustEvent( + extensionsResult, + { type: "project_trust", cwd: tempDir }, + { + cwd: tempDir, + mode: "tui", + hasUI: false, + ui: { + select: async () => undefined, + confirm: async () => false, + input: async () => undefined, + notify: () => {}, + }, + }, + ); + + expect(result.result).toEqual({ trusted: "no", remember: true }); + expect(result.errors).toEqual([]); + }); + }); + describe("shortcut conflicts", () => { it("warns when extension shortcut conflicts with built-in", async () => { const extCode = ` diff --git a/packages/coding-agent/test/resource-loader.test.ts b/packages/coding-agent/test/resource-loader.test.ts index 779f6064..abbe8975 100644 --- a/packages/coding-agent/test/resource-loader.test.ts +++ b/packages/coding-agent/test/resource-loader.test.ts @@ -199,7 +199,7 @@ Project skill`, join(userExtDir, "user.ts"), `globalThis[${JSON.stringify(loadCountKey)}] = (globalThis[${JSON.stringify(loadCountKey)}] ?? 0) + 1; export default function(pi) { - pi.on("project_trust", () => ({ trusted: true })); + pi.on("project_trust", () => ({ trusted: "yes" })); pi.registerCommand("user-trust", { description: "user trust", handler: async () => {}, From 2527441b40d5a9e870d22a6a482251c1582d5208 Mon Sep 17 00:00:00 2001 From: Eric Henry Date: Mon, 8 Jun 2026 08:32:16 -0500 Subject: [PATCH 182/352] Update generate-models.ts Removed together.ai MiniMaxAI/MiniMax-M2.5 due to it not being supported for serverless inference. Error: 400 Unable to access non-serverless model MiniMaxAI/MiniMax-M2.5. Please visit https://api.together.ai/models/MiniMaxAI/MiniMax-M2.5 to create and start a new dedicated endpoint for the model. --- packages/ai/scripts/generate-models.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index d10feb12..463e4df3 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -92,7 +92,6 @@ const TOGETHER_TOGGLE_REASONING_EFFORT_COMPAT: OpenAICompletionsCompat = { }; const TOGETHER_REASONING_ONLY_MODELS = new Set([ "deepseek-ai/DeepSeek-R1", - "MiniMaxAI/MiniMax-M2.5", "MiniMaxAI/MiniMax-M2.7", ]); const TOGETHER_REASONING_EFFORT_MODELS = new Set(["openai/gpt-oss-20b", "openai/gpt-oss-120b"]); From ce3a72444e1cc1eaa50475fb3378c7ffbb53ef49 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 8 Jun 2026 15:56:29 +0200 Subject: [PATCH 183/352] docs(coding-agent): document security model --- packages/coding-agent/docs/docs.json | 4 ++ packages/coding-agent/docs/index.md | 1 + packages/coding-agent/docs/security.md | 57 ++++++++++++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 packages/coding-agent/docs/security.md diff --git a/packages/coding-agent/docs/docs.json b/packages/coding-agent/docs/docs.json index f781abc2..bbc9e74b 100644 --- a/packages/coding-agent/docs/docs.json +++ b/packages/coding-agent/docs/docs.json @@ -19,6 +19,10 @@ "title": "Providers", "path": "providers.md" }, + { + "title": "Security", + "path": "security.md" + }, { "title": "Containerization", "path": "containerization.md" diff --git a/packages/coding-agent/docs/index.md b/packages/coding-agent/docs/index.md index 2a334e8a..71995831 100644 --- a/packages/coding-agent/docs/index.md +++ b/packages/coding-agent/docs/index.md @@ -41,6 +41,7 @@ For the full first-run flow, see [Quickstart](quickstart.md). - [Quickstart](quickstart.md) - install, authenticate, and run a first session. - [Using Pi](usage.md) - interactive mode, slash commands, context files, and CLI reference. - [Providers](providers.md) - subscription and API-key setup for built-in providers. +- [Security](security.md) - project trust, sandbox boundaries, and vulnerability reporting. - [Containerization](containerization.md) - sandbox pi with OpenShell, Gondolin, or Docker. - [Settings](settings.md) - global and project settings. - [Keybindings](keybindings.md) - default shortcuts and custom keybindings. diff --git a/packages/coding-agent/docs/security.md b/packages/coding-agent/docs/security.md new file mode 100644 index 00000000..1e70a2d5 --- /dev/null +++ b/packages/coding-agent/docs/security.md @@ -0,0 +1,57 @@ +# Security + +Pi is a local coding agent. It runs with the permissions of the user account that starts it, and it treats files writable by that user as inside the same local trust boundary. + +## Project Trust + +Project trust controls whether pi loads project-local inputs. It is not a sandbox and it does not restrict what the model can ask tools to do after you start working in a directory. + +Pi considers a project to have trust inputs when it finds any of these from the current working directory: + +- `.pi/` in the current directory +- `AGENTS.md` or `CLAUDE.md` in the current directory or an ancestor directory +- `.agents/skills` in the current directory or an ancestor directory + +When an interactive session starts in a project with trust inputs and no saved decision, pi asks whether to trust the project. Saved decisions are stored per canonical working directory in `~/.pi/agent/trust.json`. + +Trusting a project allows pi to load project-local inputs, including: + +- project instructions from `AGENTS.md` or `CLAUDE.md` +- `.pi/settings.json` +- `.pi` resources such as extensions, skills, prompt templates, themes, and system prompt files +- missing project packages configured through project settings +- project-local extensions and project package-managed extensions + +Declining trust skips those project-local inputs. Before trust is resolved, pi only loads user/global extensions and CLI `-e` extensions. User/global and CLI extensions can handle the `project_trust` event; the first extension that returns a yes/no decision owns the decision. + +Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without a saved trust decision, they ignore project-local inputs unless `--approve`/`-a` is passed. Use `--no-approve`/`-na` to ignore project-local inputs for one run even when the project is trusted. + +## No Built-in Sandbox + +Pi does not include a built-in sandbox. Built-in tools can read files, write files, edit files, and run shell commands with the permissions of the pi process. Extensions are TypeScript modules that run with the same permissions. Package installs, shell commands, language servers, test commands, and other developer tools behave as ordinary local processes. + +This is intentional. Pi is designed to operate on local source trees, invoke project toolchains, and integrate with the user's existing development environment. A partial in-process sandbox would be easy to misunderstand as a security boundary while still depending on the host shell, filesystem, package managers, credentials, and extension code. Real isolation needs to come from the operating system or a virtualization/container boundary. + +Project trust is only an input-loading guard. It prevents a repository from silently changing pi's instructions, settings, or extensions before you approve it. It does not make untrusted code, untrusted prompts, or untrusted model output safe. Prompt injection from repository files, comments, documentation, or build output is expected local-agent risk and cannot be reliably prevented by pi. + +## Running Untrusted or Unmonitored Work + +For untrusted repositories, generated code you do not intend to monitor closely, or unattended automation, run pi in a contained environment. Use a container, VM, micro-VM, remote sandbox, or policy-controlled sandbox with only the files and credentials required for the task. + +Common patterns are documented in [Containerization](containerization.md): + +- run the whole `pi` process inside OpenShell or Docker +- run host pi while routing built-in tool execution into a Gondolin micro-VM +- mount only the workspace paths the agent should access +- avoid mounting host `~/.pi/agent` unless the container should access host sessions, settings, and credentials +- pass the minimum required API keys or use short-lived credentials +- restrict network access when the task does not need it +- review diffs and outputs before copying results back to trusted systems + +If you bind-mount a host workspace read/write, writes from inside the container or VM can still modify host files. Use read-only mounts or copy files into and out of the sandbox when you need stronger protection from unintended writes. + +## Reporting Security Issues + +To report a security issue, follow the repository [Security Policy](https://github.com/earendil-works/pi-mono/blob/main/SECURITY.md). Do not open a public issue for security-sensitive reports. + +Expected local-agent behavior, lack of a built-in sandbox, prompt injection from untrusted content, and behavior of user-installed extensions or skills are generally outside the security boundary unless the report demonstrates a real privilege-boundary bypass or shows how pi grants access that the local user did not already have. From 35120d7e48dbaae38593cec6931f0795fdacd354 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 8 Jun 2026 16:01:13 +0200 Subject: [PATCH 184/352] docs: audit unreleased changelogs --- packages/coding-agent/CHANGELOG.md | 17 ++++++++++++++++- packages/tui/CHANGELOG.md | 2 ++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 83b6fbc6..9c7b82c7 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +### New Features + +- **Project trust for local inputs** - Pi now asks before loading project-local settings, resources, instructions, and packages, with saved decisions and `--approve` / `--no-approve` controls for non-interactive modes. See [Project Trust](README.md#project-trust). +- **Extension-controlled trust decisions** - Global and CLI extensions can handle `project_trust`, decide, remember, or defer project trust before project-local resources load. See [`project_trust`](docs/extensions.md#project_trust). +- **Cache-hit visibility in the footer** - The interactive footer now shows the latest prompt cache hit rate (`CH`). See [Interactive Mode](README.md#interactive-mode). +- **Richer SDK and RPC extension surfaces** - Public exports now include RPC extension UI request/response types and package asset path helpers. See [Extension UI Protocol](docs/rpc.md#extension-ui-protocol) and [SDK Exports](docs/sdk.md#exports). + ### Added - Added a `project_trust` extension event so global and CLI extensions can decide or defer project trust during startup and runtime cwd switches. @@ -12,11 +19,19 @@ ### Fixed +- Fixed package exports by removing the stale `./hooks` subpath that pointed at non-existent build output. +- Fixed inherited TUI rendering to clear stale lines when content shrinks to zero. +- Fixed inherited autocomplete suggestions to refresh after editor cursor movement ([#5499](https://github.com/earendil-works/pi/pull/5499) by [@Roman-Galeev](https://github.com/Roman-Galeev)). - Fixed `/reload` to persist project trust when an implicitly trusted session creates a project `.pi` directory. +- Fixed project trust input discovery to traverse parent directories portably. +- Fixed inherited intermittent Shift+Enter handling by making Kitty keyboard protocol fallback response-driven instead of timeout-driven ([#5188](https://github.com/earendil-works/pi/issues/5188)). - Fixed the compaction summarization system prompt to use neutral AI assistant wording for non-coding agents ([#5401](https://github.com/earendil-works/pi/issues/5401)). -- Fixed `models.json` schema support for OpenAI Responses `compat.supportsDeveloperRole` ([#5456](https://github.com/earendil-works/pi/issues/5456)). +- Fixed `models.json` schema support and inherited OpenAI Responses custom-provider handling for `compat.supportsDeveloperRole: false` ([#5456](https://github.com/earendil-works/pi/issues/5456)). +- Fixed inherited prompt history navigation to place the cursor at the start when browsing upward and at the end when browsing downward ([#5454](https://github.com/earendil-works/pi/issues/5454)). - Fixed tmux setup documentation to require tmux 3.5 for `extended-keys-format csi-u` and document the tmux 3.2-3.4 fallback ([#5432](https://github.com/earendil-works/pi/issues/5432)). +- Fixed inherited OpenRouter routing preferences on OpenAI-compatible custom providers to work when the custom provider base URL does not point directly at OpenRouter ([#5347](https://github.com/earendil-works/pi/issues/5347)). - Fixed built-in tool expand hints to style closing parentheses consistently ([#5359](https://github.com/earendil-works/pi/issues/5359)). +- Fixed skill-wrapped prompts to insert spacing between skill instructions and the user message ([#5371](https://github.com/earendil-works/pi/pull/5371) by [@Perlence](https://github.com/Perlence)). ## [0.78.1] - 2026-06-04 diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 3682b529..03025780 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -6,6 +6,8 @@ - Fixed prompt history navigation to place the cursor at the start when browsing upward and at the end when browsing downward, so repeated Up/Down traverses multiline prompts immediately ([#5454](https://github.com/earendil-works/pi/issues/5454)). - Fixed intermittent Shift+Enter handling by making Kitty keyboard protocol fallback response-driven instead of timeout-driven ([#5188](https://github.com/earendil-works/pi/issues/5188)). +- Fixed TUI rendering to clear stale lines when content shrinks to zero. +- Fixed autocomplete suggestions to re-query after editor cursor movement ([#5499](https://github.com/earendil-works/pi/pull/5499) by [@Roman-Galeev](https://github.com/Roman-Galeev)). ## [0.78.1] - 2026-06-04 From c10fb95fd993a1f6a92d5f770258fada1a20b55a Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 8 Jun 2026 17:15:51 +0200 Subject: [PATCH 185/352] Release v0.79.0 --- package-lock.json | 50 ++- packages/agent/CHANGELOG.md | 2 +- packages/agent/package.json | 4 +- packages/ai/CHANGELOG.md | 2 +- packages/ai/package.json | 2 +- packages/ai/src/image-models.generated.ts | 30 ++ packages/ai/src/models.generated.ts | 323 +++++++++--------- 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 +- packages/coding-agent/npm-shrinkwrap.json | 24 +- packages/coding-agent/package.json | 8 +- packages/tui/CHANGELOG.md | 2 +- packages/tui/package.json | 2 +- 21 files changed, 269 insertions(+), 208 deletions(-) diff --git a/package-lock.json b/package-lock.json index 65e743a5..97a05b50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6170,10 +6170,10 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.78.1", + "version": "0.79.0", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.78.1", + "@earendil-works/pi-ai": "^0.79.0", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -6188,6 +6188,30 @@ "node": ">=22.19.0" } }, + "packages/agent/node_modules/@earendil-works/pi-ai": { + "version": "0.78.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.78.1.tgz", + "integrity": "sha512-CM2pkTs1iupG/maw381lC9Q/Y/aQaMGK7GILc28ttImD0ci3LDwKroDsGkWbly5JIy3iqxdRxB9JlG7vvzCzTg==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.1", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.1.38" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, "packages/agent/node_modules/@types/node": { "version": "24.12.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", @@ -6207,7 +6231,7 @@ }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.78.1", + "version": "0.79.0", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -6252,12 +6276,12 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "0.78.1", + "version": "0.79.0", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.78.1", - "@earendil-works/pi-ai": "^0.78.1", - "@earendil-works/pi-tui": "^0.78.1", + "@earendil-works/pi-agent-core": "^0.79.0", + "@earendil-works/pi-ai": "^0.79.0", + "@earendil-works/pi-tui": "^0.79.0", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -6296,32 +6320,32 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.78.1", + "version": "0.79.0", "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.78.1" + "version": "0.79.0" }, "packages/coding-agent/examples/extensions/gondolin": { "name": "pi-extension-gondolin", - "version": "0.78.1", + "version": "0.79.0", "dependencies": { "@earendil-works/gondolin": "0.12.0" } }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.8.1", + "version": "1.9.0", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.78.1", + "version": "0.79.0", "dependencies": { "ms": "2.1.3" }, @@ -6357,7 +6381,7 @@ }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.78.1", + "version": "0.79.0", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index bdfa5474..6fb973c9 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.0] - 2026-06-08 ### Fixed diff --git a/packages/agent/package.json b/packages/agent/package.json index fc4a5478..43eeb060 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "0.78.1", + "version": "0.79.0", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -29,7 +29,7 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "^0.78.1", + "@earendil-works/pi-ai": "^0.79.0", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b72f4718..dd4ef7cd 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.0] - 2026-06-08 ### Fixed diff --git a/packages/ai/package.json b/packages/ai/package.json index e62c370d..8eca757e 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.78.1", + "version": "0.79.0", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/ai/src/image-models.generated.ts b/packages/ai/src/image-models.generated.ts index 3402e613..5038303d 100644 --- a/packages/ai/src/image-models.generated.ts +++ b/packages/ai/src/image-models.generated.ts @@ -440,6 +440,36 @@ export const IMAGE_MODELS = { cacheWrite: 0, }, } satisfies ImagesModel<"openrouter-images">, + "sourceful/riverflow-v2.5-fast:free": { + id: "sourceful/riverflow-v2.5-fast:free", + name: "Sourceful: Riverflow V2.5 Fast (free)", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + output: ["image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, + "sourceful/riverflow-v2.5-pro:free": { + id: "sourceful/riverflow-v2.5-pro:free", + name: "Sourceful: Riverflow V2.5 Pro (free)", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + output: ["image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, "x-ai/grok-imagine-image-quality": { id: "x-ai/grok-imagine-image-quality", name: "xAI: Grok Imagine Image Quality", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 32966490..07d06ef5 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -1089,6 +1089,59 @@ export const MODELS = { contextWindow: 262144, maxTokens: 131072, } satisfies Model<"bedrock-converse-stream">, + "openai.gpt-5.4": { + id: "openai.gpt-5.4", + name: "GPT-5.4", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 2.75, + output: 16.5, + cacheRead: 0.275, + cacheWrite: 0, + }, + contextWindow: 272000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, + "openai.gpt-5.5": { + id: "openai.gpt-5.5", + name: "GPT-5.5", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5.5, + output: 33, + cacheRead: 0.55, + cacheWrite: 0, + }, + contextWindow: 272000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, + "openai.gpt-oss-120b": { + id: "openai.gpt-oss-120b", + name: "gpt-oss-120b", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"bedrock-converse-stream">, "openai.gpt-oss-120b-1:0": { id: "openai.gpt-oss-120b-1:0", name: "gpt-oss-120b", @@ -1106,6 +1159,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"bedrock-converse-stream">, + "openai.gpt-oss-20b": { + id: "openai.gpt-oss-20b", + name: "gpt-oss-20b", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text"], + cost: { + input: 0.07, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"bedrock-converse-stream">, "openai.gpt-oss-20b-1:0": { id: "openai.gpt-oss-20b-1:0", name: "gpt-oss-20b", @@ -3890,6 +3960,24 @@ export const MODELS = { contextWindow: 202800, maxTokens: 131072, } satisfies Model<"anthropic-messages">, + "accounts/fireworks/routers/kimi-k2p6-fast": { + id: "accounts/fireworks/routers/kimi-k2p6-fast", + name: "Kimi K2.6 Fast", + api: "anthropic-messages", + provider: "fireworks", + baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 8, + cacheRead: 0.3, + cacheWrite: 0, + }, + contextWindow: 262000, + maxTokens: 262000, + } satisfies Model<"anthropic-messages">, "accounts/fireworks/routers/kimi-k2p6-turbo": { id: "accounts/fireworks/routers/kimi-k2p6-turbo", name: "Kimi K2.6 Turbo", @@ -6022,11 +6110,11 @@ export const MODELS = { api: "mistral-conversations", provider: "mistral", baseUrl: "https://api.mistral.ai", - reasoning: true, + reasoning: false, input: ["text", "image"], cost: { - input: 1.5, - output: 7.5, + input: 0.4, + output: 2, cacheRead: 0, cacheWrite: 0, }, @@ -6708,6 +6796,25 @@ export const MODELS = { contextWindow: 262144, maxTokens: 262144, } satisfies Model<"openai-completions">, + "nvidia/nemotron-3-ultra-550b-a55b": { + id: "nvidia/nemotron-3-ultra-550b-a55b", + name: "Nemotron 3 Ultra 550B A55B", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0.5, + output: 2.5, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "nvidia/nvidia-nemotron-nano-9b-v2": { id: "nvidia/nvidia-nemotron-nano-9b-v2", name: "nvidia-nemotron-nano-9b-v2", @@ -8353,23 +8460,6 @@ export const MODELS = { contextWindow: 204800, maxTokens: 131072, } satisfies Model<"openai-completions">, - "minimax-m3-free": { - id: "minimax-m3-free", - name: "MiniMax M3 Free", - api: "anthropic-messages", - provider: "opencode", - baseUrl: "https://opencode.ai/zen", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 32000, - } satisfies Model<"anthropic-messages">, "nemotron-3-ultra-free": { id: "nemotron-3-ultra-free", name: "Nemotron 3 Ultra Free", @@ -8608,9 +8698,9 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.6, - output: 2.4, - cacheRead: 0.12, + input: 0.3, + output: 1.2, + cacheRead: 0.06, cacheWrite: 0, }, contextWindow: 512000, @@ -8631,7 +8721,7 @@ export const MODELS = { cacheRead: 0.05, cacheWrite: 0.625, }, - contextWindow: 262144, + contextWindow: 1000000, maxTokens: 65536, } satisfies Model<"openai-completions">, "qwen3.7-max": { @@ -8665,7 +8755,7 @@ export const MODELS = { cacheRead: 0.04, cacheWrite: 0.5, }, - contextWindow: 262144, + contextWindow: 1000000, maxTokens: 65536, } satisfies Model<"anthropic-messages">, }, @@ -9101,23 +9191,6 @@ export const MODELS = { contextWindow: 2000000, maxTokens: 30000, } satisfies Model<"openai-completions">, - "baidu/ernie-4.5-vl-28b-a3b": { - id: "baidu/ernie-4.5-vl-28b-a3b", - name: "Baidu: ERNIE 4.5 VL 28B A3B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.14, - output: 0.56, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8000, - } satisfies Model<"openai-completions">, "bytedance-seed/seed-1.6": { id: "bytedance-seed/seed-1.6", name: "ByteDance Seed: Seed 1.6", @@ -9624,8 +9697,8 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.04, - output: 0.13, + input: 0.049999999999999996, + output: 0.15, cacheRead: 0, cacheWrite: 0, }, @@ -9693,12 +9766,12 @@ export const MODELS = { input: ["text", "image"], cost: { input: 0.12, - output: 0.37, - cacheRead: 0, + output: 0.36, + cacheRead: 0.09, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 16384, + maxTokens: 8192, } satisfies Model<"openai-completions">, "google/gemma-4-31b-it:free": { id: "google/gemma-4-31b-it:free", @@ -9847,7 +9920,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.02, - output: 0.049999999999999996, + output: 0.03, cacheRead: 0, cacheWrite: 0, }, @@ -9914,7 +9987,7 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.08, + input: 0.09999999999999999, output: 0.3, cacheRead: 0, cacheWrite: 0, @@ -10005,7 +10078,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 204800, - maxTokens: 131072, + maxTokens: 196608, } satisfies Model<"openai-completions">, "minimax/minimax-m3": { id: "minimax/minimax-m3", @@ -10391,13 +10464,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.684, - output: 3.42, - cacheRead: 0.144, + input: 0.6799999999999999, + output: 3.41, + cacheRead: 0.33999999999999997, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 262142, } satisfies Model<"openai-completions">, "moonshotai/kimi-k2.6:free": { id: "moonshotai/kimi-k2.6:free", @@ -10417,23 +10490,6 @@ export const MODELS = { contextWindow: 262144, maxTokens: 4096, } satisfies Model<"openai-completions">, - "nex-agi/deepseek-v3.1-nex-n1": { - id: "nex-agi/deepseek-v3.1-nex-n1", - name: "Nex AGI: DeepSeek V3.1 Nex N1", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.135, - output: 0.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 163840, - } satisfies Model<"openai-completions">, "nvidia/llama-3.3-nemotron-super-49b-v1.5": { id: "nvidia/llama-3.3-nemotron-super-49b-v1.5", name: "NVIDIA: Llama 3.3 Nemotron Super 49B V1.5", @@ -10443,7 +10499,7 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.09999999999999999, + input: 0.39999999999999997, output: 0.39999999999999997, cacheRead: 0, cacheWrite: 0, @@ -10689,23 +10745,6 @@ export const MODELS = { contextWindow: 8191, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-4-1106-preview": { - id: "openai/gpt-4-1106-preview", - name: "OpenAI: GPT-4 Turbo (older v1106)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 10, - output: 30, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-4-turbo": { id: "openai/gpt-4-turbo", name: "OpenAI: GPT-4 Turbo", @@ -11781,7 +11820,7 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.071, + input: 0.09, output: 0.09999999999999999, cacheRead: 0, cacheWrite: 0, @@ -11815,13 +11854,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.09, - output: 0.44999999999999996, + input: 0.12, + output: 0.5, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 20000, + maxTokens: 16384, } satisfies Model<"openai-completions">, "qwen/qwen3-30b-a3b-instruct-2507": { id: "qwen/qwen3-30b-a3b-instruct-2507", @@ -12274,13 +12313,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.04, + input: 0.09999999999999999, output: 0.15, cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 81920, + maxTokens: 262144, } satisfies Model<"openai-completions">, "qwen/qwen3.5-flash-02-23": { id: "qwen/qwen3.5-flash-02-23", @@ -12342,13 +12381,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.29, - output: 3.1999999999999997, + input: 0.28900000000000003, + output: 2.4, cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262140, + maxTokens: 131072, } satisfies Model<"openai-completions">, "qwen/qwen3.6-35b-a3b": { id: "qwen/qwen3.6-35b-a3b", @@ -12486,23 +12525,6 @@ export const MODELS = { contextWindow: 256000, maxTokens: 128000, } satisfies Model<"openai-completions">, - "sao10k/l3-euryale-70b": { - id: "sao10k/l3-euryale-70b", - name: "Sao10k: Llama 3 Euryale 70B v2.1", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1.48, - output: 1.48, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 8192, - } satisfies Model<"openai-completions">, "sao10k/l3.1-euryale-70b": { id: "sao10k/l3.1-euryale-70b", name: "Sao10K: Llama 3.1 Euryale 70B v2.2", @@ -13039,13 +13061,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.684, - output: 3.42, - cacheRead: 0.144, + input: 0.6799999999999999, + output: 3.41, + cacheRead: 0.33999999999999997, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 262142, } satisfies Model<"openai-completions">, "~openai/gpt-latest": { id: "~openai/gpt-latest", @@ -13236,7 +13258,7 @@ export const MODELS = { } satisfies Model<"openai-completions">, "deepseek-ai/DeepSeek-V3": { id: "deepseek-ai/DeepSeek-V3", - name: "DeepSeek V3", + name: "DeepSeek-V3", api: "openai-completions", provider: "together", baseUrl: "https://api.together.ai/v1", @@ -13384,6 +13406,25 @@ export const MODELS = { contextWindow: 262144, maxTokens: 131000, } satisfies Model<"openai-completions">, + "nvidia/nemotron-3-ultra-550b-a55b": { + id: "nvidia/nemotron-3-ultra-550b-a55b", + name: "Nemotron 3 Ultra 550B A55B", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text"], + cost: { + input: 0.6, + output: 3.6, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 512300, + maxTokens: 512300, + } satisfies Model<"openai-completions">, "openai/gpt-oss-120b": { id: "openai/gpt-oss-120b", name: "GPT OSS 120B", @@ -14213,40 +14254,6 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 384000, } satisfies Model<"anthropic-messages">, - "google/gemini-2.0-flash": { - id: "google/gemini-2.0-flash", - name: "Gemini 2.0 Flash", - api: "anthropic-messages", - provider: "vercel-ai-gateway", - baseUrl: "https://ai-gateway.vercel.sh", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.15, - output: 0.6, - cacheRead: 0.024999999999999998, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 8192, - } satisfies Model<"anthropic-messages">, - "google/gemini-2.0-flash-lite": { - id: "google/gemini-2.0-flash-lite", - name: "Gemini 2.0 Flash Lite", - api: "anthropic-messages", - provider: "vercel-ai-gateway", - baseUrl: "https://ai-gateway.vercel.sh", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.075, - output: 0.3, - cacheRead: 0.02, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 8192, - } satisfies Model<"anthropic-messages">, "google/gemini-2.5-flash": { id: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash", @@ -15089,12 +15096,12 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.5, - output: 2.5, - cacheRead: 0.15, + input: 0.6, + output: 2.4, + cacheRead: 0.12, cacheWrite: 0, }, - contextWindow: 262144, + contextWindow: 1000000, maxTokens: 65000, } satisfies Model<"anthropic-messages">, "nvidia/nemotron-nano-12b-v2-vl": { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9c7b82c7..cadde9e3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.0] - 2026-06-08 ### 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 71342f77..94c7d0e9 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.78.1", + "version": "0.79.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "0.78.1", + "version": "0.79.0", "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 40e82831..47652ec4 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.78.1", + "version": "0.79.0", "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 df981282..0fead953 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.78.1", + "version": "0.79.0", "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 94a45117..58db0ca9 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.78.1", + "version": "0.79.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-gondolin", - "version": "0.78.1", + "version": "0.79.0", "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 c6c68530..941449cb 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.78.1", + "version": "0.79.0", "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 493ce950..e80892eb 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.8.1", + "version": "1.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.8.1", + "version": "1.9.0", "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 7614a82d..f90bf797 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.8.1", + "version": "1.9.0", "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 1c16ed45..62508a4a 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.78.1", + "version": "0.79.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "0.78.1", + "version": "0.79.0", "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 03fc01e7..1ba546c5 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.78.1", + "version": "0.79.0", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index c52c882b..4c5dd554 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -1,17 +1,17 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.78.1", + "version": "0.79.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent", - "version": "0.78.1", + "version": "0.79.0", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.78.1", - "@earendil-works/pi-ai": "^0.78.1", - "@earendil-works/pi-tui": "^0.78.1", + "@earendil-works/pi-agent-core": "^0.79.0", + "@earendil-works/pi-ai": "^0.79.0", + "@earendil-works/pi-tui": "^0.79.0", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -473,11 +473,11 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.78.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.78.1.tgz", + "version": "0.79.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.0.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.78.1", + "@earendil-works/pi-ai": "^0.79.0", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -487,8 +487,8 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.78.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.78.1.tgz", + "version": "0.79.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.0.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -510,8 +510,8 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.78.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.78.1.tgz", + "version": "0.79.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.0.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 d53608ab..154dcc34 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.78.1", + "version": "0.79.0", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -36,9 +36,9 @@ "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" }, "dependencies": { - "@earendil-works/pi-agent-core": "^0.78.1", - "@earendil-works/pi-ai": "^0.78.1", - "@earendil-works/pi-tui": "^0.78.1", + "@earendil-works/pi-agent-core": "^0.79.0", + "@earendil-works/pi-ai": "^0.79.0", + "@earendil-works/pi-tui": "^0.79.0", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 03025780..98c618b3 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.0] - 2026-06-08 ### Fixed diff --git a/packages/tui/package.json b/packages/tui/package.json index 224132e2..0c3189a1 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-tui", - "version": "0.78.1", + "version": "0.79.0", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", From 2edd6b432a4e1eed0a70270540d9a78d12aea7e9 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 8 Jun 2026 17:15:54 +0200 Subject: [PATCH 186/352] Add [Unreleased] section for next cycle --- packages/agent/CHANGELOG.md | 2 ++ packages/ai/CHANGELOG.md | 2 ++ packages/coding-agent/CHANGELOG.md | 2 ++ packages/tui/CHANGELOG.md | 2 ++ 4 files changed, 8 insertions(+) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 6fb973c9..860fdb43 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.0] - 2026-06-08 ### Fixed diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index dd4ef7cd..f5890870 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.0] - 2026-06-08 ### Fixed diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index cadde9e3..7f7bb06d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.0] - 2026-06-08 ### New Features diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 98c618b3..e575eb27 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.0] - 2026-06-08 ### Fixed From 20b78eafb4e71c42b2ba1f28dc191454b8b568aa Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 8 Jun 2026 20:31:20 +0200 Subject: [PATCH 187/352] fix(coding-agent): fix changelog links Fixes #5516 --- .github/workflows/build-binaries.yml | 54 ++- package.json | 1 + packages/coding-agent/CHANGELOG.md | 4 + .../src/modes/interactive/interactive-mode.ts | 6 +- packages/coding-agent/src/utils/changelog.ts | 97 +++++ packages/coding-agent/test/changelog.test.ts | 49 +++ scripts/release-notes.mjs | 364 ++++++++++++++++++ 7 files changed, 543 insertions(+), 32 deletions(-) create mode 100644 packages/coding-agent/test/changelog.test.ts create mode 100644 scripts/release-notes.mjs diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 622888b3..4a081581 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -51,41 +51,37 @@ jobs: run: | VERSION="${RELEASE_TAG}" VERSION="${VERSION#v}" # Remove 'v' prefix - - # Extract changelog section for this version - cd packages/coding-agent - awk "/^## \[${VERSION}\]/{flag=1; next} /^## \[/{flag=0} flag" CHANGELOG.md > /tmp/release-notes.md - - # If empty, use a default message - if [ ! -s /tmp/release-notes.md ]; then - echo "Release ${VERSION}" > /tmp/release-notes.md - fi + node scripts/release-notes.mjs extract --version "${VERSION}" --tag "${RELEASE_TAG}" --out /tmp/release-notes.md - name: Create GitHub Release and upload binaries env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | cd packages/coding-agent/binaries - - # Create release with changelog notes (or update if exists) - gh release create "${RELEASE_TAG}" \ - --title "${RELEASE_TAG}" \ - --notes-file /tmp/release-notes.md \ - pi-darwin-arm64.tar.gz \ - pi-darwin-x64.tar.gz \ - pi-linux-x64.tar.gz \ - pi-linux-arm64.tar.gz \ - pi-windows-x64.zip \ - pi-windows-arm64.zip \ - 2>/dev/null || \ - gh release upload "${RELEASE_TAG}" \ - pi-darwin-arm64.tar.gz \ - pi-darwin-x64.tar.gz \ - pi-linux-x64.tar.gz \ - pi-linux-arm64.tar.gz \ - pi-windows-x64.zip \ - pi-windows-arm64.zip \ - --clobber + + if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then + gh release edit "${RELEASE_TAG}" \ + --title "${RELEASE_TAG}" \ + --notes-file /tmp/release-notes.md + gh release upload "${RELEASE_TAG}" \ + pi-darwin-arm64.tar.gz \ + pi-darwin-x64.tar.gz \ + pi-linux-x64.tar.gz \ + pi-linux-arm64.tar.gz \ + pi-windows-x64.zip \ + pi-windows-arm64.zip \ + --clobber + else + gh release create "${RELEASE_TAG}" \ + --title "${RELEASE_TAG}" \ + --notes-file /tmp/release-notes.md \ + pi-darwin-arm64.tar.gz \ + pi-darwin-x64.tar.gz \ + pi-linux-x64.tar.gz \ + pi-linux-arm64.tar.gz \ + pi-windows-x64.zip \ + pi-windows-arm64.zip + fi publish-npm: runs-on: ubuntu-latest diff --git a/package.json b/package.json index ed9dbec9..c88d7123 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "release:patch": "node scripts/release.mjs patch", "release:minor": "node scripts/release.mjs minor", "release:major": "node scripts/release.mjs major", + "release:fix-links": "node scripts/release-notes.mjs fix-github-releases", "prepare": "husky" }, "devDependencies": { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7f7bb06d..5cae58f8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed GitHub release notes and interactive changelog links to resolve package-relative documentation URLs correctly ([#5516](https://github.com/earendil-works/pi/issues/5516)). + ## [0.79.0] - 2026-06-08 ### New Features diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index f90cffab..193fb70c 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -87,7 +87,7 @@ import type { SourceInfo } from "../../core/source-info.ts"; import { isInstallTelemetryEnabled } from "../../core/telemetry.ts"; import type { TruncationResult } from "../../core/tools/truncate.ts"; import { hasProjectConfigDir, hasProjectTrustInputs, ProjectTrustStore } from "../../core/trust-manager.ts"; -import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.ts"; +import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts"; import { copyToClipboard } from "../../utils/clipboard.ts"; import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts"; import { parseGitUrl } from "../../utils/git.ts"; @@ -909,7 +909,7 @@ export class InteractiveMode { if (newEntries.length > 0) { this.settingsManager.setLastChangelogVersion(VERSION); this.reportInstallTelemetry(VERSION); - return newEntries.map((e) => e.content).join("\n\n"); + return newEntries.map((e) => normalizeChangelogLinks(e.content, e)).join("\n\n"); } return undefined; @@ -5380,7 +5380,7 @@ export class InteractiveMode { allEntries.length > 0 ? allEntries .reverse() - .map((e) => e.content) + .map((e) => normalizeChangelogLinks(e.content, e)) .join("\n\n") : "No changelog entries found."; diff --git a/packages/coding-agent/src/utils/changelog.ts b/packages/coding-agent/src/utils/changelog.ts index b9e8e35d..2c8ce4a6 100644 --- a/packages/coding-agent/src/utils/changelog.ts +++ b/packages/coding-agent/src/utils/changelog.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { existsSync, readFileSync } from "fs"; export interface ChangelogEntry { @@ -7,6 +8,102 @@ export interface ChangelogEntry { content: string; } +const GITHUB_REPO = "earendil-works/pi"; +const CHANGELOG_LINK_BASE_PATH = "packages/coding-agent"; +const LEGACY_REPO_RE = /^https:\/\/github\.com\/(?:badlogic|earendil-works)\/pi-mono(?=\/|$)/; +const URL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i; +const INLINE_MARKDOWN_LINK_RE = /(!?\[[^\]\n]+\]\()([^\s)]+)((?:\s+[^)]*)?\))/g; + +function entryVersion(entry: ChangelogEntry): string { + return `${entry.major}.${entry.minor}.${entry.patch}`; +} + +function normalizeTag(version: string | ChangelogEntry): string { + const versionString = typeof version === "string" ? version : entryVersion(version); + return versionString.startsWith("v") ? versionString : `v${versionString}`; +} + +function splitLocalTarget(target: string): { fragment: string; pathPart: string; query: string } { + const hashIndex = target.indexOf("#"); + const beforeHash = hashIndex === -1 ? target : target.slice(0, hashIndex); + const fragment = hashIndex === -1 ? "" : target.slice(hashIndex); + const queryIndex = beforeHash.indexOf("?"); + + if (queryIndex === -1) { + return { fragment, pathPart: beforeHash, query: "" }; + } + + return { + fragment, + pathPart: beforeHash.slice(0, queryIndex), + query: beforeHash.slice(queryIndex), + }; +} + +function normalizePathPart(value: string): string { + return value.replaceAll("\\", "/"); +} + +function resolveRepositoryPath(targetPath: string): string | undefined { + const normalizedTarget = normalizePathPart(targetPath); + const joined = normalizedTarget.startsWith("/") + ? path.posix.normalize(normalizedTarget.replace(/^\/+/, "")) + : path.posix.normalize(path.posix.join(CHANGELOG_LINK_BASE_PATH, normalizedTarget)); + + if (joined === "." || joined.startsWith("../") || joined === "..") { + return undefined; + } + + return joined; +} + +function isDirectoryTarget(originalPath: string, repositoryPath: string): boolean { + if (originalPath.endsWith("/")) { + return true; + } + + const basename = path.posix.basename(repositoryPath); + return !basename.includes("."); +} + +function normalizeChangelogLinkTarget(target: string, tag: string): string { + let canonicalTarget = target.replace(LEGACY_REPO_RE, `https://github.com/${GITHUB_REPO}`); + const repoUrl = `https://github.com/${GITHUB_REPO}`; + + for (const route of ["blob", "tree"]) { + for (const branch of ["main", "master"]) { + const floatingRefPrefix = `${repoUrl}/${route}/${branch}/`; + if (canonicalTarget.startsWith(floatingRefPrefix)) { + canonicalTarget = `${repoUrl}/${route}/${tag}/${canonicalTarget.slice(floatingRefPrefix.length)}`; + } + } + } + + if (canonicalTarget.startsWith("#") || canonicalTarget.startsWith("//") || URL_SCHEME_RE.test(canonicalTarget)) { + return canonicalTarget; + } + + const { fragment, pathPart, query } = splitLocalTarget(canonicalTarget); + if (!pathPart) { + return canonicalTarget; + } + + const repositoryPath = resolveRepositoryPath(pathPart); + if (!repositoryPath) { + return canonicalTarget; + } + + const route = isDirectoryTarget(pathPart, repositoryPath) ? "tree" : "blob"; + return `https://github.com/${GITHUB_REPO}/${route}/${tag}/${encodeURI(repositoryPath)}${query}${fragment}`; +} + +export function normalizeChangelogLinks(markdown: string, version: string | ChangelogEntry): string { + const tag = normalizeTag(version); + return markdown.replace(INLINE_MARKDOWN_LINK_RE, (_match, prefix, target, suffix) => { + return `${prefix}${normalizeChangelogLinkTarget(target, tag)}${suffix}`; + }); +} + /** * Parse changelog entries from CHANGELOG.md * Scans for ## lines and collects content until next ## or EOF diff --git a/packages/coding-agent/test/changelog.test.ts b/packages/coding-agent/test/changelog.test.ts new file mode 100644 index 00000000..979e7cdf --- /dev/null +++ b/packages/coding-agent/test/changelog.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "vitest"; +import { type ChangelogEntry, normalizeChangelogLinks } from "../src/utils/changelog.ts"; + +const entry: ChangelogEntry = { + major: 0, + minor: 79, + patch: 0, + content: "", +}; + +describe("normalizeChangelogLinks", () => { + test("rewrites package-relative changelog links to tag-pinned GitHub source links", () => { + const markdown = [ + "[Project Trust](README.md#project-trust)", + "[Extensions](docs/extensions.md#project_trust)", + "[Examples](examples/extensions/)", + "[Root README](../../README.md#supply-chain-hardening)", + ].join("\n"); + + expect(normalizeChangelogLinks(markdown, entry)).toBe( + [ + "[Project Trust](https://github.com/earendil-works/pi/blob/v0.79.0/packages/coding-agent/README.md#project-trust)", + "[Extensions](https://github.com/earendil-works/pi/blob/v0.79.0/packages/coding-agent/docs/extensions.md#project_trust)", + "[Examples](https://github.com/earendil-works/pi/tree/v0.79.0/packages/coding-agent/examples/extensions/)", + "[Root README](https://github.com/earendil-works/pi/blob/v0.79.0/README.md#supply-chain-hardening)", + ].join("\n"), + ); + }); + + test("canonicalizes old repository URLs without changing external links", () => { + const markdown = [ + "[#5167](https://github.com/earendil-works/pi-mono/pull/5167)", + "[#4163](https://github.com/badlogic/pi-mono/issues/4163)", + "[Agent README](https://github.com/badlogic/pi-mono/blob/main/packages/agent/README.md)", + "[External](https://example.com/docs)", + "[Local anchor](#settings)", + ].join("\n"); + + expect(normalizeChangelogLinks(markdown, "0.79.0")).toBe( + [ + "[#5167](https://github.com/earendil-works/pi/pull/5167)", + "[#4163](https://github.com/earendil-works/pi/issues/4163)", + "[Agent README](https://github.com/earendil-works/pi/blob/v0.79.0/packages/agent/README.md)", + "[External](https://example.com/docs)", + "[Local anchor](#settings)", + ].join("\n"), + ); + }); +}); diff --git a/scripts/release-notes.mjs b/scripts/release-notes.mjs new file mode 100644 index 00000000..545bdc4f --- /dev/null +++ b/scripts/release-notes.mjs @@ -0,0 +1,364 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const DEFAULT_REPO = "earendil-works/pi"; +const DEFAULT_BASE_PATH = "packages/coding-agent"; +const DEFAULT_CHANGELOG = "packages/coding-agent/CHANGELOG.md"; +const DEFAULT_FIX_SINCE_TAG = "v0.74.0"; +const LEGACY_REPO_RE = /^https:\/\/github\.com\/(?:badlogic|earendil-works)\/pi-mono(?=\/|$)/; +const URL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i; +const INLINE_MARKDOWN_LINK_RE = /(!?\[[^\]\n]+\]\()([^\s)]+)((?:\s+[^)]*)?\))/g; + +function printUsage() { + console.log(`Usage: node scripts/release-notes.mjs [options] + +Commands: + extract Extract release notes from the coding-agent changelog + fix-github-releases Rewrite existing GitHub release note links in place + +extract options: + --version Version to extract + --tag Release tag used for repository links (defaults to v) + --changelog Changelog path (default: ${DEFAULT_CHANGELOG}) + --out Output file (default: stdout) + --repo GitHub repository for generated links (default: ${DEFAULT_REPO}) + --base-path Base path for relative changelog links (default: ${DEFAULT_BASE_PATH}) + +fix-github-releases options: + --repo GitHub repository to patch (default: ${DEFAULT_REPO}) + --tag Patch only one release tag + --since-tag Oldest release tag to patch (default: ${DEFAULT_FIX_SINCE_TAG}) + --base-path Base path for relative changelog links (default: ${DEFAULT_BASE_PATH}) + --dry-run Print releases that would change without updating GitHub +`); +} + +function commandForPlatform(command) { + return process.platform === "win32" ? `${command}.cmd` : command; +} + +function run(command, args, options = {}) { + const result = spawnSync(commandForPlatform(command), args, { + cwd: options.cwd, + encoding: "utf8", + maxBuffer: options.maxBuffer ?? 20 * 1024 * 1024, + stdio: options.capture ? ["inherit", "pipe", "pipe"] : "inherit", + }); + + if (result.status !== 0) { + const output = [result.stdout, result.stderr].filter(Boolean).join("\n"); + throw new Error(output ? `Command failed: ${command} ${args.join(" ")}\n${output}` : `Command failed: ${command} ${args.join(" ")}`); + } + + return result.stdout ?? ""; +} + +function parseOptions(args) { + const options = { + basePath: DEFAULT_BASE_PATH, + changelog: DEFAULT_CHANGELOG, + dryRun: false, + out: undefined, + repo: DEFAULT_REPO, + sinceTag: DEFAULT_FIX_SINCE_TAG, + tag: undefined, + version: undefined, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--help") { + printUsage(); + process.exit(0); + } + if (arg === "--dry-run") { + options.dryRun = true; + continue; + } + + const optionNames = new Set(["--base-path", "--changelog", "--out", "--repo", "--since-tag", "--tag", "--version"]); + if (!optionNames.has(arg)) { + throw new Error(`Unknown option: ${arg}`); + } + + const value = args[++i]; + if (!value) { + throw new Error(`${arg} requires a value`); + } + + if (arg === "--base-path") options.basePath = value; + if (arg === "--changelog") options.changelog = value; + if (arg === "--out") options.out = value; + if (arg === "--repo") options.repo = value; + if (arg === "--since-tag") options.sinceTag = value; + if (arg === "--tag") options.tag = value; + if (arg === "--version") options.version = value; + } + + return options; +} + +function normalizeTag(tagOrVersion) { + if (!tagOrVersion) { + return undefined; + } + return tagOrVersion.startsWith("v") ? tagOrVersion : `v${tagOrVersion}`; +} + +function versionFromTag(tag) { + return tag.startsWith("v") ? tag.slice(1) : tag; +} + +function compareVersions(a, b) { + const aParts = versionFromTag(a).split(".").map(Number); + const bParts = versionFromTag(b).split(".").map(Number); + + for (let i = 0; i < 3; i++) { + const diff = (aParts[i] || 0) - (bParts[i] || 0); + if (diff !== 0) { + return diff; + } + } + + return 0; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function extractChangelogSection(changelog, version) { + const headingRe = new RegExp(`^## \\[${escapeRegExp(version)}\\](?:\\s+-\\s+\\d{4}-\\d{2}-\\d{2})?\\s*$`, "m"); + const heading = headingRe.exec(changelog); + + if (!heading) { + return ""; + } + + const sectionStart = heading.index + heading[0].length; + const rest = changelog.slice(sectionStart); + const nextHeading = rest.search(/^## \[/m); + const section = nextHeading === -1 ? rest : rest.slice(0, nextHeading); + return section.trim(); +} + +function splitLocalTarget(target) { + const hashIndex = target.indexOf("#"); + const beforeHash = hashIndex === -1 ? target : target.slice(0, hashIndex); + const fragment = hashIndex === -1 ? "" : target.slice(hashIndex); + const queryIndex = beforeHash.indexOf("?"); + + if (queryIndex === -1) { + return { fragment, pathPart: beforeHash, query: "" }; + } + + return { + fragment, + pathPart: beforeHash.slice(0, queryIndex), + query: beforeHash.slice(queryIndex), + }; +} + +function normalizePathPart(value) { + return value.replaceAll("\\", "/"); +} + +function normalizeBasePath(basePath) { + const normalized = path.posix.normalize(normalizePathPart(basePath)).replace(/\/+$/, ""); + return normalized === "." ? "" : normalized; +} + +function resolveRepositoryPath(targetPath, basePath) { + const normalizedTarget = normalizePathPart(targetPath); + const joined = normalizedTarget.startsWith("/") + ? path.posix.normalize(normalizedTarget.replace(/^\/+/, "")) + : path.posix.normalize(path.posix.join(normalizeBasePath(basePath), normalizedTarget)); + + if (joined === "." || joined.startsWith("../") || joined === "..") { + return undefined; + } + + return joined; +} + +function isDirectoryTarget(originalPath, repositoryPath) { + if (originalPath.endsWith("/")) { + return true; + } + + const basename = path.posix.basename(repositoryPath); + return !basename.includes("."); +} + +function normalizeLinkTarget(target, options) { + let canonicalTarget = target.replace(LEGACY_REPO_RE, `https://github.com/${options.repo}`); + const repoUrl = `https://github.com/${options.repo}`; + + for (const route of ["blob", "tree"]) { + for (const branch of ["main", "master"]) { + const floatingRefPrefix = `${repoUrl}/${route}/${branch}/`; + if (canonicalTarget.startsWith(floatingRefPrefix)) { + canonicalTarget = `${repoUrl}/${route}/${options.tag}/${canonicalTarget.slice(floatingRefPrefix.length)}`; + } + } + } + + if (canonicalTarget.startsWith("#") || canonicalTarget.startsWith("//") || URL_SCHEME_RE.test(canonicalTarget)) { + return canonicalTarget; + } + + const { fragment, pathPart, query } = splitLocalTarget(canonicalTarget); + if (!pathPart) { + return canonicalTarget; + } + + const repositoryPath = resolveRepositoryPath(pathPart, options.basePath); + if (!repositoryPath) { + return canonicalTarget; + } + + const route = isDirectoryTarget(pathPart, repositoryPath) ? "tree" : "blob"; + return `https://github.com/${options.repo}/${route}/${options.tag}/${encodeURI(repositoryPath)}${query}${fragment}`; +} + +function normalizeReleaseNoteLinks(markdown, options) { + const changes = []; + const normalized = markdown.replace(INLINE_MARKDOWN_LINK_RE, (match, prefix, target, suffix) => { + const normalizedTarget = normalizeLinkTarget(target, options); + if (normalizedTarget !== target) { + changes.push({ from: target, to: normalizedTarget }); + } + return `${prefix}${normalizedTarget}${suffix}`; + }); + + return { changes, markdown: normalized }; +} + +function writeOutput(content, outPath) { + if (outPath) { + writeFileSync(outPath, content); + return; + } + + process.stdout.write(content); +} + +function extractReleaseNotes(options) { + const version = options.version ?? (options.tag ? versionFromTag(options.tag) : undefined); + if (!version) { + throw new Error("extract requires --version or --tag"); + } + + if (!existsSync(options.changelog)) { + throw new Error(`Changelog does not exist: ${options.changelog}`); + } + + const tag = normalizeTag(options.tag ?? version); + const changelog = readFileSync(options.changelog, "utf8"); + const section = extractChangelogSection(changelog, version); + const rawNotes = section ? `${section}\n` : `Release ${version}\n`; + const { markdown } = normalizeReleaseNoteLinks(rawNotes, { basePath: options.basePath, repo: options.repo, tag }); + writeOutput(markdown, options.out); +} + +function listGithubReleases(repo) { + const output = run("gh", ["api", `repos/${repo}/releases`, "--paginate", "--jq", ".[] | {id, tag_name, body} | @json"], { + capture: true, + }); + return output + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => JSON.parse(line)); +} + +function uniqueChanges(changes) { + const seen = new Set(); + const unique = []; + for (const change of changes) { + const key = `${change.from}\n${change.to}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + unique.push(change); + } + return unique; +} + +function updateGithubRelease(repo, tag, body) { + const tempDir = mkdtempSync(path.join(tmpdir(), "pi-release-notes-")); + try { + const notesPath = path.join(tempDir, "notes.md"); + writeFileSync(notesPath, body); + run("gh", ["release", "edit", tag, "--repo", repo, "--notes-file", notesPath], { capture: true }); + } finally { + rmSync(tempDir, { force: true, recursive: true }); + } +} + +function fixGithubReleases(options) { + const tagFilter = normalizeTag(options.tag); + const sinceTag = normalizeTag(options.sinceTag); + const matchingReleases = listGithubReleases(options.repo).filter((release) => !tagFilter || release.tag_name === tagFilter); + + if (tagFilter && matchingReleases.length === 0) { + throw new Error(`Release not found: ${tagFilter}`); + } + + const releases = matchingReleases.filter((release) => compareVersions(release.tag_name, sinceTag) >= 0); + if (tagFilter && releases.length === 0) { + console.log(`Skipping ${tagFilter}: older than ${sinceTag}.`); + console.log(`${options.dryRun ? "Would update" : "Updated"} 0 releases.`); + return; + } + + let changedCount = 0; + for (const release of releases) { + const tag = release.tag_name; + const body = release.body ?? ""; + const result = normalizeReleaseNoteLinks(body, { basePath: options.basePath, repo: options.repo, tag }); + if (result.markdown === body) { + continue; + } + + changedCount++; + const unique = uniqueChanges(result.changes); + console.log(`${options.dryRun ? "Would update" : "Updating"} ${tag} (${unique.length} link${unique.length === 1 ? "" : "s"})`); + for (const change of unique) { + console.log(` ${change.from}`); + console.log(` -> ${change.to}`); + } + + if (!options.dryRun) { + updateGithubRelease(options.repo, tag, result.markdown); + } + } + + const prefix = options.dryRun ? "Would update" : "Updated"; + console.log(`${prefix} ${changedCount} release${changedCount === 1 ? "" : "s"}.`); +} + +try { + const [command, ...args] = process.argv.slice(2); + if (!command || command === "--help") { + printUsage(); + process.exit(command ? 0 : 1); + } + + const options = parseOptions(args); + if (command === "extract") { + extractReleaseNotes(options); + } else if (command === "fix-github-releases") { + fixGithubReleases(options); + } else { + throw new Error(`Unknown command: ${command}`); + } +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} From 8cef3c8d7f1c73c820bd4a1689823e65161f3e86 Mon Sep 17 00:00:00 2001 From: ajm_ensighten Date: Mon, 8 Jun 2026 17:01:51 -0500 Subject: [PATCH 188/352] fix(amazon-bedrock): extract region from inference profile ARNs Application inference profile ARNs encode the region (arn:aws:bedrock:::...) but the provider ignored it, falling through to AWS_REGION which may point to a different region. Extract the region from the ARN when present, taking priority over environment variables. Fixes #4860 --- packages/ai/src/providers/amazon-bedrock.ts | 11 +++++--- .../test/bedrock-endpoint-resolution.test.ts | 26 +++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index 0de0ea6e..e357bac5 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -143,10 +143,13 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt // in Node.js/Bun environment only if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) { - // Region resolution: explicit option > env vars > SDK default chain. - // When AWS_PROFILE is set, we leave region undefined so the SDK can - // resovle it from aws profile configs. Otherwise fall back to us-east-1. - if (configuredRegion) { + // Region resolution: ARN-embedded > explicit option > env vars > SDK default chain. + // When the model ID is an inference profile ARN, extract the region from it. + // This avoids conflicts with AWS_REGION set for other services. + const arnRegionMatch = model.id.match(/^arn:aws(?:-[a-z0-9-]+)?:bedrock:([a-z0-9-]+):/); + if (arnRegionMatch) { + config.region = arnRegionMatch[1]; + } else if (configuredRegion) { config.region = configuredRegion; } else if (endpointRegion && useExplicitEndpoint) { config.region = endpointRegion; diff --git a/packages/ai/test/bedrock-endpoint-resolution.test.ts b/packages/ai/test/bedrock-endpoint-resolution.test.ts index 2483fcae..412c62e0 100644 --- a/packages/ai/test/bedrock-endpoint-resolution.test.ts +++ b/packages/ai/test/bedrock-endpoint-resolution.test.ts @@ -128,4 +128,30 @@ describe("bedrock endpoint resolution", () => { expect(config.endpoint).toBe("https://bedrock-vpc.example.com"); expect(config.region).toBe("us-west-2"); }); + + it("extracts region from inference profile ARN regardless of AWS_REGION", async () => { + process.env.AWS_REGION = "us-east-1"; + const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8"); + const model: Model<"bedrock-converse-stream"> = { + ...baseModel, + id: "arn:aws:bedrock:us-west-2:123456789012:application-inference-profile/abc123", + }; + + const config = await captureClientConfig(model); + + expect(config.region).toBe("us-west-2"); + }); + + it("extracts region from GovCloud inference profile ARN", async () => { + process.env.AWS_REGION = "us-east-1"; + const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8"); + const model: Model<"bedrock-converse-stream"> = { + ...baseModel, + id: "arn:aws-us-gov:bedrock:us-gov-west-1:123456789012:application-inference-profile/abc123", + }; + + const config = await captureClientConfig(model); + + expect(config.region).toBe("us-gov-west-1"); + }); }); From c6bdfa1971030cecd36102ea52604179528936b1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Jun 2026 09:40:40 +0000 Subject: [PATCH 189/352] chore: approve contributor davidlifschitz --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 9c9fc5e3..456331cc 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -231,3 +231,5 @@ psoukie pr vastxie pr ItsumoSeito pr + +davidlifschitz pr From 2326d5cb4ae19148ed087b52ff737ee0633a7c87 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 9 Jun 2026 12:23:41 +0200 Subject: [PATCH 190/352] fix(ai): disable Moonshot thinking when requested closes #5531 --- packages/ai/CHANGELOG.md | 4 ++ packages/ai/scripts/generate-models.ts | 1 + packages/ai/src/models.generated.ts | 67 ++++++++++++++++---------- 3 files changed, 47 insertions(+), 25 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index f5890870..6ccb5013 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Moonshot Kimi thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5531](https://github.com/earendil-works/pi/issues/5531)). + ## [0.79.0] - 2026-06-08 ### Fixed diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 463e4df3..5e38d4c7 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -1215,6 +1215,7 @@ async function loadModelsDevData(): Promise[]> { supportsReasoningEffort: false, maxTokensField: "max_tokens", supportsStrictMode: false, + thinkingFormat: "deepseek", }; for (const { key, provider, baseUrl } of moonshotVariants) { diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 07d06ef5..99fccbd6 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -11,7 +11,7 @@ export const MODELS = { api: "bedrock-converse-stream", provider: "amazon-bedrock", baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 0.33, @@ -1131,7 +1131,7 @@ export const MODELS = { api: "bedrock-converse-stream", provider: "amazon-bedrock", baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.15, @@ -1148,7 +1148,7 @@ export const MODELS = { api: "bedrock-converse-stream", provider: "amazon-bedrock", baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.15, @@ -1165,7 +1165,7 @@ export const MODELS = { api: "bedrock-converse-stream", provider: "amazon-bedrock", baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.07, @@ -1182,7 +1182,7 @@ export const MODELS = { api: "bedrock-converse-stream", provider: "amazon-bedrock", baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.07, @@ -6299,7 +6299,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai", baseUrl: "https://api.moonshot.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: false, input: ["text"], cost: { @@ -6317,7 +6317,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai", baseUrl: "https://api.moonshot.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: false, input: ["text"], cost: { @@ -6335,7 +6335,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai", baseUrl: "https://api.moonshot.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text"], cost: { @@ -6353,7 +6353,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai", baseUrl: "https://api.moonshot.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text"], cost: { @@ -6371,7 +6371,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai", baseUrl: "https://api.moonshot.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: false, input: ["text"], cost: { @@ -6389,7 +6389,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai", baseUrl: "https://api.moonshot.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text", "image"], cost: { @@ -6407,7 +6407,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai", baseUrl: "https://api.moonshot.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text", "image"], cost: { @@ -6427,7 +6427,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: false, input: ["text"], cost: { @@ -6445,7 +6445,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: false, input: ["text"], cost: { @@ -6463,7 +6463,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text"], cost: { @@ -6481,7 +6481,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text"], cost: { @@ -6499,7 +6499,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: false, input: ["text"], cost: { @@ -6517,7 +6517,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text", "image"], cost: { @@ -6535,7 +6535,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text", "image"], cost: { @@ -10490,6 +10490,23 @@ export const MODELS = { contextWindow: 262144, maxTokens: 4096, } satisfies Model<"openai-completions">, + "nex-agi/nex-n2-pro:free": { + id: "nex-agi/nex-n2-pro:free", + name: "Nex AGI: Nex-N2-Pro (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, "nvidia/llama-3.3-nemotron-super-49b-v1.5": { id: "nvidia/llama-3.3-nemotron-super-49b-v1.5", name: "NVIDIA: Llama 3.3 Nemotron Super 49B V1.5", @@ -13111,9 +13128,9 @@ export const MODELS = { api: "openai-completions", provider: "together", baseUrl: "https://api.together.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, reasoning: true, - thinkingLevelMap: {"off":null,"minimal":null,"low":null,"medium":null}, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, input: ["text"], cost: { input: 0.3, @@ -13508,8 +13525,8 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.08, - output: 0.29, + input: 0.12, + output: 0.5, cacheRead: 0, cacheWrite: 0, }, @@ -16373,7 +16390,7 @@ export const MODELS = { cacheRead: 0.2, cacheWrite: 0, }, - contextWindow: 2000000, + contextWindow: 1000000, maxTokens: 30000, } satisfies Model<"openai-completions">, "grok-4.20-0309-reasoning": { @@ -16390,7 +16407,7 @@ export const MODELS = { cacheRead: 0.2, cacheWrite: 0, }, - contextWindow: 2000000, + contextWindow: 1000000, maxTokens: 30000, } satisfies Model<"openai-completions">, "grok-4.3": { From def99d395ee61b4fe34b31f1dae4dff0c09ada83 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Jun 2026 10:51:30 +0000 Subject: [PATCH 191/352] chore: approve contributor vdxz --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 456331cc..12eddd38 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -233,3 +233,5 @@ vastxie pr ItsumoSeito pr davidlifschitz pr + +vdxz pr From 8da077bcca94b8d812a785118da2bcc38c2bcc63 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 9 Jun 2026 12:51:27 +0200 Subject: [PATCH 192/352] fix(tui): wrap CJK text at grapheme boundaries closes #5495 --- packages/tui/CHANGELOG.md | 4 ++ packages/tui/src/utils.ts | 63 +++++++++++++++++++++-------- packages/tui/test/wrap-ansi.test.ts | 24 +++++++++++ 3 files changed, 75 insertions(+), 16 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index e575eb27..eeea095d 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed wrapping for mixed Latin and CJK text so unspaced CJK runs can break at grapheme boundaries without leaving large trailing gaps ([#5495](https://github.com/earendil-works/pi/issues/5495)). + ## [0.79.0] - 2026-06-08 ### Fixed diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 02c40c79..3f27639e 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -45,6 +45,9 @@ const rgiEmojiRegex = /^\p{RGI_Emoji}$/v; const WIDTH_CACHE_SIZE = 512; const widthCache = new Map(); +const cjkBreakRegex = + /[\p{Script_Extensions=Han}\p{Script_Extensions=Hiragana}\p{Script_Extensions=Katakana}\p{Script_Extensions=Hangul}\p{Script_Extensions=Bopomofo}]/u; + function isPrintableAscii(str: string): boolean { for (let i = 0; i < str.length; i++) { const code = str.charCodeAt(i); @@ -605,9 +608,18 @@ function splitIntoTokensWithAnsi(text: string): string[] { const tokens: string[] = []; let current = ""; let pendingAnsi = ""; // ANSI codes waiting to be attached to next visible content - let inWhitespace = false; + let currentKind: "space" | "word" | null = null; let i = 0; + const flushCurrent = (): void => { + if (!current) { + return; + } + tokens.push(current); + current = ""; + currentKind = null; + }; + while (i < text.length) { const ansiResult = extractAnsiCode(text, i); if (ansiResult) { @@ -617,29 +629,48 @@ function splitIntoTokensWithAnsi(text: string): string[] { continue; } - const char = text[i]; - const charIsSpace = char === " "; - - if (charIsSpace !== inWhitespace && current) { - // Switching between whitespace and non-whitespace, push current token - tokens.push(current); - current = ""; + let end = i; + while (end < text.length && !extractAnsiCode(text, end)) { + end++; } - // Attach any pending ANSI codes to this visible character - if (pendingAnsi) { - current += pendingAnsi; - pendingAnsi = ""; + for (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) { + const segmentIsSpace = segment === " "; + if (!segmentIsSpace && cjkBreakRegex.test(segment)) { + flushCurrent(); + const token = pendingAnsi + segment; + pendingAnsi = ""; + tokens.push(token); + continue; + } + + const segmentKind = segmentIsSpace ? "space" : "word"; + if (current && currentKind !== segmentKind) { + flushCurrent(); + } + + // Attach any pending ANSI codes to this visible character + if (pendingAnsi) { + current += pendingAnsi; + pendingAnsi = ""; + } + + currentKind = segmentKind; + current += segment; } - inWhitespace = charIsSpace; - current += char; - i++; + i = end; } // Handle any remaining pending ANSI codes (attach to last token) if (pendingAnsi) { - current += pendingAnsi; + if (current) { + current += pendingAnsi; + } else if (tokens.length > 0) { + tokens[tokens.length - 1] += pendingAnsi; + } else { + current = pendingAnsi; + } } if (current) { diff --git a/packages/tui/test/wrap-ansi.test.ts b/packages/tui/test/wrap-ansi.test.ts index 52d59148..a1183f75 100644 --- a/packages/tui/test/wrap-ansi.test.ts +++ b/packages/tui/test/wrap-ansi.test.ts @@ -111,6 +111,30 @@ describe("wrapTextWithAnsi", () => { } }); + it("should break CJK runs at grapheme boundaries after Latin text", () => { + const text = "This is an example 中文汉字测试段落内容中文汉字测试段落内容."; + const wrapped = wrapTextWithAnsi(text, 40); + + assert.deepStrictEqual(wrapped, ["This is an example 中文汉字测试段落内容", "中文汉字测试段落内容."]); + for (const line of wrapped) { + assert.ok(visibleWidth(line) <= 40); + } + }); + + it("should preserve color codes when wrapping CJK runs", () => { + const red = "\x1b[31m"; + const reset = "\x1b[0m"; + const text = `${red}This is an example 中文汉字测试段落内容中文汉字测试段落内容.${reset}`; + const wrapped = wrapTextWithAnsi(text, 40); + + assert.strictEqual(wrapped.length, 2); + assert.strictEqual(wrapped[0], `${red}This is an example 中文汉字测试段落内容`); + assert.strictEqual(wrapped[1], `${red}中文汉字测试段落内容.${reset}`); + for (const line of wrapped) { + assert.ok(visibleWidth(line) <= 40); + } + }); + it("should ignore OSC 133 semantic markers in visible width", () => { const text = "\x1b]133;A\x07hello\x1b]133;B\x07"; assert.strictEqual(visibleWidth(text), 5); From 84cdd02400fed01a248d04ffed918e5f7eab71da Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 9 Jun 2026 12:53:17 +0200 Subject: [PATCH 193/352] fix(ai): disable Azure OpenAI response storage closes #5530 --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/providers/azure-openai-responses.ts | 1 + packages/ai/test/azure-openai-base-url.test.ts | 11 +++++++++++ 3 files changed, 13 insertions(+) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 6ccb5013..7f7dfc2a 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed Moonshot Kimi thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5531](https://github.com/earendil-works/pi/issues/5531)). +- Fixed Azure OpenAI Responses requests to disable server-side response storage ([#5530](https://github.com/earendil-works/pi/issues/5530)). ## [0.79.0] - 2026-06-08 diff --git a/packages/ai/src/providers/azure-openai-responses.ts b/packages/ai/src/providers/azure-openai-responses.ts index 921a482e..ecb4c7e6 100644 --- a/packages/ai/src/providers/azure-openai-responses.ts +++ b/packages/ai/src/providers/azure-openai-responses.ts @@ -256,6 +256,7 @@ function buildParams( input: messages, stream: true, prompt_cache_key: clampOpenAIPromptCacheKey(options?.sessionId), + store: false, }; if (options?.maxTokens) { diff --git a/packages/ai/test/azure-openai-base-url.test.ts b/packages/ai/test/azure-openai-base-url.test.ts index 530c5f47..15b8a528 100644 --- a/packages/ai/test/azure-openai-base-url.test.ts +++ b/packages/ai/test/azure-openai-base-url.test.ts @@ -13,6 +13,7 @@ interface CapturedAzureClientOptions { interface CapturedAzureResponsesPayload { prompt_cache_key?: string; + store?: boolean; } const azureMock = vi.hoisted(() => ({ @@ -144,6 +145,16 @@ describe("azure-openai-responses base URL normalization", () => { expect(azureMock.lastParams?.prompt_cache_key).toBe("x".repeat(64)); }); + it("disables server-side response storage", async () => { + const model = getModel("azure-openai-responses", "gpt-4o-mini"); + await streamAzureOpenAIResponses(model, context, { + apiKey: "test-api-key", + azureBaseUrl: "https://my-resource.openai.azure.com", + }).result(); + + expect(azureMock.lastParams?.store).toBe(false); + }); + it("builds correct default URL from AZURE_OPENAI_RESOURCE_NAME", async () => { process.env.AZURE_OPENAI_RESOURCE_NAME = "my-resource"; const model = getModel("azure-openai-responses", "gpt-4o-mini"); From 081a0a2befecc598702009fa1cccbcb489925198 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Jun 2026 11:02:46 +0000 Subject: [PATCH 194/352] chore: approve contributor dangooddd --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 12eddd38..0513093f 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -235,3 +235,5 @@ ItsumoSeito pr davidlifschitz pr vdxz pr + +dangooddd pr From db3f9953eecff52a7d70cc8e16cfaa46b80e165d Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 9 Jun 2026 13:02:58 +0200 Subject: [PATCH 195/352] feat(coding-agent): expose project trust to extensions closes #5523 --- packages/coding-agent/CHANGELOG.md | 4 ++++ packages/coding-agent/docs/extensions.md | 6 ++++++ packages/coding-agent/src/core/agent-session.ts | 1 + packages/coding-agent/src/core/extensions/runner.ts | 6 ++++++ packages/coding-agent/src/core/extensions/types.ts | 3 +++ .../src/modes/interactive/interactive-mode.ts | 1 + .../coding-agent/test/extensions-runner.test.ts | 13 +++++++++++++ .../test/trigger-compact-extension.test.ts | 1 + 8 files changed, 35 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5cae58f8..a36f356d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `ctx.isProjectTrusted()` for extensions to observe the effective project trust decision, including temporary trust decisions ([#5523](https://github.com/earendil-works/pi/issues/5523)). + ### Fixed - Fixed GitHub release notes and interactive changelog links to resolve package-relative documentation URLs correctly ([#5516](https://github.com/earendil-works/pi/issues/5516)). diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 601d378e..a7f4745f 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -892,6 +892,12 @@ Current run mode: `"tui"`, `"rpc"`, `"json"`, or `"print"`. Use `ctx.mode === "t Current working directory. +### ctx.isProjectTrusted() + +Returns whether project-local trust is active for the current session context. This includes temporary trust decisions and CLI trust overrides, not just saved decisions in the global trust store. + +Use this before reading project-local extension configuration that should only be honored for trusted projects. + ### ctx.sessionManager Read-only access to session state. See [Session Format](session-format.md) for the full SessionManager API and entry types. diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index de76a619..514bb0ad 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -2239,6 +2239,7 @@ export class AgentSession { { getModel: () => this.model, isIdle: () => !this.isStreaming, + isProjectTrusted: () => this.settingsManager.isProjectTrusted(), getSignal: () => this.agent.signal, abort: () => { if (this._extensionAbortHandler) { diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 3030b65d..9cb0e657 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -270,6 +270,7 @@ export class ExtensionRunner { private errorListeners: Set = new Set(); private getModel: () => Model | undefined = () => undefined; private isIdleFn: () => boolean = () => true; + private isProjectTrustedFn: () => boolean = () => true; private getSignalFn: () => AbortSignal | undefined = () => undefined; private waitForIdleFn: () => Promise = async () => {}; private abortFn: () => void = () => {}; @@ -330,6 +331,7 @@ export class ExtensionRunner { // Context actions (required) this.getModel = contextActions.getModel; this.isIdleFn = contextActions.isIdle; + this.isProjectTrustedFn = contextActions.isProjectTrusted; this.getSignalFn = contextActions.getSignal; this.abortFn = contextActions.abort; this.hasPendingMessagesFn = contextActions.hasPendingMessages; @@ -648,6 +650,10 @@ export class ExtensionRunner { runner.assertActive(); return runner.isIdleFn(); }, + isProjectTrusted: () => { + runner.assertActive(); + return runner.isProjectTrustedFn(); + }, get signal() { runner.assertActive(); return runner.getSignalFn(); diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 7575d8af..a869a55d 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -314,6 +314,8 @@ export interface ExtensionContext { model: Model | undefined; /** Whether the agent is idle (not streaming) */ isIdle(): boolean; + /** Whether project-local trust is active for this context. */ + isProjectTrusted(): boolean; /** The current abort signal, or undefined when the agent is not streaming. */ signal: AbortSignal | undefined; /** Abort the current agent operation */ @@ -1528,6 +1530,7 @@ export interface ExtensionActions { export interface ExtensionContextActions { getModel: () => Model | undefined; isIdle: () => boolean; + isProjectTrusted: () => boolean; getSignal: () => AbortSignal | undefined; abort: () => void; hasPendingMessages: () => boolean; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 193fb70c..08bb669c 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -1669,6 +1669,7 @@ export class InteractiveMode { modelRegistry: this.session.modelRegistry, model: this.session.model, isIdle: () => !this.session.isStreaming, + isProjectTrusted: () => this.settingsManager.isProjectTrusted(), signal: this.session.agent.signal, abort: () => { this.restoreQueuedMessagesToEditor({ abort: true }); diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index f4939367..cd611962 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -76,6 +76,7 @@ describe("ExtensionRunner", () => { const extensionContextActions: ExtensionContextActions = { getModel: () => undefined, isIdle: () => true, + isProjectTrusted: () => true, getSignal: () => undefined, abort: () => {}, hasPendingMessages: () => false, @@ -496,6 +497,18 @@ describe("ExtensionRunner", () => { expect(ctx.hasUI).toBe(false); }); + it("exposes project trust state on ExtensionContext", async () => { + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); + runner.bindCore(extensionActions, { + ...extensionContextActions, + isProjectTrusted: () => false, + }); + + const ctx = runner.createContext(); + expect(ctx.isProjectTrusted()).toBe(false); + }); + it("exposes rpc mode with hasUI true when an RPC UI context is provided", async () => { const result = await discoverAndLoadExtensions([], tempDir, tempDir); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); diff --git a/packages/coding-agent/test/trigger-compact-extension.test.ts b/packages/coding-agent/test/trigger-compact-extension.test.ts index c114fac9..80f3d46c 100644 --- a/packages/coding-agent/test/trigger-compact-extension.test.ts +++ b/packages/coding-agent/test/trigger-compact-extension.test.ts @@ -12,6 +12,7 @@ function createContext(tokens: number | null, compact = vi.fn()): ExtensionConte modelRegistry: {} as ExtensionContext["modelRegistry"], model: undefined, isIdle: () => true, + isProjectTrusted: () => true, signal: undefined, abort: vi.fn(), hasPendingMessages: () => false, From e4907b3b097f2d307e6246cfde5b2f24d7e0e6a3 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 9 Jun 2026 13:05:37 +0200 Subject: [PATCH 196/352] fix(tui): restore prompt draft after history browsing closes #5494 --- .../src/modes/interactive/interactive-mode.ts | 2 +- packages/tui/CHANGELOG.md | 1 + packages/tui/src/components/editor.ts | 56 +++++++++++-------- packages/tui/test/editor.test.ts | 15 +++-- 4 files changed, 45 insertions(+), 29 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 08bb669c..fe73f454 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -5455,7 +5455,7 @@ export class InteractiveMode { **Navigation** | Key | Action | |-----|--------| -| \`${cursorUp}\` / \`${cursorDown}\` / \`${cursorLeft}\` / \`${cursorRight}\` | Move cursor / browse history (Up when empty) | +| \`${cursorUp}\` / \`${cursorDown}\` / \`${cursorLeft}\` / \`${cursorRight}\` | Move cursor / browse history | | \`${cursorWordLeft}\` / \`${cursorWordRight}\` | Move by word | | \`${cursorLineStart}\` | Start of line | | \`${cursorLineEnd}\` | End of line | diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index eeea095d..289b1a4c 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed prompt history navigation to restore the current draft when returning from history browsing ([#5494](https://github.com/earendil-works/pi/issues/5494)). - Fixed wrapping for mixed Latin and CJK text so unspaced CJK runs can break at grapheme boundaries without leaving large trailing gaps ([#5495](https://github.com/earendil-works/pi/issues/5495)). ## [0.79.0] - 2026-06-08 diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 64b2c254..3b3350b1 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -266,6 +266,7 @@ export class Editor implements Component, Focusable { // Prompt history for up/down navigation private history: string[] = []; private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc. + private historyDraft: EditorState | null = null; // Kill ring for Emacs-style kill/yank operations private killRing = new KillRing(); @@ -356,10 +357,6 @@ export class Editor implements Component, Focusable { } } - private isEditorEmpty(): boolean { - return this.state.lines.length === 1 && this.state.lines[0] === ""; - } - private isOnFirstVisualLine(): boolean { const visualLines = this.buildVisualLineMap(this.lastWidth); const currentVisualLine = this.findCurrentVisualLine(visualLines); @@ -382,18 +379,33 @@ export class Editor implements Component, Focusable { // Capture state when first entering history browsing mode if (this.historyIndex === -1 && newIndex >= 0) { this.pushUndoSnapshot(); + this.historyDraft = structuredClone(this.state); } this.historyIndex = newIndex; if (this.historyIndex === -1) { - // Returned to "current" state - clear editor - this.setTextInternal(""); + const draft = this.historyDraft; + this.historyDraft = null; + if (draft) { + this.state = draft; + this.preferredVisualCol = null; + this.snappedFromCursorCol = null; + this.scrollOffset = 0; + if (this.onChange) this.onChange(this.getText()); + } else { + this.setTextInternal(""); + } } else { this.setTextInternal(this.history[this.historyIndex] || "", direction === -1 ? "start" : "end"); } } + private exitHistoryBrowsing(): void { + this.historyIndex = -1; + this.historyDraft = null; + } + /** Internal setText that doesn't reset history state - used by navigateHistory */ private setTextInternal(text: string, cursorPlacement: "start" | "end" = "end"): void { const lines = text.split("\n"); @@ -758,9 +770,7 @@ export class Editor implements Component, Focusable { // Arrow key navigation (with history support) if (kb.matches(data, "tui.editor.cursorUp")) { - if (this.isEditorEmpty()) { - this.navigateHistory(-1); - } else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) { + if (this.isOnFirstVisualLine() && this.history.length > 0) { this.navigateHistory(-1); } else if (this.isOnFirstVisualLine()) { // Already at top - jump to start of line @@ -948,7 +958,7 @@ export class Editor implements Component, Focusable { setText(text: string): void { this.cancelAutocomplete(); this.lastAction = null; - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); const normalized = this.normalizeText(text); // Push undo snapshot if content differs (makes programmatic changes undoable) if (this.getText() !== normalized) { @@ -967,7 +977,7 @@ export class Editor implements Component, Focusable { this.cancelAutocomplete(); this.pushUndoSnapshot(); this.lastAction = null; - this.historyIndex = -1; + this.exitHistoryBrowsing(); this.insertTextAtCursorInternal(text); } @@ -1030,7 +1040,7 @@ export class Editor implements Component, Focusable { // All the editor methods from before... private insertCharacter(char: string, skipUndoCoalescing?: boolean): void { - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); // Undo coalescing (fish-style): // - Consecutive word chars coalesce into one undo unit @@ -1091,7 +1101,7 @@ export class Editor implements Component, Focusable { private handlePaste(pastedText: string): void { this.cancelAutocomplete(); - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); this.lastAction = null; this.pushUndoSnapshot(); @@ -1159,7 +1169,7 @@ export class Editor implements Component, Focusable { private addNewLine(): void { this.cancelAutocomplete(); - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); this.lastAction = null; this.pushUndoSnapshot(); @@ -1200,7 +1210,7 @@ export class Editor implements Component, Focusable { this.state = { lines: [""], cursorLine: 0, cursorCol: 0 }; this.pastes.clear(); this.pasteCounter = 0; - this.historyIndex = -1; + this.exitHistoryBrowsing(); this.scrollOffset = 0; this.undoStack.clear(); this.lastAction = null; @@ -1210,7 +1220,7 @@ export class Editor implements Component, Focusable { } private handleBackspace(): void { - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); this.lastAction = null; if (this.state.cursorCol > 0) { @@ -1427,7 +1437,7 @@ export class Editor implements Component, Focusable { } private deleteToStartOfLine(): void { - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1462,7 +1472,7 @@ export class Editor implements Component, Focusable { } private deleteToEndOfLine(): void { - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1494,7 +1504,7 @@ export class Editor implements Component, Focusable { } private deleteWordBackwards(): void { - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1539,7 +1549,7 @@ export class Editor implements Component, Focusable { } private deleteWordForward(): void { - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1581,7 +1591,7 @@ export class Editor implements Component, Focusable { } private handleForwardDelete(): void { - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); this.lastAction = null; const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1837,7 +1847,7 @@ export class Editor implements Component, Focusable { * Insert text at cursor position (used by yank operations). */ private insertYankedText(text: string): void { - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); const lines = text.split("\n"); if (lines.length === 1) { @@ -1922,7 +1932,7 @@ export class Editor implements Component, Focusable { } private undo(): void { - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); const snapshot = this.undoStack.pop(); if (!snapshot) return; Object.assign(this.state, snapshot); diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 9f92643a..f7c391f0 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -79,16 +79,20 @@ describe("Editor component", () => { assert.strictEqual(editor.getText(), "first"); }); - it("returns to empty editor on Down arrow after browsing history", () => { + it("restores draft on Down arrow after browsing history", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.addToHistory("prompt"); + editor.setText("draft"); + editor.handleInput("\x1b[D"); + editor.handleInput("\x1b[D"); editor.handleInput("\x1b[A"); // Up - shows "prompt" assert.strictEqual(editor.getText(), "prompt"); - editor.handleInput("\x1b[B"); // Down - clears editor - assert.strictEqual(editor.getText(), ""); + editor.handleInput("\x1b[B"); // Down - restores draft + assert.strictEqual(editor.getText(), "draft"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 }); }); it("navigates forward through history with Down arrow", () => { @@ -97,6 +101,7 @@ describe("Editor component", () => { editor.addToHistory("first"); editor.addToHistory("second"); editor.addToHistory("third"); + editor.setText("draft"); // Go to oldest editor.handleInput("\x1b[A"); // third @@ -110,8 +115,8 @@ describe("Editor component", () => { editor.handleInput("\x1b[B"); // third assert.strictEqual(editor.getText(), "third"); - editor.handleInput("\x1b[B"); // empty - assert.strictEqual(editor.getText(), ""); + editor.handleInput("\x1b[B"); // draft + assert.strictEqual(editor.getText(), "draft"); }); it("exits history mode when typing a character", () => { From 1906074369594ce55454cf35fa1006a50ae08743 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 9 Jun 2026 13:14:24 +0200 Subject: [PATCH 197/352] fix(coding-agent): handle invalid models json during migration closes #5418 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/migrations.ts | 75 ++++++++++--------- .../test/config-value-migration.test.ts | 19 +++++ 3 files changed, 60 insertions(+), 35 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a36f356d..95c7561e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed invalid `models.json` syntax to skip startup config migrations and report the normal file-path-aware models error instead of a raw JSON parse stack trace ([#5418](https://github.com/earendil-works/pi/issues/5418)). - Fixed GitHub release notes and interactive changelog links to resolve package-relative documentation URLs correctly ([#5516](https://github.com/earendil-works/pi/issues/5516)). ## [0.79.0] - 2026-06-08 diff --git a/packages/coding-agent/src/migrations.ts b/packages/coding-agent/src/migrations.ts index eb07f220..5cce43b8 100644 --- a/packages/coding-agent/src/migrations.ts +++ b/packages/coding-agent/src/migrations.ts @@ -144,47 +144,52 @@ function migrateModelsJsonConfigValues(agentDir: string): ConfigValueMigration[] const modelsPath = join(agentDir, "models.json"); if (!existsSync(modelsPath)) return []; - const parsed = JSON.parse(stripJsonComments(readFileSync(modelsPath, "utf-8"))) as unknown; - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return []; - const modelsData = parsed as Record; - const providers = modelsData.providers; - if (typeof providers !== "object" || providers === null || Array.isArray(providers)) return []; + try { + const parsed = JSON.parse(stripJsonComments(readFileSync(modelsPath, "utf-8"))) as unknown; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return []; + const modelsData = parsed as Record; + const providers = modelsData.providers; + if (typeof providers !== "object" || providers === null || Array.isArray(providers)) return []; - const migrations: ConfigValueMigration[] = []; - for (const [provider, providerConfig] of Object.entries(providers)) { - if (typeof providerConfig !== "object" || providerConfig === null || Array.isArray(providerConfig)) continue; - const providerRecord = providerConfig as Record; - const providerLocation = `models.json.providers[${JSON.stringify(provider)}]`; - migrateStringProperty(providerRecord, "apiKey", `${providerLocation}.apiKey`, migrations); - migrateHeadersConfig(providerRecord.headers, `${providerLocation}.headers`, migrations); + const migrations: ConfigValueMigration[] = []; + for (const [provider, providerConfig] of Object.entries(providers)) { + if (typeof providerConfig !== "object" || providerConfig === null || Array.isArray(providerConfig)) continue; + const providerRecord = providerConfig as Record; + const providerLocation = `models.json.providers[${JSON.stringify(provider)}]`; + migrateStringProperty(providerRecord, "apiKey", `${providerLocation}.apiKey`, migrations); + migrateHeadersConfig(providerRecord.headers, `${providerLocation}.headers`, migrations); - if (Array.isArray(providerRecord.models)) { - for (let index = 0; index < providerRecord.models.length; index++) { - const modelConfig = providerRecord.models[index]; - if (typeof modelConfig !== "object" || modelConfig === null || Array.isArray(modelConfig)) continue; - const modelRecord = modelConfig as Record; - const modelKey = typeof modelRecord.id === "string" ? JSON.stringify(modelRecord.id) : String(index); - migrateHeadersConfig(modelRecord.headers, `${providerLocation}.models[${modelKey}].headers`, migrations); + if (Array.isArray(providerRecord.models)) { + for (let index = 0; index < providerRecord.models.length; index++) { + const modelConfig = providerRecord.models[index]; + if (typeof modelConfig !== "object" || modelConfig === null || Array.isArray(modelConfig)) continue; + const modelRecord = modelConfig as Record; + const modelKey = typeof modelRecord.id === "string" ? JSON.stringify(modelRecord.id) : String(index); + migrateHeadersConfig(modelRecord.headers, `${providerLocation}.models[${modelKey}].headers`, migrations); + } + } + + const modelOverrides = providerRecord.modelOverrides; + if (typeof modelOverrides === "object" && modelOverrides !== null && !Array.isArray(modelOverrides)) { + for (const [modelId, modelOverride] of Object.entries(modelOverrides)) { + if (typeof modelOverride !== "object" || modelOverride === null || Array.isArray(modelOverride)) + continue; + const modelOverrideRecord = modelOverride as Record; + migrateHeadersConfig( + modelOverrideRecord.headers, + `${providerLocation}.modelOverrides[${JSON.stringify(modelId)}].headers`, + migrations, + ); + } } } - const modelOverrides = providerRecord.modelOverrides; - if (typeof modelOverrides === "object" && modelOverrides !== null && !Array.isArray(modelOverrides)) { - for (const [modelId, modelOverride] of Object.entries(modelOverrides)) { - if (typeof modelOverride !== "object" || modelOverride === null || Array.isArray(modelOverride)) continue; - const modelOverrideRecord = modelOverride as Record; - migrateHeadersConfig( - modelOverrideRecord.headers, - `${providerLocation}.modelOverrides[${JSON.stringify(modelId)}].headers`, - migrations, - ); - } - } + if (migrations.length === 0) return []; + writeFileSync(modelsPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf-8"); + return migrations; + } catch { + return []; } - - if (migrations.length === 0) return []; - writeFileSync(modelsPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf-8"); - return migrations; } function migrateExplicitEnvVarConfigValues(): void { diff --git a/packages/coding-agent/test/config-value-migration.test.ts b/packages/coding-agent/test/config-value-migration.test.ts index 35d4f155..d0bb8125 100644 --- a/packages/coding-agent/test/config-value-migration.test.ts +++ b/packages/coding-agent/test/config-value-migration.test.ts @@ -3,6 +3,8 @@ import * as os from "node:os"; import * as path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ENV_AGENT_DIR } from "../src/config.ts"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { ModelRegistry } from "../src/core/model-registry.ts"; import { runMigrations } from "../src/migrations.ts"; describe("config value env var syntax migration", () => { @@ -68,6 +70,23 @@ describe("config value env var syntax migration", () => { expect(logMessage).toContain('auth.json["anthropic"].key: ANTHROPIC_API_KEY -> $ANTHROPIC_API_KEY'); }); + it.each([ + ["malformed", '{\n "providers": {\n'], + ["blank", ""], + ])("does not throw on %s models.json during config migration", (_name, content) => { + const agentDir = createAgentDir(); + const modelsPath = path.join(agentDir, "models.json"); + fs.writeFileSync(modelsPath, content, "utf-8"); + + withAgentDir(agentDir, () => expect(() => runMigrations(agentDir)).not.toThrow()); + + expect(fs.readFileSync(modelsPath, "utf-8")).toBe(content); + const registry = ModelRegistry.create(AuthStorage.create(path.join(agentDir, "auth.json")), modelsPath); + const loadError = registry.getError(); + expect(loadError).toContain("Failed to parse models.json"); + expect(loadError).toContain(`File: ${modelsPath}`); + }); + it("rewrites legacy uppercase models.json API key and header values", () => { const agentDir = createAgentDir(); fs.writeFileSync( From 28c83e83854ce4c93ce0ef257ce40a1158c6f771 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 9 Jun 2026 13:21:59 +0200 Subject: [PATCH 198/352] fix(coding-agent): sync queue modes on reload closes #5377 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/core/agent-session.ts | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 95c7561e..260175d9 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed `/reload` to apply updated `steeringMode` and `followUpMode` settings to the current session ([#5377](https://github.com/earendil-works/pi/issues/5377)). - Fixed invalid `models.json` syntax to skip startup config migrations and report the normal file-path-aware models error instead of a raw JSON parse stack trace ([#5418](https://github.com/earendil-works/pi/issues/5418)). - Fixed GitHub release notes and interactive changelog links to resolve package-relative documentation URLs correctly ([#5516](https://github.com/earendil-works/pi/issues/5516)). diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 514bb0ad..201b8084 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1606,6 +1606,11 @@ export class AgentSession { // Queue Mode Management // ========================================================================= + private syncQueueModesFromSettings(): void { + this.agent.steeringMode = this.settingsManager.getSteeringMode(); + this.agent.followUpMode = this.settingsManager.getFollowUpMode(); + } + /** * Set steering message mode. * Saves to settings. @@ -2431,6 +2436,7 @@ export class AgentSession { const previousFlagValues = this._extensionRunner.getFlagValues(); await emitSessionShutdownEvent(this._extensionRunner, { type: "session_shutdown", reason: "reload" }); await this.settingsManager.reload(); + this.syncQueueModesFromSettings(); resetApiProviders(); await this._resourceLoader.reload(); this._buildRuntime({ From 66335d3a49c43b4025ca67c5f50e1ccb35e3ea9c Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Tue, 9 Jun 2026 13:23:07 +0200 Subject: [PATCH 199/352] feat(coding-agent): add experimental feature guard (#5547) --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/experimental.ts | 3 ++ packages/coding-agent/src/core/index.ts | 1 + .../coding-agent/test/experimental.test.ts | 44 +++++++++++++++++++ 4 files changed, 49 insertions(+) create mode 100644 packages/coding-agent/src/core/experimental.ts create mode 100644 packages/coding-agent/test/experimental.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 260175d9..8235501a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added `areExperimentalFeaturesEnabled` feature guard to allow users to opt-in to early features. - Added `ctx.isProjectTrusted()` for extensions to observe the effective project trust decision, including temporary trust decisions ([#5523](https://github.com/earendil-works/pi/issues/5523)). ### Fixed diff --git a/packages/coding-agent/src/core/experimental.ts b/packages/coding-agent/src/core/experimental.ts new file mode 100644 index 00000000..12d33c74 --- /dev/null +++ b/packages/coding-agent/src/core/experimental.ts @@ -0,0 +1,3 @@ +export function areExperimentalFeaturesEnabled(): boolean { + return process.env.PI_EXPERIMENTAL === "1"; +} diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts index 71c45e9c..b7654f42 100644 --- a/packages/coding-agent/src/core/index.ts +++ b/packages/coding-agent/src/core/index.ts @@ -28,6 +28,7 @@ export { export { type BashExecutorOptions, type BashResult, executeBashWithOperations } from "./bash-executor.ts"; export type { CompactionResult } from "./compaction/index.ts"; export { createEventBus, type EventBus, type EventBusController } from "./event-bus.ts"; +export { areExperimentalFeaturesEnabled } from "./experimental.ts"; // Extensions system export { type AgentEndEvent, diff --git a/packages/coding-agent/test/experimental.test.ts b/packages/coding-agent/test/experimental.test.ts new file mode 100644 index 00000000..665616e8 --- /dev/null +++ b/packages/coding-agent/test/experimental.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { areExperimentalFeaturesEnabled } from "../src/core/experimental.ts"; + +describe("areExperimentalFeaturesEnabled", () => { + const originalPiExperimental = process.env.PI_EXPERIMENTAL; + + afterEach(() => { + if (originalPiExperimental === undefined) { + delete process.env.PI_EXPERIMENTAL; + } else { + process.env.PI_EXPERIMENTAL = originalPiExperimental; + } + }); + + it("returns false when PI_EXPERIMENTAL is unset", () => { + delete process.env.PI_EXPERIMENTAL; + + expect(areExperimentalFeaturesEnabled()).toBe(false); + }); + + it("returns false when PI_EXPERIMENTAL is empty", () => { + process.env.PI_EXPERIMENTAL = ""; + + expect(areExperimentalFeaturesEnabled()).toBe(false); + }); + + it("returns true when PI_EXPERIMENTAL is set to 1", () => { + process.env.PI_EXPERIMENTAL = "1"; + + expect(areExperimentalFeaturesEnabled()).toBe(true); + }); + + it("returns false when PI_EXPERIMENTAL is set to 0", () => { + process.env.PI_EXPERIMENTAL = "0"; + + expect(areExperimentalFeaturesEnabled()).toBe(false); + }); + + it("returns false when PI_EXPERIMENTAL is set to a non-1 value", () => { + process.env.PI_EXPERIMENTAL = "true"; + + expect(areExperimentalFeaturesEnabled()).toBe(false); + }); +}); From 5cb4f597f790400e541ea5299b50e1f2607b88c5 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 9 Jun 2026 13:25:54 +0200 Subject: [PATCH 200/352] feat(ui): Improved project approval settings --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/README.md | 18 +- packages/coding-agent/docs/extensions.md | 4 +- packages/coding-agent/docs/packages.md | 2 - packages/coding-agent/docs/security.md | 14 +- packages/coding-agent/docs/settings.md | 11 +- packages/coding-agent/docs/usage.md | 19 +- packages/coding-agent/src/cli/args.ts | 2 +- .../coding-agent/src/cli/project-trust.ts | 62 +++++ packages/coding-agent/src/cli/startup-ui.ts | 87 ++++++ .../coding-agent/src/core/project-trust.ts | 95 +++++++ .../coding-agent/src/core/resource-loader.ts | 48 ++-- .../coding-agent/src/core/settings-manager.ts | 14 + .../coding-agent/src/core/trust-manager.ts | 105 ++++++- packages/coding-agent/src/index.ts | 9 +- packages/coding-agent/src/main.ts | 260 +----------------- .../components/settings-selector.ts | 28 +- .../interactive/components/trust-selector.ts | 66 +++-- .../src/modes/interactive/interactive-mode.ts | 14 +- .../coding-agent/src/package-manager-cli.ts | 96 ++++++- .../test/package-command-paths.test.ts | 64 +++++ .../coding-agent/test/resource-loader.test.ts | 4 +- .../test/settings-manager.test.ts | 17 ++ .../coding-agent/test/trust-manager.test.ts | 53 +++- .../coding-agent/test/trust-selector.test.ts | 45 ++- 25 files changed, 767 insertions(+), 374 deletions(-) create mode 100644 packages/coding-agent/src/cli/project-trust.ts create mode 100644 packages/coding-agent/src/cli/startup-ui.ts create mode 100644 packages/coding-agent/src/core/project-trust.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8235501a..ab19a02e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,8 +4,12 @@ ### Added +<<<<<<< Updated upstream - Added `areExperimentalFeaturesEnabled` feature guard to allow users to opt-in to early features. - Added `ctx.isProjectTrusted()` for extensions to observe the effective project trust decision, including temporary trust decisions ([#5523](https://github.com/earendil-works/pi/issues/5523)). +======= +- Added a global `defaultProjectTrust` setting to choose whether unresolved project trust asks, always trusts, or never trusts by default. +>>>>>>> Stashed changes ### Fixed diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 202b6f3e..6e5c7059 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -291,15 +291,17 @@ See [docs/settings.md](docs/settings.md) for all options. ### Project Trust -On interactive startup, pi asks before trusting a project folder that contains project-local inputs and has no saved decision in `~/.pi/agent/trust.json`. Trusting a project allows pi to read project instructions (`AGENTS.md`/`CLAUDE.md`), load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. +On interactive startup, pi asks before trusting a project folder that contains project-local extensions or settings and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. -Before the trust decision, pi loads only user/global extensions and CLI `-e` extensions so they can handle the `project_trust` event. Project-local extensions, project package-managed extensions, project settings, and project instructions are loaded only after the project is trusted. This split also applies when switching to a session from a different cwd whose trust has not been resolved in the current process. +Before the trust decision, pi loads only context files, user/global extensions, and CLI `-e` extensions so they can handle the `project_trust` event. Project-local extensions, project package-managed extensions, and project settings are loaded only after the project is trusted. This split also applies when switching to a session from a different cwd whose trust has not been resolved in the current process. -Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without a saved trust decision, they ignore project-local inputs unless `--approve`/`-a` is passed. Use `--no-approve`/`-na` to ignore project-local inputs for one run even when the project is trusted. +Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore trust-gated project inputs, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. -`pi config` assumes project trust for that command so you can view and change project resource settings before starting a session. It does not save a trust decision; starting a session in that folder still prompts. Pass `--no-approve` to hide project-local inputs in `pi config`. +If no extension or saved decision applies, `defaultProjectTrust` controls the fallback behavior. Set it to `"ask"`, `"always"`, or `"never"` in `~/.pi/agent/settings.json`, or change it with `/settings`. -Use `/trust` in interactive mode to save a project trust decision for future sessions. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect. +`pi config` and package commands use the same project trust flow. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them. + +Use `/trust` in interactive mode to save a project trust decision for future sessions, including trust for the immediate parent folder. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect. ### Telemetry and update checks @@ -316,8 +318,8 @@ Use `--offline` or `PI_OFFLINE=1` to disable all startup network operations desc Pi loads `AGENTS.md` (or `CLAUDE.md`) at startup from: - `~/.pi/agent/AGENTS.md` (global) -- Parent directories (walking up from cwd, only when the project is trusted) -- Current directory (only when the project is trusted) +- Parent directories (walking up from cwd) +- Current directory Use for project instructions (`AGENTS.md`/`CLAUDE.md`), conventions, common commands. All matching files are concatenated. @@ -525,7 +527,7 @@ pi list # List installed packages pi config # Enable/disable package resources ``` -Project package commands accept `--approve`/`--no-approve` to trust or ignore project-local package settings for one command. +`pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command. ### Modes diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index a7f4745f..ab5747ab 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -339,7 +339,7 @@ exit (Ctrl+C, Ctrl+D, SIGHUP, SIGTERM) #### project_trust -Fired before pi decides whether to trust a project with trust inputs (`.pi`, `AGENTS.md`/`CLAUDE.md`, or `.agents/skills`). It runs during startup and when session replacement (for example `/resume`) enters a cwd whose trust has not been resolved in the current process. Only user/global extensions and CLI `-e` extensions participate; project-local extensions are not loaded until after trust is resolved. +Fired before pi decides whether to trust a project with dynamic configs (`.pi` or `.agents/skills`). It runs during startup and when session replacement (for example `/resume`) enters a cwd whose trust has not been resolved in the current process. Only user/global extensions and CLI `-e` extensions participate; project-local extensions are not loaded until after trust is resolved. ```typescript pi.on("project_trust", async (event, ctx) => { @@ -352,7 +352,7 @@ pi.on("project_trust", async (event, ctx) => { }); ``` -A `project_trust` handler must return `{ trusted: "yes" | "no" | "undecided" }`. A user/global or CLI extension that returns `"yes"` or `"no"` owns the decision; the first yes/no decision wins and suppresses the built-in trust prompt. Use `remember: true` to persist a yes/no decision; otherwise it applies only to the current process. Return `"undecided"` to let later handlers or the built-in trust flow decide. Check `ctx.hasUI` before prompting. If no handler returns yes/no, normal trust resolution continues, including the built-in trust prompt when UI is available. +A `project_trust` handler must return `{ trusted: "yes" | "no" | "undecided" }`. A user/global or CLI extension that returns `"yes"` or `"no"` owns the decision; the first yes/no decision wins and suppresses the built-in trust prompt. Use `remember: true` to persist a yes/no decision; otherwise it applies only to the current process. Return `"undecided"` to let later handlers or the built-in trust flow decide. Check `ctx.hasUI` before prompting. If no handler returns yes/no, normal trust resolution continues: saved `trust.json` decisions apply first, then `defaultProjectTrust` controls whether pi asks, trusts, or declines by default. ### Resource Events diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index 1469ea98..7009b773 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -40,8 +40,6 @@ These commands manage pi packages, not the pi CLI installation. To uninstall pi 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. -Project package commands read project settings only when the project is trusted. Use `--approve` to trust project-local files for one command, or `--no-approve` to ignore them for one command. - To try a package without installing it, use `--extension` or `-e`. This installs to a temporary directory for the current run only: ```bash diff --git a/packages/coding-agent/docs/security.md b/packages/coding-agent/docs/security.md index 1e70a2d5..0c6d387a 100644 --- a/packages/coding-agent/docs/security.md +++ b/packages/coding-agent/docs/security.md @@ -4,27 +4,25 @@ Pi is a local coding agent. It runs with the permissions of the user account tha ## Project Trust -Project trust controls whether pi loads project-local inputs. It is not a sandbox and it does not restrict what the model can ask tools to do after you start working in a directory. +Project trust controls whether pi loads project-local settings, resources, packages, and extensions. It is not a sandbox and it does not restrict what the model can ask tools to do after you start working in a directory. Pi considers a project to have trust inputs when it finds any of these from the current working directory: - `.pi/` in the current directory -- `AGENTS.md` or `CLAUDE.md` in the current directory or an ancestor directory - `.agents/skills` in the current directory or an ancestor directory -When an interactive session starts in a project with trust inputs and no saved decision, pi asks whether to trust the project. Saved decisions are stored per canonical working directory in `~/.pi/agent/trust.json`. +When an interactive session starts in a project with configs in `.pi` or `.agents/skills` and no saved decision for the current directory or a parent directory, pi follows `defaultProjectTrust` from global settings. The default value is `"ask"`, which asks whether to trust the project when UI is available. Saved decisions are stored by canonical directory in `~/.pi/agent/trust.json`, and the closest saved decision on the current or parent path applies before the global default. -Trusting a project allows pi to load project-local inputs, including: +Trusting a project allows pi to load trust-gated project inputs, including: -- project instructions from `AGENTS.md` or `CLAUDE.md` - `.pi/settings.json` - `.pi` resources such as extensions, skills, prompt templates, themes, and system prompt files - missing project packages configured through project settings - project-local extensions and project package-managed extensions -Declining trust skips those project-local inputs. Before trust is resolved, pi only loads user/global extensions and CLI `-e` extensions. User/global and CLI extensions can handle the `project_trust` event; the first extension that returns a yes/no decision owns the decision. +Declining trust skips protected resources. `AGENTS.md` and `CLAUDE.md` context files are loaded regardless of project trust unless context loading is disabled. Before trust is resolved, pi only loads context files, user/global extensions, and CLI `-e` extensions. User/global and CLI extensions can handle the `project_trust` event; the first extension that returns a yes/no decision owns the decision. -Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without a saved trust decision, they ignore project-local inputs unless `--approve`/`-a` is passed. Use `--no-approve`/`-na` to ignore project-local inputs for one run even when the project is trusted. +Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, `defaultProjectTrust: "ask"` and `"never"` ignore such resources, while `"always"` trusts them. Use `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. ## No Built-in Sandbox @@ -32,7 +30,7 @@ Pi does not include a built-in sandbox. Built-in tools can read files, write fil This is intentional. Pi is designed to operate on local source trees, invoke project toolchains, and integrate with the user's existing development environment. A partial in-process sandbox would be easy to misunderstand as a security boundary while still depending on the host shell, filesystem, package managers, credentials, and extension code. Real isolation needs to come from the operating system or a virtualization/container boundary. -Project trust is only an input-loading guard. It prevents a repository from silently changing pi's instructions, settings, or extensions before you approve it. It does not make untrusted code, untrusted prompts, or untrusted model output safe. Prompt injection from repository files, comments, documentation, or build output is expected local-agent risk and cannot be reliably prevented by pi. +Project trust is only an input-loading guard. It prevents a repository from silently changing pi's settings or extensions before you approve it. It does not make untrusted code, untrusted prompts, or untrusted model output safe. Prompt injection from repository files, comments, documentation, context files, or build output is expected local-agent risk and cannot be reliably prevented by pi. ## Running Untrusted or Unmonitored Work diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index df2d0bd6..3c4e9e6f 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -11,13 +11,15 @@ Edit directly or use `/settings` for common options. ## Project Trust -On interactive startup, pi asks before trusting a project folder that contains project-local inputs and has no saved decision in `~/.pi/agent/trust.json`. Trusting a project allows pi to read project instructions (`AGENTS.md`/`CLAUDE.md`), load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. +On interactive startup, pi asks before trusting a project folder that contains trust-gated project inputs and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. -Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without a saved trust decision, they ignore project-local inputs unless `--approve`/`-a` is passed. Use `--no-approve`/`-na` to ignore project-local inputs for one run even when the project is trusted. +Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore trust-gated project inputs, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. -`pi config` assumes project trust for that command so you can view and change project resource settings before starting a session. It does not save a trust decision; starting a session in that folder still prompts. Pass `--no-approve` to hide project-local inputs in `pi config`. +If no extension or saved decision applies, `defaultProjectTrust` controls the fallback behavior. Set it to `"ask"`, `"always"`, or `"never"` in `~/.pi/agent/settings.json`, or change it with `/settings`. -Use `/trust` in interactive mode to save a project trust decision for future sessions. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect. +`pi config` and package commands use the same project trust flow. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them. + +Use `/trust` in interactive mode to save a project trust decision for future sessions, including trust for the immediate parent folder. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect. ## All Settings @@ -50,6 +52,7 @@ Use `/trust` in interactive mode to save a project trust decision for future ses |---------|------|---------|-------------| | `theme` | string | `"dark"` | Theme name (`"dark"`, `"light"`, or custom) | | `quietStartup` | boolean | `false` | Hide startup header | +| `defaultProjectTrust` | string | `"ask"` | Fallback project trust behavior: `"ask"`, `"always"`, or `"never"`. Global setting only | | `collapseChangelog` | boolean | `false` | Show condensed changelog after updates | | `enableInstallTelemetry` | boolean | `true` | Send an anonymous install/update version ping after first install or changelog-detected updates. This does not control update checks | | `doubleEscapeAction` | string | `"tree"` | Action for double-escape: `"tree"`, `"fork"`, or `"none"` | diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index ad9b80e1..bb8a4c25 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -96,8 +96,8 @@ See [Sessions](sessions.md) and [Compaction](compaction.md) for details. Pi loads `AGENTS.md` or `CLAUDE.md` at startup from: - `~/.pi/agent/AGENTS.md` for global instructions -- parent directories, walking up from the current working directory when the project is trusted -- the current directory when the project is trusted +- parent directories, walking up from the current working directory +- the current directory Use context files for project conventions, commands, safety rules, and preferences. Disable loading with `--no-context-files` or `-nc`. @@ -112,13 +112,18 @@ Append to the default prompt without replacing it with `APPEND_SYSTEM.md` in eit ### Project Trust -On interactive startup, pi asks before trusting a project folder that contains project-local inputs and has no saved decision in `~/.pi/agent/trust.json`. Trusting a project allows pi to read project instructions (`AGENTS.md`/`CLAUDE.md`), load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. +On interactive startup, pi asks before trusting a project folder that contains project-local extensions or settings and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. -Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without a saved trust decision, they ignore project-local inputs unless `--approve`/`-a` is passed. Use `--no-approve`/`-na` to ignore project-local inputs for one run even when the project is trusted. +Before the trust decision, pi loads only context files, user/global extensions, and CLI `-e` extensions so they can handle the `project_trust` event. Project-local extensions, project package-managed extensions, and project settings are loaded only after the project is trusted. This split also applies when switching to a session from a different cwd whose trust has not been resolved in the current process. -`pi config` assumes project trust for that command so you can view and change project resource settings before starting a session. It does not save a trust decision; starting a session in that folder still prompts. Pass `--no-approve` to hide project-local inputs in `pi config`. +Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore trust-gated project inputs, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. + +If no extension or saved decision applies, `defaultProjectTrust` controls the fallback behavior. Set it to `"ask"`, `"always"`, or `"never"` in `~/.pi/agent/settings.json`, or change it with `/settings`. + +`pi config` and package commands use the same project trust flow. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them. + +Use `/trust` in interactive mode to save a project trust decision for future sessions, including trust for the immediate parent folder. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect. -Use `/trust` in interactive mode to save a project trust decision for future sessions. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect. ## Exporting and Sharing Sessions @@ -148,7 +153,7 @@ pi list # List installed packages pi config # Enable/disable package resources ``` -These commands manage pi packages, not the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). Project package commands accept `--approve`/`--no-approve` to trust or ignore project-local package settings for one command. +These commands manage pi packages, not the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). `pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command. See [Pi Packages](packages.md) for package sources and security notes. diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 0258c0c1..ff747400 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -232,7 +232,7 @@ ${chalk.bold("Commands:")} ${APP_NAME} update [source|self|pi] Update pi and installed extensions ${APP_NAME} list [--approve|--no-approve] List installed extensions from settings - ${APP_NAME} config [--no-approve] + ${APP_NAME} config [--approve|--no-approve] Open TUI to enable/disable package resources ${APP_NAME} --help Show help for install/remove/uninstall/update/list diff --git a/packages/coding-agent/src/cli/project-trust.ts b/packages/coding-agent/src/cli/project-trust.ts new file mode 100644 index 00000000..b27871a9 --- /dev/null +++ b/packages/coding-agent/src/cli/project-trust.ts @@ -0,0 +1,62 @@ +import chalk from "chalk"; +import type { ProjectTrustContext } from "../core/extensions/types.ts"; +import type { AppMode } from "../core/project-trust.ts"; +import type { SettingsManager } from "../core/settings-manager.ts"; +import { showStartupInput, showStartupSelector } from "./startup-ui.ts"; + +export function createProjectTrustContext(options: { + cwd: string; + mode: AppMode; + settingsManager: SettingsManager; + hasUI: boolean; +}): ProjectTrustContext { + return { + cwd: options.cwd, + mode: options.mode === "interactive" ? "tui" : options.mode, + hasUI: options.hasUI, + ui: { + select: async (title, selectOptions) => { + if (!options.hasUI) { + return undefined; + } + if (options.mode !== "interactive") { + return undefined; + } + return showStartupSelector( + options.settingsManager, + title, + selectOptions.map((option) => ({ label: option, value: option })), + ); + }, + confirm: async (title, message) => { + if (!options.hasUI) { + return false; + } + if (options.mode !== "interactive") { + return false; + } + return ( + (await showStartupSelector(options.settingsManager, `${title}\n${message}`, [ + { label: "Yes", value: true }, + { label: "No", value: false }, + ])) ?? false + ); + }, + input: async (title, placeholder) => { + if (!options.hasUI) { + return undefined; + } + if (options.mode !== "interactive") { + return undefined; + } + return showStartupInput(options.settingsManager, title, placeholder); + }, + notify: (message, type = "info") => { + if (options.mode !== "interactive") { + const color = type === "error" ? chalk.red : type === "warning" ? chalk.yellow : chalk.cyan; + console.error(color(message)); + } + }, + }, + }; +} diff --git a/packages/coding-agent/src/cli/startup-ui.ts b/packages/coding-agent/src/cli/startup-ui.ts new file mode 100644 index 00000000..1f17013c --- /dev/null +++ b/packages/coding-agent/src/cli/startup-ui.ts @@ -0,0 +1,87 @@ +import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui"; +import { KeybindingsManager } from "../core/keybindings.ts"; +import type { SettingsManager } from "../core/settings-manager.ts"; +import { ExtensionInputComponent } from "../modes/interactive/components/extension-input.ts"; +import { ExtensionSelectorComponent } from "../modes/interactive/components/extension-selector.ts"; +import { initTheme } from "../modes/interactive/theme/theme.ts"; + +function createStartupTui(settingsManager: SettingsManager): TUI { + initTheme(settingsManager.getTheme()); + setKeybindings(KeybindingsManager.create()); + const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor()); + ui.setClearOnShrink(settingsManager.getClearOnShrink()); + return ui; +} + +async function clearStartupTui(ui: TUI): Promise { + ui.clear(); + ui.requestRender(); + await new Promise((resolve) => setTimeout(resolve, 25)); +} + +export async function showStartupSelector( + settingsManager: SettingsManager, + title: string, + options: Array<{ label: string; value: T }>, +): Promise { + return new Promise((resolve) => { + const ui = createStartupTui(settingsManager); + + let settled = false; + const finish = async (result: T | undefined) => { + if (settled) { + return; + } + settled = true; + await clearStartupTui(ui); + ui.stop(); + resolve(result); + }; + + const selector = new ExtensionSelectorComponent( + title, + options.map((option) => option.label), + (option) => void finish(options.find((entry) => entry.label === option)?.value), + () => void finish(undefined), + { tui: ui }, + ); + ui.addChild(selector); + ui.setFocus(selector); + ui.start(); + }); +} + +export async function showStartupInput( + settingsManager: SettingsManager, + title: string, + placeholder?: string, +): Promise { + return new Promise((resolve) => { + const ui = createStartupTui(settingsManager); + + let settled = false; + const finish = async (result: string | undefined) => { + if (settled) { + return; + } + settled = true; + input.dispose(); + await clearStartupTui(ui); + ui.stop(); + resolve(result); + }; + + const input = new ExtensionInputComponent( + title, + placeholder, + (value) => void finish(value), + () => void finish(undefined), + { + tui: ui, + }, + ); + ui.addChild(input); + ui.setFocus(input); + ui.start(); + }); +} diff --git a/packages/coding-agent/src/core/project-trust.ts b/packages/coding-agent/src/core/project-trust.ts new file mode 100644 index 00000000..c8b57250 --- /dev/null +++ b/packages/coding-agent/src/core/project-trust.ts @@ -0,0 +1,95 @@ +import { emitProjectTrustEvent } from "./extensions/runner.ts"; +import type { LoadExtensionsResult, ProjectTrustContext } from "./extensions/types.ts"; +import type { DefaultProjectTrust } from "./settings-manager.ts"; +import { + getProjectTrustOptions, + hasProjectTrustInputs, + type ProjectTrustOption, + type ProjectTrustStore, +} from "./trust-manager.ts"; + +export type AppMode = "interactive" | "print" | "json" | "rpc"; + +export interface ResolveProjectTrustedOptions { + cwd: string; + trustStore: ProjectTrustStore; + trustOverride?: boolean; + defaultProjectTrust?: DefaultProjectTrust; + extensionsResult?: LoadExtensionsResult; + projectTrustContext: ProjectTrustContext; + onExtensionError?: (message: string) => void; +} + +function formatProjectTrustPrompt(cwd: string): string { + return `Trust project folder?\n${cwd}\n\nThis allows pi to load .pi settings and resources, install missing project packages, and execute project extensions.`; +} + +async function selectProjectTrustOption( + cwd: string, + ctx: ProjectTrustContext, +): Promise { + const options = getProjectTrustOptions(cwd, { includeSessionOnly: true }); + const selected = await ctx.ui.select( + formatProjectTrustPrompt(cwd), + options.map((option) => option.label), + ); + return options.find((option) => option.label === selected); +} + +function saveProjectTrustPromptResult(trustStore: ProjectTrustStore, result: ProjectTrustOption): void { + if (result.updates.length > 0) { + trustStore.setMany(result.updates); + } +} + +export async function resolveProjectTrusted(options: ResolveProjectTrustedOptions): Promise { + if (options.trustOverride !== undefined) { + return options.trustOverride; + } + if (!hasProjectTrustInputs(options.cwd)) { + return true; + } + + if (options.extensionsResult) { + const { result, errors } = await emitProjectTrustEvent( + options.extensionsResult, + { type: "project_trust", cwd: options.cwd }, + options.projectTrustContext, + ); + for (const error of errors) { + options.onExtensionError?.(`Extension "${error.extensionPath}" project_trust error: ${error.error}`); + } + if (result) { + const trusted = result.trusted === "yes"; + if (result.remember === true) { + options.trustStore.set(options.cwd, trusted); + } + return trusted; + } + } + + const decision = options.trustStore.get(options.cwd); + if (decision !== null) { + return decision; + } + + switch (options.defaultProjectTrust ?? "ask") { + case "always": + return true; + case "never": + return false; + case "ask": + break; + } + + if (!options.projectTrustContext.hasUI) { + return false; + } + + const selected = await selectProjectTrustOption(options.cwd, options.projectTrustContext); + if (selected !== undefined) { + saveProjectTrustPromptResult(options.trustStore, selected); + return selected.trusted; + } + return false; +} diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index 394679bb..b35787af 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -79,7 +79,6 @@ function loadContextFileFromDir(dir: string): { path: string; content: string } export function loadProjectContextFiles(options: { cwd: string; agentDir: string; - projectTrusted?: boolean; }): Array<{ path: string; content: string }> { const resolvedCwd = resolvePath(options.cwd); const resolvedAgentDir = resolvePath(options.agentDir); @@ -93,29 +92,27 @@ export function loadProjectContextFiles(options: { seenPaths.add(globalContext.path); } - if (options.projectTrusted !== false) { - const ancestorContextFiles: Array<{ path: string; content: string }> = []; + const ancestorContextFiles: Array<{ path: string; content: string }> = []; - let currentDir = resolvedCwd; - const root = resolve("/"); + let currentDir = resolvedCwd; + const root = resolve("/"); - while (true) { - const contextFile = loadContextFileFromDir(currentDir); - if (contextFile && !seenPaths.has(contextFile.path)) { - ancestorContextFiles.unshift(contextFile); - seenPaths.add(contextFile.path); - } - - if (currentDir === root) break; - - const parentDir = resolve(currentDir, ".."); - if (parentDir === currentDir) break; - currentDir = parentDir; + while (true) { + const contextFile = loadContextFileFromDir(currentDir); + if (contextFile && !seenPaths.has(contextFile.path)) { + ancestorContextFiles.unshift(contextFile); + seenPaths.add(contextFile.path); } - contextFiles.push(...ancestorContextFiles); + if (currentDir === root) break; + + const parentDir = resolve(currentDir, ".."); + if (parentDir === currentDir) break; + currentDir = parentDir; } + contextFiles.push(...ancestorContextFiles); + return contextFiles; } @@ -325,14 +322,18 @@ export class DefaultResourceLoader implements ResourceLoader { } } + async loadProjectTrustExtensions(): Promise { + // Force untrusted project settings for the bootstrap pass. This keeps project-local + // extensions/packages out while still loading user/global and temporary CLI extensions. + this.settingsManager.setProjectTrusted(false); + await this.settingsManager.reload(); + return this.loadCurrentExtensionSet({ includeInlineFactories: true }); + } + async reload(options?: ResourceLoaderReloadOptions): Promise { let preTrustExtensions: LoadExtensionsResult | undefined; if (options?.resolveProjectTrust) { - // Force untrusted project settings for the bootstrap pass. This keeps project-local - // extensions/packages out while still loading user/global and temporary CLI extensions. - this.settingsManager.setProjectTrusted(false); - await this.settingsManager.reload(); - preTrustExtensions = await this.loadCurrentExtensionSet({ includeInlineFactories: true }); + preTrustExtensions = await this.loadProjectTrustExtensions(); const projectTrusted = await options.resolveProjectTrust({ extensionsResult: preTrustExtensions }); this.settingsManager.setProjectTrusted(projectTrusted); } @@ -454,7 +455,6 @@ export class DefaultResourceLoader implements ResourceLoader { : loadProjectContextFiles({ cwd: this.cwd, agentDir: this.agentDir, - projectTrusted: this.settingsManager.isProjectTrusted(), }), }; const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles; diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 2ef32d6d..058e84e6 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -57,6 +57,8 @@ export interface WarningSettings { anthropicExtraUsage?: boolean; // default: true } +export type DefaultProjectTrust = "ask" | "always" | "never"; + export type TransportSetting = Transport; /** @@ -89,6 +91,7 @@ export interface Settings { hideThinkingBlock?: boolean; shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows) quietStartup?: boolean; + defaultProjectTrust?: DefaultProjectTrust; // default: "ask"; global setting only shellCommandPrefix?: string; // Prefix prepended to every bash command (e.g., "shopt -s expand_aliases" for alias support) npmCommand?: string[]; // Command used for npm package lookup/install operations, argv-style (e.g., ["mise", "exec", "node@20", "--", "npm"]) collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full) @@ -853,6 +856,17 @@ export class SettingsManager { this.save(); } + getDefaultProjectTrust(): DefaultProjectTrust { + const value = this.globalSettings.defaultProjectTrust; + return value === "always" || value === "never" ? value : "ask"; + } + + setDefaultProjectTrust(defaultProjectTrust: DefaultProjectTrust): void { + this.globalSettings.defaultProjectTrust = defaultProjectTrust; + this.markModified("defaultProjectTrust"); + this.save(); + } + getShellCommandPrefix(): string | undefined { return this.settings.shellCommandPrefix; } diff --git a/packages/coding-agent/src/core/trust-manager.ts b/packages/coding-agent/src/core/trust-manager.ts index c86c8518..69f616ae 100644 --- a/packages/coding-agent/src/core/trust-manager.ts +++ b/packages/coding-agent/src/core/trust-manager.ts @@ -6,14 +6,87 @@ import { canonicalizePath, resolvePath } from "../utils/paths.ts"; export type ProjectTrustDecision = boolean | null; -type TrustFile = Record; +export interface ProjectTrustStoreEntry { + path: string; + decision: boolean; +} -const CONTEXT_FILE_NAMES = ["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]; +export interface ProjectTrustUpdate { + path: string; + decision: ProjectTrustDecision; +} + +export interface ProjectTrustOption { + label: string; + trusted: boolean; + updates: ProjectTrustUpdate[]; + savedPath?: string; +} + +type TrustFile = Record; function normalizeCwd(cwd: string): string { return canonicalizePath(resolvePath(cwd)); } +function findNearestTrustEntry(data: TrustFile, cwd: string): ProjectTrustStoreEntry | null { + let currentDir = normalizeCwd(cwd); + while (true) { + const value = data[currentDir]; + if (value === true || value === false) { + return { path: currentDir, decision: value }; + } + + const parentDir = dirname(currentDir); + if (parentDir === currentDir) { + return null; + } + currentDir = parentDir; + } +} + +export function getProjectTrustPath(cwd: string): string { + return normalizeCwd(cwd); +} + +export function getProjectTrustParentPath(cwd: string): string | undefined { + const trustPath = getProjectTrustPath(cwd); + const parentDir = dirname(trustPath); + return parentDir === trustPath ? undefined : parentDir; +} + +export function getProjectTrustOptions(cwd: string, options?: { includeSessionOnly?: boolean }): ProjectTrustOption[] { + const trustPath = getProjectTrustPath(cwd); + const trustOptions: ProjectTrustOption[] = [ + { label: "Trust", trusted: true, updates: [{ path: trustPath, decision: true }], savedPath: trustPath }, + ]; + const parentPath = getProjectTrustParentPath(cwd); + if (parentPath !== undefined) { + trustOptions.push({ + label: `Trust parent folder (${parentPath})`, + trusted: true, + updates: [ + { path: parentPath, decision: true }, + { path: trustPath, decision: null }, + ], + savedPath: parentPath, + }); + } + if (options?.includeSessionOnly) { + trustOptions.push({ label: "Trust (this session only)", trusted: true, updates: [] }); + } + trustOptions.push({ + label: "Do not trust", + trusted: false, + updates: [{ path: trustPath, decision: false }], + savedPath: trustPath, + }); + if (options?.includeSessionOnly) { + trustOptions.push({ label: "Do not trust (this session only)", trusted: false, updates: [] }); + } + return trustOptions; +} + function readTrustFile(path: string): TrustFile { if (!existsSync(path)) { return {}; @@ -105,11 +178,6 @@ export function hasProjectTrustInputs(cwd: string): boolean { } while (true) { - for (const filename of CONTEXT_FILE_NAMES) { - if (existsSync(join(currentDir, filename))) { - return true; - } - } if (existsSync(join(currentDir, ".agents", "skills"))) { return true; } @@ -130,21 +198,30 @@ export class ProjectTrustStore { } get(cwd: string): ProjectTrustDecision { + return this.getEntry(cwd)?.decision ?? null; + } + + getEntry(cwd: string): ProjectTrustStoreEntry | null { return withTrustFileLock(this.trustPath, () => { const data = readTrustFile(this.trustPath); - const value = data[normalizeCwd(cwd)]; - return value === true || value === false ? value : null; + return findNearestTrustEntry(data, cwd); }); } set(cwd: string, decision: ProjectTrustDecision): void { + this.setMany([{ path: cwd, decision }]); + } + + setMany(decisions: ProjectTrustUpdate[]): void { withTrustFileLock(this.trustPath, () => { const data = readTrustFile(this.trustPath); - const key = normalizeCwd(cwd); - if (decision === null) { - delete data[key]; - } else { - data[key] = decision; + for (const { path, decision } of decisions) { + const key = normalizeCwd(path); + if (decision === null) { + delete data[key]; + } else { + data[key] = decision; + } } writeTrustFile(this.trustPath, data); }); diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 398f5430..958c7ebb 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -220,6 +220,7 @@ export { } from "./core/session-manager.ts"; export { type CompactionSettings, + type DefaultProjectTrust, type ImageSettings, type PackageSource, type RetrySettings, @@ -287,7 +288,13 @@ export { type WriteToolOptions, withFileMutationQueue, } from "./core/tools/index.ts"; -export { hasProjectTrustInputs, type ProjectTrustDecision, ProjectTrustStore } from "./core/trust-manager.ts"; +export { + hasProjectTrustInputs, + type ProjectTrustDecision, + ProjectTrustStore, + type ProjectTrustStoreEntry, + type ProjectTrustUpdate, +} from "./core/trust-manager.ts"; // Main entry point export { type MainOptions, main } from "./main.ts"; // Run modes for programmatic SDK usage diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 335d814a..a4f47dc4 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -7,13 +7,14 @@ import { createInterface } from "node:readline"; import { type ImageContent, modelsAreEqual } from "@earendil-works/pi-ai"; -import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui"; import chalk from "chalk"; import { type Args, type Mode, parseArgs, printHelp } from "./cli/args.ts"; import { processFileArguments } from "./cli/file-processor.ts"; import { buildInitialMessage } from "./cli/initial-message.ts"; import { listModels } from "./cli/list-models.ts"; +import { createProjectTrustContext } from "./cli/project-trust.ts"; import { selectSession } from "./cli/session-picker.ts"; +import { showStartupSelector } from "./cli/startup-ui.ts"; import { ENV_SESSION_DIR, expandTildePath, getAgentDir, getPackageDir, VERSION } from "./config.ts"; import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "./core/agent-session-runtime.ts"; import { @@ -24,13 +25,12 @@ import { import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts"; import { AuthStorage } from "./core/auth-storage.ts"; import { exportFromFile } from "./core/export-html/index.ts"; -import { emitProjectTrustEvent } from "./core/extensions/runner.ts"; -import type { ExtensionFactory, LoadExtensionsResult, ProjectTrustContext } from "./core/extensions/types.ts"; +import type { ExtensionFactory } from "./core/extensions/types.ts"; import { configureHttpDispatcher } from "./core/http-dispatcher.ts"; -import { KeybindingsManager } from "./core/keybindings.ts"; import type { ModelRegistry } from "./core/model-registry.ts"; import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.ts"; import { restoreStdout, takeOverStdout } from "./core/output-guard.ts"; +import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts"; import type { CreateAgentSessionOptions } from "./core/sdk.ts"; import { formatMissingSessionCwdPrompt, @@ -44,8 +44,6 @@ import { printTimings, resetTimings, time } from "./core/timings.ts"; import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts"; import { runMigrations, showDeprecationWarnings } from "./migrations.ts"; import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts"; -import { ExtensionInputComponent } from "./modes/interactive/components/extension-input.ts"; -import { ExtensionSelectorComponent } from "./modes/interactive/components/extension-selector.ts"; import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts"; import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts"; import { isLocalPath, normalizePath, resolvePath } from "./utils/paths.ts"; @@ -97,16 +95,14 @@ function isTruthyEnvFlag(value: string | undefined): boolean { return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes"; } -type AppMode = "interactive" | "print" | "json" | "rpc"; - -function resolveAppMode(parsed: Args, stdinIsTTY: boolean): AppMode { +function resolveAppMode(parsed: Args, stdinIsTTY: boolean, stdoutIsTTY: boolean): AppMode { if (parsed.mode === "rpc") { return "rpc"; } if (parsed.mode === "json") { return "json"; } - if (parsed.print || !stdinIsTTY) { + if (parsed.print || !stdinIsTTY || !stdoutIsTTY) { return "print"; } return "interactive"; @@ -439,87 +435,6 @@ function resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | u return paths?.map((value) => (isLocalPath(value) ? resolvePath(value, cwd) : value)); } -function createStartupTui(settingsManager: SettingsManager): TUI { - initTheme(settingsManager.getTheme()); - setKeybindings(KeybindingsManager.create()); - const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor()); - ui.setClearOnShrink(settingsManager.getClearOnShrink()); - return ui; -} - -async function clearStartupTui(ui: TUI): Promise { - ui.clear(); - ui.requestRender(); - await new Promise((resolve) => setTimeout(resolve, 25)); -} - -async function showStartupSelector( - settingsManager: SettingsManager, - title: string, - options: Array<{ label: string; value: T }>, -): Promise { - return new Promise((resolve) => { - const ui = createStartupTui(settingsManager); - - let settled = false; - const finish = async (result: T | undefined) => { - if (settled) { - return; - } - settled = true; - await clearStartupTui(ui); - ui.stop(); - resolve(result); - }; - - const selector = new ExtensionSelectorComponent( - title, - options.map((option) => option.label), - (option) => void finish(options.find((entry) => entry.label === option)?.value), - () => void finish(undefined), - { tui: ui }, - ); - ui.addChild(selector); - ui.setFocus(selector); - ui.start(); - }); -} - -async function showStartupInput( - settingsManager: SettingsManager, - title: string, - placeholder?: string, -): Promise { - return new Promise((resolve) => { - const ui = createStartupTui(settingsManager); - - let settled = false; - const finish = async (result: string | undefined) => { - if (settled) { - return; - } - settled = true; - input.dispose(); - await clearStartupTui(ui); - ui.stop(); - resolve(result); - }; - - const input = new ExtensionInputComponent( - title, - placeholder, - (value) => void finish(value), - () => void finish(undefined), - { - tui: ui, - }, - ); - ui.addChild(input); - ui.setFocus(input); - ui.start(); - }); -} - async function promptForMissingSessionCwd( issue: SessionCwdIssue, settingsManager: SettingsManager, @@ -530,160 +445,6 @@ async function promptForMissingSessionCwd( ]); } -interface ProjectTrustPromptResult { - trusted: boolean; - remember: boolean; -} - -const PROJECT_TRUST_PROMPT_OPTIONS: Array<{ label: string; value: ProjectTrustPromptResult }> = [ - { label: "Trust", value: { trusted: true, remember: true } }, - { label: "Trust (this session only)", value: { trusted: true, remember: false } }, - { label: "Do not trust", value: { trusted: false, remember: true } }, - { label: "Do not trust (this session only)", value: { trusted: false, remember: false } }, -]; - -function formatProjectTrustPrompt(cwd: string): string { - return `Trust project folder?\n${cwd}\n\nThis allows pi to read project instructions (AGENTS.md/CLAUDE.md), load .pi settings and resources, install missing project packages, and execute project extensions.`; -} - -async function promptForProjectTrust( - cwd: string, - settingsManager: SettingsManager, -): Promise { - return showStartupSelector(settingsManager, formatProjectTrustPrompt(cwd), PROJECT_TRUST_PROMPT_OPTIONS); -} - -async function promptForProjectTrustWithContext( - cwd: string, - ctx: ProjectTrustContext, -): Promise { - const selected = await ctx.ui.select( - formatProjectTrustPrompt(cwd), - PROJECT_TRUST_PROMPT_OPTIONS.map((option) => option.label), - ); - return PROJECT_TRUST_PROMPT_OPTIONS.find((option) => option.label === selected)?.value; -} - -function createProjectTrustContext(options: { - cwd: string; - mode: AppMode; - settingsManager: SettingsManager; - hasUI: boolean; -}): ProjectTrustContext { - return { - cwd: options.cwd, - mode: options.mode === "interactive" ? "tui" : options.mode, - hasUI: options.hasUI, - ui: { - select: async (title, selectOptions) => { - if (!options.hasUI) { - return undefined; - } - if (options.mode !== "interactive") { - return undefined; - } - return showStartupSelector( - options.settingsManager, - title, - selectOptions.map((option) => ({ label: option, value: option })), - ); - }, - confirm: async (title, message) => { - if (!options.hasUI) { - return false; - } - if (options.mode !== "interactive") { - return false; - } - return ( - (await showStartupSelector(options.settingsManager, `${title}\n${message}`, [ - { label: "Yes", value: true }, - { label: "No", value: false }, - ])) ?? false - ); - }, - input: async (title, placeholder) => { - if (!options.hasUI) { - return undefined; - } - if (options.mode !== "interactive") { - return undefined; - } - return showStartupInput(options.settingsManager, title, placeholder); - }, - notify: (message, type = "info") => { - if (options.mode !== "interactive") { - const color = type === "error" ? chalk.red : type === "warning" ? chalk.yellow : chalk.cyan; - console.error(color(message)); - } - }, - }, - }; -} - -async function resolveProjectTrusted(options: { - cwd: string; - trustStore: ProjectTrustStore; - trustOverride?: boolean; - appMode: AppMode; - settingsManagerForPrompt: SettingsManager; - extensionsResult?: LoadExtensionsResult; - projectTrustContext?: ProjectTrustContext; - onExtensionError?: (message: string) => void; -}): Promise { - if (options.trustOverride !== undefined) { - return options.trustOverride; - } - if (!hasProjectTrustInputs(options.cwd)) { - return true; - } - - if (options.extensionsResult && options.projectTrustContext) { - const { result, errors } = await emitProjectTrustEvent( - options.extensionsResult, - { type: "project_trust", cwd: options.cwd }, - options.projectTrustContext, - ); - for (const error of errors) { - options.onExtensionError?.(`Extension "${error.extensionPath}" project_trust error: ${error.error}`); - } - if (result) { - const trusted = result.trusted === "yes"; - if (result.remember === true) { - options.trustStore.set(options.cwd, trusted); - } - return trusted; - } - } - - const decision = options.trustStore.get(options.cwd); - if (decision !== null) { - return decision; - } - if (options.projectTrustContext?.hasUI) { - const selected = await promptForProjectTrustWithContext(options.cwd, options.projectTrustContext); - if (selected !== undefined) { - if (selected.remember) { - options.trustStore.set(options.cwd, selected.trusted); - } - return selected.trusted; - } - return false; - } - if (options.appMode !== "interactive") { - return false; - } - - const selected = await promptForProjectTrust(options.cwd, options.settingsManagerForPrompt); - if (selected !== undefined) { - if (selected.remember) { - options.trustStore.set(options.cwd, selected.trusted); - } - return selected.trusted; - } - return false; -} - export interface MainOptions { extensionFactories?: ExtensionFactory[]; } @@ -700,11 +461,11 @@ export async function main(args: string[], options?: MainOptions) { cleanupWindowsSelfUpdateQuarantine(getPackageDir()); } - if (await handlePackageCommand(args)) { + if (await handlePackageCommand(args, { extensionFactories: options?.extensionFactories })) { return; } - if (await handleConfigCommand(args)) { + if (await handleConfigCommand(args, { extensionFactories: options?.extensionFactories })) { return; } @@ -719,7 +480,7 @@ export async function main(args: string[], options?: MainOptions) { } } time("parseArgs"); - let appMode = resolveAppMode(parsed, process.stdin.isTTY); + let appMode = resolveAppMode(parsed, process.stdin.isTTY, process.stdout.isTTY); const shouldTakeOverStdout = appMode !== "interactive"; if (shouldTakeOverStdout) { takeOverStdout(); @@ -837,8 +598,7 @@ export async function main(args: string[], options?: MainOptions) { cwd, trustStore, trustOverride: parsed.projectTrustOverride, - appMode: isInitialRuntime ? trustPromptMode : "print", - settingsManagerForPrompt: startupSettingsManager, + defaultProjectTrust: startupSettingsManager.getDefaultProjectTrust(), extensionsResult, projectTrustContext: projectTrustContext ?? diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 7d210028..39d25f80 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -12,7 +12,7 @@ import { Text, } from "@earendil-works/pi-tui"; import { formatHttpIdleTimeoutMs, HTTP_IDLE_TIMEOUT_CHOICES } from "../../../core/http-dispatcher.ts"; -import type { WarningSettings } from "../../../core/settings-manager.ts"; +import type { DefaultProjectTrust, WarningSettings } from "../../../core/settings-manager.ts"; import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { keyDisplayText } from "./keybinding-hints.ts"; @@ -31,6 +31,16 @@ const THINKING_DESCRIPTIONS: Record = { xhigh: "Maximum reasoning (~32k tokens)", }; +const DEFAULT_PROJECT_TRUST_LABELS: Record = { + ask: "Ask", + always: "Always trust", + never: "Never trust", +}; + +const DEFAULT_PROJECT_TRUST_BY_LABEL = new Map( + Object.entries(DEFAULT_PROJECT_TRUST_LABELS).map(([value, label]) => [label, value as DefaultProjectTrust]), +); + export interface SettingsConfig { autoCompact: boolean; showImages: boolean; @@ -55,6 +65,7 @@ export interface SettingsConfig { editorPaddingX: number; autocompleteMaxVisible: number; quietStartup: boolean; + defaultProjectTrust: DefaultProjectTrust; clearOnShrink: boolean; showTerminalProgress: boolean; warnings: WarningSettings; @@ -83,6 +94,7 @@ export interface SettingsCallbacks { onEditorPaddingXChange: (padding: number) => void; onAutocompleteMaxVisibleChange: (maxVisible: number) => void; onQuietStartupChange: (enabled: boolean) => void; + onDefaultProjectTrustChange: (defaultProjectTrust: DefaultProjectTrust) => void; onClearOnShrinkChange: (enabled: boolean) => void; onShowTerminalProgressChange: (enabled: boolean) => void; onWarningsChange: (warnings: WarningSettings) => void; @@ -277,6 +289,13 @@ export class SettingsSelectorComponent extends Container { currentValue: config.enableInstallTelemetry ? "true" : "false", values: ["true", "false"], }, + { + id: "default-project-trust", + label: "Default project trust", + description: "Fallback behavior when no extension or saved trust decision decides project trust", + currentValue: DEFAULT_PROJECT_TRUST_LABELS[config.defaultProjectTrust], + values: Object.values(DEFAULT_PROJECT_TRUST_LABELS), + }, { id: "double-escape-action", label: "Double-escape action", @@ -512,6 +531,13 @@ export class SettingsSelectorComponent extends Container { case "install-telemetry": callbacks.onEnableInstallTelemetryChange(newValue === "true"); break; + case "default-project-trust": { + const defaultProjectTrust = DEFAULT_PROJECT_TRUST_BY_LABEL.get(newValue); + if (defaultProjectTrust) { + callbacks.onDefaultProjectTrustChange(defaultProjectTrust); + } + break; + } case "double-escape-action": callbacks.onDoubleEscapeActionChange(newValue as "fork" | "tree"); break; diff --git a/packages/coding-agent/src/modes/interactive/components/trust-selector.ts b/packages/coding-agent/src/modes/interactive/components/trust-selector.ts index b3664768..b7b1fe00 100644 --- a/packages/coding-agent/src/modes/interactive/components/trust-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/trust-selector.ts @@ -1,51 +1,51 @@ import { Container, getKeybindings, Spacer, Text } from "@earendil-works/pi-tui"; -import type { ProjectTrustDecision } from "../../../core/trust-manager.ts"; +import { + getProjectTrustOptions, + getProjectTrustPath, + type ProjectTrustOption, + type ProjectTrustStoreEntry, +} from "../../../core/trust-manager.ts"; import { theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { keyHint, rawKeyHint } from "./keybinding-hints.ts"; -interface TrustOption { - label: string; - trusted: boolean; -} +export type TrustSelection = Pick; export interface TrustSelectorOptions { cwd: string; - savedDecision: ProjectTrustDecision; + savedDecision: ProjectTrustStoreEntry | null; projectTrusted: boolean; - onSelect: (trusted: boolean) => void; + onSelect: (selection: TrustSelection) => void; onCancel: () => void; } -const TRUST_OPTIONS: TrustOption[] = [ - { label: "Trust", trusted: true }, - { label: "Do not trust", trusted: false }, -]; - -function formatDecision(decision: ProjectTrustDecision): string { - if (decision === true) { - return "trusted"; +function formatDecision(cwd: string, decision: ProjectTrustStoreEntry | null): string { + if (decision === null) { + return "none"; } - if (decision === false) { - return "untrusted"; + const label = decision.decision ? "trusted" : "untrusted"; + if (decision.path !== getProjectTrustPath(cwd)) { + return `${label} (inherited from ${decision.path})`; } - return "none"; + return `${label} (${decision.path})`; } export class TrustSelectorComponent extends Container { private selectedIndex: number; private readonly listContainer: Container; - private readonly savedDecision: ProjectTrustDecision; - private readonly onSelectCallback: (trusted: boolean) => void; + private readonly trustOptions: ProjectTrustOption[]; + private readonly savedDecision: ProjectTrustStoreEntry | null; + private readonly onSelectCallback: (selection: TrustSelection) => void; private readonly onCancelCallback: () => void; constructor(options: TrustSelectorOptions) { super(); this.savedDecision = options.savedDecision; + this.trustOptions = getProjectTrustOptions(options.cwd); this.selectedIndex = Math.max( 0, - TRUST_OPTIONS.findIndex((option) => option.trusted === options.savedDecision), + this.trustOptions.findIndex((option) => this.isSavedOption(option)), ); this.onSelectCallback = options.onSelect; this.onCancelCallback = options.onCancel; @@ -55,7 +55,9 @@ export class TrustSelectorComponent extends Container { this.addChild(new Text(theme.fg("accent", theme.bold("Project trust")), 1, 0)); this.addChild(new Text(theme.fg("muted", options.cwd), 1, 0)); this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("muted", `Saved decision: ${formatDecision(options.savedDecision)}`), 1, 0)); + this.addChild( + new Text(theme.fg("muted", `Saved decision: ${formatDecision(options.cwd, options.savedDecision)}`), 1, 0), + ); this.addChild( new Text(theme.fg("muted", `Current session: ${options.projectTrusted ? "trusted" : "untrusted"}`), 1, 0), ); @@ -81,16 +83,24 @@ export class TrustSelectorComponent extends Container { this.updateList(); } + private isSavedOption(option: ProjectTrustOption): boolean { + return ( + option.savedPath !== undefined && + this.savedDecision?.decision === option.trusted && + this.savedDecision.path === option.savedPath + ); + } + private updateList(): void { this.listContainer.clear(); - for (let i = 0; i < TRUST_OPTIONS.length; i++) { - const option = TRUST_OPTIONS[i]; + for (let i = 0; i < this.trustOptions.length; i++) { + const option = this.trustOptions[i]; if (!option) { continue; } const isSelected = i === this.selectedIndex; - const isCurrent = option.trusted === this.savedDecision; + const isCurrent = this.isSavedOption(option); const checkmark = isCurrent ? theme.fg("success", " ✓") : ""; const prefix = isSelected ? theme.fg("accent", "→ ") : " "; const label = isSelected ? theme.fg("accent", option.label) : theme.fg("text", option.label); @@ -104,12 +114,12 @@ export class TrustSelectorComponent extends Container { this.selectedIndex = Math.max(0, this.selectedIndex - 1); this.updateList(); } else if (kb.matches(keyData, "tui.select.down") || keyData === "j") { - this.selectedIndex = Math.min(TRUST_OPTIONS.length - 1, this.selectedIndex + 1); + this.selectedIndex = Math.min(this.trustOptions.length - 1, this.selectedIndex + 1); this.updateList(); } else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") { - const selected = TRUST_OPTIONS[this.selectedIndex]; + const selected = this.trustOptions[this.selectedIndex]; if (selected) { - this.onSelectCallback(selected.trusted); + this.onSelectCallback({ trusted: selected.trusted, updates: selected.updates }); } } else if (kb.matches(keyData, "tui.select.cancel")) { this.onCancelCallback(); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index fe73f454..4bacb19a 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -3277,7 +3277,7 @@ export class InteractiveMode { new Text( theme.fg( "warning", - "This project is not trusted. Project instructions (AGENTS.md/CLAUDE.md), .pi resources, and project packages are ignored. Use /trust to save a trust decision, then restart pi.", + "This project is not trusted. Project .pi resources and packages are ignored. Use /trust to save a trust decision, then restart pi.", ), 1, 0, @@ -3966,6 +3966,7 @@ export class InteractiveMode { doubleEscapeAction: this.settingsManager.getDoubleEscapeAction(), treeFilterMode: this.settingsManager.getTreeFilterMode(), showHardwareCursor: this.settingsManager.getShowHardwareCursor(), + defaultProjectTrust: this.settingsManager.getDefaultProjectTrust(), editorPaddingX: this.settingsManager.getEditorPaddingX(), autocompleteMaxVisible: this.settingsManager.getAutocompleteMaxVisible(), quietStartup: this.settingsManager.getQuietStartup(), @@ -4059,6 +4060,9 @@ export class InteractiveMode { onQuietStartupChange: (enabled) => { this.settingsManager.setQuietStartup(enabled); }, + onDefaultProjectTrustChange: (defaultProjectTrust) => { + this.settingsManager.setDefaultProjectTrust(defaultProjectTrust); + }, onDoubleEscapeActionChange: (action) => { this.settingsManager.setDoubleEscapeAction(action); }, @@ -4213,17 +4217,17 @@ export class InteractiveMode { private showTrustSelector(): void { const cwd = this.sessionManager.getCwd(); const trustStore = new ProjectTrustStore(this.runtimeHost.services.agentDir); - const savedDecision = trustStore.get(cwd); + const savedDecision = trustStore.getEntry(cwd); this.showSelector((done) => { const selector = new TrustSelectorComponent({ cwd, savedDecision, projectTrusted: this.settingsManager.isProjectTrusted(), - onSelect: (trusted) => { - trustStore.set(cwd, trusted); + onSelect: (selection) => { + trustStore.setMany(selection.updates); done(); this.showStatus( - `Saved trust decision: ${trusted ? "trusted" : "untrusted"}. Restart pi for this to take effect.`, + `Saved trust decision: ${selection.trusted ? "trusted" : "untrusted"}. Restart pi for this to take effect.`, ); }, onCancel: () => { diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index ea90d318..94dbff85 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -1,6 +1,7 @@ import { Markdown, type MarkdownTheme } from "@earendil-works/pi-tui"; import chalk from "chalk"; import { selectConfig } from "./cli/config-selector.ts"; +import { createProjectTrustContext } from "./cli/project-trust.ts"; import { APP_NAME, detectInstallMethod, @@ -12,7 +13,10 @@ import { type SelfUpdateCommand, VERSION, } from "./config.ts"; +import type { ExtensionFactory } from "./core/extensions/types.ts"; import { DefaultPackageManager } from "./core/package-manager.ts"; +import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts"; +import { DefaultResourceLoader } from "./core/resource-loader.ts"; import { SettingsManager } from "./core/settings-manager.ts"; import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts"; import { spawnProcess } from "./utils/child-process.ts"; @@ -425,22 +429,82 @@ function parseProjectTrustOverride(args: readonly string[]): boolean | undefined return trustOverride; } -function resolveProjectTrusted(cwd: string, agentDir: string, trustOverride: boolean | undefined): boolean { - if (trustOverride !== undefined) { - return trustOverride; - } - return !hasProjectTrustInputs(cwd) || new ProjectTrustStore(agentDir).get(cwd) === true; +export interface PackageCommandRuntimeOptions { + extensionFactories?: ExtensionFactory[]; } -export async function handleConfigCommand(args: string[]): Promise { +interface CommandSettingsResult { + settingsManager: SettingsManager; + projectTrustWarnings: string[]; +} + +function getCommandAppMode(): AppMode { + return process.stdin.isTTY && process.stdout.isTTY ? "interactive" : "print"; +} + +function reportProjectTrustWarnings(warnings: readonly string[]): void { + for (const warning of warnings) { + console.error(chalk.yellow(`Warning: ${warning}`)); + } +} + +async function createCommandSettingsManager(options: { + cwd: string; + agentDir: string; + projectTrustOverride?: boolean; + extensionFactories?: ExtensionFactory[]; +}): Promise { + const settingsManager = SettingsManager.create(options.cwd, options.agentDir, { projectTrusted: false }); + const projectTrustWarnings: string[] = []; + const appMode = getCommandAppMode(); + const extensionsResult = + options.projectTrustOverride === undefined && hasProjectTrustInputs(options.cwd) + ? await new DefaultResourceLoader({ + cwd: options.cwd, + agentDir: options.agentDir, + settingsManager, + extensionFactories: options.extensionFactories, + }).loadProjectTrustExtensions() + : undefined; + for (const error of extensionsResult?.errors ?? []) { + projectTrustWarnings.push(`Failed to load extension "${error.path}": ${error.error}`); + } + + const projectTrusted = await resolveProjectTrusted({ + cwd: options.cwd, + trustStore: new ProjectTrustStore(options.agentDir), + trustOverride: options.projectTrustOverride, + defaultProjectTrust: settingsManager.getDefaultProjectTrust(), + extensionsResult, + projectTrustContext: createProjectTrustContext({ + cwd: options.cwd, + mode: appMode, + settingsManager, + hasUI: appMode === "interactive", + }), + onExtensionError: (message) => projectTrustWarnings.push(message), + }); + settingsManager.setProjectTrusted(projectTrusted); + return { settingsManager, projectTrustWarnings }; +} + +export async function handleConfigCommand( + args: string[], + runtimeOptions: PackageCommandRuntimeOptions = {}, +): Promise { if (args[0] !== "config") { return false; } const cwd = process.cwd(); const agentDir = getAgentDir(); - const projectTrusted = parseProjectTrustOverride(args) ?? true; - const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted }); + const { settingsManager, projectTrustWarnings } = await createCommandSettingsManager({ + cwd, + agentDir, + projectTrustOverride: parseProjectTrustOverride(args), + extensionFactories: runtimeOptions.extensionFactories, + }); + reportProjectTrustWarnings(projectTrustWarnings); reportSettingsErrors(settingsManager, "config command"); const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager }); const resolvedPaths = await packageManager.resolve(); @@ -455,7 +519,10 @@ export async function handleConfigCommand(args: string[]): Promise { process.exit(0); } -export async function handlePackageCommand(args: string[]): Promise { +export async function handlePackageCommand( + args: string[], + runtimeOptions: PackageCommandRuntimeOptions = {}, +): Promise { const options = parsePackageCommand(args); if (!options) { return false; @@ -505,13 +572,18 @@ export async function handlePackageCommand(args: string[]): Promise { const cwd = process.cwd(); const agentDir = getAgentDir(); const writesProjectPackageConfig = (options.command === "install" || options.command === "remove") && options.local; - const projectTrusted = resolveProjectTrusted(cwd, agentDir, options.projectTrustOverride); - if (!projectTrusted && writesProjectPackageConfig) { + const { settingsManager, projectTrustWarnings } = await createCommandSettingsManager({ + cwd, + agentDir, + projectTrustOverride: options.projectTrustOverride, + extensionFactories: runtimeOptions.extensionFactories, + }); + reportProjectTrustWarnings(projectTrustWarnings); + if (!settingsManager.isProjectTrusted() && writesProjectPackageConfig) { console.error(chalk.red("Project is not trusted. Use --approve to modify local package config.")); process.exitCode = 1; return true; } - const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted }); reportSettingsErrors(settingsManager, "package command"); const selfUpdateNpmCommand = settingsManager.getGlobalSettings().npmCommand; diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index 1fc587c8..25a55b35 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -157,6 +157,70 @@ describe("package commands", () => { } }); + it("uses default project trust for list", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" })); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect(main(["list"])).resolves.toBeUndefined(); + + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stdout).toContain("Project packages:"); + expect(stdout).toContain("npm:@project/pkg"); + expect(stdout).not.toContain("No packages installed."); + expect(process.exitCode).toBeUndefined(); + } finally { + logSpy.mockRestore(); + } + }); + + it("uses project_trust extensions for package commands", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect( + main(["list"], { + extensionFactories: [ + (pi) => { + pi.on("project_trust", () => ({ trusted: "yes" })); + }, + ], + }), + ).resolves.toBeUndefined(); + + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stdout).toContain("Project packages:"); + expect(stdout).toContain("npm:@project/pkg"); + expect(stdout).not.toContain("No packages installed."); + expect(process.exitCode).toBeUndefined(); + } finally { + logSpy.mockRestore(); + } + }); + + it("lets trust.json override default project trust", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" })); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); + new ProjectTrustStore(agentDir).set(projectDir, false); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect(main(["list"])).resolves.toBeUndefined(); + + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stdout).toContain("No packages installed."); + expect(stdout).not.toContain("Project packages:"); + expect(process.exitCode).toBeUndefined(); + } finally { + logSpy.mockRestore(); + } + }); + it("blocks local package changes when project is untrusted", async () => { mkdirSync(join(projectDir, ".pi"), { recursive: true }); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/packages/coding-agent/test/resource-loader.test.ts b/packages/coding-agent/test/resource-loader.test.ts index abbe8975..7258b121 100644 --- a/packages/coding-agent/test/resource-loader.test.ts +++ b/packages/coding-agent/test/resource-loader.test.ts @@ -376,7 +376,7 @@ Content`, expect(loader.getSystemPrompt()).toBe("You are a helpful assistant."); }); - it("should skip project resources when project is not trusted", async () => { + it("should skip trust-gated project resources when project is not trusted", async () => { const piDir = join(cwd, ".pi"); const extensionsDir = join(piDir, "extensions"); const skillDir = join(piDir, "skills", "project-skill"); @@ -414,7 +414,7 @@ Project skill content`, expect(loader.getAgentsFiles().agentsFiles.some((file) => file.path === join(agentDir, "AGENTS.md"))).toBe( true, ); - expect(loader.getAgentsFiles().agentsFiles.some((file) => file.path === join(cwd, "AGENTS.md"))).toBe(false); + expect(loader.getAgentsFiles().agentsFiles.some((file) => file.path === join(cwd, "AGENTS.md"))).toBe(true); expect(loader.getExtensions().extensions).toHaveLength(0); expect(loader.getExtensions().errors).toEqual([]); expect(loader.getSkills().skills.some((skill) => skill.name === "project-skill")).toBe(false); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index b28d086a..279bece1 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -250,6 +250,23 @@ describe("SettingsManager", () => { expect(manager.getProjectSettings()).toEqual({}); expect(JSON.parse(readFileSync(projectSettingsPath, "utf-8"))).toEqual({ packages: ["npm:existing"] }); }); + + it("should read default project trust from global settings only", () => { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" })); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ defaultProjectTrust: "never" })); + + const manager = SettingsManager.create(projectDir, agentDir); + + expect(manager.getDefaultProjectTrust()).toBe("always"); + }); + + it("should default invalid project trust settings to ask", () => { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "sometimes" })); + + const manager = SettingsManager.create(projectDir, agentDir); + + expect(manager.getDefaultProjectTrust()).toBe("ask"); + }); }); describe("project settings directory creation", () => { diff --git a/packages/coding-agent/test/trust-manager.test.ts b/packages/coding-agent/test/trust-manager.test.ts index d91dde49..2716da36 100644 --- a/packages/coding-agent/test/trust-manager.test.ts +++ b/packages/coding-agent/test/trust-manager.test.ts @@ -2,7 +2,12 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { hasProjectConfigDir, hasProjectTrustInputs, ProjectTrustStore } from "../src/core/trust-manager.ts"; +import { + getProjectTrustPath, + hasProjectConfigDir, + hasProjectTrustInputs, + ProjectTrustStore, +} from "../src/core/trust-manager.ts"; describe("ProjectTrustStore", () => { let tempDir: string; @@ -25,12 +30,52 @@ describe("ProjectTrustStore", () => { const store = new ProjectTrustStore(agentDir); expect(store.get(cwd)).toBeNull(); + expect(store.getEntry(cwd)).toBeNull(); store.set(cwd, true); expect(store.get(cwd)).toBe(true); + expect(store.getEntry(cwd)).toEqual({ path: getProjectTrustPath(cwd), decision: true }); store.set(cwd, false); expect(store.get(cwd)).toBe(false); + expect(store.getEntry(cwd)).toEqual({ path: getProjectTrustPath(cwd), decision: false }); store.set(cwd, null); expect(store.get(cwd)).toBeNull(); + expect(store.getEntry(cwd)).toBeNull(); + }); + + it("inherits the closest saved decision from parent directories", () => { + const store = new ProjectTrustStore(agentDir); + const parentDir = join(tempDir, "trusted-parent"); + const childDir = join(parentDir, "project"); + const grandchildDir = join(childDir, "nested"); + mkdirSync(grandchildDir, { recursive: true }); + + store.set(parentDir, true); + expect(store.get(childDir)).toBe(true); + expect(store.getEntry(childDir)).toEqual({ path: getProjectTrustPath(parentDir), decision: true }); + expect(store.get(grandchildDir)).toBe(true); + expect(store.getEntry(grandchildDir)).toEqual({ path: getProjectTrustPath(parentDir), decision: true }); + + store.set(childDir, false); + expect(store.get(grandchildDir)).toBe(false); + expect(store.getEntry(grandchildDir)).toEqual({ path: getProjectTrustPath(childDir), decision: false }); + }); + + it("can clear a child override to inherit parent trust", () => { + const store = new ProjectTrustStore(agentDir); + const parentDir = join(tempDir, "trusted-parent"); + const childDir = join(parentDir, "project"); + mkdirSync(childDir, { recursive: true }); + + store.set(parentDir, true); + store.set(childDir, false); + expect(store.getEntry(childDir)).toEqual({ path: getProjectTrustPath(childDir), decision: false }); + + store.setMany([ + { path: parentDir, decision: true }, + { path: childDir, decision: null }, + ]); + expect(store.get(childDir)).toBe(true); + expect(store.getEntry(childDir)).toEqual({ path: getProjectTrustPath(parentDir), decision: true }); }); it("fails loudly without overwriting malformed trust stores", () => { @@ -53,9 +98,13 @@ describe("ProjectTrustStore", () => { rmSync(join(cwd, ".pi"), { recursive: true, force: true }); writeFileSync(join(cwd, "AGENTS.md"), "Project instructions"); - expect(hasProjectTrustInputs(cwd)).toBe(true); + expect(hasProjectTrustInputs(cwd)).toBe(false); rmSync(join(cwd, "AGENTS.md"), { force: true }); + writeFileSync(join(cwd, "CLAUDE.md"), "Legacy project instructions"); + expect(hasProjectTrustInputs(cwd)).toBe(false); + rmSync(join(cwd, "CLAUDE.md"), { force: true }); + mkdirSync(join(cwd, ".agents", "skills"), { recursive: true }); expect(hasProjectTrustInputs(cwd)).toBe(true); }); diff --git a/packages/coding-agent/test/trust-selector.test.ts b/packages/coding-agent/test/trust-selector.test.ts index 65c73c21..33be9a97 100644 --- a/packages/coding-agent/test/trust-selector.test.ts +++ b/packages/coding-agent/test/trust-selector.test.ts @@ -17,7 +17,7 @@ describe("TrustSelectorComponent", () => { it("marks the saved trusted decision", () => { const selector = new TrustSelectorComponent({ cwd: "/project", - savedDecision: true, + savedDecision: { path: "/project", decision: true }, projectTrusted: true, onSelect: () => {}, onCancel: () => {}, @@ -25,7 +25,7 @@ describe("TrustSelectorComponent", () => { const output = stripAnsi(selector.render(120).join("\n")); - expect(output).toContain("Saved decision: trusted"); + expect(output).toContain("Saved decision: trusted (/project)"); expect(output).toContain("Current session: trusted"); expect(output).toContain("Trust ✓"); expect(output).not.toContain("Do not trust ✓"); @@ -43,6 +43,45 @@ describe("TrustSelectorComponent", () => { selector.handleInput("\n"); - expect(onSelect).toHaveBeenCalledWith(true); + expect(onSelect).toHaveBeenCalledWith({ trusted: true, updates: [{ path: "/project", decision: true }] }); + }); + + it("labels saved ancestor decisions as inherited", () => { + const selector = new TrustSelectorComponent({ + cwd: "/parent/project/nested", + savedDecision: { path: "/parent", decision: true }, + projectTrusted: true, + onSelect: () => {}, + onCancel: () => {}, + }); + + const output = stripAnsi(selector.render(120).join("\n")); + + expect(output).toContain("Saved decision: trusted (inherited from /parent)"); + }); + + it("adds a trust parent option", () => { + const onSelect = vi.fn(); + const selector = new TrustSelectorComponent({ + cwd: "/parent/project", + savedDecision: { path: "/parent", decision: true }, + projectTrusted: true, + onSelect, + onCancel: () => {}, + }); + + const output = stripAnsi(selector.render(120).join("\n")); + expect(output).toContain("Saved decision: trusted (inherited from /parent)"); + expect(output).toContain("Trust parent folder (/parent) ✓"); + + selector.handleInput("\n"); + + expect(onSelect).toHaveBeenCalledWith({ + trusted: true, + updates: [ + { path: "/parent", decision: true }, + { path: "/parent/project", decision: null }, + ], + }); }); }); From 359a0769f1d6826f1893bc4a53a302e5a02ec649 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 9 Jun 2026 13:36:05 +0200 Subject: [PATCH 201/352] fix: simplify help --- packages/coding-agent/src/cli/args.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index ff747400..839c60e8 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -230,10 +230,8 @@ ${chalk.bold("Commands:")} ${APP_NAME} remove [-l] Remove extension source from settings ${APP_NAME} uninstall [-l] Alias for remove ${APP_NAME} update [source|self|pi] Update pi and installed extensions - ${APP_NAME} list [--approve|--no-approve] - List installed extensions from settings - ${APP_NAME} config [--approve|--no-approve] - Open TUI to enable/disable package resources + ${APP_NAME} list List installed extensions from settings + ${APP_NAME} config Open TUI to enable/disable package resources ${APP_NAME} --help Show help for install/remove/uninstall/update/list ${chalk.bold("Options:")} From 64b51efb6ef6f1e42676e866da906a5f900e592d Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 9 Jun 2026 13:37:16 +0200 Subject: [PATCH 202/352] fix(ai): use z.ai thinking payload closes #5330 --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/providers/openai-completions.ts | 3 ++- packages/ai/src/types.ts | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 7f7dfc2a..a7d9a93a 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed z.ai thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5330](https://github.com/earendil-works/pi/issues/5330)). - Fixed Moonshot Kimi thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5531](https://github.com/earendil-works/pi/issues/5531)). - Fixed Azure OpenAI Responses requests to disable server-side response storage ([#5530](https://github.com/earendil-works/pi/issues/5530)). diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 9fb3a357..0f87c897 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -554,7 +554,8 @@ function buildParams( } if (compat.thinkingFormat === "zai" && model.reasoning) { - (params as any).enable_thinking = !!options?.reasoningEffort; + const zaiParams = params as typeof params & { thinking?: { type: "enabled" | "disabled" } }; + zaiParams.thinking = { type: options?.reasoningEffort ? "enabled" : "disabled" }; } else if (compat.thinkingFormat === "qwen" && model.reasoning) { (params as any).enable_thinking = !!options?.reasoningEffort; } else if (compat.thinkingFormat === "qwen-chat-template" && model.reasoning) { diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 802b8b39..897e87d9 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -392,7 +392,7 @@ export interface OpenAICompletionsCompat { requiresThinkingAsText?: boolean; /** Whether all replayed assistant messages must include an empty reasoning_content field when reasoning is enabled. Default: auto-detected from URL. */ requiresReasoningContentOnAssistantMessages?: boolean; - /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort when supported, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses top-level enable_thinking: boolean, "qwen" uses top-level enable_thinking: boolean, "qwen-chat-template" uses chat_template_kwargs.enable_thinking, "string-thinking" uses top-level thinking: string, and "ant-ling" uses reasoning: { effort } only when the mapped effort is non-null. Default: "openai". */ + /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort when supported, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses thinking: { type }, "qwen" uses top-level enable_thinking: boolean, "qwen-chat-template" uses chat_template_kwargs.enable_thinking, "string-thinking" uses top-level thinking: string, and "ant-ling" uses reasoning: { effort } only when the mapped effort is non-null. Default: "openai". */ thinkingFormat?: | "openai" | "openrouter" From 9632bddd3803df8ee8668209555b1acd5d4e7f7d Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 9 Jun 2026 13:39:18 +0200 Subject: [PATCH 203/352] fix(coding-agent): stabilize OAuth login prompt rows closes #5433 --- packages/coding-agent/CHANGELOG.md | 1 + .../interactive/components/login-dialog.ts | 11 ++- .../5433-extension-oauth-prompt-input.test.ts | 93 +++++++++++++++++++ packages/coding-agent/vitest.config.ts | 3 + 4 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/test/suite/regressions/5433-extension-oauth-prompt-input.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8235501a..e3c060dd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed +- Fixed extension OAuth login prompts to keep previous submitted prompt rows stable instead of mirroring the active input value ([#5433](https://github.com/earendil-works/pi/issues/5433)). - Fixed `/reload` to apply updated `steeringMode` and `followUpMode` settings to the current session ([#5377](https://github.com/earendil-works/pi/issues/5377)). - Fixed invalid `models.json` syntax to skip startup config migrations and report the normal file-path-aware models error instead of a raw JSON parse stack trace ([#5418](https://github.com/earendil-works/pi/issues/5418)). - Fixed GitHub release notes and interactive changelog links to resolve package-relative documentation URLs correctly ([#5516](https://github.com/earendil-works/pi/issues/5516)). diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index 84d9a2c1..958db6d6 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -56,7 +56,9 @@ export class LoginDialogComponent extends Container implements Focusable { this.input = new Input(); this.input.onSubmit = () => { if (this.inputResolver) { - this.inputResolver(this.input.getValue()); + const value = this.input.getValue(); + this.replaceInputWithSubmittedText(value); + this.inputResolver(value); this.inputResolver = undefined; this.inputRejecter = undefined; } @@ -73,6 +75,12 @@ export class LoginDialogComponent extends Container implements Focusable { return this.abortController.signal; } + private replaceInputWithSubmittedText(value: string): void { + this.contentContainer.children = this.contentContainer.children.map((child) => + child === this.input ? new Text(`> ${value}`, 0, 0) : child, + ); + } + private cancel(): void { this.abortController.abort(); if (this.inputRejecter) { @@ -128,6 +136,7 @@ export class LoginDialogComponent extends Container implements Focusable { * Show input for manual code/URL entry (for callback server providers) */ showManualInput(prompt: string): Promise { + this.input.setValue(""); this.contentContainer.addChild(new Spacer(1)); this.contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0)); this.contentContainer.addChild(this.input); diff --git a/packages/coding-agent/test/suite/regressions/5433-extension-oauth-prompt-input.test.ts b/packages/coding-agent/test/suite/regressions/5433-extension-oauth-prompt-input.test.ts new file mode 100644 index 00000000..06562b3d --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/5433-extension-oauth-prompt-input.test.ts @@ -0,0 +1,93 @@ +import { setKeybindings, type TUI } from "@earendil-works/pi-tui"; +import { beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; +import { KeybindingsManager } from "../../../src/core/keybindings.ts"; +import { LoginDialogComponent } from "../../../src/modes/interactive/components/login-dialog.ts"; +import { initTheme } from "../../../src/modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../../../src/utils/ansi.ts"; + +vi.mock("../../../src/utils/open-browser.ts", () => ({ + openBrowser: vi.fn(), +})); + +function createDialog(): LoginDialogComponent { + return new LoginDialogComponent( + { requestRender: vi.fn() } as unknown as TUI, + "prompt-repro", + () => {}, + "Prompt Repro", + ); +} + +function renderDialog(dialog: LoginDialogComponent): string[] { + return stripAnsi(dialog.render(120).join("\n")) + .split("\n") + .map((line) => line.trimEnd()); +} + +function countRenderedValue(lines: string[], value: string): number { + return lines.filter((line) => line.trim() === `> ${value}`).length; +} + +describe("LoginDialogComponent OAuth prompts", () => { + beforeAll(() => { + initTheme("dark"); + }); + + beforeEach(() => { + setKeybindings(new KeybindingsManager()); + }); + + test("keeps previous prompt input stable when a later prompt is active", async () => { + const dialog = createDialog(); + + const firstPrompt = dialog.showPrompt("First prompt:", "first-value"); + dialog.handleInput("first-value"); + dialog.handleInput("\n"); + await expect(firstPrompt).resolves.toBe("first-value"); + + const secondPrompt = dialog.showPrompt("Second prompt:"); + dialog.handleInput("second-secret-demo"); + + const lines = renderDialog(dialog); + expect(lines.join("\n")).toContain("First prompt:"); + expect(lines.join("\n")).toContain("Second prompt:"); + expect(countRenderedValue(lines, "first-value")).toBe(1); + expect(countRenderedValue(lines, "second-secret-demo")).toBe(1); + + dialog.handleInput("\n"); + await expect(secondPrompt).resolves.toBe("second-secret-demo"); + }); + + test("preserves auth instructions when showing a prompt", () => { + const dialog = createDialog(); + + dialog.showAuth("https://example.invalid/login", "Authorize the extension"); + dialog.showPrompt("First prompt:"); + + const output = renderDialog(dialog).join("\n"); + expect(output).toContain("https://example.invalid/login"); + expect(output).toContain("Authorize the extension"); + expect(output).toContain("First prompt:"); + }); + + test("keeps previous manual input stable when a later prompt is active", async () => { + const dialog = createDialog(); + + const manualInput = dialog.showManualInput("Paste callback URL:"); + dialog.handleInput("callback-value"); + dialog.handleInput("\n"); + await expect(manualInput).resolves.toBe("callback-value"); + + const prompt = dialog.showPrompt("Second prompt:"); + dialog.handleInput("second-secret-demo"); + + const lines = renderDialog(dialog); + expect(lines.join("\n")).toContain("Paste callback URL:"); + expect(lines.join("\n")).toContain("Second prompt:"); + expect(countRenderedValue(lines, "callback-value")).toBe(1); + expect(countRenderedValue(lines, "second-secret-demo")).toBe(1); + + dialog.handleInput("\n"); + await expect(prompt).resolves.toBe("second-secret-demo"); + }); +}); diff --git a/packages/coding-agent/vitest.config.ts b/packages/coding-agent/vitest.config.ts index d3857107..67ce0fca 100644 --- a/packages/coding-agent/vitest.config.ts +++ b/packages/coding-agent/vitest.config.ts @@ -4,6 +4,7 @@ import { defineConfig } from "vitest/config"; const aiSrcIndex = fileURLToPath(new URL("../ai/src/index.ts", import.meta.url)); const aiSrcOAuth = fileURLToPath(new URL("../ai/src/oauth.ts", import.meta.url)); const agentSrcIndex = fileURLToPath(new URL("../agent/src/index.ts", import.meta.url)); +const tuiSrcIndex = fileURLToPath(new URL("../tui/src/index.ts", import.meta.url)); export default defineConfig({ test: { @@ -21,9 +22,11 @@ export default defineConfig({ { find: /^@earendil-works\/pi-ai$/, replacement: aiSrcIndex }, { find: /^@earendil-works\/pi-ai\/oauth$/, replacement: aiSrcOAuth }, { find: /^@earendil-works\/pi-agent-core$/, replacement: agentSrcIndex }, + { find: /^@earendil-works\/pi-tui$/, replacement: tuiSrcIndex }, { find: /^@mariozechner\/pi-ai$/, replacement: aiSrcIndex }, { find: /^@mariozechner\/pi-ai\/oauth$/, replacement: aiSrcOAuth }, { find: /^@mariozechner\/pi-agent-core$/, replacement: agentSrcIndex }, + { find: /^@mariozechner\/pi-tui$/, replacement: tuiSrcIndex }, ], }, }); From 3d02d1da11ea4402f1a80c3df4df9fe57d53fe84 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 9 Jun 2026 13:40:41 +0200 Subject: [PATCH 204/352] fix(ai): map OpenCode max tokens closes #5331 --- packages/ai/CHANGELOG.md | 1 + packages/ai/scripts/generate-models.ts | 4 +++ packages/ai/src/models.generated.ts | 30 ++++++++++++++----- .../openai-completions-tool-choice.test.ts | 27 +++++++++++++++++ 4 files changed, 54 insertions(+), 8 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index a7d9a93a..13a5f688 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed z.ai thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5330](https://github.com/earendil-works/pi/issues/5330)). +- Fixed OpenCode completions model metadata to send explicit `maxTokens` as `max_tokens` ([#5331](https://github.com/earendil-works/pi/issues/5331)). - Fixed Moonshot Kimi thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5531](https://github.com/earendil-works/pi/issues/5531)). - Fixed Azure OpenAI Responses requests to disable server-side response storage ([#5530](https://github.com/earendil-works/pi/issues/5530)). diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 5e38d4c7..408a8034 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -1057,6 +1057,10 @@ async function loadModelsDevData(): Promise[]> { } } + if (api === "openai-completions") { + compat = { ...(compat ?? {}), maxTokensField: "max_tokens" }; + } + models.push({ id: modelId, name: m.name || modelId, diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 99fccbd6..9ebd12da 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -7770,6 +7770,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -7947,7 +7948,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", - compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + compat: {"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, input: ["text"], @@ -7966,7 +7967,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", - compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + compat: {"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, input: ["text"], @@ -8039,6 +8040,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -8056,6 +8058,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -8361,7 +8364,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", - compat: {"supportsReasoningEffort":false}, + compat: {"supportsReasoningEffort":false,"maxTokensField":"max_tokens"}, reasoning: true, thinkingLevelMap: {"off":null,"minimal":null,"low":null,"medium":null}, input: ["text", "image"], @@ -8380,6 +8383,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text", "image"], cost: { @@ -8397,7 +8401,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", - compat: {"thinkingFormat":"deepseek","supportsReasoningEffort":false}, + compat: {"thinkingFormat":"deepseek","supportsReasoningEffort":false,"maxTokensField":"max_tokens"}, reasoning: true, input: ["text", "image"], cost: { @@ -8415,6 +8419,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text", "image"], cost: { @@ -8432,6 +8437,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -8449,6 +8455,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -8466,6 +8473,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -8519,7 +8527,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", - compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + compat: {"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, input: ["text"], @@ -8538,7 +8546,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", - compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + compat: {"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, input: ["text"], @@ -8557,6 +8565,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -8574,6 +8583,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -8591,6 +8601,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text", "image"], cost: { @@ -8608,7 +8619,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", - compat: {"thinkingFormat":"deepseek","supportsReasoningEffort":false}, + compat: {"thinkingFormat":"deepseek","supportsReasoningEffort":false,"maxTokensField":"max_tokens"}, reasoning: true, thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, input: ["text", "image"], @@ -8627,6 +8638,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text", "image"], cost: { @@ -8644,6 +8656,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -8678,6 +8691,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -8712,7 +8726,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", - compat: {"thinkingFormat":"qwen"}, + compat: {"thinkingFormat":"qwen","maxTokensField":"max_tokens"}, reasoning: true, input: ["text", "image"], cost: { diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index be6419d6..319b18af 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -1120,6 +1120,33 @@ describe("openai-completions tool_choice", () => { expect(params.reasoning_effort).toBeUndefined(); }); + it("sends max_tokens for OpenCode completions models", async () => { + const cases = [getModel("opencode-go", "kimi-k2.6")!, getModel("opencode", "grok-build-0.1")!] as const; + + for (const model of cases) { + let payload: unknown; + expect(model.compat?.maxTokensField).toBe("max_tokens"); + + await streamSimple( + model, + { + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + maxTokens: 123, + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = (payload ?? mockState.lastParams) as { max_tokens?: number; max_completion_tokens?: number }; + expect(params.max_tokens).toBe(123); + expect(params.max_completion_tokens).toBeUndefined(); + } + }); + it("omits reasoning effort for OpenCode Grok Build", async () => { const model = getModel("opencode", "grok-build-0.1")!; let payload: unknown; From c20ea06d4a823b7e20f6fb5469494d4682549dac Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 9 Jun 2026 13:43:54 +0200 Subject: [PATCH 205/352] fix: --help and --version redirect --- packages/coding-agent/src/main.ts | 15 ++++++++++----- .../coding-agent/test/stdout-cleanliness.test.ts | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index a4f47dc4..38f95246 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -112,6 +112,10 @@ function toPrintOutputMode(appMode: AppMode): Exclude { return appMode === "json" ? "json" : "text"; } +function isPlainRuntimeMetadataCommand(parsed: Args): boolean { + return !parsed.print && parsed.mode === undefined && (parsed.help === true || parsed.listModels !== undefined); +} + async function prepareInitialMessage( parsed: Args, autoResizeImages: boolean, @@ -480,11 +484,6 @@ export async function main(args: string[], options?: MainOptions) { } } time("parseArgs"); - let appMode = resolveAppMode(parsed, process.stdin.isTTY, process.stdout.isTTY); - const shouldTakeOverStdout = appMode !== "interactive"; - if (shouldTakeOverStdout) { - takeOverStdout(); - } if (parsed.version) { console.log(VERSION); @@ -505,6 +504,12 @@ export async function main(args: string[], options?: MainOptions) { process.exit(0); } + let appMode = resolveAppMode(parsed, process.stdin.isTTY, process.stdout.isTTY); + const shouldTakeOverStdout = appMode !== "interactive" && !isPlainRuntimeMetadataCommand(parsed); + if (shouldTakeOverStdout) { + takeOverStdout(); + } + if (parsed.mode === "rpc" && parsed.fileArgs.length > 0) { console.error(chalk.red("Error: @file arguments are not supported in RPC mode")); process.exit(1); diff --git a/packages/coding-agent/test/stdout-cleanliness.test.ts b/packages/coding-agent/test/stdout-cleanliness.test.ts index 057db06a..f1b31eb3 100644 --- a/packages/coding-agent/test/stdout-cleanliness.test.ts +++ b/packages/coding-agent/test/stdout-cleanliness.test.ts @@ -80,6 +80,22 @@ async function runCli(args: string[]): Promise<{ stdout: string; stderr: string; } describe("stdout cleanliness in non-interactive modes", () => { + it("prints --version to stdout when stdout is redirected", async () => { + const result = await runCli(["--version"]); + + expect(result.code).toBe(0); + expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/); + expect(result.stderr).toBe(""); + }); + + it("prints plain --help to stdout when stdout is redirected", async () => { + const result = await runCli(["--help"]); + + expect(result.code).toBe(0); + expect(result.stdout).toContain("Usage:"); + expect(result.stderr).not.toContain("Usage:"); + }); + it("keeps stdout empty for --mode json --help while routing trusted startup chatter to stderr", async () => { const result = await runCli(["--mode", "json", "--help", "--approve"]); From d81ac2092092022903142ffde43bf520ce784fba Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 9 Jun 2026 15:12:07 +0300 Subject: [PATCH 206/352] feat(coding-agent): add prompt template argument defaults --- packages/coding-agent/CHANGELOG.md | 6 ++ .../coding-agent/docs/prompt-templates.md | 9 ++- .../coding-agent/src/core/prompt-templates.ts | 59 +++++++++---------- .../test/prompt-templates.test.ts | 46 +++++++++++++++ 4 files changed, 89 insertions(+), 31 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index cadde9e3..23fe5f82 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Added + +- Added default-value expansion for prompt template positional arguments, e.g. `${1:-7}` ([#5507](https://github.com/earendil-works/pi/issues/5507)). + ## [0.79.0] - 2026-06-08 ### New Features diff --git a/packages/coding-agent/docs/prompt-templates.md b/packages/coding-agent/docs/prompt-templates.md index 00450858..d8990bea 100644 --- a/packages/coding-agent/docs/prompt-templates.md +++ b/packages/coding-agent/docs/prompt-templates.md @@ -64,10 +64,11 @@ Type `/` followed by the template name in the editor. Autocomplete shows availab ## Arguments -Templates support positional arguments and simple slicing: +Templates support positional arguments, defaults, and simple slicing: - `$1`, `$2`, ... positional args - `$@` or `$ARGUMENTS` for all args joined +- `${1:-default}` uses arg 1 when present/non-empty, otherwise `default` - `${@:N}` for args from the Nth position (1-indexed) - `${@:N:L}` for `L` args starting at N @@ -80,6 +81,12 @@ description: Create a component Create a React component named $1 with features: $@ ``` +Default values are useful for optional arguments: + +```markdown +Summarize the current state in ${1:-7} bullet points. +``` + Usage: `/component Button "onClick handler" "disabled support"` ## Loading Rules diff --git a/packages/coding-agent/src/core/prompt-templates.ts b/packages/coding-agent/src/core/prompt-templates.ts index 581a34eb..6b5b1e24 100644 --- a/packages/coding-agent/src/core/prompt-templates.ts +++ b/packages/coding-agent/src/core/prompt-templates.ts @@ -59,46 +59,45 @@ export function parseCommandArgs(argsString: string): string[] { * Supports: * - $1, $2, ... for positional args * - $@ and $ARGUMENTS for all args + * - ${N:-default} for positional arg N with default when missing/empty * - ${@:N} for args from Nth onwards (bash-style slicing) * - ${@:N:L} for L args starting from Nth * - * Note: Replacement happens on the template string only. Argument values + * Note: Replacement happens on the template string only. Argument and default values * containing patterns like $1, $@, or $ARGUMENTS are NOT recursively substituted. */ export function substituteArgs(content: string, args: string[]): string { - let result = content; - - // Replace $1, $2, etc. with positional args FIRST (before wildcards) - // This prevents wildcard replacement values containing $ patterns from being re-substituted - result = result.replace(/\$(\d+)/g, (_, num) => { - const index = parseInt(num, 10) - 1; - return args[index] ?? ""; - }); - - // Replace ${@:start} or ${@:start:length} with sliced args (bash-style) - // Process BEFORE simple $@ to avoid conflicts - result = result.replace(/\$\{@:(\d+)(?::(\d+))?\}/g, (_, startStr, lengthStr) => { - let start = parseInt(startStr, 10) - 1; // Convert to 0-indexed (user provides 1-indexed) - // Treat 0 as 1 (bash convention: args start at 1) - if (start < 0) start = 0; - - if (lengthStr) { - const length = parseInt(lengthStr, 10); - return args.slice(start, start + length).join(" "); - } - return args.slice(start).join(" "); - }); - - // Pre-compute all args joined (optimization) const allArgs = args.join(" "); - // Replace $ARGUMENTS with all args joined (new syntax, aligns with Claude, Codex, OpenCode) - result = result.replace(/\$ARGUMENTS/g, allArgs); + return content.replace( + /\$\{(\d+):-([^}]*)\}|\$\{@:(\d+)(?::(\d+))?\}|\$(ARGUMENTS|@|\d+)/g, + (_match, defaultNum, defaultValue, sliceStart, sliceLength, simple) => { + if (defaultNum) { + const index = parseInt(defaultNum, 10) - 1; + const value = args[index]; + return value ? value : defaultValue; + } - // Replace $@ with all args joined (existing syntax) - result = result.replace(/\$@/g, allArgs); + if (sliceStart) { + let start = parseInt(sliceStart, 10) - 1; // Convert to 0-indexed (user provides 1-indexed) + // Treat 0 as 1 (bash convention: args start at 1) + if (start < 0) start = 0; - return result; + if (sliceLength) { + const length = parseInt(sliceLength, 10); + return args.slice(start, start + length).join(" "); + } + return args.slice(start).join(" "); + } + + if (simple === "ARGUMENTS" || simple === "@") { + return allArgs; + } + + const index = parseInt(simple, 10) - 1; + return args[index] ?? ""; + }, + ); } function loadTemplateFromFile(filePath: string, sourceInfo: SourceInfo): PromptTemplate | null { diff --git a/packages/coding-agent/test/prompt-templates.test.ts b/packages/coding-agent/test/prompt-templates.test.ts index 1e4bcf5c..681a7c62 100644 --- a/packages/coding-agent/test/prompt-templates.test.ts +++ b/packages/coding-agent/test/prompt-templates.test.ts @@ -190,6 +190,52 @@ describe("substituteArgs", () => { }); }); +// ============================================================================ +// substituteArgs - Positional Defaults +// ============================================================================ + +describe("substituteArgs - positional defaults", () => { + test("should use default when positional arg is missing", () => { + expect(substituteArgs(`List exactly \${1:-7} next steps`, [])).toBe("List exactly 7 next steps"); + }); + + test("should use positional arg when present", () => { + expect(substituteArgs(`List exactly \${1:-7} next steps`, ["3"])).toBe("List exactly 3 next steps"); + }); + + test("should use default when positional arg is empty", () => { + expect(substituteArgs(`Mode: \${1:-brief}`, [""])).toBe("Mode: brief"); + }); + + test("should support multiple positional defaults", () => { + expect(substituteArgs(`\${1:-7} \${2:-brief}`, [])).toBe("7 brief"); + expect(substituteArgs(`\${1:-7} \${2:-brief}`, ["3"])).toBe("3 brief"); + expect(substituteArgs(`\${1:-7} \${2:-brief}`, ["3", "verbose"])).toBe("3 verbose"); + }); + + test("should not recursively substitute patterns in arg values", () => { + expect(substituteArgs(`\${1:-7}`, ["$ARGUMENTS"])).toBe("$ARGUMENTS"); + expect(substituteArgs(`\${1:-7}`, ["$1"])).toBe("$1"); + }); + + test("should not recursively substitute patterns in default values", () => { + expect(substituteArgs(`\${1:-$ARGUMENTS}`, ["a", "b"])).toBe("a"); + expect(substituteArgs(`\${3:-$ARGUMENTS}`, ["a", "b"])).toBe("$ARGUMENTS"); + }); + + test("should support defaults with spaces", () => { + expect(substituteArgs(`\${1:-seven steps}`, [])).toBe("seven steps"); + }); + + test("should support out-of-range positional defaults", () => { + expect(substituteArgs(`\${3:-fallback}`, ["a", "b"])).toBe("fallback"); + }); + + test("should mix positional defaults with existing placeholders", () => { + expect(substituteArgs(`$1 \${2:-x} $ARGUMENTS`, ["a"])).toBe("a x a"); + }); +}); + // ============================================================================ // substituteArgs - Array Slicing (Bash-Style) // ============================================================================ From 69ea1a6310d4f34d0b75fc97f4e987ec09477689 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 9 Jun 2026 14:13:48 +0200 Subject: [PATCH 207/352] docs(coding-agent): clarify model name display docs closes #4841 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/models.md | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ee5775a1..6a5ecd59 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -17,6 +17,7 @@ - Fixed `/reload` to apply updated `steeringMode` and `followUpMode` settings to the current session ([#5377](https://github.com/earendil-works/pi/issues/5377)). - Fixed invalid `models.json` syntax to skip startup config migrations and report the normal file-path-aware models error instead of a raw JSON parse stack trace ([#5418](https://github.com/earendil-works/pi/issues/5418)). - Fixed GitHub release notes and interactive changelog links to resolve package-relative documentation URLs correctly ([#5516](https://github.com/earendil-works/pi/issues/5516)). +- Clarified custom model docs that `name` and `modelOverrides.name` do not replace model IDs in the footer or primary model lists ([#4841](https://github.com/earendil-works/pi/issues/4841)). ## [0.79.0] - 2026-06-08 diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index d457643d..39b56aa1 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -198,7 +198,7 @@ If your command is slow, expensive, rate-limited, or should keep using a previou | Field | Required | Default | Description | |-------|----------|---------|-------------| | `id` | Yes | — | Model identifier (passed to the API) | -| `name` | No | `id` | Human-readable model label. Used for matching (`--model` patterns) and shown in model details/status text. | +| `name` | No | `id` | Human-readable model label. Used for matching (`--model` patterns) and shown as secondary model detail text. | | `api` | No | provider's `api` | Override provider's API for this model | | `reasoning` | No | `false` | Supports extended thinking | | `thinkingLevelMap` | No | omitted | Maps pi thinking levels to provider values and marks unsupported levels (see below) | @@ -209,8 +209,8 @@ If your command is slow, expensive, rate-limited, or should keep using a previou | `compat` | No | provider `compat` | Provider compatibility overrides. Merged with provider-level `compat` when both are set. | Current behavior: -- `/model` and `--list-models` list entries by model `id`. -- The configured `name` is used for model matching and detail/status text. +- `/model`, `--list-models`, and the interactive footer display entries by model `id`. +- The configured `name` is used for model matching and secondary model detail text. It does not replace the footer/status-bar model id. ### Thinking Level Map @@ -320,6 +320,7 @@ Behavior notes: - `modelOverrides` are applied to built-in provider models. - Unknown model IDs are ignored. - You can combine provider-level `baseUrl`/`headers` with `modelOverrides`. +- Overriding `name` changes model matching and secondary detail text only; the footer and primary model lists continue to show the model `id`. - If `models` is also defined for a provider, custom models are merged after built-in overrides. A custom model with the same `id` replaces the overridden built-in model entry. ## Anthropic Messages Compatibility From b7e721cb38973eb6b58f8eb6988177aab662ea75 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 9 Jun 2026 14:14:54 +0200 Subject: [PATCH 208/352] feat(tui): support autocomplete trigger characters closes #4703 --- packages/coding-agent/CHANGELOG.md | 4 +- packages/coding-agent/docs/extensions.md | 4 +- .../src/modes/interactive/interactive-mode.ts | 5 ++ .../test/interactive-mode-status.test.ts | 30 +++++++++++ packages/tui/CHANGELOG.md | 4 ++ packages/tui/src/autocomplete.ts | 3 ++ packages/tui/src/components/editor.ts | 50 ++++++++++++++---- packages/tui/test/editor.test.ts | 52 +++++++++++++++++++ 8 files changed, 138 insertions(+), 14 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6a5ecd59..7bdf6bcd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,12 +4,10 @@ ### Added -<<<<<<< Updated upstream - Added `areExperimentalFeaturesEnabled` feature guard to allow users to opt-in to early features. - Added `ctx.isProjectTrusted()` for extensions to observe the effective project trust decision, including temporary trust decisions ([#5523](https://github.com/earendil-works/pi/issues/5523)). -======= - Added a global `defaultProjectTrust` setting to choose whether unresolved project trust asks, always trusts, or never trusts by default. ->>>>>>> Stashed changes +- Added extension autocomplete trigger character support for `ctx.ui.addAutocompleteProvider()` wrappers ([#4703](https://github.com/earendil-works/pi/issues/4703)). ### Fixed diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index ab5747ab..cb02960f 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -2281,6 +2281,7 @@ ctx.ui.pasteToEditor("pasted content"); // Stack custom autocomplete behavior on top of the built-in provider ctx.ui.addAutocompleteProvider((current) => ({ + triggerCharacters: ["#"], async getSuggestions(lines, line, col, options) { const beforeCursor = (lines[line] ?? "").slice(0, col); const match = beforeCursor.match(/(?:^|[ \t])#([^\s#]*)$/); @@ -2329,7 +2330,7 @@ Custom working-indicator frames are rendered verbatim. If you want colors, add t ### Autocomplete Providers -Use `ctx.ui.addAutocompleteProvider()` to stack custom autocomplete logic on top of the built-in slash-command and path provider. +Use `ctx.ui.addAutocompleteProvider()` to stack custom autocomplete logic on top of the built-in slash-command and path provider. Set `triggerCharacters` for custom natural triggers such as `$`. Typical pattern: @@ -2341,6 +2342,7 @@ Typical pattern: ```typescript pi.on("session_start", (_event, ctx) => { ctx.ui.addAutocompleteProvider((current) => ({ + triggerCharacters: ["#"], async getSuggestions(lines, cursorLine, cursorCol, options) { const line = lines[cursorLine] ?? ""; const beforeCursor = line.slice(0, cursorCol); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 4bacb19a..d50611af 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -555,8 +555,13 @@ export class InteractiveMode { private setupAutocompleteProvider(): void { let provider = this.createBaseAutocompleteProvider(); + const triggerCharacters: string[] = []; for (const wrapProvider of this.autocompleteProviderWrappers) { provider = wrapProvider(provider); + triggerCharacters.push(...(provider.triggerCharacters ?? [])); + } + if (triggerCharacters.length > 0) { + provider.triggerCharacters = [...new Set(triggerCharacters)]; } this.autocompleteProvider = provider; diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 5f4a8e21..7ec54c7b 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -324,6 +324,36 @@ describe("InteractiveMode.setupAutocompleteProvider", () => { expect(provider.shouldTriggerFileCompletion?.(["foo"], 0, 3)).toBe(true); expect(calls).toEqual(["shouldTrigger:wrap2", "shouldTrigger:wrap1"]); }); + + test("merges triggerCharacters from wrapper factories", () => { + const defaultEditor = { setAutocompleteProvider: vi.fn() }; + const customEditor = { setAutocompleteProvider: vi.fn() }; + const passThrough = + (triggerCharacters: string[]): AutocompleteProviderFactory => + (current) => ({ + triggerCharacters, + getSuggestions: (lines, cursorLine, cursorCol, options) => + current.getSuggestions(lines, cursorLine, cursorCol, options), + applyCompletion: (lines, cursorLine, cursorCol, item, prefix) => + current.applyCompletion(lines, cursorLine, cursorCol, item, prefix), + }); + + const fakeThis = { + createBaseAutocompleteProvider: () => new CombinedAutocompleteProvider([], "/tmp/project", undefined), + defaultEditor, + editor: customEditor, + autocompleteProviderWrappers: [passThrough(["$"]), passThrough(["!"])], + }; + + ( + InteractiveMode as unknown as { + prototype: { setupAutocompleteProvider: (this: typeof fakeThis) => void }; + } + ).prototype.setupAutocompleteProvider.call(fakeThis); + + const provider = defaultEditor.setAutocompleteProvider.mock.calls[0]?.[0] as AutocompleteProvider; + expect(provider.triggerCharacters).toEqual(["$", "!"]); + }); }); describe("InteractiveMode.showLoadedResources", () => { diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 289b1a4c..b3cfcef2 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `AutocompleteProvider.triggerCharacters` so editor autocomplete can naturally trigger on provider-defined token prefixes ([#4703](https://github.com/earendil-works/pi/issues/4703)). + ### Fixed - Fixed prompt history navigation to restore the current draft when returning from history browsing ([#5494](https://github.com/earendil-works/pi/issues/5494)). diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index 5408967d..205748d8 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -239,6 +239,9 @@ export interface AutocompleteSuggestions { } export interface AutocompleteProvider { + /** Characters that should naturally trigger this provider at token boundaries. */ + triggerCharacters?: string[]; + // Get autocomplete suggestions for current text/cursor position // Returns null if no suggestions available getSuggestions( diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 3b3350b1..128254b2 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -219,6 +219,20 @@ const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { }; const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20; +const DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS = ["@", "#"]; + +function escapeCharacterClass(value: string): string { + return value.replace(/[\\^$.*+?()[\]{}|-]/g, "\\$&"); +} + +function buildTriggerPattern(triggerCharacters: string[]): RegExp { + return new RegExp(`(?:^|[\\s])[${triggerCharacters.map(escapeCharacterClass).join("")}][^\\s]*$`); +} + +function buildDebouncePattern(triggerCharacters: string[]): RegExp { + const escapedWithoutAt = triggerCharacters.filter((character) => character !== "@").map(escapeCharacterClass); + return new RegExp(`(?:^|[ \\t])(?:@(?:"[^"]*|[^\\s]*)|[${escapedWithoutAt.join("")}][^\\s]*)$`); +} export class Editor implements Component, Focusable { private state: EditorState = { @@ -245,6 +259,9 @@ export class Editor implements Component, Focusable { // Autocomplete support private autocompleteProvider?: AutocompleteProvider; + private autocompleteTriggerCharacters = [...DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS]; + private autocompleteTriggerPattern = buildTriggerPattern(this.autocompleteTriggerCharacters); + private autocompleteDebouncePattern = buildDebouncePattern(this.autocompleteTriggerCharacters); private autocompleteList?: SelectList; private autocompleteState: "regular" | "force" | null = null; private autocompletePrefix: string = ""; @@ -339,6 +356,7 @@ export class Editor implements Component, Focusable { setAutocompleteProvider(provider: AutocompleteProvider): void { this.cancelAutocomplete(); this.autocompleteProvider = provider; + this.setAutocompleteTriggerCharacters(provider.triggerCharacters ?? []); } /** @@ -1072,8 +1090,8 @@ export class Editor implements Component, Focusable { if (char === "/" && this.isAtStartOfMessage()) { this.tryTriggerAutocomplete(); } - // Auto-trigger for symbol-based completion like @ or # at token boundaries - else if (char === "@" || char === "#") { + // Auto-trigger for symbol-based completion like @, #, or provider triggers at token boundaries + else if (this.autocompleteTriggerCharacters.includes(char)) { const currentLine = this.state.lines[this.state.cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2]; @@ -1089,8 +1107,8 @@ export class Editor implements Component, Focusable { if (this.isInSlashCommandContext(textBeforeCursor)) { this.tryTriggerAutocomplete(); } - // Check if we're in a symbol-based completion context like @ or # - else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) { + // Check if we're in a symbol-based completion context like @, #, or provider triggers + else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) { this.tryTriggerAutocomplete(); } } @@ -1269,8 +1287,8 @@ export class Editor implements Component, Focusable { if (this.isInSlashCommandContext(textBeforeCursor)) { this.tryTriggerAutocomplete(); } - // Symbol-based completion context like @ or # - else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) { + // Symbol-based completion context like @, #, or provider triggers + else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) { this.tryTriggerAutocomplete(); } } @@ -1633,8 +1651,8 @@ export class Editor implements Component, Focusable { if (this.isInSlashCommandContext(textBeforeCursor)) { this.tryTriggerAutocomplete(); } - // Symbol-based completion context like @ or # - else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) { + // Symbol-based completion context like @, #, or provider triggers + else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) { this.tryTriggerAutocomplete(); } } @@ -2132,6 +2150,19 @@ export class Editor implements Component, Focusable { await this.autocompleteRequestTask; } + private setAutocompleteTriggerCharacters(triggerCharacters: string[]): void { + const next = [...DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS]; + for (const character of triggerCharacters) { + if (character.length !== 1 || character === "/" || isWhitespaceChar(character) || next.includes(character)) { + continue; + } + next.push(character); + } + this.autocompleteTriggerCharacters = next; + this.autocompleteTriggerPattern = buildTriggerPattern(next); + this.autocompleteDebouncePattern = buildDebouncePattern(next); + } + private getAutocompleteDebounceMs(options: { force: boolean; explicitTab: boolean }): number { if (options.explicitTab || options.force) { return 0; @@ -2139,8 +2170,7 @@ export class Editor implements Component, Focusable { const currentLine = this.state.lines[this.state.cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); - const isSymbolAutocompleteContext = /(?:^|[ \t])(?:@(?:"[^"]*|[^\s]*)|#[^\s]*)$/.test(textBeforeCursor); - return isSymbolAutocompleteContext ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0; + return this.autocompleteDebouncePattern.test(textBeforeCursor) ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0; } private async runAutocompleteRequest( diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index f7c391f0..2c8a7b62 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -2347,6 +2347,58 @@ describe("Editor component", () => { assert.strictEqual(editor.isShowingAutocomplete(), true); }); + it("debounces custom triggerCharacters autocomplete while typing", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let suggestionCalls = 0; + + editor.setAutocompleteProvider({ + triggerCharacters: ["$"], + getSuggestions: async (lines, _cursorLine, cursorCol) => { + suggestionCalls += 1; + const prefix = (lines[0] || "").slice(0, cursorCol); + return { items: [{ value: "$skill-name", label: "skill-name" }], prefix }; + }, + applyCompletion, + }); + + editor.handleInput("$"); + editor.handleInput("s"); + editor.handleInput("k"); + + assert.strictEqual(suggestionCalls, 0); + await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAutocomplete(); + + assert.strictEqual(suggestionCalls, 1); + assert.strictEqual(editor.isShowingAutocomplete(), true); + }); + + it("resets custom triggerCharacters when provider changes", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let suggestionCalls = 0; + + editor.setAutocompleteProvider({ + triggerCharacters: ["$"], + getSuggestions: async () => ({ items: [{ value: "$skill-name", label: "skill-name" }], prefix: "$" }), + applyCompletion, + }); + editor.setAutocompleteProvider({ + getSuggestions: async () => { + suggestionCalls += 1; + return { items: [{ value: "$skill-name", label: "skill-name" }], prefix: "$" }; + }, + applyCompletion, + }); + + editor.handleInput("$"); + editor.handleInput("s"); + await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAutocomplete(); + + assert.strictEqual(suggestionCalls, 0); + assert.strictEqual(editor.isShowingAutocomplete(), false); + }); + it("aborts active @ autocomplete when typing continues", async () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); let aborts = 0; From ae7a885da2514667ce760337edbf8a10ca1daa30 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 9 Jun 2026 14:17:33 +0200 Subject: [PATCH 209/352] Closes #5045, /new should not persist if original session was ephemeral --- packages/coding-agent/src/core/agent-session-runtime.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/core/agent-session-runtime.ts b/packages/coding-agent/src/core/agent-session-runtime.ts index be855ea4..7f29275a 100644 --- a/packages/coding-agent/src/core/agent-session-runtime.ts +++ b/packages/coding-agent/src/core/agent-session-runtime.ts @@ -232,7 +232,9 @@ export class AgentSessionRuntime { const previousSessionFile = this.session.sessionFile; const sessionDir = this.session.sessionManager.getSessionDir(); - const sessionManager = SessionManager.create(this.cwd, sessionDir); + const sessionManager = this.session.sessionManager.isPersisted() + ? SessionManager.create(this.cwd, sessionDir) + : SessionManager.inMemory(this.cwd); if (options?.parentSession) { sessionManager.newSession({ parentSession: options.parentSession }); } From aa039821693f2ac3a6d82f4fde18424adb95bc0b Mon Sep 17 00:00:00 2001 From: haoqixu Date: Wed, 10 Jun 2026 01:59:29 +0800 Subject: [PATCH 210/352] fix(coding-agent): parse :thinking suffix from custom model IDs in fallback path Fixes #5552 --- .../coding-agent/src/core/model-resolver.ts | 27 +++- packages/coding-agent/src/main.ts | 1 + .../coding-agent/test/model-resolver.test.ts | 121 ++++++++++++++++++ 3 files changed, 144 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 53b8ad11..c9afb7b2 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -340,9 +340,10 @@ export interface ResolveCliModelResult { export function resolveCliModel(options: { cliProvider?: string; cliModel?: string; + cliThinking?: string; modelRegistry: ModelRegistry; }): ResolveCliModelResult { - const { cliProvider, cliModel, modelRegistry } = options; + const { cliProvider, cliModel, cliThinking, modelRegistry } = options; if (!cliModel) { return { model: undefined, warning: undefined, error: undefined }; @@ -451,12 +452,28 @@ export function resolveCliModel(options: { } if (provider) { - const fallbackModel = buildFallbackModel(provider, pattern, availableModels); + // Parse thinking level suffix from the pattern before building the fallback model, + // but only when --thinking is not explicitly provided. + // e.g. "zai-org/GLM-5.1-FP8:high" → modelId="zai-org/GLM-5.1-FP8", fallbackThinking="high" + let fallbackPattern = pattern; + let fallbackThinking: ThinkingLevel | undefined; + if (!cliThinking) { + const lastColon = pattern.lastIndexOf(":"); + if (lastColon !== -1) { + const suffix = pattern.substring(lastColon + 1); + if (isValidThinkingLevel(suffix)) { + fallbackPattern = pattern.substring(0, lastColon); + fallbackThinking = suffix; + } + } + } + + const fallbackModel = buildFallbackModel(provider, fallbackPattern, availableModels); if (fallbackModel) { const fallbackWarning = warning - ? `${warning} Model "${pattern}" not found for provider "${provider}". Using custom model id.` - : `Model "${pattern}" not found for provider "${provider}". Using custom model id.`; - return { model: fallbackModel, thinkingLevel: undefined, warning: fallbackWarning, error: undefined }; + ? `${warning} Model "${fallbackPattern}" not found for provider "${provider}". Using custom model id.` + : `Model "${fallbackPattern}" not found for provider "${provider}". Using custom model id.`; + return { model: fallbackModel, thinkingLevel: fallbackThinking, warning: fallbackWarning, error: undefined }; } } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 38f95246..d6b95b73 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -360,6 +360,7 @@ function buildSessionOptions( const resolved = resolveCliModel({ cliProvider: parsed.provider, cliModel: parsed.model, + cliThinking: parsed.thinking, modelRegistry, }); if (resolved.warning) { diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 0ac54939..4b4caf0f 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -370,6 +370,127 @@ describe("resolveCliModel", () => { expect(result.model?.provider).toBe("openrouter"); expect(result.model?.id).toBe("qwen/qwen3-coder:exacto"); }); + + describe("custom model fallback with :thinking suffix (#5552)", () => { + // Models for a provider that has registered models but the specific model ID + // is not in the registry (triggers buildFallbackModel path). + const neuralwattModel: Model<"anthropic-messages"> = { + id: "some-base-model", + name: "Some Base Model", + api: "anthropic-messages", + provider: "neuralwatt", + baseUrl: "https://api.neuralwatt.com", + reasoning: false, + input: ["text"], + cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 }, + contextWindow: 128000, + maxTokens: 8192, + }; + + const modelsWithNeuralwatt = [...allModels, neuralwattModel]; + + test("strips :thinking suffix from custom model id in fallback path", () => { + const registry = { + getAll: () => modelsWithNeuralwatt, + } as unknown as Parameters[0]["modelRegistry"]; + + const result = resolveCliModel({ + cliModel: "neuralwatt/zai-org/GLM-5.1-FP8:high", + modelRegistry: registry, + }); + + expect(result.error).toBeUndefined(); + expect(result.model?.provider).toBe("neuralwatt"); + // The :high suffix must NOT leak into the model id sent to the API + expect(result.model?.id).toBe("zai-org/GLM-5.1-FP8"); + expect(result.thinkingLevel).toBe("high"); + }); + + test("custom model without thinking suffix works normally in fallback path", () => { + const registry = { + getAll: () => modelsWithNeuralwatt, + } as unknown as Parameters[0]["modelRegistry"]; + + const result = resolveCliModel({ + cliModel: "neuralwatt/zai-org/GLM-5.1-FP8", + modelRegistry: registry, + }); + + expect(result.error).toBeUndefined(); + expect(result.model?.provider).toBe("neuralwatt"); + expect(result.model?.id).toBe("zai-org/GLM-5.1-FP8"); + expect(result.thinkingLevel).toBeUndefined(); + }); + + test("all valid thinking levels work in fallback path", () => { + const registry = { + getAll: () => modelsWithNeuralwatt, + } as unknown as Parameters[0]["modelRegistry"]; + + for (const level of ["off", "minimal", "low", "medium", "high", "xhigh"]) { + const result = resolveCliModel({ + cliModel: `neuralwatt/zai-org/GLM-5.1-FP8:${level}`, + modelRegistry: registry, + }); + + expect(result.error).toBeUndefined(); + expect(result.model?.id).toBe("zai-org/GLM-5.1-FP8"); + expect(result.thinkingLevel).toBe(level); + } + }); + + test("invalid thinking suffix on custom model is treated as part of model id", () => { + const registry = { + getAll: () => modelsWithNeuralwatt, + } as unknown as Parameters[0]["modelRegistry"]; + + const result = resolveCliModel({ + cliModel: "neuralwatt/zai-org/GLM-5.1-FP8:banana", + modelRegistry: registry, + }); + + expect(result.error).toBeUndefined(); + expect(result.model?.provider).toBe("neuralwatt"); + // Invalid suffix stays in the id (it's not a thinking level) + expect(result.model?.id).toBe("zai-org/GLM-5.1-FP8:banana"); + expect(result.thinkingLevel).toBeUndefined(); + }); + + test("explicit --provider with custom model:thinking strips suffix correctly", () => { + const registry = { + getAll: () => modelsWithNeuralwatt, + } as unknown as Parameters[0]["modelRegistry"]; + + const result = resolveCliModel({ + cliProvider: "neuralwatt", + cliModel: "zai-org/GLM-5.1-FP8:high", + modelRegistry: registry, + }); + + expect(result.error).toBeUndefined(); + expect(result.model?.provider).toBe("neuralwatt"); + expect(result.model?.id).toBe("zai-org/GLM-5.1-FP8"); + expect(result.thinkingLevel).toBe("high"); + }); + + test("with explicit --thinking, :suffix is kept as part of model id", () => { + const registry = { + getAll: () => modelsWithNeuralwatt, + } as unknown as Parameters[0]["modelRegistry"]; + + const result = resolveCliModel({ + cliModel: "neuralwatt/zai-org/GLM-5.1-FP8:high", + cliThinking: "medium", + modelRegistry: registry, + }); + + expect(result.error).toBeUndefined(); + expect(result.model?.provider).toBe("neuralwatt"); + // :high is kept as part of the model id since --thinking was explicit + expect(result.model?.id).toBe("zai-org/GLM-5.1-FP8:high"); + expect(result.thinkingLevel).toBeUndefined(); + }); + }); }); describe("default model selection", () => { From dfd4571cb0ed46b18d3eb045814720f779844b95 Mon Sep 17 00:00:00 2001 From: Sviatoslav Abakumov Date: Tue, 9 Jun 2026 23:46:09 +0400 Subject: [PATCH 211/352] fix(tui): separate list items with blank lines in loose lists --- packages/tui/src/components/markdown.ts | 5 ++++ packages/tui/test/markdown.test.ts | 31 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index d5e3f477..ed8ad780 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -572,6 +572,7 @@ export class Markdown implements Component { for (let i = 0; i < token.items.length; i++) { const item = token.items[i]; + const isLastItem = i === token.items.length - 1; const bullet = token.ordered ? this.options.preserveOrderedListMarkers ? (this.getOrderedListMarker(item) ?? `${startNumber + i}. `) @@ -604,6 +605,10 @@ export class Markdown implements Component { if (!renderedAnyLine) { lines.push(firstPrefix); } + + if (token.loose && !isLastItem) { + lines.push(""); + } } return lines; diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index fd5345df..9c0be05c 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -149,6 +149,37 @@ describe("Markdown component", () => { assert.ok(plainLines.some((line) => line.includes("2. Second ordered"))); }); + it("should render blank lines between loose list items", () => { + const markdown = new Markdown( + `1. Lorem ipsum dolor sit amet. + + Ut enim ad minim veniam. + +2. Duis aute irure dolor. + + Excepteur sint occaecat cupidatat. + +3. Beep boop`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, [ + "1. Lorem ipsum dolor sit amet.", + "", + " Ut enim ad minim veniam.", + "", + "2. Duis aute irure dolor.", + "", + " Excepteur sint occaecat cupidatat.", + "", + "3. Beep boop", + ]); + }); + it("should render task list markers", () => { const markdown = new Markdown("- [ ] beep\n- [x] boop", 0, 0, defaultMarkdownTheme); From a0c2465d47ee89c88aea7670ea51ceb9aab9e254 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 9 Jun 2026 22:33:02 +0200 Subject: [PATCH 212/352] docs: audit unreleased changelogs --- packages/ai/CHANGELOG.md | 1 + packages/coding-agent/CHANGELOG.md | 20 ++++++++++++++++++-- packages/tui/CHANGELOG.md | 1 + 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 13a5f688..8e26b4e2 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed Amazon Bedrock inference profile ARN region resolution to prefer the ARN's embedded region over `AWS_REGION` ([#5527](https://github.com/earendil-works/pi/pull/5527) by [@AJM10565](https://github.com/AJM10565)). - Fixed z.ai thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5330](https://github.com/earendil-works/pi/issues/5330)). - Fixed OpenCode completions model metadata to send explicit `maxTokens` as `max_tokens` ([#5331](https://github.com/earendil-works/pi/issues/5331)). - Fixed Moonshot Kimi thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5531](https://github.com/earendil-works/pi/issues/5531)). diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f71130db..8aaa2411 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,20 +2,36 @@ ## [Unreleased] +### New Features + +- **Prompt template defaults** - Prompt templates can use default positional arguments such as `${1:-7}` for optional values. See [Prompt Template Arguments](docs/prompt-templates.md#arguments). +- **Configurable project trust defaults** - `defaultProjectTrust` lets users choose whether unresolved project trust asks, always trusts, or never trusts by default, and extensions can inspect effective trust decisions. See [Project Trust](docs/security.md#project-trust) and [`ctx.isProjectTrusted()`](docs/extensions.md#ctxisprojecttrusted). +- **Natural extension autocomplete triggers** - Extension autocomplete providers can declare trigger characters such as `#` or `$` so suggestions open without slash-command prefixes. See [Autocomplete Providers](docs/extensions.md#autocomplete-providers). + ### Added -- Added default-value expansion for prompt template positional arguments, e.g. `${1:-7}` ([#5507](https://github.com/earendil-works/pi/issues/5507)). -- Added `areExperimentalFeaturesEnabled` feature guard to allow users to opt-in to early features. +- Added default-value expansion for prompt template positional arguments, e.g. `${1:-7}` ([#5553](https://github.com/earendil-works/pi/pull/5553) by [@dannote](https://github.com/dannote)). +- Added `areExperimentalFeaturesEnabled` feature guard to allow users to opt in to early features ([#5547](https://github.com/earendil-works/pi/pull/5547) by [@vegarsti](https://github.com/vegarsti)). - Added `ctx.isProjectTrusted()` for extensions to observe the effective project trust decision, including temporary trust decisions ([#5523](https://github.com/earendil-works/pi/issues/5523)). - Added a global `defaultProjectTrust` setting to choose whether unresolved project trust asks, always trusts, or never trusts by default. - Added extension autocomplete trigger character support for `ctx.ui.addAutocompleteProvider()` wrappers ([#4703](https://github.com/earendil-works/pi/issues/4703)). ### Fixed +- Fixed inherited Amazon Bedrock inference profile ARN region resolution to prefer the ARN's embedded region over `AWS_REGION` ([#5527](https://github.com/earendil-works/pi/pull/5527) by [@AJM10565](https://github.com/AJM10565)). +- Fixed inherited IME hardware cursor positioning while slash-command autocomplete is visible ([#5283](https://github.com/earendil-works/pi/pull/5283) by [@smoosex](https://github.com/smoosex)). +- Fixed inherited z.ai thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5330](https://github.com/earendil-works/pi/issues/5330)). +- Fixed inherited OpenCode completions model metadata to send explicit `maxTokens` as `max_tokens` ([#5331](https://github.com/earendil-works/pi/issues/5331)). +- Fixed inherited Moonshot Kimi thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5531](https://github.com/earendil-works/pi/issues/5531)). +- Fixed inherited Azure OpenAI Responses requests to disable server-side response storage ([#5530](https://github.com/earendil-works/pi/issues/5530)). +- Fixed inherited prompt history navigation to restore the current draft when returning from history browsing ([#5494](https://github.com/earendil-works/pi/issues/5494)). +- Fixed inherited wrapping for mixed Latin and CJK text so unspaced CJK runs can break at grapheme boundaries without leaving large trailing gaps ([#5495](https://github.com/earendil-works/pi/issues/5495)). - Fixed extension OAuth login prompts to keep previous submitted prompt rows stable instead of mirroring the active input value ([#5433](https://github.com/earendil-works/pi/issues/5433)). - Fixed `/reload` to apply updated `steeringMode` and `followUpMode` settings to the current session ([#5377](https://github.com/earendil-works/pi/issues/5377)). - Fixed invalid `models.json` syntax to skip startup config migrations and report the normal file-path-aware models error instead of a raw JSON parse stack trace ([#5418](https://github.com/earendil-works/pi/issues/5418)). - Fixed GitHub release notes and interactive changelog links to resolve package-relative documentation URLs correctly ([#5516](https://github.com/earendil-works/pi/issues/5516)). +- Fixed CLI help and version output, including plain redirected `--help`/`--version` output and simplified `list`/`config` help text. +- Fixed `/new` from ephemeral sessions to keep the new session ephemeral instead of persisting it by default ([#5045](https://github.com/earendil-works/pi/issues/5045)). - Clarified custom model docs that `name` and `modelOverrides.name` do not replace model IDs in the footer or primary model lists ([#4841](https://github.com/earendil-works/pi/issues/4841)). ## [0.79.0] - 2026-06-08 diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index b3cfcef2..c1a48483 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed IME hardware cursor positioning while slash-command autocomplete is visible ([#5283](https://github.com/earendil-works/pi/pull/5283) by [@smoosex](https://github.com/smoosex)). - Fixed prompt history navigation to restore the current draft when returning from history browsing ([#5494](https://github.com/earendil-works/pi/issues/5494)). - Fixed wrapping for mixed Latin and CJK text so unspaced CJK runs can break at grapheme boundaries without leaving large trailing gaps ([#5495](https://github.com/earendil-works/pi/issues/5495)). From 5a9d72ea027c1ee3960154b0b6f09cab4e6c0937 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 9 Jun 2026 22:42:48 +0200 Subject: [PATCH 213/352] feat(ai): add Claude Fable 5 metadata --- packages/ai/CHANGELOG.md | 4 ++++ packages/ai/scripts/generate-models.ts | 9 ++++++++- packages/ai/src/providers/amazon-bedrock.ts | 9 +++++++-- packages/ai/src/providers/anthropic.ts | 4 ++-- .../anthropic-force-adaptive-thinking.test.ts | 7 +++++++ .../ai/test/bedrock-thinking-payload.test.ts | 19 +++++++++++++++++++ packages/ai/test/supports-xhigh.test.ts | 14 +++++++++++++- 7 files changed, 60 insertions(+), 6 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 8e26b4e2..dfd89b7d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added Claude Fable 5 to Anthropic and Amazon Bedrock model metadata, with adaptive thinking and `xhigh` effort support. + ### Fixed - Fixed Amazon Bedrock inference profile ARN region resolution to prefer the ARN's embedded region over `AWS_REGION` ([#5527](https://github.com/earendil-works/pi/pull/5527) by [@AJM10565](https://github.com/AJM10565)). diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 408a8034..2cc0e171 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -232,7 +232,8 @@ function isAnthropicAdaptiveThinkingModel(modelId: string): boolean { modelId.includes("opus-4-8") || modelId.includes("opus-4.8") || modelId.includes("sonnet-4-6") || - modelId.includes("sonnet-4.6") + modelId.includes("sonnet-4.6") || + modelId.includes("fable-5") ); } @@ -294,6 +295,12 @@ function applyThinkingLevelMetadata(model: Model): void { ) { mergeThinkingLevelMap(model, { xhigh: "xhigh" }); } + if ( + (model.api === "anthropic-messages" || model.api === "bedrock-converse-stream") && + model.id.includes("fable-5") + ) { + mergeThinkingLevelMap(model, { xhigh: "xhigh" }); + } if (model.api === "anthropic-messages" && isAnthropicAdaptiveThinkingModel(model.id)) { mergeAnthropicMessagesCompat(model, { forceAdaptiveThinking: true }); } diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index e357bac5..b022b522 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -528,13 +528,18 @@ function getModelMatchCandidates(modelId: string, modelName?: string): string[] function supportsAdaptiveThinking(modelId: string, modelName?: string): boolean { const candidates = getModelMatchCandidates(modelId, modelName); return candidates.some( - (s) => s.includes("opus-4-6") || s.includes("opus-4-7") || s.includes("opus-4-8") || s.includes("sonnet-4-6"), + (s) => + s.includes("opus-4-6") || + s.includes("opus-4-7") || + s.includes("opus-4-8") || + s.includes("sonnet-4-6") || + s.includes("fable-5"), ); } function supportsNativeXhighEffort(model: Model<"bedrock-converse-stream">): boolean { const candidates = getModelMatchCandidates(model.id, model.name); - return candidates.some((s) => s.includes("opus-4-7") || s.includes("opus-4-8")); + return candidates.some((s) => s.includes("opus-4-7") || s.includes("opus-4-8") || s.includes("fable-5")); } function mapThinkingLevelToEffort( diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 56b82d8c..158311b0 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -200,7 +200,7 @@ export interface AnthropicOptions extends StreamOptions { * Effort level for adaptive thinking models. * Controls how much thinking Claude allocates: * - "max": Always thinks with no constraints (Opus 4.6 only) - * - "xhigh": Highest reasoning level (Opus 4.7) + * - "xhigh": Highest reasoning level (Opus 4.7+, Fable 5) * - "high": Always thinks, deep reasoning * - "medium": Moderate thinking, may skip for simple queries * - "low": Minimal thinking, skips for simple tasks @@ -711,7 +711,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti /** * Map ThinkingLevel to Anthropic effort levels for adaptive thinking. - * Note: effort "max" is only valid on Opus 4.6, while Opus 4.7 supports "xhigh". + * Note: effort "max" is only valid on Opus 4.6, while Opus 4.7+ and Fable 5 support "xhigh". */ function mapThinkingLevelToEffort( model: Model<"anthropic-messages">, diff --git a/packages/ai/test/anthropic-force-adaptive-thinking.test.ts b/packages/ai/test/anthropic-force-adaptive-thinking.test.ts index 5b3511ca..e797c3a9 100644 --- a/packages/ai/test/anthropic-force-adaptive-thinking.test.ts +++ b/packages/ai/test/anthropic-force-adaptive-thinking.test.ts @@ -83,6 +83,13 @@ describe("Anthropic forceAdaptiveThinking compat override", () => { expect(payload.output_config).toEqual({ effort: "medium" }); }); + it("uses adaptive thinking with native xhigh effort for Claude Fable 5", async () => { + const payload = await capturePayload(getModel("anthropic", "claude-fable-5"), { reasoning: "xhigh" }); + + expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(payload.output_config).toEqual({ effort: "xhigh" }); + }); + it("allows built-in adaptive models to opt out with compat.forceAdaptiveThinking false", async () => { const model: Model<"anthropic-messages"> = { ...getModel("anthropic", "claude-opus-4-8"), diff --git a/packages/ai/test/bedrock-thinking-payload.test.ts b/packages/ai/test/bedrock-thinking-payload.test.ts index 93143e38..8f4e06e7 100644 --- a/packages/ai/test/bedrock-thinking-payload.test.ts +++ b/packages/ai/test/bedrock-thinking-payload.test.ts @@ -83,6 +83,25 @@ describe("Bedrock thinking payload", () => { expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined(); }); + it("uses adaptive thinking for Claude Fable 5 when reasoning is enabled", async () => { + const model = getModel("amazon-bedrock", "global.anthropic.claude-fable-5"); + + const payload = await capturePayload(model); + + expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "high" }); + expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined(); + }); + + it("maps xhigh reasoning to effort=xhigh for Claude Fable 5", async () => { + const model = getModel("amazon-bedrock", "global.anthropic.claude-fable-5"); + + const payload = await capturePayload(model, { reasoning: "xhigh" }); + + expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "xhigh" }); + }); + it("omits display for GovCloud model ids on non-adaptive Claude thinking", async () => { const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-sonnet-4-5-20250929-v1:0"); const model: Model<"bedrock-converse-stream"> = { diff --git a/packages/ai/test/supports-xhigh.test.ts b/packages/ai/test/supports-xhigh.test.ts index d5ec3b5b..80b3e683 100644 --- a/packages/ai/test/supports-xhigh.test.ts +++ b/packages/ai/test/supports-xhigh.test.ts @@ -20,7 +20,13 @@ describe("getSupportedThinkingLevels", () => { expect(getSupportedThinkingLevels(model!)).toContain("xhigh"); }); - it("does not include xhigh for non-Opus Anthropic models", () => { + it("includes xhigh for Anthropic Claude Fable 5 on anthropic-messages API", () => { + const model = getModel("anthropic", "claude-fable-5"); + expect(model).toBeDefined(); + expect(getSupportedThinkingLevels(model!)).toContain("xhigh"); + }); + + it("does not include xhigh for Claude Sonnet 4.5", () => { const model = getModel("anthropic", "claude-sonnet-4-5"); expect(model).toBeDefined(); expect(getSupportedThinkingLevels(model!)).not.toContain("xhigh"); @@ -79,4 +85,10 @@ describe("getSupportedThinkingLevels", () => { expect(model).toBeDefined(); expect(getSupportedThinkingLevels(model!)).toContain("xhigh"); }); + + it("includes xhigh for Bedrock Claude Fable 5", () => { + const model = getModel("amazon-bedrock", "global.anthropic.claude-fable-5"); + expect(model).toBeDefined(); + expect(getSupportedThinkingLevels(model!)).toContain("xhigh"); + }); }); From 6b5923f107ea548c35a1e4bacb93591bdc2bed45 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 9 Jun 2026 22:57:20 +0200 Subject: [PATCH 214/352] fix(ai): correct Azure gpt-5.4/5.5 context window and gpt-5-pro maxTokens Azure Foundry deploys gpt-5.4 and gpt-5.5 with a 1,050,000 context window, but the Azure provider cloned OpenAI-direct's 272k API limit. Override the context window in the Azure derivation. Also fix gpt-5-pro maxTokens, which upstream metadata set to 272000 (a duplicate of the input sub-limit) instead of the actual 128000 max output; corrected at the source so OpenAI-direct and Azure both match. closes #5559 --- packages/ai/CHANGELOG.md | 2 ++ packages/ai/scripts/generate-models.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index dfd89b7d..66de1162 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -13,6 +13,8 @@ - Fixed OpenCode completions model metadata to send explicit `maxTokens` as `max_tokens` ([#5331](https://github.com/earendil-works/pi/issues/5331)). - Fixed Moonshot Kimi thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5531](https://github.com/earendil-works/pi/issues/5531)). - Fixed Azure OpenAI Responses requests to disable server-side response storage ([#5530](https://github.com/earendil-works/pi/issues/5530)). +- Fixed Azure GPT-5.4 and GPT-5.5 context window metadata to 1,050,000 tokens, matching Azure Foundry deployments instead of OpenAI's 272k limit ([#5559](https://github.com/earendil-works/pi/issues/5559)). +- Fixed OpenAI and Azure GPT-5 Pro `maxTokens` metadata to 128,000, correcting an upstream value that duplicated the 272,000 input sub-limit as the output limit ([#5559](https://github.com/earendil-works/pi/issues/5559)). ## [0.79.0] - 2026-06-08 diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 2cc0e171..3253de64 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -1366,6 +1366,11 @@ async function generateModels() { candidate.contextWindow = 272000; candidate.maxTokens = 128000; } + // models.dev reports gpt-5-pro output as 272000 (a duplicate of the input sub-limit); + // the actual max output is 128000. Also propagates to the derived Azure clone. + if (candidate.provider === "openai" && candidate.id === "gpt-5-pro") { + candidate.maxTokens = 128000; + } // Keep selected OpenRouter model metadata stable until upstream settles. if (candidate.provider === "openrouter" && candidate.id === "moonshotai/kimi-k2.5") { candidate.cost.input = 0.41; @@ -2064,6 +2069,12 @@ async function generateModels() { ]; allModels.push(...vertexModels); + // Azure Foundry deploys these with larger context windows than OpenAI's own API, + // which caps gpt-5.4/gpt-5.5 at 272k. See models-sold-directly-by-azure docs. + const AZURE_CONTEXT_WINDOW_OVERRIDES: Record = { + "gpt-5.4": 1050000, + "gpt-5.5": 1050000, + }; const azureOpenAiModels: Model[] = allModels .filter((model) => model.provider === "openai" && model.api === "openai-responses") .map((model) => ({ @@ -2071,6 +2082,7 @@ async function generateModels() { api: "azure-openai-responses", provider: "azure-openai-responses", baseUrl: "", + contextWindow: AZURE_CONTEXT_WINDOW_OVERRIDES[model.id] ?? model.contextWindow, })); allModels.push(...azureOpenAiModels); From 66f432cae4c2800436dc34256bd2092edb1dca7b Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 9 Jun 2026 23:07:41 +0200 Subject: [PATCH 215/352] fix(ai): regenerate models for Claude Fable 5 and Azure metadata overrides Also add inherited coding-agent changelog entries for Fable 5 and the Azure gpt-5.4/5.5 context window and gpt-5-pro maxTokens fixes. --- packages/ai/src/models.generated.ts | 202 ++++++++++++++++++++++++++-- packages/coding-agent/CHANGELOG.md | 4 + 2 files changed, 196 insertions(+), 10 deletions(-) diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 9ebd12da..57da60bc 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -350,6 +350,24 @@ export const MODELS = { contextWindow: 163840, maxTokens: 81920, } satisfies Model<"bedrock-converse-stream">, + "eu.anthropic.claude-fable-5": { + id: "eu.anthropic.claude-fable-5", + name: "Claude Fable 5 (EU)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.eu-central-1.amazonaws.com", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 11, + output: 55, + cacheRead: 1.1, + cacheWrite: 13.75, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, "eu.anthropic.claude-haiku-4-5-20251001-v1:0": { id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", name: "Claude Haiku 4.5 (EU)", @@ -472,6 +490,24 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 64000, } satisfies Model<"bedrock-converse-stream">, + "global.anthropic.claude-fable-5": { + id: "global.anthropic.claude-fable-5", + name: "Claude Fable 5 (Global)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 10, + output: 50, + cacheRead: 1, + cacheWrite: 12.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, "global.anthropic.claude-haiku-4-5-20251001-v1:0": { id: "global.anthropic.claude-haiku-4-5-20251001-v1:0", name: "Claude Haiku 4.5 (Global)", @@ -1346,6 +1382,24 @@ export const MODELS = { contextWindow: 262000, maxTokens: 262000, } satisfies Model<"bedrock-converse-stream">, + "us.anthropic.claude-fable-5": { + id: "us.anthropic.claude-fable-5", + name: "Claude Fable 5 (US)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 10, + output: 50, + cacheRead: 1, + cacheWrite: 12.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, "us.anthropic.claude-haiku-4-5-20251001-v1:0": { id: "us.anthropic.claude-haiku-4-5-20251001-v1:0", name: "Claude Haiku 4.5 (US)", @@ -1816,6 +1870,25 @@ export const MODELS = { contextWindow: 200000, maxTokens: 4096, } satisfies Model<"anthropic-messages">, + "claude-fable-5": { + id: "claude-fable-5", + name: "Claude Fable 5", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 10, + output: 50, + cacheRead: 1, + cacheWrite: 12.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, "claude-haiku-4-5": { id: "claude-haiku-4-5", name: "Claude Haiku 4.5 (latest)", @@ -2373,7 +2446,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 400000, - maxTokens: 272000, + maxTokens: 128000, } satisfies Model<"azure-openai-responses">, "gpt-5.1": { id: "gpt-5.1", @@ -2606,7 +2679,7 @@ export const MODELS = { cacheRead: 0.25, cacheWrite: 0, }, - contextWindow: 272000, + contextWindow: 1050000, maxTokens: 128000, } satisfies Model<"azure-openai-responses">, "gpt-5.4-mini": { @@ -2678,7 +2751,7 @@ export const MODELS = { cacheRead: 0.5, cacheWrite: 0, }, - contextWindow: 272000, + contextWindow: 1050000, maxTokens: 128000, } satisfies Model<"azure-openai-responses">, "gpt-5.5-pro": { @@ -2992,6 +3065,25 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } satisfies Model<"anthropic-messages">, + "claude-fable-5": { + id: "claude-fable-5", + name: "Claude Fable 5", + api: "anthropic-messages", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 10, + output: 50, + cacheRead: 1, + cacheWrite: 12.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, "claude-haiku-4-5": { id: "claude-haiku-4-5", name: "Claude Haiku 4.5 (latest)", @@ -7226,7 +7318,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 400000, - maxTokens: 272000, + maxTokens: 128000, } satisfies Model<"openai-responses">, "gpt-5.1": { id: "gpt-5.1", @@ -7782,6 +7874,25 @@ export const MODELS = { contextWindow: 200000, maxTokens: 32000, } satisfies Model<"openai-completions">, + "claude-fable-5": { + id: "claude-fable-5", + name: "Claude Fable 5", + api: "anthropic-messages", + provider: "opencode", + baseUrl: "https://opencode.ai/zen", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 10, + output: 50, + cacheRead: 1, + cacheWrite: 12.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, "claude-haiku-4-5": { id: "claude-haiku-4-5", name: "Claude Haiku 4.5", @@ -8485,6 +8596,24 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"openai-completions">, + "north-mini-code-free": { + id: "north-mini-code-free", + name: "North Mini Code Free", + api: "openai-completions", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, "qwen3.5-plus": { id: "qwen3.5-plus", name: "Qwen3.5 Plus", @@ -8910,6 +9039,23 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } satisfies Model<"openai-completions">, + "anthropic/claude-fable-5": { + id: "anthropic/claude-fable-5", + name: "Anthropic: Claude Fable 5", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 10, + output: 50, + cacheRead: 1, + cacheWrite: 12.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "anthropic/claude-haiku-4.5": { id: "anthropic/claude-haiku-4.5", name: "Anthropic: Claude Haiku 4.5", @@ -10070,8 +10216,8 @@ export const MODELS = { input: ["text"], cost: { input: 0.15, - output: 1.15, - cacheRead: 0, + output: 0.8999999999999999, + cacheRead: 0.049999999999999996, cacheWrite: 0, }, contextWindow: 204800, @@ -10086,13 +10232,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.27899999999999997, - output: 1.2, - cacheRead: 0, + input: 0.27, + output: 1.08, + cacheRead: 0.054, cacheWrite: 0, }, contextWindow: 204800, - maxTokens: 196608, + maxTokens: 131072, } satisfies Model<"openai-completions">, "minimax/minimax-m3": { id: "minimax/minimax-m3", @@ -12998,6 +13144,23 @@ export const MODELS = { contextWindow: 202752, maxTokens: 131072, } satisfies Model<"openai-completions">, + "~anthropic/claude-fable-latest": { + id: "~anthropic/claude-fable-latest", + name: "Anthropic: Claude Fable Latest", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 10, + output: 50, + cacheRead: 1, + cacheWrite: 12.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "~anthropic/claude-haiku-latest": { id: "~anthropic/claude-haiku-latest", name: "Anthropic Claude Haiku Latest", @@ -13904,6 +14067,25 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } satisfies Model<"anthropic-messages">, + "anthropic/claude-fable-5": { + id: "anthropic/claude-fable-5", + name: "Claude Fable 5", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 10, + output: 50, + cacheRead: 1, + cacheWrite: 12.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, "anthropic/claude-haiku-4.5": { id: "anthropic/claude-haiku-4.5", name: "Claude Haiku 4.5", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8aaa2411..8ee5ec41 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### New Features +- **Claude Fable 5** - Claude Fable 5 is now available on the Anthropic and Amazon Bedrock providers, with adaptive thinking and `xhigh` effort support. - **Prompt template defaults** - Prompt templates can use default positional arguments such as `${1:-7}` for optional values. See [Prompt Template Arguments](docs/prompt-templates.md#arguments). - **Configurable project trust defaults** - `defaultProjectTrust` lets users choose whether unresolved project trust asks, always trusts, or never trusts by default, and extensions can inspect effective trust decisions. See [Project Trust](docs/security.md#project-trust) and [`ctx.isProjectTrusted()`](docs/extensions.md#ctxisprojecttrusted). - **Natural extension autocomplete triggers** - Extension autocomplete providers can declare trigger characters such as `#` or `$` so suggestions open without slash-command prefixes. See [Autocomplete Providers](docs/extensions.md#autocomplete-providers). @@ -15,6 +16,7 @@ - Added `ctx.isProjectTrusted()` for extensions to observe the effective project trust decision, including temporary trust decisions ([#5523](https://github.com/earendil-works/pi/issues/5523)). - Added a global `defaultProjectTrust` setting to choose whether unresolved project trust asks, always trusts, or never trusts by default. - Added extension autocomplete trigger character support for `ctx.ui.addAutocompleteProvider()` wrappers ([#4703](https://github.com/earendil-works/pi/issues/4703)). +- Added Claude Fable 5 model support inherited from `@earendil-works/pi-ai` for the Anthropic and Amazon Bedrock providers, with adaptive thinking and `xhigh` effort support. ### Fixed @@ -24,6 +26,8 @@ - Fixed inherited OpenCode completions model metadata to send explicit `maxTokens` as `max_tokens` ([#5331](https://github.com/earendil-works/pi/issues/5331)). - Fixed inherited Moonshot Kimi thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5531](https://github.com/earendil-works/pi/issues/5531)). - Fixed inherited Azure OpenAI Responses requests to disable server-side response storage ([#5530](https://github.com/earendil-works/pi/issues/5530)). +- Fixed inherited Azure GPT-5.4 and GPT-5.5 context window metadata to 1,050,000 tokens, matching Azure Foundry deployments instead of OpenAI's 272k limit ([#5559](https://github.com/earendil-works/pi/issues/5559)). +- Fixed inherited OpenAI and Azure GPT-5 Pro `maxTokens` metadata to 128,000, correcting an upstream value that duplicated the input sub-limit as the output limit ([#5559](https://github.com/earendil-works/pi/issues/5559)). - Fixed inherited prompt history navigation to restore the current draft when returning from history browsing ([#5494](https://github.com/earendil-works/pi/issues/5494)). - Fixed inherited wrapping for mixed Latin and CJK text so unspaced CJK runs can break at grapheme boundaries without leaving large trailing gaps ([#5495](https://github.com/earendil-works/pi/issues/5495)). - Fixed extension OAuth login prompts to keep previous submitted prompt rows stable instead of mirroring the active input value ([#5433](https://github.com/earendil-works/pi/issues/5433)). From 4d9f9f455ddc6eb917d2042f46d6e9d95d407102 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 9 Jun 2026 23:14:38 +0200 Subject: [PATCH 216/352] fix(ai): regenerate image models for upstream Riverflow rename --- packages/ai/src/image-models.generated.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/ai/src/image-models.generated.ts b/packages/ai/src/image-models.generated.ts index 5038303d..09c74180 100644 --- a/packages/ai/src/image-models.generated.ts +++ b/packages/ai/src/image-models.generated.ts @@ -440,9 +440,9 @@ export const IMAGE_MODELS = { cacheWrite: 0, }, } satisfies ImagesModel<"openrouter-images">, - "sourceful/riverflow-v2.5-fast:free": { - id: "sourceful/riverflow-v2.5-fast:free", - name: "Sourceful: Riverflow V2.5 Fast (free)", + "sourceful/riverflow-v2.5-fast": { + id: "sourceful/riverflow-v2.5-fast", + name: "Sourceful: Riverflow V2.5 Fast", api: "openrouter-images", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -455,9 +455,9 @@ export const IMAGE_MODELS = { cacheWrite: 0, }, } satisfies ImagesModel<"openrouter-images">, - "sourceful/riverflow-v2.5-pro:free": { - id: "sourceful/riverflow-v2.5-pro:free", - name: "Sourceful: Riverflow V2.5 Pro (free)", + "sourceful/riverflow-v2.5-pro": { + id: "sourceful/riverflow-v2.5-pro", + name: "Sourceful: Riverflow V2.5 Pro", api: "openrouter-images", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", From 28df940f0d07b65284849a483be7b06e2ca046ee Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 9 Jun 2026 23:14:52 +0200 Subject: [PATCH 217/352] Release v0.79.1 --- package-lock.json | 50 +++++-------------- packages/agent/CHANGELOG.md | 2 +- packages/agent/package.json | 4 +- packages/ai/CHANGELOG.md | 2 +- packages/ai/package.json | 2 +- packages/coding-agent/CHANGELOG.md | 2 +- .../package-lock.json | 4 +- .../custom-provider-anthropic/package.json | 2 +- .../custom-provider-gitlab-duo/package.json | 2 +- .../extensions/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 +- packages/coding-agent/npm-shrinkwrap.json | 24 ++++----- packages/coding-agent/package.json | 8 +-- packages/tui/CHANGELOG.md | 2 +- packages/tui/package.json | 2 +- 19 files changed, 50 insertions(+), 74 deletions(-) diff --git a/package-lock.json b/package-lock.json index 97a05b50..37edf4a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6170,10 +6170,10 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.79.0", + "version": "0.79.1", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.79.0", + "@earendil-works/pi-ai": "^0.79.1", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -6188,30 +6188,6 @@ "node": ">=22.19.0" } }, - "packages/agent/node_modules/@earendil-works/pi-ai": { - "version": "0.78.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.78.1.tgz", - "integrity": "sha512-CM2pkTs1iupG/maw381lC9Q/Y/aQaMGK7GILc28ttImD0ci3LDwKroDsGkWbly5JIy3iqxdRxB9JlG7vvzCzTg==", - "license": "MIT", - "dependencies": { - "@anthropic-ai/sdk": "0.91.1", - "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.1", - "@smithy/node-http-handler": "4.7.3", - "http-proxy-agent": "7.0.2", - "https-proxy-agent": "7.0.6", - "openai": "6.26.0", - "partial-json": "0.1.7", - "typebox": "1.1.38" - }, - "bin": { - "pi-ai": "dist/cli.js" - }, - "engines": { - "node": ">=22.19.0" - } - }, "packages/agent/node_modules/@types/node": { "version": "24.12.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", @@ -6231,7 +6207,7 @@ }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.79.0", + "version": "0.79.1", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -6276,12 +6252,12 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "0.79.0", + "version": "0.79.1", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.0", - "@earendil-works/pi-ai": "^0.79.0", - "@earendil-works/pi-tui": "^0.79.0", + "@earendil-works/pi-agent-core": "^0.79.1", + "@earendil-works/pi-ai": "^0.79.1", + "@earendil-works/pi-tui": "^0.79.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -6320,32 +6296,32 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.79.0", + "version": "0.79.1", "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.79.0" + "version": "0.79.1" }, "packages/coding-agent/examples/extensions/gondolin": { "name": "pi-extension-gondolin", - "version": "0.79.0", + "version": "0.79.1", "dependencies": { "@earendil-works/gondolin": "0.12.0" } }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.9.0", + "version": "1.9.1", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.79.0", + "version": "0.79.1", "dependencies": { "ms": "2.1.3" }, @@ -6381,7 +6357,7 @@ }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.79.0", + "version": "0.79.1", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 860fdb43..97709013 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.1] - 2026-06-09 ## [0.79.0] - 2026-06-08 diff --git a/packages/agent/package.json b/packages/agent/package.json index 43eeb060..afd0368c 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "0.79.0", + "version": "0.79.1", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -29,7 +29,7 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "^0.79.0", + "@earendil-works/pi-ai": "^0.79.1", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 66de1162..02c35b44 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.1] - 2026-06-09 ### Added diff --git a/packages/ai/package.json b/packages/ai/package.json index 8eca757e..9a54de2f 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.79.0", + "version": "0.79.1", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8ee5ec41..7ddebddf 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.1] - 2026-06-09 ### 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 94c7d0e9..dc287898 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.79.0", + "version": "0.79.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "0.79.0", + "version": "0.79.1", "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 47652ec4..4b1dd16b 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.79.0", + "version": "0.79.1", "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 0fead953..fba74f25 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.79.0", + "version": "0.79.1", "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 58db0ca9..b71b386c 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.79.0", + "version": "0.79.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-gondolin", - "version": "0.79.0", + "version": "0.79.1", "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 941449cb..51448000 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.79.0", + "version": "0.79.1", "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 e80892eb..360c0def 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.9.0", + "version": "1.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.9.0", + "version": "1.9.1", "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 f90bf797..f285b6ce 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.9.0", + "version": "1.9.1", "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 62508a4a..ee75d24f 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.79.0", + "version": "0.79.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "0.79.0", + "version": "0.79.1", "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 1ba546c5..fab9f145 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.79.0", + "version": "0.79.1", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 4c5dd554..9132d489 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -1,17 +1,17 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.79.0", + "version": "0.79.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent", - "version": "0.79.0", + "version": "0.79.1", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.0", - "@earendil-works/pi-ai": "^0.79.0", - "@earendil-works/pi-tui": "^0.79.0", + "@earendil-works/pi-agent-core": "^0.79.1", + "@earendil-works/pi-ai": "^0.79.1", + "@earendil-works/pi-tui": "^0.79.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -473,11 +473,11 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.79.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.0.tgz", + "version": "0.79.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.1.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.79.0", + "@earendil-works/pi-ai": "^0.79.1", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -487,8 +487,8 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.79.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.0.tgz", + "version": "0.79.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.1.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -510,8 +510,8 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.79.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.0.tgz", + "version": "0.79.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.1.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 154dcc34..b9381465 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.79.0", + "version": "0.79.1", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -36,9 +36,9 @@ "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" }, "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.0", - "@earendil-works/pi-ai": "^0.79.0", - "@earendil-works/pi-tui": "^0.79.0", + "@earendil-works/pi-agent-core": "^0.79.1", + "@earendil-works/pi-ai": "^0.79.1", + "@earendil-works/pi-tui": "^0.79.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index c1a48483..6098afa0 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.1] - 2026-06-09 ### Added diff --git a/packages/tui/package.json b/packages/tui/package.json index 0c3189a1..94ba86ad 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-tui", - "version": "0.79.0", + "version": "0.79.1", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", From 82f2b1e908da8c6e629d0eca2cc020f47c584cdc Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 9 Jun 2026 23:14:55 +0200 Subject: [PATCH 218/352] Add [Unreleased] section for next cycle --- packages/agent/CHANGELOG.md | 2 ++ packages/ai/CHANGELOG.md | 2 ++ packages/coding-agent/CHANGELOG.md | 2 ++ packages/tui/CHANGELOG.md | 2 ++ 4 files changed, 8 insertions(+) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 97709013..c1c02515 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.1] - 2026-06-09 ## [0.79.0] - 2026-06-08 diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 02c35b44..aa97a6b7 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.1] - 2026-06-09 ### Added diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7ddebddf..ca8d5592 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.1] - 2026-06-09 ### New Features diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 6098afa0..ef8a1ba1 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.1] - 2026-06-09 ### Added From dacb367e9e51a649eb16aee17ddece7ada896ee9 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 9 Jun 2026 23:20:20 +0200 Subject: [PATCH 219/352] fix(ai): expect Claude Fable 5 in adaptive thinking model test --- packages/ai/test/anthropic-adaptive-thinking-models.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/ai/test/anthropic-adaptive-thinking-models.test.ts b/packages/ai/test/anthropic-adaptive-thinking-models.test.ts index 9386ce0b..da042e2f 100644 --- a/packages/ai/test/anthropic-adaptive-thinking-models.test.ts +++ b/packages/ai/test/anthropic-adaptive-thinking-models.test.ts @@ -3,8 +3,11 @@ import { getModels, getProviders } from "../src/models.ts"; import type { Api, Model } from "../src/types.ts"; const EXPECTED_CURRENT_ADAPTIVE_THINKING_MODELS = [ + "anthropic/claude-fable-5", "anthropic/claude-opus-4-8", + "opencode/claude-fable-5", "opencode/claude-opus-4-8", + "vercel-ai-gateway/anthropic/claude-fable-5", "vercel-ai-gateway/anthropic/claude-opus-4.8", ]; @@ -22,7 +25,7 @@ describe("Anthropic adaptive thinking model metadata", () => { expect(flaggedModels).toEqual(expect.arrayContaining([...EXPECTED_CURRENT_ADAPTIVE_THINKING_MODELS].sort())); expect(flaggedModels).toEqual( - flaggedModels.filter((modelId) => /(opus[-.]4[-.][678]|sonnet[-.]4[-.]6)/.test(modelId)), + flaggedModels.filter((modelId) => /(opus[-.]4[-.][678]|sonnet[-.]4[-.]6|fable[-.]5)/.test(modelId)), ); }); }); From 9ccfcd7cfcacdf593c0b24929d1d847e6cdf6711 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 10 Jun 2026 00:27:11 +0200 Subject: [PATCH 220/352] fix(ai): omit disabled thinking for Claude Fable 5 --- packages/ai/CHANGELOG.md | 4 ++++ packages/ai/scripts/generate-models.ts | 2 +- packages/ai/src/providers/anthropic.ts | 2 +- packages/ai/test/anthropic-thinking-disable.test.ts | 7 +++++++ packages/ai/test/supports-xhigh.test.ts | 6 ++++-- 5 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index aa97a6b7..f757493c 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Claude Fable 5 thinking-off requests to omit Anthropic's unsupported `thinking.type: "disabled"` payload ([#5567](https://github.com/earendil-works/pi/pull/5567) by [@tmustier](https://github.com/tmustier)). + ## [0.79.1] - 2026-06-09 ### Added diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 3253de64..5e5fff94 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -299,7 +299,7 @@ function applyThinkingLevelMetadata(model: Model): void { (model.api === "anthropic-messages" || model.api === "bedrock-converse-stream") && model.id.includes("fable-5") ) { - mergeThinkingLevelMap(model, { xhigh: "xhigh" }); + mergeThinkingLevelMap(model, { off: null, xhigh: "xhigh" }); } if (model.api === "anthropic-messages" && isAnthropicAdaptiveThinkingModel(model.id)) { mergeAnthropicMessagesCompat(model, { forceAdaptiveThinking: true }); diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 158311b0..efe4bd75 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -972,7 +972,7 @@ function buildParams( display, }; } - } else if (options?.thinkingEnabled === false) { + } else if (options?.thinkingEnabled === false && model.thinkingLevelMap?.off !== null) { params.thinking = { type: "disabled" }; } } diff --git a/packages/ai/test/anthropic-thinking-disable.test.ts b/packages/ai/test/anthropic-thinking-disable.test.ts index 5ee89b1e..13d333e5 100644 --- a/packages/ai/test/anthropic-thinking-disable.test.ts +++ b/packages/ai/test/anthropic-thinking-disable.test.ts @@ -132,6 +132,13 @@ describe("Anthropic thinking disable payload", () => { expect(payload.output_config).toBeUndefined(); }); + it("omits thinking.type=disabled for Claude Fable 5 when thinking is off", async () => { + const payload = await capturePayload(getModel("anthropic", "claude-fable-5")); + + expect(payload.thinking).toBeUndefined(); + expect(payload.output_config).toBeUndefined(); + }); + it("uses adaptive thinking for Claude Opus 4.8 when reasoning is enabled", async () => { const payload = await capturePayload(getModel("anthropic", "claude-opus-4-8"), { reasoning: "high" }); diff --git a/packages/ai/test/supports-xhigh.test.ts b/packages/ai/test/supports-xhigh.test.ts index 80b3e683..ec6dc87b 100644 --- a/packages/ai/test/supports-xhigh.test.ts +++ b/packages/ai/test/supports-xhigh.test.ts @@ -20,10 +20,11 @@ describe("getSupportedThinkingLevels", () => { expect(getSupportedThinkingLevels(model!)).toContain("xhigh"); }); - it("includes xhigh for Anthropic Claude Fable 5 on anthropic-messages API", () => { + it("includes xhigh but not off for Anthropic Claude Fable 5 on anthropic-messages API", () => { const model = getModel("anthropic", "claude-fable-5"); expect(model).toBeDefined(); expect(getSupportedThinkingLevels(model!)).toContain("xhigh"); + expect(getSupportedThinkingLevels(model!)).not.toContain("off"); }); it("does not include xhigh for Claude Sonnet 4.5", () => { @@ -86,9 +87,10 @@ describe("getSupportedThinkingLevels", () => { expect(getSupportedThinkingLevels(model!)).toContain("xhigh"); }); - it("includes xhigh for Bedrock Claude Fable 5", () => { + it("includes xhigh but not off for Bedrock Claude Fable 5", () => { const model = getModel("amazon-bedrock", "global.anthropic.claude-fable-5"); expect(model).toBeDefined(); expect(getSupportedThinkingLevels(model!)).toContain("xhigh"); + expect(getSupportedThinkingLevels(model!)).not.toContain("off"); }); }); From eb01f59dc9ce7bb773d872c7d01ec8d11a77d1b8 Mon Sep 17 00:00:00 2001 From: haoqixu Date: Wed, 10 Jun 2026 15:39:44 +0800 Subject: [PATCH 221/352] fix(tui): wrap CJK text at character boundaries in editor --- packages/tui/src/components/editor.ts | 23 +++++++++++++++++++---- packages/tui/src/utils.ts | 2 +- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 128254b2..724dd460 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -4,7 +4,14 @@ import { decodePrintableKey, matchesKey } from "../keys.ts"; import { KillRing } from "../kill-ring.ts"; import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.ts"; import { UndoStack } from "../undo-stack.ts"; -import { getGraphemeSegmenter, getWordSegmenter, isWhitespaceChar, truncateToWidth, visibleWidth } from "../utils.ts"; +import { + cjkBreakRegex, + getGraphemeSegmenter, + getWordSegmenter, + isWhitespaceChar, + truncateToWidth, + visibleWidth, +} from "../utils.ts"; import { findWordBackward, findWordForward } from "../word-navigation.ts"; import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list.ts"; @@ -174,13 +181,21 @@ export function wordWrapLine(line: string, maxWidth: number, preSegmented?: Intl // Advance. currentWidth += gWidth; - // Record wrap opportunity: whitespace followed by non-whitespace. - // Multiple spaces join (no break between them); the break point is - // after the last space before the next word. + // Record wrap opportunity: whitespace followed by non-whitespace + // (multiple spaces join; the break point is after the last space), + // or at a boundary where either side is CJK (CJK allows breaking + // between any adjacent characters). const next = segments[i + 1]; if (isWs && next && (isPasteMarker(next.segment) || !isWhitespaceChar(next.segment))) { wrapOppIndex = next.index; wrapOppWidth = currentWidth; + } else if (!isWs && next && !isWhitespaceChar(next.segment)) { + const isCjk = !isPasteMarker(grapheme) && cjkBreakRegex.test(grapheme); + const nextIsCjk = !isPasteMarker(next.segment) && cjkBreakRegex.test(next.segment); + if (isCjk || nextIsCjk) { + wrapOppIndex = next.index; + wrapOppWidth = currentWidth; + } } } diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 3f27639e..bf228ce0 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -45,7 +45,7 @@ const rgiEmojiRegex = /^\p{RGI_Emoji}$/v; const WIDTH_CACHE_SIZE = 512; const widthCache = new Map(); -const cjkBreakRegex = +export const cjkBreakRegex = /[\p{Script_Extensions=Han}\p{Script_Extensions=Hiragana}\p{Script_Extensions=Katakana}\p{Script_Extensions=Hangul}\p{Script_Extensions=Bopomofo}]/u; function isPrintableAscii(str: string): boolean { From 0b6c95dda0305ddbfa986d684b7169d773c79258 Mon Sep 17 00:00:00 2001 From: Burak Varli Date: Tue, 9 Jun 2026 19:37:57 +0000 Subject: [PATCH 222/352] feat(ai): link AWS data retention docs in Bedrock validation errors When Bedrock rejects a request with "data retention mode '' is not available for this model", append a pointer to the AWS data retention documentation so users can configure a supported mode. --- packages/ai/CHANGELOG.md | 2 ++ packages/ai/src/providers/amazon-bedrock.ts | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index f757493c..5af75b1e 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +- When Amazon Bedrock rejects an unsupported data retention mode, the error now links the AWS data retention documentation ([#5561](https://github.com/earendil-works/pi/pull/5561) by [@unexge](https://github.com/unexge)). + ### Fixed - Fixed Claude Fable 5 thinking-off requests to omit Anthropic's unsupported `thinking.type: "disabled"` payload ([#5567](https://github.com/earendil-works/pi/pull/5567) by [@tmustier](https://github.com/tmustier)). diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index b022b522..a4ace1c2 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -290,6 +290,13 @@ const BEDROCK_ERROR_PREFIXES: Record = { ServiceUnavailableException: "Service unavailable", }; +/** + * Some models reject the account/profile's configured Bedrock data retention mode + * (e.g. "data retention mode 'default' is not available for this model"). Point + * users at the AWS docs explaining how to configure a supported mode. + */ +const BEDROCK_DATA_RETENTION_DOCS_URL = "https://docs.aws.amazon.com/bedrock/latest/userguide/data-retention.html"; + /** * Format a Bedrock error with a human-readable prefix. * AWS SDK exceptions (both from `client.send()` and from stream event items) @@ -299,11 +306,14 @@ const BEDROCK_ERROR_PREFIXES: Record = { */ function formatBedrockError(error: unknown): string { const message = error instanceof Error ? error.message : JSON.stringify(error); + const dataRetentionHint = /data retention mode/i.test(message) + ? ` See ${BEDROCK_DATA_RETENTION_DOCS_URL} for supported data retention modes.` + : ""; if (error instanceof BedrockRuntimeServiceException) { const prefix = BEDROCK_ERROR_PREFIXES[error.name] ?? error.name; - return `${prefix}: ${message}`; + return `${prefix}: ${message}${dataRetentionHint}`; } - return message; + return `${message}${dataRetentionHint}`; } /** From a7f9fe681d6e08462c7849e83a2dd662d7d262cd Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 10 Jun 2026 11:00:48 +0200 Subject: [PATCH 223/352] fix: bump shell-quote to 1.8.4 in lockfile (GHSA-w7jw-789q-3m8p) --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 37edf4a1..b32c59fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4731,9 +4731,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "license": "MIT", "engines": { "node": ">= 0.4" From 0ab2aa86af862ca1cf4b0b86fcbee14d00e1441f Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Wed, 10 Jun 2026 12:11:49 +0200 Subject: [PATCH 224/352] feat(coding-agent): add experimental first-time setup flow (#5587) Behind PI_EXPERIMENTAL=1, show a first-time setup dialog on interactive startup when the default agent directory is used and settings.json does not exist. The dialog preselects the detected terminal appearance with an explicit dark/light choice (live preview) and asks for opt-in analytics data sharing. Submitting writes settings.json, which serves as the completion marker; opting in stores a generated trackingId. Escape at any point skips out of setup. --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/docs/settings.md | 2 + packages/coding-agent/src/cli/startup-ui.ts | 62 +++++++- .../coding-agent/src/core/settings-manager.ts | 22 +++ packages/coding-agent/src/main.ts | 9 +- .../components/first-time-setup.ts | 145 ++++++++++++++++++ .../src/modes/interactive/components/index.ts | 5 + .../test/first-time-setup.test.ts | 95 ++++++++++++ 8 files changed, 342 insertions(+), 2 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/components/first-time-setup.ts create mode 100644 packages/coding-agent/test/first-time-setup.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ca8d5592..f24e827e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added an experimental first-time setup flow behind `PI_EXPERIMENTAL=1` that asks for a dark/light theme choice (preselecting the detected appearance) and opt-in analytics data sharing on first launch with the default agent directory; opting in stores a `trackingId` in `settings.json`. + ## [0.79.1] - 2026-06-09 ### New Features diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 3c4e9e6f..2cf843d1 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -55,6 +55,8 @@ Use `/trust` in interactive mode to save a project trust decision for future ses | `defaultProjectTrust` | string | `"ask"` | Fallback project trust behavior: `"ask"`, `"always"`, or `"never"`. Global setting only | | `collapseChangelog` | boolean | `false` | Show condensed changelog after updates | | `enableInstallTelemetry` | boolean | `true` | Send an anonymous install/update version ping after first install or changelog-detected updates. This does not control update checks | +| `enableAnalytics` | boolean | `false` | Opt-in analytics data sharing. Currently only asked for during the experimental first-time setup (`PI_EXPERIMENTAL=1`) | +| `trackingId` | string | - | Analytics tracking identifier, generated when `enableAnalytics` is turned on | | `doubleEscapeAction` | string | `"tree"` | Action for double-escape: `"tree"`, `"fork"`, or `"none"` | | `treeFilterMode` | string | `"default"` | Default filter for `/tree`: `"default"`, `"no-tools"`, `"user-only"`, `"labeled-only"`, `"all"` | | `editorPaddingX` | number | `0` | Horizontal padding for input editor (0-3) | diff --git a/packages/coding-agent/src/cli/startup-ui.ts b/packages/coding-agent/src/cli/startup-ui.ts index 1f17013c..5a9de203 100644 --- a/packages/coding-agent/src/cli/startup-ui.ts +++ b/packages/coding-agent/src/cli/startup-ui.ts @@ -1,9 +1,16 @@ import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui"; +import { existsSync } from "fs"; +import { ENV_AGENT_DIR, getSettingsPath } from "../config.ts"; +import { areExperimentalFeaturesEnabled } from "../core/experimental.ts"; import { KeybindingsManager } from "../core/keybindings.ts"; import type { SettingsManager } from "../core/settings-manager.ts"; import { ExtensionInputComponent } from "../modes/interactive/components/extension-input.ts"; import { ExtensionSelectorComponent } from "../modes/interactive/components/extension-selector.ts"; -import { initTheme } from "../modes/interactive/theme/theme.ts"; +import { + FirstTimeSetupComponent, + type FirstTimeSetupResult, +} from "../modes/interactive/components/first-time-setup.ts"; +import { detectTerminalBackground, initTheme, setTheme } from "../modes/interactive/theme/theme.ts"; function createStartupTui(settingsManager: SettingsManager): TUI { initTheme(settingsManager.getTheme()); @@ -19,6 +26,22 @@ async function clearStartupTui(ui: TUI): Promise { await new Promise((resolve) => setTimeout(resolve, 25)); } +/** + * First-time setup runs when all of these hold: + * - experimental features are enabled (PI_EXPERIMENTAL=1) + * - the default agent directory is used (no custom agent dir override) + * - setup was not completed before (settings.json does not exist) + */ +export function shouldRunFirstTimeSetup(settingsPath: string = getSettingsPath()): boolean { + if (!areExperimentalFeaturesEnabled()) { + return false; + } + if (process.env[ENV_AGENT_DIR]) { + return false; + } + return !existsSync(settingsPath); +} + export async function showStartupSelector( settingsManager: SettingsManager, title: string, @@ -51,6 +74,43 @@ export async function showStartupSelector( }); } +/** Show the first-time setup dialog and persist the result */ +export async function showFirstTimeSetup(settingsManager: SettingsManager): Promise { + return new Promise((resolve) => { + const ui = createStartupTui(settingsManager); + + let settled = false; + const finish = async (result: FirstTimeSetupResult | undefined) => { + if (settled) { + return; + } + settled = true; + if (result) { + settingsManager.setTheme(result.theme); + settingsManager.setEnableAnalytics(result.shareAnalytics); + await settingsManager.flush(); + } + await clearStartupTui(ui); + ui.stop(); + resolve(); + }; + + const component = new FirstTimeSetupComponent({ + detectedTheme: detectTerminalBackground().theme, + onThemePreview: (themeName) => { + setTheme(themeName); + ui.invalidate(); + ui.requestRender(); + }, + onSubmit: (result) => void finish(result), + onCancel: () => void finish(undefined), + }); + ui.addChild(component); + ui.setFocus(component); + ui.start(); + }); +} + export async function showStartupInput( settingsManager: SettingsManager, title: string, diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 058e84e6..8acd5998 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -1,4 +1,5 @@ import type { Transport } from "@earendil-works/pi-ai"; +import { randomUUID } from "crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { dirname, join } from "path"; import lockfile from "proper-lockfile"; @@ -96,6 +97,8 @@ export interface Settings { npmCommand?: string[]; // Command used for npm package lookup/install operations, argv-style (e.g., ["mise", "exec", "node@20", "--", "npm"]) collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full) enableInstallTelemetry?: boolean; // default: true - anonymous version/update ping after changelog-detected updates + enableAnalytics?: boolean; // default: false - opt-in analytics data sharing + trackingId?: string; // analytics tracking identifier, generated when analytics is enabled packages?: PackageSource[]; // Array of npm/git package sources (string or object with filtering) extensions?: string[]; // Array of local extension file paths or directories skills?: string[]; // Array of local skill file paths or directories @@ -907,6 +910,25 @@ export class SettingsManager { this.save(); } + getEnableAnalytics(): boolean { + return this.settings.enableAnalytics ?? false; + } + + getTrackingId(): string | undefined { + return this.settings.trackingId; + } + + /** Set the analytics opt-in preference; generates a tracking identifier on first opt-in */ + setEnableAnalytics(enabled: boolean): void { + this.globalSettings.enableAnalytics = enabled; + this.markModified("enableAnalytics"); + if (enabled && !this.globalSettings.trackingId) { + this.globalSettings.trackingId = randomUUID(); + this.markModified("trackingId"); + } + this.save(); + } + getPackages(): PackageSource[] { return [...(this.settings.packages ?? [])]; } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index d6b95b73..0bc24685 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -14,7 +14,7 @@ import { buildInitialMessage } from "./cli/initial-message.ts"; import { listModels } from "./cli/list-models.ts"; import { createProjectTrustContext } from "./cli/project-trust.ts"; import { selectSession } from "./cli/session-picker.ts"; -import { showStartupSelector } from "./cli/startup-ui.ts"; +import { shouldRunFirstTimeSetup, showFirstTimeSetup, showStartupSelector } from "./cli/startup-ui.ts"; import { ENV_SESSION_DIR, expandTildePath, getAgentDir, getPackageDir, VERSION } from "./config.ts"; import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "./core/agent-session-runtime.ts"; import { @@ -528,6 +528,13 @@ export async function main(args: string[], options?: MainOptions) { const startupSettingsManager = SettingsManager.create(cwd, agentDir); reportDiagnostics(collectSettingsDiagnostics(startupSettingsManager, "startup session lookup")); + // Experimental first-time setup: theme choice and analytics opt-in. + // Runs before any runtime services are created so the chosen settings apply everywhere. + if (appMode === "interactive" && !parsed.help && parsed.listModels === undefined && shouldRunFirstTimeSetup()) { + await showFirstTimeSetup(startupSettingsManager); + time("firstTimeSetup"); + } + // Decide the final runtime cwd before creating cwd-bound runtime services. // --session and --resume may select a session from another project, so project-local // settings, resources, provider registrations, and models must be resolved only after diff --git a/packages/coding-agent/src/modes/interactive/components/first-time-setup.ts b/packages/coding-agent/src/modes/interactive/components/first-time-setup.ts new file mode 100644 index 00000000..6ad8ddba --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/first-time-setup.ts @@ -0,0 +1,145 @@ +import { Container, getKeybindings, Spacer, Text } from "@earendil-works/pi-tui"; +import { APP_NAME } from "../../../config.ts"; +import { type TerminalTheme, theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyHint, rawKeyHint } from "./keybinding-hints.ts"; + +export interface FirstTimeSetupResult { + theme: TerminalTheme; + shareAnalytics: boolean; +} + +export interface FirstTimeSetupOptions { + detectedTheme: TerminalTheme; + onThemePreview: (themeName: TerminalTheme) => void; + onSubmit: (result: FirstTimeSetupResult) => void; + onCancel: () => void; +} + +const THEME_OPTIONS: Array<{ value: TerminalTheme; label: string }> = [ + { value: "dark", label: "Dark" }, + { value: "light", label: "Light" }, +]; + +const ANALYTICS_OPTIONS: Array<{ value: boolean; label: string }> = [ + { value: true, label: "Share anonymous usage data" }, + { value: false, label: "Don't share" }, +]; + +const SETUP_LOGO_LINES = ["██████", "██ ██", "████ ██", "██ ██"]; + +/** First-time setup dialog: theme choice and analytics opt-in. */ +export class FirstTimeSetupComponent extends Container { + private step: "theme" | "analytics" = "theme"; + private themeIndex: number; + private analyticsIndex = 0; + private readonly options: FirstTimeSetupOptions; + + constructor(options: FirstTimeSetupOptions) { + super(); + this.options = options; + this.themeIndex = Math.max( + 0, + THEME_OPTIONS.findIndex((option) => option.value === options.detectedTheme), + ); + this.update(); + } + + // Rebuild the whole dialog on every change so theme previews recolor all text. + private update(): void { + this.clear(); + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("accent", SETUP_LOGO_LINES.join("\n")), 1, 0)); + this.addChild(new Spacer(1)); + this.addChild( + new Text(theme.fg("accent", theme.bold(`Welcome to ${APP_NAME}, the minimal coding agent.`)), 1, 0), + ); + this.addChild(new Spacer(1)); + + if (this.step === "theme") { + this.addChild(new Text(theme.fg("text", "Pick a theme."), 1, 0)); + this.addChild(new Text(theme.fg("muted", `Detected system appearance: ${this.options.detectedTheme}`), 1, 0)); + this.addChild(new Spacer(1)); + this.addOptionList( + THEME_OPTIONS.map((option) => option.label), + this.themeIndex, + ); + } else { + this.addChild(new Text(theme.fg("text", `Help improve ${APP_NAME} by sharing anonymous usage data?`), 1, 0)); + this.addChild( + new Text( + theme.fg( + "muted", + "Opting in stores a tracking identifier in settings.json and enables anonymous\nusage analytics. You can change this at any time in settings.json.", + ), + 1, + 0, + ), + ); + this.addChild(new Spacer(1)); + this.addOptionList( + ANALYTICS_OPTIONS.map((option) => option.label), + this.analyticsIndex, + ); + } + + this.addChild(new Spacer(1)); + this.addChild( + new Text( + rawKeyHint("↑↓", "navigate") + + " " + + keyHint("tui.select.confirm", this.step === "theme" ? "continue" : "finish") + + " " + + keyHint("tui.select.cancel", "skip setup"), + 1, + 0, + ), + ); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + } + + private addOptionList(labels: string[], selectedIndex: number): void { + for (let i = 0; i < labels.length; i++) { + const isSelected = i === selectedIndex; + const prefix = isSelected ? theme.fg("accent", "→ ") : " "; + const label = isSelected ? theme.fg("accent", labels[i]) : theme.fg("text", labels[i]); + this.addChild(new Text(`${prefix}${label}`, 1, 0)); + } + } + + private moveSelection(delta: number): void { + if (this.step === "theme") { + const next = Math.max(0, Math.min(THEME_OPTIONS.length - 1, this.themeIndex + delta)); + if (next !== this.themeIndex) { + this.themeIndex = next; + this.options.onThemePreview(THEME_OPTIONS[this.themeIndex].value); + } + } else { + this.analyticsIndex = Math.max(0, Math.min(ANALYTICS_OPTIONS.length - 1, this.analyticsIndex + delta)); + } + this.update(); + } + + handleInput(keyData: string): void { + const kb = getKeybindings(); + if (kb.matches(keyData, "tui.select.up") || keyData === "k") { + this.moveSelection(-1); + } else if (kb.matches(keyData, "tui.select.down") || keyData === "j") { + this.moveSelection(1); + } else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") { + if (this.step === "theme") { + this.step = "analytics"; + this.update(); + } else { + this.options.onSubmit({ + theme: THEME_OPTIONS[this.themeIndex].value, + shareAnalytics: ANALYTICS_OPTIONS[this.analyticsIndex].value, + }); + } + } else if (kb.matches(keyData, "tui.select.cancel")) { + this.options.onCancel(); + } + } +} diff --git a/packages/coding-agent/src/modes/interactive/components/index.ts b/packages/coding-agent/src/modes/interactive/components/index.ts index fa47d642..38c2b987 100644 --- a/packages/coding-agent/src/modes/interactive/components/index.ts +++ b/packages/coding-agent/src/modes/interactive/components/index.ts @@ -13,6 +13,11 @@ export { DynamicBorder } from "./dynamic-border.ts"; export { ExtensionEditorComponent } from "./extension-editor.ts"; export { ExtensionInputComponent } from "./extension-input.ts"; export { ExtensionSelectorComponent } from "./extension-selector.ts"; +export { + FirstTimeSetupComponent, + type FirstTimeSetupOptions, + type FirstTimeSetupResult, +} from "./first-time-setup.ts"; export { FooterComponent } from "./footer.ts"; export { keyHint, keyText, rawKeyHint } from "./keybinding-hints.ts"; export { LoginDialogComponent } from "./login-dialog.ts"; diff --git a/packages/coding-agent/test/first-time-setup.test.ts b/packages/coding-agent/test/first-time-setup.test.ts new file mode 100644 index 00000000..c470e6dc --- /dev/null +++ b/packages/coding-agent/test/first-time-setup.test.ts @@ -0,0 +1,95 @@ +import { mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { shouldRunFirstTimeSetup } from "../src/cli/startup-ui.ts"; +import { ENV_AGENT_DIR } from "../src/config.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; + +describe("shouldRunFirstTimeSetup", () => { + const originalPiExperimental = process.env.PI_EXPERIMENTAL; + const originalAgentDir = process.env[ENV_AGENT_DIR]; + let tempDir: string; + let settingsPath: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "pi-first-time-setup-")); + settingsPath = join(tempDir, "settings.json"); + process.env.PI_EXPERIMENTAL = "1"; + delete process.env[ENV_AGENT_DIR]; + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + if (originalPiExperimental === undefined) { + delete process.env.PI_EXPERIMENTAL; + } else { + process.env.PI_EXPERIMENTAL = originalPiExperimental; + } + if (originalAgentDir === undefined) { + delete process.env[ENV_AGENT_DIR]; + } else { + process.env[ENV_AGENT_DIR] = originalAgentDir; + } + }); + + it("returns true when experimental, default agent dir, and no settings.json", () => { + expect(shouldRunFirstTimeSetup(settingsPath)).toBe(true); + }); + + it("returns false when experimental features are disabled", () => { + delete process.env.PI_EXPERIMENTAL; + + expect(shouldRunFirstTimeSetup(settingsPath)).toBe(false); + }); + + it("returns false when a custom agent dir is set", () => { + process.env[ENV_AGENT_DIR] = tempDir; + + expect(shouldRunFirstTimeSetup(settingsPath)).toBe(false); + }); + + it("returns false when settings.json already exists", () => { + writeFileSync(settingsPath, "{}", "utf-8"); + + expect(shouldRunFirstTimeSetup(settingsPath)).toBe(false); + }); +}); + +describe("analytics settings", () => { + it("defaults to disabled with no tracking identifier", () => { + const manager = SettingsManager.inMemory(); + + expect(manager.getEnableAnalytics()).toBe(false); + expect(manager.getTrackingId()).toBeUndefined(); + }); + + it("generates a tracking identifier on opt-in", () => { + const manager = SettingsManager.inMemory(); + + manager.setEnableAnalytics(true); + + expect(manager.getEnableAnalytics()).toBe(true); + expect(manager.getTrackingId()).toMatch(/^[0-9a-f-]{36}$/); + }); + + it("does not generate a tracking identifier on opt-out", () => { + const manager = SettingsManager.inMemory(); + + manager.setEnableAnalytics(false); + + expect(manager.getEnableAnalytics()).toBe(false); + expect(manager.getTrackingId()).toBeUndefined(); + }); + + it("keeps the tracking identifier when toggling analytics", () => { + const manager = SettingsManager.inMemory(); + + manager.setEnableAnalytics(true); + const trackingId = manager.getTrackingId(); + manager.setEnableAnalytics(false); + manager.setEnableAnalytics(true); + + expect(manager.getTrackingId()).toBe(trackingId); + }); +}); From 406a2214aa1dce746a1902605daf04e6727349dc Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Wed, 10 Jun 2026 19:46:59 +0200 Subject: [PATCH 225/352] fix(coding-agent): refine setup copy --- .../src/modes/interactive/components/first-time-setup.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/first-time-setup.ts b/packages/coding-agent/src/modes/interactive/components/first-time-setup.ts index 6ad8ddba..de5f76e0 100644 --- a/packages/coding-agent/src/modes/interactive/components/first-time-setup.ts +++ b/packages/coding-agent/src/modes/interactive/components/first-time-setup.ts @@ -66,12 +66,12 @@ export class FirstTimeSetupComponent extends Container { this.themeIndex, ); } else { - this.addChild(new Text(theme.fg("text", `Help improve ${APP_NAME} by sharing anonymous usage data?`), 1, 0)); + this.addChild(new Text(theme.fg("text", "Opt-in to anonymous usage data sharing?"), 1, 0)); this.addChild( new Text( theme.fg( "muted", - "Opting in stores a tracking identifier in settings.json and enables anonymous\nusage analytics. You can change this at any time in settings.json.", + "Opting in stores a tracking identifier in settings.json and enables anonymous\nusage analytics. This helps us to better debug, reproduce, and resolve issues\nand bugs within Pi. You can observe what is shared using /privacy and make\nchanges anytime in settings.json.", ), 1, 0, From 1da903983ad72c60995507e813a00bb2bd6faf09 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Thu, 11 Jun 2026 21:36:26 +0200 Subject: [PATCH 226/352] fix(coding-agent): skip first-time setup for forks (#5627) --- packages/coding-agent/src/cli/startup-ui.ts | 30 +++++++++++++- .../test/first-time-setup-fork.test.ts | 39 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/test/first-time-setup-fork.test.ts diff --git a/packages/coding-agent/src/cli/startup-ui.ts b/packages/coding-agent/src/cli/startup-ui.ts index 5a9de203..ec7aa5e7 100644 --- a/packages/coding-agent/src/cli/startup-ui.ts +++ b/packages/coding-agent/src/cli/startup-ui.ts @@ -1,6 +1,6 @@ import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui"; import { existsSync } from "fs"; -import { ENV_AGENT_DIR, getSettingsPath } from "../config.ts"; +import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR, getSettingsPath, PACKAGE_NAME } from "../config.ts"; import { areExperimentalFeaturesEnabled } from "../core/experimental.ts"; import { KeybindingsManager } from "../core/keybindings.ts"; import type { SettingsManager } from "../core/settings-manager.ts"; @@ -12,6 +12,24 @@ import { } from "../modes/interactive/components/first-time-setup.ts"; import { detectTerminalBackground, initTheme, setTheme } from "../modes/interactive/theme/theme.ts"; +const OFFICIAL_PACKAGE_NAME = "@earendil-works/pi-coding-agent"; +const OFFICIAL_APP_NAME = "pi"; +const OFFICIAL_CONFIG_DIR_NAME = ".pi"; + +interface DistributionMetadata { + packageName: string; + appName: string; + configDirName: string; +} + +function isOfficialDistribution({ packageName, appName, configDirName }: DistributionMetadata): boolean { + return ( + packageName === OFFICIAL_PACKAGE_NAME && + appName === OFFICIAL_APP_NAME && + configDirName === OFFICIAL_CONFIG_DIR_NAME + ); +} + function createStartupTui(settingsManager: SettingsManager): TUI { initTheme(settingsManager.getTheme()); setKeybindings(KeybindingsManager.create()); @@ -28,11 +46,21 @@ async function clearStartupTui(ui: TUI): Promise { /** * First-time setup runs when all of these hold: + * - this is the official Pi distribution (not a fork/rebrand) * - experimental features are enabled (PI_EXPERIMENTAL=1) * - the default agent directory is used (no custom agent dir override) * - setup was not completed before (settings.json does not exist) */ export function shouldRunFirstTimeSetup(settingsPath: string = getSettingsPath()): boolean { + if ( + !isOfficialDistribution({ + packageName: PACKAGE_NAME, + appName: APP_NAME, + configDirName: CONFIG_DIR_NAME, + }) + ) { + return false; + } if (!areExperimentalFeaturesEnabled()) { return false; } diff --git a/packages/coding-agent/test/first-time-setup-fork.test.ts b/packages/coding-agent/test/first-time-setup-fork.test.ts new file mode 100644 index 00000000..e9b89207 --- /dev/null +++ b/packages/coding-agent/test/first-time-setup-fork.test.ts @@ -0,0 +1,39 @@ +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/config.ts", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...(actual as Record), + PACKAGE_NAME: "@example/pi-coding-agent", + }; +}); + +import { shouldRunFirstTimeSetup } from "../src/cli/startup-ui.ts"; + +describe("shouldRunFirstTimeSetup in forked distributions", () => { + const originalPiExperimental = process.env.PI_EXPERIMENTAL; + let tempDir: string; + let settingsPath: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "pi-first-time-setup-fork-")); + settingsPath = join(tempDir, "settings.json"); + process.env.PI_EXPERIMENTAL = "1"; + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + if (originalPiExperimental === undefined) { + delete process.env.PI_EXPERIMENTAL; + } else { + process.env.PI_EXPERIMENTAL = originalPiExperimental; + } + }); + + it("returns false for a forked package", () => { + expect(shouldRunFirstTimeSetup(settingsPath)).toBe(false); + }); +}); From 3f44d3e2eb0e1770157fab22cc842b6f53088605 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Fri, 12 Jun 2026 09:00:15 +0200 Subject: [PATCH 227/352] fix(ai): remove stale OpenRouter Kimi free model assertion (#5650) --- packages/ai/test/openai-completions-tool-choice.test.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index 319b18af..28c91795 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -890,11 +890,9 @@ describe("openai-completions tool_choice", () => { }); it("stores OpenRouter Kimi K2.6 reasoning replay compat in built-in metadata", () => { - for (const modelId of ["moonshotai/kimi-k2.6", "moonshotai/kimi-k2.6:free"] as const) { - const model = getModel("openrouter", modelId)!; - expect(model.compat?.supportsDeveloperRole).toBe(false); - expect(model.compat?.requiresReasoningContentOnAssistantMessages).toBe(true); - } + const model = getModel("openrouter", "moonshotai/kimi-k2.6")!; + expect(model.compat?.supportsDeveloperRole).toBe(false); + expect(model.compat?.requiresReasoningContentOnAssistantMessages).toBe(true); }); it("stores Xiaomi MiMo reasoning replay compat in built-in metadata", () => { From 1c243365606f6629e21f4a99e03b63f9de422921 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 12 Jun 2026 12:59:32 +0200 Subject: [PATCH 228/352] fix(tui): keep WezTerm Kitty images visible Closes #5618. --- packages/tui/CHANGELOG.md | 4 ++ packages/tui/src/tui.ts | 103 ++++++++++++++++++++++----- packages/tui/test/tui-render.test.ts | 89 ++++++++++++++++++++++- 3 files changed, 178 insertions(+), 18 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index ef8a1ba1..da5cd46c 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed WezTerm inline Kitty image rendering so reserved row clears do not erase all but the top strip of tool image previews ([#5618](https://github.com/earendil-works/pi/issues/5618)). + ## [0.79.1] - 2026-06-09 ### Added diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 9f6dda4b..2399564d 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -13,24 +13,42 @@ import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth const KITTY_SEQUENCE_PREFIX = "\x1b_G"; -function extractKittyImageIds(line: string): number[] { +interface KittyImageHeader { + ids: number[]; + rows: number; +} + +function parseKittyImageHeader(line: string): KittyImageHeader | undefined { const sequenceStart = line.indexOf(KITTY_SEQUENCE_PREFIX); - if (sequenceStart === -1) return []; + if (sequenceStart === -1) return undefined; const paramsStart = sequenceStart + KITTY_SEQUENCE_PREFIX.length; const paramsEnd = line.indexOf(";", paramsStart); - if (paramsEnd === -1) return []; + if (paramsEnd === -1) return undefined; + const ids: number[] = []; + let rows = 1; const params = line.slice(paramsStart, paramsEnd); for (const param of params.split(",")) { const [key, value] = param.split("=", 2); - if (key !== "i" || value === undefined) continue; - const id = Number(value); - if (Number.isInteger(id) && id > 0 && id <= 0xffffffff) { - return [id]; + if (value === undefined) continue; + const numberValue = Number(value); + if (!Number.isInteger(numberValue) || numberValue <= 0 || numberValue > 0xffffffff) continue; + if (key === "i") { + ids.push(numberValue); + } else if (key === "r") { + rows = numberValue; } } - return []; + return { ids, rows }; +} + +function extractKittyImageIds(line: string): number[] { + return parseKittyImageHeader(line)?.ids ?? []; +} + +function extractKittyImageRows(line: string): number { + return parseKittyImageHeader(line)?.rows ?? 1; } /** @@ -1021,14 +1039,41 @@ export class TUI extends Container { return buffer; } - private expandLastChangedForKittyImages(firstChanged: number, lastChanged: number): number { - let expandedLastChanged = lastChanged; - for (let i = firstChanged; i < this.previousLines.length; i++) { - if (extractKittyImageIds(this.previousLines[i]).length > 0) { - expandedLastChanged = Math.max(expandedLastChanged, i); - } + private getKittyImageReservedRows(lines: string[], index: number, maxIndex = lines.length - 1): number { + const rows = extractKittyImageRows(lines[index] ?? ""); + if (rows <= 1) return 1; + + const maxRows = Math.min(rows, maxIndex - index + 1, lines.length - index); + let reservedRows = 1; + while (reservedRows < maxRows) { + const line = lines[index + reservedRows] ?? ""; + if (isImageLine(line) || visibleWidth(line) > 0) break; + reservedRows++; } - return expandedLastChanged; + return reservedRows; + } + + private expandChangedRangeForKittyImages( + firstChanged: number, + lastChanged: number, + newLines: string[], + ): { firstChanged: number; lastChanged: number } { + let expandedFirstChanged = firstChanged; + let expandedLastChanged = lastChanged; + const expandForLines = (lines: string[]): void => { + for (let i = 0; i < lines.length; i++) { + if (extractKittyImageIds(lines[i]).length === 0) continue; + const blockEnd = i + this.getKittyImageReservedRows(lines, i) - 1; + if (i >= firstChanged || (i <= lastChanged && blockEnd >= firstChanged)) { + expandedFirstChanged = Math.min(expandedFirstChanged, i); + expandedLastChanged = Math.max(expandedLastChanged, blockEnd); + } + } + }; + + expandForLines(this.previousLines); + expandForLines(newLines); + return { firstChanged: expandedFirstChanged, lastChanged: expandedLastChanged }; } private deleteChangedKittyImages(firstChanged: number, lastChanged: number): string { @@ -1247,7 +1292,9 @@ export class TUI extends Container { lastChanged = newLines.length - 1; } if (firstChanged !== -1) { - lastChanged = this.expandLastChangedForKittyImages(firstChanged, lastChanged); + const expandedRange = this.expandChangedRangeForKittyImages(firstChanged, lastChanged, newLines); + firstChanged = expandedRange.firstChanged; + lastChanged = expandedRange.lastChanged; } const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0; @@ -1350,9 +1397,31 @@ export class TUI extends Container { const renderEnd = Math.min(lastChanged, newLines.length - 1); for (let i = firstChanged; i <= renderEnd; i++) { if (i > firstChanged) buffer += "\r\n"; - buffer += "\x1b[2K"; // Clear current line const line = newLines[i]; const isImage = isImageLine(line); + const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i, renderEnd) : 1; + if (imageReservedRows > 1) { + const imageStartScreenRow = i - viewportTop; + if (imageStartScreenRow < 0 || imageStartScreenRow + imageReservedRows > height) { + logRedraw( + `kitty image pre-clear would scroll (${imageStartScreenRow} + ${imageReservedRows} > ${height})`, + ); + fullRender(true); + return; + } + + buffer += "\x1b[2K"; + for (let row = 1; row < imageReservedRows; row++) { + buffer += "\r\n\x1b[2K"; + } + buffer += `\x1b[${imageReservedRows - 1}A`; + buffer += line; + buffer += `\x1b[${imageReservedRows - 1}B`; + i += imageReservedRows - 1; + continue; + } + + buffer += "\x1b[2K"; // Clear current line if (!isImage && visibleWidth(line) > width) { // Log all lines to crash file for debugging const crashLogPath = path.join(os.homedir(), ".pi", "agent", "pi-crash.log"); diff --git a/packages/tui/test/tui-render.test.ts b/packages/tui/test/tui-render.test.ts index 230a0005..4e31e8f4 100644 --- a/packages/tui/test/tui-render.test.ts +++ b/packages/tui/test/tui-render.test.ts @@ -1,7 +1,14 @@ import assert from "node:assert"; import { describe, it } from "node:test"; import type { Terminal as XtermTerminalType } from "@xterm/headless"; -import { deleteKittyImage, encodeKitty } from "../src/terminal-image.ts"; +import { Image } from "../src/components/image.ts"; +import { + deleteKittyImage, + encodeKitty, + resetCapabilitiesCache, + setCapabilities, + setCellDimensions, +} from "../src/terminal-image.ts"; import { type Component, TUI } from "../src/tui.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; @@ -65,6 +72,86 @@ function getCellItalic(terminal: VirtualTerminal, row: number, col: number): num } describe("TUI Kitty image cleanup", () => { + it("clears reserved Kitty image rows before drawing appended image placements", async () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + setCellDimensions({ widthPx: 10, heightPx: 10 }); + try { + const terminal = new LoggingVirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = ["before"]; + tui.start(); + await terminal.waitForRender(); + terminal.clearWrites(); + + const image = new Image( + "AAAA", + "image/png", + { fallbackColor: (value) => value }, + { maxWidthCells: 2 }, + { widthPx: 20, heightPx: 20 }, + ); + const imageLines = image.render(40); + const imageSequence = imageLines[0]; + component.lines = ["before", ...imageLines, "after"]; + tui.requestRender(); + await terminal.waitForRender(); + + const writes = terminal.getWrites(); + assert.ok( + writes.includes(`\x1b[2K\r\n\x1b[2K\x1b[1A${imageSequence}\x1b[1B`), + "reserved rows should be cleared before the image placement is drawn", + ); + assert.ok( + !writes.includes(`${imageSequence}\r\n\x1b[2K`), + "reserved row clears must not run after the image placement is drawn", + ); + + tui.stop(); + } finally { + resetCapabilitiesCache(); + setCellDimensions({ widthPx: 9, heightPx: 18 }); + } + }); + + it("falls back to full redraw when Kitty image pre-clear would scroll", async () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + setCellDimensions({ widthPx: 10, heightPx: 10 }); + try { + const terminal = new LoggingVirtualTerminal(40, 2); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = ["before"]; + tui.start(); + await terminal.waitForRender(); + const redrawsBeforeImage = tui.fullRedraws; + terminal.clearWrites(); + + const image = new Image( + "AAAA", + "image/png", + { fallbackColor: (value) => value }, + { maxWidthCells: 3 }, + { widthPx: 30, heightPx: 30 }, + ); + component.lines = ["before", ...image.render(40), "after"]; + tui.requestRender(); + await terminal.waitForRender(); + + assert.ok(tui.fullRedraws > redrawsBeforeImage, "unsafe image pre-clear should force a full redraw"); + assert.ok(terminal.getWrites().includes("\x1b[2J"), "fallback should clear and fully redraw"); + + tui.stop(); + } finally { + resetCapabilitiesCache(); + setCellDimensions({ widthPx: 9, heightPx: 18 }); + } + }); + it("deletes changed image ids before drawing moved placements", async () => { const terminal = new LoggingVirtualTerminal(40, 10); const tui = new TUI(terminal); From a455f62f72359f5f2260c16ee3ed653ce968de3d Mon Sep 17 00:00:00 2001 From: Ramiz Wachtler Date: Fri, 12 Jun 2026 14:52:46 +0200 Subject: [PATCH 229/352] fix(ai): preserve Anthropic refusal details (#5666) Propagate Anthropic refusal stop_details explanation to errorMessage --- packages/ai/src/providers/anthropic.ts | 31 ++++++---- .../ai/test/anthropic-sse-parsing.test.ts | 58 +++++++++++++++++++ 2 files changed, 79 insertions(+), 10 deletions(-) diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index efe4bd75..90b46146 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -5,6 +5,7 @@ import type { MessageCreateParamsStreaming, MessageParam, RawMessageStreamEvent, + RefusalStopDetails, } from "@anthropic-ai/sdk/resources/messages.js"; import { calculateCost } from "../models.ts"; import type { @@ -660,7 +661,11 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti } } else if (event.type === "message_delta") { if (event.delta.stop_reason) { - output.stopReason = mapStopReason(event.delta.stop_reason); + const stopReasonResult = mapStopReason(event.delta.stop_reason, event.delta.stop_details); + output.stopReason = stopReasonResult.stopReason; + if (stopReasonResult.errorMessage) { + output.errorMessage = stopReasonResult.errorMessage; + } } // Only update usage fields if present (not null). // Preserves input_tokens from message_start when proxies omit it in message_delta. @@ -688,7 +693,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti } if (output.stopReason === "aborted" || output.stopReason === "error") { - throw new Error("An unknown error occurred"); + throw new Error(output.errorMessage || "An unknown error occurred"); } stream.push({ type: "done", reason: output.stopReason, message: output }); @@ -1202,22 +1207,28 @@ function convertTools( }); } -function mapStopReason(reason: Anthropic.Messages.StopReason | string): StopReason { +function mapStopReason( + reason: Anthropic.Messages.StopReason | string, + stopDetails?: RefusalStopDetails | null, +): { stopReason: StopReason; errorMessage?: string } { switch (reason) { case "end_turn": - return "stop"; + return { stopReason: "stop" }; case "max_tokens": - return "length"; + return { stopReason: "length" }; case "tool_use": - return "toolUse"; + return { stopReason: "toolUse" }; case "refusal": - return "error"; + return { + stopReason: "error", + errorMessage: stopDetails?.explanation || `The model refused to complete the request`, + }; case "pause_turn": // Stop is good enough -> resubmit - return "stop"; + return { stopReason: "stop" }; case "stop_sequence": - return "stop"; // We don't supply stop sequences, so this should never happen + return { stopReason: "stop" }; // We don't supply stop sequences, so this should never happen case "sensitive": // Content flagged by safety filters (not yet in SDK types) - return "error"; + return { stopReason: "error" }; default: // Handle unknown stop reasons gracefully (API may add new values) throw new Error(`Unhandled stop reason: ${reason}`); diff --git a/packages/ai/test/anthropic-sse-parsing.test.ts b/packages/ai/test/anthropic-sse-parsing.test.ts index 03024966..d8daf7f7 100644 --- a/packages/ai/test/anthropic-sse-parsing.test.ts +++ b/packages/ai/test/anthropic-sse-parsing.test.ts @@ -166,6 +166,64 @@ describe("Anthropic raw SSE parsing", () => { }); }); + it("preserves refusal stop details from message_delta", async () => { + const model = getModel("anthropic", "claude-fable-5"); + const context: Context = { + messages: [{ role: "user", content: "blocked request", timestamp: Date.now() }], + }; + const explanation = + "This request triggered restrictions on violative cyber content and was blocked under Anthropic's Usage Policy. To learn more, provide feedback, or request an exemption based on how you use Claude, visit our help center: https://support.claude.com/en/articles/14604842-real-time-cyber-safeguards-on-claude."; + const response = createSseResponse([ + { + event: "message_start", + data: JSON.stringify({ + type: "message_start", + message: { + id: "msg_01XFUDYJgAACzvnptvVoYEL", + usage: { + input_tokens: 412, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }, + }), + }, + { + event: "message_delta", + data: JSON.stringify({ + type: "message_delta", + delta: { + stop_reason: "refusal", + stop_details: { + type: "refusal", + category: "cyber", + explanation, + }, + }, + usage: { + input_tokens: 412, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }), + }, + { + event: "message_stop", + data: JSON.stringify({ type: "message_stop" }), + }, + ]); + + const stream = streamAnthropic(model, context, { + client: createFakeAnthropicClient(response), + }); + const result = await stream.result(); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toBe(explanation); + }); + it("ignores unknown SSE events after message_stop", async () => { const model = getModel("anthropic", "claude-haiku-4-5"); const context: Context = { From be7d5cf58594fa2c6c82c6d8f051c0ef5b479bd0 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 12 Jun 2026 17:07:50 +0200 Subject: [PATCH 230/352] fix(ai): relax Codex SSE header timeout --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/providers/openai-codex-responses.ts | 4 +++- packages/ai/test/openai-codex-stream.test.ts | 12 ++++++++++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 5af75b1e..93fc9a80 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -6,6 +6,7 @@ ### Fixed +- Increased the OpenAI Codex Responses SSE response-header timeout to 20 seconds to reduce false-positive stalls while retaining the bounded wait introduced for zero-event hangs ([#4945](https://github.com/earendil-works/pi/issues/4945)). - Fixed Claude Fable 5 thinking-off requests to omit Anthropic's unsupported `thinking.type: "disabled"` payload ([#5567](https://github.com/earendil-works/pi/pull/5567) by [@tmustier](https://github.com/tmustier)). ## [0.79.1] - 2026-06-09 diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 5836ee1b..7fe9e256 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -53,7 +53,9 @@ const JWT_CLAIM_PATH = "https://api.openai.com/auth" as const; const DEFAULT_MAX_RETRIES = 0; const BASE_DELAY_MS = 1000; const DEFAULT_MAX_RETRY_DELAY_MS = 60_000; -const DEFAULT_SSE_HEADER_TIMEOUT_MS = 10_000; +// Keep a bounded pre-header timeout so zero-event Codex SSE stalls fail instead of +// leaving callers stuck on "Working..." indefinitely. See #4945. +const DEFAULT_SSE_HEADER_TIMEOUT_MS = 20_000; const DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS = 15_000; const CODEX_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]); const WEBSOCKET_MESSAGE_TOO_BIG_CLOSE_CODE = 1009; diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index 9cf0f981..fe4f3ea6 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -361,13 +361,21 @@ describe("openai-codex streaming", () => { apiKey: token, transport: "sse", }).result(); + let settled = false; + const observedResultPromise = resultPromise.then((result) => { + settled = true; + return result; + }); await vi.advanceTimersByTimeAsync(0); expect(fetchMock).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(10_000); - const result = await resultPromise; + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(10_000); + const result = await observedResultPromise; expect(result.stopReason).toBe("error"); - expect(result.errorMessage).toBe("Codex SSE response headers timed out after 10000ms"); + expect(result.errorMessage).toBe("Codex SSE response headers timed out after 20000ms"); }); it("aborts SSE body reads after response headers arrive", async () => { From 1fc80f4f6cc3db77824223c7bf3ec83515ae7792 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 12 Jun 2026 17:26:42 +0200 Subject: [PATCH 231/352] fix(coding-agent): preserve custom fallback thinking closes #5552 --- packages/coding-agent/CHANGELOG.md | 4 ++++ packages/coding-agent/src/core/model-resolver.ts | 7 +++++-- packages/coding-agent/test/model-resolver.test.ts | 1 + 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f24e827e..4d3ee3c6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,10 @@ - Added an experimental first-time setup flow behind `PI_EXPERIMENTAL=1` that asks for a dark/light theme choice (preselecting the detected appearance) and opt-in analytics data sharing on first launch with the default agent directory; opting in stores a `trackingId` in `settings.json`. +### Fixed + +- Fixed custom fallback model IDs with `:` suffixes to preserve the requested thinking level when the provider template model does not advertise reasoning ([#5552](https://github.com/earendil-works/pi/issues/5552)). + ## [0.79.1] - 2026-06-09 ### New Features diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index c9afb7b2..292ccf37 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -340,7 +340,7 @@ export interface ResolveCliModelResult { export function resolveCliModel(options: { cliProvider?: string; cliModel?: string; - cliThinking?: string; + cliThinking?: ThinkingLevel; modelRegistry: ModelRegistry; }): ResolveCliModelResult { const { cliProvider, cliModel, cliThinking, modelRegistry } = options; @@ -470,10 +470,13 @@ export function resolveCliModel(options: { const fallbackModel = buildFallbackModel(provider, fallbackPattern, availableModels); if (fallbackModel) { + const requestedThinking = cliThinking ?? fallbackThinking; + const model = + requestedThinking && requestedThinking !== "off" ? { ...fallbackModel, reasoning: true } : fallbackModel; const fallbackWarning = warning ? `${warning} Model "${fallbackPattern}" not found for provider "${provider}". Using custom model id.` : `Model "${fallbackPattern}" not found for provider "${provider}". Using custom model id.`; - return { model: fallbackModel, thinkingLevel: fallbackThinking, warning: fallbackWarning, error: undefined }; + return { model, thinkingLevel: fallbackThinking, warning: fallbackWarning, error: undefined }; } } diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 4b4caf0f..32861a9e 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -403,6 +403,7 @@ describe("resolveCliModel", () => { expect(result.model?.provider).toBe("neuralwatt"); // The :high suffix must NOT leak into the model id sent to the API expect(result.model?.id).toBe("zai-org/GLM-5.1-FP8"); + expect(result.model?.reasoning).toBe(true); expect(result.thinkingLevel).toBe("high"); }); From 6102dd2084276fd894600d9eabd84679da2ed1da Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 12 Jun 2026 17:56:43 +0200 Subject: [PATCH 232/352] fix(coding-agent): handle missing export themes closes #5596 --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/agent-session.ts | 5 +- .../5596-missing-theme-export.test.ts | 89 +++++++++++++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/5596-missing-theme-export.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4d3ee3c6..eb2fa5a9 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed `/share` and `/export` HTML exports to use the active fallback theme when the configured custom theme no longer exists ([#5596](https://github.com/earendil-works/pi/issues/5596)). - Fixed custom fallback model IDs with `:` suffixes to preserve the requested thinking level when the provider template model does not advertise reasoning ([#5552](https://github.com/earendil-works/pi/issues/5552)). ## [0.79.1] - 2026-06-09 diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 201b8084..af9d32d7 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -33,7 +33,7 @@ import { resetApiProviders, streamSimple, } from "@earendil-works/pi-ai"; -import { theme } from "../modes/interactive/theme/theme.ts"; +import { getThemeByName, theme } from "../modes/interactive/theme/theme.ts"; import { stripFrontmatter } from "../utils/frontmatter.ts"; import { resolvePath } from "../utils/paths.ts"; import { sleep } from "../utils/sleep.ts"; @@ -3017,7 +3017,8 @@ export class AgentSession { * @returns Path to exported file */ async exportToHtml(outputPath?: string): Promise { - const themeName = this.settingsManager.getTheme(); + const configuredThemeName = this.settingsManager.getTheme(); + const themeName = configuredThemeName && getThemeByName(configuredThemeName) ? configuredThemeName : undefined; // Create tool renderer if we have an extension runner (for custom tool HTML rendering) const toolRenderer: ToolHtmlRenderer = createToolHtmlRenderer({ diff --git a/packages/coding-agent/test/suite/regressions/5596-missing-theme-export.test.ts b/packages/coding-agent/test/suite/regressions/5596-missing-theme-export.test.ts new file mode 100644 index 00000000..58bc17f8 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/5596-missing-theme-export.test.ts @@ -0,0 +1,89 @@ +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Agent } from "@earendil-works/pi-agent-core"; +import { fauxAssistantMessage, registerFauxProvider } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it } from "vitest"; +import { AgentSession } from "../../../src/core/agent-session.ts"; +import { AuthStorage } from "../../../src/core/auth-storage.ts"; +import { convertToLlm } from "../../../src/core/messages.ts"; +import { ModelRegistry } from "../../../src/core/model-registry.ts"; +import { SessionManager } from "../../../src/core/session-manager.ts"; +import { SettingsManager } from "../../../src/core/settings-manager.ts"; +import { initTheme } from "../../../src/modes/interactive/theme/theme.ts"; +import { createTestResourceLoader } from "../../utilities.ts"; + +describe("regression #5596: missing configured theme export", () => { + const cleanups: Array<() => void> = []; + + afterEach(() => { + while (cleanups.length > 0) { + cleanups.pop()?.(); + } + initTheme("dark"); + }); + + it("exports with the active fallback theme when the configured theme is missing", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "pi-5596-")); + const faux = registerFauxProvider({ + models: [{ id: "faux-1", reasoning: false }], + }); + faux.setResponses([fauxAssistantMessage("hello")]); + + const model = faux.getModel(); + const authStorage = AuthStorage.inMemory(); + authStorage.setRuntimeApiKey(model.provider, "faux-key"); + const modelRegistry = ModelRegistry.inMemory(authStorage); + modelRegistry.registerProvider(model.provider, { + baseUrl: model.baseUrl, + apiKey: "faux-key", + api: faux.api, + models: faux.models.map((registeredModel) => ({ + id: registeredModel.id, + name: registeredModel.name, + api: registeredModel.api, + reasoning: registeredModel.reasoning, + input: registeredModel.input, + cost: registeredModel.cost, + contextWindow: registeredModel.contextWindow, + maxTokens: registeredModel.maxTokens, + baseUrl: registeredModel.baseUrl, + })), + }); + + const settingsManager = SettingsManager.inMemory({ theme: "missing-theme" }); + const sessionManager = SessionManager.create(tempDir, join(tempDir, "sessions")); + const agent = new Agent({ + getApiKey: () => "faux-key", + initialState: { + model, + systemPrompt: "You are a test assistant.", + tools: [], + }, + convertToLlm, + }); + const session = new AgentSession({ + agent, + sessionManager, + settingsManager, + cwd: tempDir, + modelRegistry, + resourceLoader: createTestResourceLoader(), + }); + cleanups.push(() => { + session.dispose(); + faux.unregister(); + if (existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + await session.prompt("hi"); + initTheme(settingsManager.getTheme()); + + const outputPath = join(tempDir, "export.html"); + await expect(session.exportToHtml(outputPath)).resolves.toBe(outputPath); + expect(existsSync(outputPath)).toBe(true); + expect(settingsManager.getTheme()).toBe("missing-theme"); + }); +}); From 0caca6cf3fe28693b2152b9af31ac3cdb8168f3e Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 12 Jun 2026 18:24:55 +0200 Subject: [PATCH 233/352] fix(tui): support slash-separated fuzzy filter tokens --- packages/tui/CHANGELOG.md | 1 + packages/tui/src/fuzzy.ts | 4 ++-- packages/tui/test/fuzzy.test.ts | 7 +++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index da5cd46c..6405fe69 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed slash-separated fuzzy queries so provider/model completions remain matchable after insertion. - Fixed WezTerm inline Kitty image rendering so reserved row clears do not erase all but the top strip of tool image previews ([#5618](https://github.com/earendil-works/pi/issues/5618)). ## [0.79.1] - 2026-06-09 diff --git a/packages/tui/src/fuzzy.ts b/packages/tui/src/fuzzy.ts index 35df468a..73c10dcf 100644 --- a/packages/tui/src/fuzzy.ts +++ b/packages/tui/src/fuzzy.ts @@ -94,7 +94,7 @@ export function fuzzyMatch(query: string, text: string): FuzzyMatch { /** * Filter and sort items by fuzzy match quality (best matches first). - * Supports space-separated tokens: all tokens must match. + * Supports whitespace- and slash-separated tokens: all tokens must match. */ export function fuzzyFilter(items: T[], query: string, getText: (item: T) => string): T[] { if (!query.trim()) { @@ -103,7 +103,7 @@ export function fuzzyFilter(items: T[], query: string, getText: (item: T) => const tokens = query .trim() - .split(/\s+/) + .split(/[\s/]+/) .filter((t) => t.length > 0); if (tokens.length === 0) { diff --git a/packages/tui/test/fuzzy.test.ts b/packages/tui/test/fuzzy.test.ts index 7415e963..af4662af 100644 --- a/packages/tui/test/fuzzy.test.ts +++ b/packages/tui/test/fuzzy.test.ts @@ -102,4 +102,11 @@ describe("fuzzyFilter", () => { assert.ok(result.map((r) => r.name).includes("foo")); assert.ok(result.map((r) => r.name).includes("foobar")); }); + + it("matches slash-separated provider/model queries against reordered text", () => { + const item = { id: "gpt-5.5", provider: "openai-codex" }; + const result = fuzzyFilter([item], "openai-codex/gpt-5.5", (model) => `${model.id} ${model.provider}`); + + assert.deepStrictEqual(result, [item]); + }); }); From 1b2c32c653582628e17622a4c32b3fbdeeff8ce1 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 12 Jun 2026 18:32:23 +0200 Subject: [PATCH 234/352] fix(coding-agent): resolve authenticated slash model ids closes #5643 --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/model-resolver.ts | 21 ++++++++++ .../coding-agent/test/model-resolver.test.ts | 41 +++++++++++++++++++ 3 files changed, 63 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index eb2fa5a9..0bf10123 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed `--model` resolution for authenticated custom model IDs whose slash prefix matches an unauthenticated built-in provider ([#5643](https://github.com/earendil-works/pi/issues/5643)). - Fixed `/share` and `/export` HTML exports to use the active fallback theme when the configured custom theme no longer exists ([#5596](https://github.com/earendil-works/pi/issues/5596)). - Fixed custom fallback model IDs with `:` suffixes to preserve the requested thinking level when the provider template model does not advertise reasoning ([#5552](https://github.com/earendil-works/pi/issues/5552)). diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 292ccf37..0017df00 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -422,6 +422,27 @@ export function resolveCliModel(options: { }); if (model) { + // If provider inference matched an unauthenticated provider/model pair, prefer + // one exact raw model-id match that is authenticated. This keeps + // "provider/model" syntax preferred when usable, but handles models whose + // literal id starts with a known provider name (for example + // commandcode model id "xiaomi/mimo-v2.5-pro"). + if (inferredProvider) { + const rawExactMatches = availableModels.filter( + (m) => m.id.toLowerCase() === cliModel.toLowerCase() && !modelsAreEqual(m, model), + ); + if (rawExactMatches.length > 0 && !modelRegistry.hasConfiguredAuth(model)) { + const authenticatedRawMatches = rawExactMatches.filter((m) => modelRegistry.hasConfiguredAuth(m)); + if (authenticatedRawMatches.length === 1) { + return { + model: authenticatedRawMatches[0], + thinkingLevel: undefined, + warning: undefined, + error: undefined, + }; + } + } + } return { model, thinkingLevel, warning, error: undefined }; } diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 32861a9e..163c3ab5 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -344,6 +344,7 @@ describe("resolveCliModel", () => { }; const registry = { getAll: () => [...allModels, zaiModel, gatewayModel], + hasConfiguredAuth: () => true, } as unknown as Parameters[0]["modelRegistry"]; const result = resolveCliModel({ @@ -356,6 +357,46 @@ describe("resolveCliModel", () => { expect(result.model?.id).toBe("glm-5"); }); + test("prefers an authenticated exact raw model id over an unauthenticated inferred provider", () => { + const commandcodeModel: Model<"anthropic-messages"> = { + id: "xiaomi/mimo-v2.5-pro", + name: "Xiaomi MiMo via Commandcode", + api: "anthropic-messages", + provider: "commandcode", + baseUrl: "https://example.invalid", + reasoning: false, + input: ["text"], + cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 }, + contextWindow: 128000, + maxTokens: 8192, + }; + const xiaomiModel: Model<"anthropic-messages"> = { + id: "mimo-v2.5-pro", + name: "Xiaomi MiMo", + api: "anthropic-messages", + provider: "xiaomi", + baseUrl: "https://api.xiaomimimo.com", + reasoning: false, + input: ["text"], + cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 }, + contextWindow: 128000, + maxTokens: 8192, + }; + const registry = { + getAll: () => [...allModels, commandcodeModel, xiaomiModel], + hasConfiguredAuth: (model: Model<"anthropic-messages">) => model.provider === "commandcode", + } as unknown as Parameters[0]["modelRegistry"]; + + const result = resolveCliModel({ + cliModel: "xiaomi/mimo-v2.5-pro", + modelRegistry: registry, + }); + + expect(result.error).toBeUndefined(); + expect(result.model?.provider).toBe("commandcode"); + expect(result.model?.id).toBe("xiaomi/mimo-v2.5-pro"); + }); + test("resolves provider-prefixed fuzzy patterns (openrouter/qwen -> openrouter model)", () => { const registry = { getAll: () => allModels, From adf567c1c63228fb545e9abf7b12d7af19a049ee Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 12 Jun 2026 18:49:49 +0200 Subject: [PATCH 235/352] fix(coding-agent): rechain fork paths without labels closes #5669 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/core/session-manager.ts | 12 ++++++++++-- .../test/session-manager/labels.test.ts | 13 +++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0bf10123..2927a100 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - Fixed `--model` resolution for authenticated custom model IDs whose slash prefix matches an unauthenticated built-in provider ([#5643](https://github.com/earendil-works/pi/issues/5643)). +- Fixed `/fork` to keep session parent chains connected when the forked path contains labels ([#5669](https://github.com/earendil-works/pi/issues/5669)). - Fixed `/share` and `/export` HTML exports to use the active fallback theme when the configured custom theme no longer exists ([#5596](https://github.com/earendil-works/pi/issues/5596)). - Fixed custom fallback model IDs with `:` suffixes to preserve the requested thinking level when the provider template model does not advertise reasoning ([#5552](https://github.com/earendil-works/pi/issues/5552)). diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index c2bae164..62942480 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1290,8 +1290,16 @@ export class SessionManager { throw new Error(`Entry ${leafId} not found`); } - // Filter out LabelEntry from path - we'll recreate them from the resolved map - const pathWithoutLabels = path.filter((e) => e.type !== "label"); + // Filter out LabelEntry from path - we'll recreate them from the resolved map. + // Because labels are real tree entries, later entries can be children of labels; + // removing labels requires re-chaining the retained path to avoid orphaned subtrees. + const pathWithoutLabels: SessionEntry[] = []; + let pathParentId: string | null = null; + for (const entry of path) { + if (entry.type === "label") continue; + pathWithoutLabels.push({ ...entry, parentId: pathParentId }); + pathParentId = entry.id; + } const newSessionId = createSessionId(); const timestamp = new Date().toISOString(); diff --git a/packages/coding-agent/test/session-manager/labels.test.ts b/packages/coding-agent/test/session-manager/labels.test.ts index 7500746e..353454c7 100644 --- a/packages/coding-agent/test/session-manager/labels.test.ts +++ b/packages/coding-agent/test/session-manager/labels.test.ts @@ -142,6 +142,19 @@ describe("SessionManager labels", () => { expect(msg2Node?.labelTimestamp).toBe(msg2LabelEntry.timestamp); }); + it("rewires children of removed labels when forking", () => { + const session = SessionManager.inMemory(); + + const msg1Id = session.appendMessage({ role: "user", content: "hello", timestamp: 1 }); + session.appendLabelChange(msg1Id, "checkpoint"); + const modelChangeId = session.appendModelChange("anthropic", "claude-test"); + const msg2Id = session.appendMessage({ role: "user", content: "followup", timestamp: 2 }); + + session.createBranchedSession(msg2Id); + + expect(session.getEntry(modelChangeId)?.parentId).toBe(msg1Id); + }); + it("labels not on path are not preserved in createBranchedSession", () => { const session = SessionManager.inMemory(); From daab056ac16d2aa7447f99205ae6e3d1c19ecfa6 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 12 Jun 2026 18:59:11 +0200 Subject: [PATCH 236/352] fix(agent): ignore late tool progress updates closes #5573 --- packages/agent/CHANGELOG.md | 4 + packages/agent/src/agent-loop.ts | 6 ++ packages/agent/src/types.ts | 7 +- packages/agent/test/agent.test.ts | 166 +++++++++++++++++++++++++++++- 4 files changed, 181 insertions(+), 2 deletions(-) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index c1c02515..287f07c3 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed late tool progress callbacks after tool settlement to be ignored instead of emitting stale `tool_execution_update` events ([#5573](https://github.com/earendil-works/pi/issues/5573)). + ## [0.79.1] - 2026-06-09 ## [0.79.0] - 2026-06-08 diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 28f037f5..77bd9568 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -631,6 +631,7 @@ async function executePreparedToolCall( emit: AgentEventSink, ): Promise { const updateEvents: Promise[] = []; + let acceptingUpdates = true; try { const result = await prepared.tool.execute( @@ -638,6 +639,7 @@ async function executePreparedToolCall( prepared.args as never, signal, (partialResult) => { + if (!acceptingUpdates) return; updateEvents.push( Promise.resolve( emit({ @@ -651,14 +653,18 @@ async function executePreparedToolCall( ); }, ); + acceptingUpdates = false; await Promise.all(updateEvents); return { result, isError: false }; } catch (error) { + acceptingUpdates = false; await Promise.all(updateEvents); return { result: createErrorToolResult(error instanceof Error ? error.message : String(error)), isError: true, }; + } finally { + acceptingUpdates = false; } } diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index f24d2496..cb99a79a 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -354,7 +354,12 @@ export interface AgentToolResult { terminate?: boolean; } -/** Callback used by tools to stream partial execution updates. */ +/** + * Callback used by tools to stream partial execution updates. + * + * The callback is scoped to the current `execute()` invocation. Calls made after + * the tool promise settles are ignored. + */ export type AgentToolUpdateCallback = (partialResult: AgentToolResult) => void; /** Tool definition used by the agent runtime. */ diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 82cc58de..4cc51f74 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -1,6 +1,7 @@ import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@earendil-works/pi-ai"; +import { Type } from "typebox"; import { describe, expect, it } from "vitest"; -import { Agent } from "../src/index.ts"; +import { Agent, type AgentEvent, type AgentTool, type AgentToolUpdateCallback } from "../src/index.ts"; // Mock stream that mimics AssistantMessageEventStream class MockAssistantStream extends EventStream { @@ -36,6 +37,28 @@ function createAssistantMessage(text: string): AssistantMessage { }; } +type ToolCallContent = Extract; + +function createAssistantToolUseMessage(content: ToolCallContent[]): AssistantMessage { + return { + role: "assistant", + content, + api: "openai-responses", + provider: "openai", + model: "mock", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: Date.now(), + }; +} + function createDeferred(): { promise: Promise; resolve: () => void; @@ -242,6 +265,147 @@ describe("Agent", () => { expect(receivedSignal?.aborted).toBe(true); }); + it("should ignore tool updates after the tool execution settles", async () => { + const toolSchema = Type.Object({}); + let delayedUpdate: AgentToolUpdateCallback<{ status: string }> | undefined; + const events: AgentEvent[] = []; + const unhandledRejections: unknown[] = []; + const onUnhandledRejection = (error: unknown) => { + unhandledRejections.push(error); + }; + const tool: AgentTool = { + name: "delayed_tool", + label: "Delayed Tool", + description: "Captures progress callbacks", + parameters: toolSchema, + async execute(_toolCallId, _params, _signal, onUpdate) { + delayedUpdate = onUpdate; + onUpdate?.({ + content: [{ type: "text", text: "running" }], + details: { status: "running" }, + }); + return { + content: [{ type: "text", text: "ok" }], + details: { status: "done" }, + terminate: true, + }; + }, + }; + const agent = new Agent({ + initialState: { tools: [tool] }, + streamFn: () => { + const stream = new MockAssistantStream(); + queueMicrotask(() => { + stream.push({ + type: "done", + reason: "toolUse", + message: createAssistantToolUseMessage([ + { type: "toolCall", id: "call-1", name: "delayed_tool", arguments: {} }, + ]), + }); + }); + return stream; + }, + }); + agent.subscribe((event) => { + events.push(event); + }); + + process.on("unhandledRejection", onUnhandledRejection); + try { + await agent.prompt("run tool"); + const eventCountAfterPrompt = events.length; + + delayedUpdate?.({ + content: [{ type: "text", text: "late" }], + details: { status: "late" }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(events.filter((event) => event.type === "tool_execution_update")).toHaveLength(1); + expect(events).toHaveLength(eventCountAfterPrompt); + expect(unhandledRejections).toEqual([]); + } finally { + process.off("unhandledRejection", onUnhandledRejection); + } + }); + + it("should ignore a settled parallel tool update while another tool is still running", async () => { + const toolSchema = Type.Object({}); + const slowStarted = createDeferred(); + const settledToolEnded = createDeferred(); + const releaseSlow = createDeferred(); + let settledToolUpdate: AgentToolUpdateCallback<{ status: string }> | undefined; + const events: AgentEvent[] = []; + const settledTool: AgentTool = { + name: "settled_tool", + label: "Settled Tool", + description: "Captures progress callbacks", + parameters: toolSchema, + async execute(_toolCallId, _params, _signal, onUpdate) { + settledToolUpdate = onUpdate; + return { + content: [{ type: "text", text: "done" }], + details: { status: "done" }, + terminate: true, + }; + }, + }; + const slowTool: AgentTool = { + name: "slow_tool", + label: "Slow Tool", + description: "Keeps the agent run active", + parameters: toolSchema, + async execute() { + slowStarted.resolve(); + await releaseSlow.promise; + return { + content: [{ type: "text", text: "done" }], + details: { status: "done" }, + terminate: true, + }; + }, + }; + const agent = new Agent({ + initialState: { tools: [settledTool, slowTool] }, + streamFn: () => { + const stream = new MockAssistantStream(); + queueMicrotask(() => { + stream.push({ + type: "done", + reason: "toolUse", + message: createAssistantToolUseMessage([ + { type: "toolCall", id: "call-1", name: "settled_tool", arguments: {} }, + { type: "toolCall", id: "call-2", name: "slow_tool", arguments: {} }, + ]), + }); + }); + return stream; + }, + }); + agent.subscribe((event) => { + events.push(event); + if (event.type === "tool_execution_end" && event.toolCallId === "call-1") { + settledToolEnded.resolve(); + } + }); + + const promptPromise = agent.prompt("run tools"); + await Promise.all([slowStarted.promise, settledToolEnded.promise]); + const eventCountBeforeLateUpdate = events.length; + + settledToolUpdate?.({ + content: [{ type: "text", text: "late" }], + details: { status: "late" }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(events).toHaveLength(eventCountBeforeLateUpdate); + + releaseSlow.resolve(); + await promptPromise; + expect(events.filter((event) => event.type === "tool_execution_update")).toHaveLength(0); + }); + it("should update state with mutators", () => { const agent = new Agent(); From 17721d5e0153ad761fa6155eb90d62b9a5ee3283 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 12 Jun 2026 20:22:53 +0200 Subject: [PATCH 237/352] fix(tui): preserve unordered user list markers (closes #5657) --- packages/coding-agent/CHANGELOG.md | 1 + packages/tui/CHANGELOG.md | 1 + packages/tui/src/components/markdown.ts | 11 +++++++++-- packages/tui/test/markdown.test.ts | 17 ++++++++++++++--- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2927a100..e770ebdb 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed inherited user-message transcript rendering so standalone `+` messages no longer render as `-` ([#5657](https://github.com/earendil-works/pi/issues/5657)). - Fixed `--model` resolution for authenticated custom model IDs whose slash prefix matches an unauthenticated built-in provider ([#5643](https://github.com/earendil-works/pi/issues/5643)). - Fixed `/fork` to keep session parent chains connected when the forked path contains labels ([#5669](https://github.com/earendil-works/pi/issues/5669)). - Fixed `/share` and `/export` HTML exports to use the active fallback theme when the configured custom theme no longer exists ([#5596](https://github.com/earendil-works/pi/issues/5596)). diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 6405fe69..b5e277a9 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed Markdown source list marker preservation to include unordered markers, so standalone `+` user messages no longer render as `-` ([#5657](https://github.com/earendil-works/pi/issues/5657)). - Fixed slash-separated fuzzy queries so provider/model completions remain matchable after insertion. - Fixed WezTerm inline Kitty image rendering so reserved row clears do not erase all but the top strip of tool image previews ([#5618](https://github.com/earendil-works/pi/issues/5618)). diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index ed8ad780..83f15aa5 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -71,7 +71,7 @@ export interface MarkdownTheme { } export interface MarkdownOptions { - /** Preserve source ordered-list markers instead of normalizing them from the list start. */ + /** Preserve source list markers instead of normalizing them. */ preserveOrderedListMarkers?: boolean; } @@ -561,6 +561,11 @@ export class Markdown implements Component { return match ? `${match[1]} ` : undefined; } + private getUnorderedListMarker(item: Tokens.ListItem): string | undefined { + const match = /^(?: {0,3})([-+*])(?:[ \t]+|(?=\r?\n|$))/.exec(item.raw); + return match ? `${match[1]} ` : undefined; + } + /** * Render a list with proper nesting support */ @@ -577,7 +582,9 @@ export class Markdown implements Component { ? this.options.preserveOrderedListMarkers ? (this.getOrderedListMarker(item) ?? `${startNumber + i}. `) : `${startNumber + i}. ` - : "- "; + : this.options.preserveOrderedListMarkers + ? (this.getUnorderedListMarker(item) ?? "- ") + : "- "; const taskMarker = item.task ? `[${item.checked ? "x" : " "}] ` : ""; const marker = bullet + taskMarker; const firstPrefix = indent + this.theme.listBullet(marker); diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 9c0be05c..dc595b11 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -112,9 +112,9 @@ describe("Markdown component", () => { assert.deepStrictEqual(lines, ["1. alpha", "2. beta", "3. gamma"]); }); - it("should preserve source ordered list markers when configured", () => { + it("should preserve source list markers when configured", () => { const markdown = new Markdown( - " 4. forth\n 3. third\n\n10) ten\n7) seven", + " 4. forth\n 3. third\n\n10) ten\n7) seven\n\n+ plus\n* star\n- minus\n+", 0, 0, defaultMarkdownTheme, @@ -126,7 +126,18 @@ describe("Markdown component", () => { const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); - assert.deepStrictEqual(lines, ["4. forth", "3. third", "", "10) ten", "7) seven"]); + assert.deepStrictEqual(lines, [ + "4. forth", + "3. third", + "", + "10) ten", + "7) seven", + "", + "+ plus", + "* star", + "- minus", + "+", + ]); }); it("should render mixed ordered and unordered nested lists", () => { From a7cdc679e715b5bd36ea11fdb5b26db706746009 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 12 Jun 2026 23:03:10 +0200 Subject: [PATCH 238/352] fix(ai): correct GPT-5 context window metadata closes #5644 --- packages/ai/CHANGELOG.md | 1 + packages/ai/scripts/generate-models.ts | 14 +- packages/ai/src/models.generated.ts | 923 +++++++----------- .../ai/test/openai-model-metadata.test.ts | 15 + 4 files changed, 374 insertions(+), 579 deletions(-) create mode 100644 packages/ai/test/openai-model-metadata.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 93fc9a80..b7b1112d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -6,6 +6,7 @@ ### Fixed +- Fixed OpenAI GPT-5.4/GPT-5.5 and OpenAI Codex GPT-5.4/GPT-5.4 mini/GPT-5.5 context window metadata to match current OpenAI limits ([#5644](https://github.com/earendil-works/pi/issues/5644)). - Increased the OpenAI Codex Responses SSE response-header timeout to 20 seconds to reduce false-positive stalls while retaining the bounded wait introduced for zero-event hangs ([#4945](https://github.com/earendil-works/pi/issues/4945)). - Fixed Claude Fable 5 thinking-off requests to omit Anthropic's unsupported `thinking.type: "disabled"` payload ([#5567](https://github.com/earendil-works/pi/pull/5567) by [@tmustier](https://github.com/tmustier)). diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 5e5fff94..a3892cf1 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -1363,7 +1363,7 @@ async function generateModels() { candidate.maxTokens = 128000; } if (candidate.provider === "openai" && (candidate.id === "gpt-5.4" || candidate.id === "gpt-5.5")) { - candidate.contextWindow = 272000; + candidate.contextWindow = 1050000; candidate.maxTokens = 128000; } // models.dev reports gpt-5-pro output as 272000 (a duplicate of the input sub-limit); @@ -1630,7 +1630,7 @@ async function generateModels() { cacheRead: 0.25, cacheWrite: 0, }, - contextWindow: 272000, + contextWindow: 1050000, maxTokens: 128000, }); } @@ -1757,9 +1757,9 @@ async function generateModels() { // OpenAI Codex (ChatGPT OAuth) models // NOTE: These are not fetched from models.dev; we keep a small, explicit list to avoid aliases. - // Context window is based on observed server limits (400s above ~272k), not marketing numbers. const CODEX_BASE_URL = "https://chatgpt.com/backend-api"; - const CODEX_CONTEXT = 272000; + const CODEX_GPT_54_CONTEXT = 1000000; + const CODEX_STANDARD_CONTEXT = 400000; const CODEX_SPARK_CONTEXT = 128000; const CODEX_MAX_TOKENS = 128000; const codexModels: Model<"openai-codex-responses">[] = [ @@ -1784,7 +1784,7 @@ async function generateModels() { reasoning: true, input: ["text", "image"], cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 }, - contextWindow: CODEX_CONTEXT, + contextWindow: CODEX_GPT_54_CONTEXT, maxTokens: CODEX_MAX_TOKENS, }, { @@ -1796,7 +1796,7 @@ async function generateModels() { reasoning: true, input: ["text", "image"], cost: { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 }, - contextWindow: CODEX_CONTEXT, + contextWindow: CODEX_STANDARD_CONTEXT, maxTokens: CODEX_MAX_TOKENS, }, { @@ -1808,7 +1808,7 @@ async function generateModels() { reasoning: true, input: ["text", "image"], cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, - contextWindow: CODEX_CONTEXT, + contextWindow: CODEX_STANDARD_CONTEXT, maxTokens: CODEX_MAX_TOKENS, }, ]; diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 57da60bc..eeed2e92 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -357,7 +357,7 @@ export const MODELS = { provider: "amazon-bedrock", baseUrl: "https://bedrock-runtime.eu-central-1.amazonaws.com", reasoning: true, - thinkingLevelMap: {"xhigh":"xhigh"}, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], cost: { input: 11, @@ -497,7 +497,7 @@ export const MODELS = { provider: "amazon-bedrock", baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", reasoning: true, - thinkingLevelMap: {"xhigh":"xhigh"}, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], cost: { input: 10, @@ -1389,7 +1389,7 @@ export const MODELS = { provider: "amazon-bedrock", baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", reasoning: true, - thinkingLevelMap: {"xhigh":"xhigh"}, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], cost: { input: 10, @@ -1878,7 +1878,7 @@ export const MODELS = { baseUrl: "https://api.anthropic.com", compat: {"forceAdaptiveThinking":true}, reasoning: true, - thinkingLevelMap: {"xhigh":"xhigh"}, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], cost: { input: 10, @@ -2919,30 +2919,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.25, - output: 0.69, + input: 0.35, + output: 0.75, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "llama3.1-8b": { - id: "llama3.1-8b", - name: "Llama 3.1 8B", - api: "openai-completions", - provider: "cerebras", - baseUrl: "https://api.cerebras.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.1, - output: 0.1, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32000, - maxTokens: 8000, + maxTokens: 40960, } satisfies Model<"openai-completions">, "zai-glm-4.7": { id: "zai-glm-4.7", @@ -2950,7 +2933,7 @@ export const MODELS = { api: "openai-completions", provider: "cerebras", baseUrl: "https://api.cerebras.ai/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 2.25, @@ -2959,7 +2942,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 40000, + maxTokens: 40960, } satisfies Model<"openai-completions">, }, "cloudflare-ai-gateway": { @@ -3073,7 +3056,7 @@ export const MODELS = { baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", compat: {"forceAdaptiveThinking":true}, reasoning: true, - thinkingLevelMap: {"xhigh":"xhigh"}, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], cost: { input: 10, @@ -3722,6 +3705,24 @@ export const MODELS = { contextWindow: 262144, maxTokens: 256000, } satisfies Model<"openai-completions">, + "@cf/moonshotai/kimi-k2.7-code": { + id: "@cf/moonshotai/kimi-k2.7-code", + name: "Kimi K2.7 Code", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + compat: {"sendSessionAffinityHeaders":true}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.95, + output: 4, + cacheRead: 0.19, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, "@cf/nvidia/nemotron-3-120b-a12b": { id: "@cf/nvidia/nemotron-3-120b-a12b", name: "Nemotron 3 Super 120B", @@ -4090,6 +4091,25 @@ export const MODELS = { } satisfies Model<"anthropic-messages">, }, "github-copilot": { + "claude-fable-5": { + id: "claude-fable-5", + name: "Claude Fable 5", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 10, + output: 50, + cacheRead: 1, + cacheWrite: 12.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "claude-haiku-4.5": { id: "claude-haiku-4.5", name: "Claude Haiku 4.5 (latest)", @@ -5022,77 +5042,9 @@ export const MODELS = { } satisfies Model<"google-vertex">, }, "groq": { - "deepseek-r1-distill-llama-70b": { - id: "deepseek-r1-distill-llama-70b", - name: "DeepSeek R1 Distill Llama 70B", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.75, - output: 0.99, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "gemma2-9b-it": { - id: "gemma2-9b-it", - name: "Gemma 2 9B", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.2, - output: 0.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "groq/compound": { - id: "groq/compound", - name: "Compound", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "groq/compound-mini": { - id: "groq/compound-mini", - name: "Compound Mini", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, "llama-3.1-8b-instant": { id: "llama-3.1-8b-instant", - name: "Llama 3.1 8B Instant", + name: "Llama 3.1 8B", api: "openai-completions", provider: "groq", baseUrl: "https://api.groq.com/openai/v1", @@ -5109,7 +5061,7 @@ export const MODELS = { } satisfies Model<"openai-completions">, "llama-3.3-70b-versatile": { id: "llama-3.3-70b-versatile", - name: "Llama 3.3 70B Versatile", + name: "Llama 3.3 70B", api: "openai-completions", provider: "groq", baseUrl: "https://api.groq.com/openai/v1", @@ -5124,60 +5076,9 @@ export const MODELS = { contextWindow: 131072, maxTokens: 32768, } satisfies Model<"openai-completions">, - "llama3-70b-8192": { - id: "llama3-70b-8192", - name: "Llama 3 70B", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.59, - output: 0.79, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "llama3-8b-8192": { - id: "llama3-8b-8192", - name: "Llama 3 8B", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.05, - output: 0.08, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "meta-llama/llama-4-maverick-17b-128e-instruct": { - id: "meta-llama/llama-4-maverick-17b-128e-instruct", - name: "Llama 4 Maverick 17B", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.2, - output: 0.6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, "meta-llama/llama-4-scout-17b-16e-instruct": { id: "meta-llama/llama-4-scout-17b-16e-instruct", - name: "Llama 4 Scout 17B", + name: "Llama 4 Scout 17B 16E", api: "openai-completions", provider: "groq", baseUrl: "https://api.groq.com/openai/v1", @@ -5192,57 +5093,6 @@ export const MODELS = { contextWindow: 131072, maxTokens: 8192, } satisfies Model<"openai-completions">, - "mistral-saba-24b": { - id: "mistral-saba-24b", - name: "Mistral Saba 24B", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.79, - output: 0.79, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "moonshotai/kimi-k2-instruct": { - id: "moonshotai/kimi-k2-instruct", - name: "Kimi K2 Instruct", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1, - output: 3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "moonshotai/kimi-k2-instruct-0905": { - id: "moonshotai/kimi-k2-instruct-0905", - name: "Kimi K2 Instruct 0905", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1, - output: 3, - cacheRead: 0.5, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "openai/gpt-oss-120b": { id: "openai/gpt-oss-120b", name: "GPT OSS 120B", @@ -5294,26 +5144,9 @@ export const MODELS = { contextWindow: 131072, maxTokens: 65536, } satisfies Model<"openai-completions">, - "qwen-qwq-32b": { - id: "qwen-qwq-32b", - name: "Qwen QwQ 32B", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.29, - output: 0.39, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "qwen/qwen3-32b": { id: "qwen/qwen3-32b", - name: "Qwen3 32B", + name: "Qwen3-32B", api: "openai-completions", provider: "groq", baseUrl: "https://api.groq.com/openai/v1", @@ -5729,6 +5562,24 @@ export const MODELS = { } satisfies Model<"openai-completions">, }, "kimi-coding": { + "k2p7": { + id: "k2p7", + name: "Kimi K2.7 Code", + api: "anthropic-messages", + provider: "kimi-coding", + baseUrl: "https://api.kimi.com/coding", + headers: {"User-Agent":"KimiCLI/1.5"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32768, + } satisfies Model<"anthropic-messages">, "kimi-for-coding": { id: "kimi-for-coding", name: "Kimi For Coding", @@ -6511,6 +6362,24 @@ export const MODELS = { contextWindow: 262144, maxTokens: 262144, } satisfies Model<"openai-completions">, + "kimi-k2.7-code": { + id: "kimi-k2.7-code", + name: "Kimi K2.7 Code", + api: "openai-completions", + provider: "moonshotai", + baseUrl: "https://api.moonshot.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.95, + output: 4, + cacheRead: 0.19, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, }, "moonshotai-cn": { "kimi-k2-0711-preview": { @@ -6763,8 +6632,8 @@ export const MODELS = { baseUrl: "https://integrate.api.nvidia.com/v1", headers: {"NVCF-POLL-SECONDS":"3600"}, compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, - reasoning: false, - input: ["text"], + reasoning: true, + input: ["text", "image"], cost: { input: 0, output: 0, @@ -6793,44 +6662,6 @@ export const MODELS = { contextWindow: 262144, maxTokens: 262144, } satisfies Model<"openai-completions">, - "nvidia/llama-3.3-nemotron-super-49b-v1": { - id: "nvidia/llama-3.3-nemotron-super-49b-v1", - name: "Llama 3.3 Nemotron Super 49B v1", - api: "openai-completions", - provider: "nvidia", - baseUrl: "https://integrate.api.nvidia.com/v1", - headers: {"NVCF-POLL-SECONDS":"3600"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"openai-completions">, - "nvidia/llama-3.3-nemotron-super-49b-v1.5": { - id: "nvidia/llama-3.3-nemotron-super-49b-v1.5", - name: "Llama 3.3 Nemotron Super 49B v1.5", - api: "openai-completions", - provider: "nvidia", - baseUrl: "https://integrate.api.nvidia.com/v1", - headers: {"NVCF-POLL-SECONDS":"3600"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"openai-completions">, "nvidia/nemotron-3-nano-30b-a3b": { id: "nvidia/nemotron-3-nano-30b-a3b", name: "nemotron-3-nano-30b-a3b", @@ -6926,6 +6757,25 @@ export const MODELS = { contextWindow: 131072, maxTokens: 131072, } satisfies Model<"openai-completions">, + "openai/gpt-oss-120b": { + id: "openai/gpt-oss-120b", + name: "GPT-OSS-120B", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 8192, + } satisfies Model<"openai-completions">, "openai/gpt-oss-20b": { id: "openai/gpt-oss-20b", name: "GPT OSS 20B", @@ -6945,25 +6795,6 @@ export const MODELS = { contextWindow: 131072, maxTokens: 32768, } satisfies Model<"openai-completions">, - "qwen/qwen3-coder-480b-a35b-instruct": { - id: "qwen/qwen3-coder-480b-a35b-instruct", - name: "Qwen3 Coder 480B A35B Instruct", - api: "openai-completions", - provider: "nvidia", - baseUrl: "https://integrate.api.nvidia.com/v1", - headers: {"NVCF-POLL-SECONDS":"3600"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, - reasoning: false, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 66536, - } satisfies Model<"openai-completions">, "qwen/qwen3.5-122b-a10b": { id: "qwen/qwen3.5-122b-a10b", name: "Qwen3.5 122B-A10B", @@ -7551,7 +7382,7 @@ export const MODELS = { cacheRead: 0.25, cacheWrite: 0, }, - contextWindow: 272000, + contextWindow: 1050000, maxTokens: 128000, } satisfies Model<"openai-responses">, "gpt-5.4-mini": { @@ -7623,7 +7454,7 @@ export const MODELS = { cacheRead: 0.5, cacheWrite: 0, }, - contextWindow: 272000, + contextWindow: 1050000, maxTokens: 128000, } satisfies Model<"openai-responses">, "gpt-5.5-pro": { @@ -7815,7 +7646,7 @@ export const MODELS = { cacheRead: 0.25, cacheWrite: 0, }, - contextWindow: 272000, + contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"openai-codex-responses">, "gpt-5.4-mini": { @@ -7833,7 +7664,7 @@ export const MODELS = { cacheRead: 0.075, cacheWrite: 0, }, - contextWindow: 272000, + contextWindow: 400000, maxTokens: 128000, } satisfies Model<"openai-codex-responses">, "gpt-5.5": { @@ -7851,7 +7682,7 @@ export const MODELS = { cacheRead: 0.5, cacheWrite: 0, }, - contextWindow: 272000, + contextWindow: 400000, maxTokens: 128000, } satisfies Model<"openai-codex-responses">, }, @@ -7882,7 +7713,7 @@ export const MODELS = { baseUrl: "https://opencode.ai/zen", compat: {"forceAdaptiveThinking":true}, reasoning: true, - thinkingLevelMap: {"xhigh":"xhigh"}, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], cost: { input: 10, @@ -8066,7 +7897,7 @@ export const MODELS = { cost: { input: 0.14, output: 0.28, - cacheRead: 0.03, + cacheRead: 0.028, cacheWrite: 0, }, contextWindow: 1000000, @@ -8091,6 +7922,25 @@ export const MODELS = { contextWindow: 200000, maxTokens: 128000, } satisfies Model<"openai-completions">, + "deepseek-v4-pro": { + id: "deepseek-v4-pro", + name: "DeepSeek V4 Pro", + api: "openai-completions", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, + input: ["text"], + cost: { + input: 1.74, + output: 3.84, + cacheRead: 0.145, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 384000, + } satisfies Model<"openai-completions">, "gemini-3-flash": { id: "gemini-3-flash", name: "Gemini 3 Flash", @@ -8724,24 +8574,6 @@ export const MODELS = { contextWindow: 202752, maxTokens: 32768, } satisfies Model<"openai-completions">, - "kimi-k2.5": { - id: "kimi-k2.5", - name: "Kimi K2.5", - api: "openai-completions", - provider: "opencode-go", - baseUrl: "https://opencode.ai/zen/go/v1", - compat: {"maxTokensField":"max_tokens"}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.6, - output: 3, - cacheRead: 0.1, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 65536, - } satisfies Model<"openai-completions">, "kimi-k2.6": { id: "kimi-k2.6", name: "Kimi K2.6", @@ -8761,6 +8593,24 @@ export const MODELS = { contextWindow: 262144, maxTokens: 65536, } satisfies Model<"openai-completions">, + "kimi-k2.7-code": { + id: "kimi-k2.7-code", + name: "Kimi K2.7 Code", + api: "openai-completions", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"maxTokensField":"max_tokens"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.95, + output: 4, + cacheRead: 0.19, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, "mimo-v2.5": { id: "mimo-v2.5", name: "MiMo V2.5", @@ -8797,23 +8647,6 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 128000, } satisfies Model<"openai-completions">, - "minimax-m2.5": { - id: "minimax-m2.5", - name: "MiniMax M2.5", - api: "anthropic-messages", - provider: "opencode-go", - baseUrl: "https://opencode.ai/zen/go", - reasoning: true, - input: ["text"], - cost: { - input: 0.3, - output: 1.2, - cacheRead: 0.03, - cacheWrite: 0, - }, - contextWindow: 204800, - maxTokens: 65536, - } satisfies Model<"anthropic-messages">, "minimax-m2.7": { id: "minimax-m2.7", name: "MiniMax M2.7", @@ -8834,16 +8667,16 @@ export const MODELS = { } satisfies Model<"openai-completions">, "minimax-m3": { id: "minimax-m3", - name: "MiniMax M3", + name: "MiniMax M3 (3x usage)", api: "anthropic-messages", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go", reasoning: true, input: ["text", "image"], cost: { - input: 0.3, - output: 1.2, - cacheRead: 0.06, + input: 0.1, + output: 0.4, + cacheRead: 0.02, cacheWrite: 0, }, contextWindow: 512000, @@ -9600,13 +9433,13 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh"}, input: ["text"], cost: { - input: 0.0983, - output: 0.1966, - cacheRead: 0.019700000000000002, + input: 0.098, + output: 0.196, + cacheRead: 0.02, cacheWrite: 0, }, contextWindow: 1048576, - maxTokens: 131072, + maxTokens: 4096, } satisfies Model<"openai-completions">, "deepseek/deepseek-v4-pro": { id: "deepseek/deepseek-v4-pro", @@ -9926,12 +9759,12 @@ export const MODELS = { input: ["text", "image"], cost: { input: 0.12, - output: 0.36, + output: 0.35, cacheRead: 0.09, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 8192, + maxTokens: 262144, } satisfies Model<"openai-completions">, "google/gemma-4-31b-it:free": { id: "google/gemma-4-31b-it:free", @@ -10232,9 +10065,9 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.27, - output: 1.08, - cacheRead: 0.054, + input: 0.25, + output: 1, + cacheRead: 0.049999999999999996, cacheWrite: 0, }, contextWindow: 204800, @@ -10632,19 +10465,18 @@ export const MODELS = { contextWindow: 262144, maxTokens: 262142, } satisfies Model<"openai-completions">, - "moonshotai/kimi-k2.6:free": { - id: "moonshotai/kimi-k2.6:free", - name: "MoonshotAI: Kimi K2.6 (free)", + "moonshotai/kimi-k2.7-code": { + id: "moonshotai/kimi-k2.7-code", + name: "MoonshotAI: Kimi K2.7 Code", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - compat: {"supportsDeveloperRole":false,"requiresReasoningContentOnAssistantMessages":true}, reasoning: true, input: ["text", "image"], cost: { - input: 0, - output: 0, - cacheRead: 0, + input: 0.95, + output: 4, + cacheRead: 0.19, cacheWrite: 0, }, contextWindow: 262144, @@ -10820,23 +10652,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 128000, } satisfies Model<"openai-completions">, - "nvidia/nemotron-nano-9b-v2": { - id: "nvidia/nemotron-nano-9b-v2", - name: "NVIDIA: Nemotron Nano 9B V2", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.04, - output: 0.16, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "nvidia/nemotron-nano-9b-v2:free": { id: "nvidia/nemotron-nano-9b-v2:free", name: "NVIDIA: Nemotron Nano 9B V2 (free)", @@ -12558,13 +12373,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.28900000000000003, - output: 2.4, + input: 0.28850000000000003, + output: 3.17, cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 131072, + maxTokens: 262140, } satisfies Model<"openai-completions">, "qwen/qwen3.6-35b-a3b": { id: "qwen/qwen3.6-35b-a3b", @@ -12575,13 +12390,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.14, + input: 0.15, output: 1, - cacheRead: 0, + cacheRead: 0.049999999999999996, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262140, + maxTokens: 262144, } satisfies Model<"openai-completions">, "qwen/qwen3.6-flash": { id: "qwen/qwen3.6-flash", @@ -12660,10 +12475,10 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.39999999999999997, - output: 1.5999999999999999, - cacheRead: 0.08, - cacheWrite: 0.5, + input: 0.32, + output: 1.28, + cacheRead: 0.064, + cacheWrite: 0.39999999999999997, }, contextWindow: 1000000, maxTokens: 65536, @@ -12923,23 +12738,6 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 131072, } satisfies Model<"openai-completions">, - "z-ai/glm-4-32b": { - id: "z-ai/glm-4-32b", - name: "Z.ai: GLM 4 32B ", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.09999999999999999, - output: 0.09999999999999999, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "z-ai/glm-4.5": { id: "z-ai/glm-4.5", name: "Z.ai: GLM 4.5", @@ -12974,23 +12772,6 @@ export const MODELS = { contextWindow: 131072, maxTokens: 131070, } satisfies Model<"openai-completions">, - "z-ai/glm-4.5-air:free": { - id: "z-ai/glm-4.5-air:free", - name: "Z.ai: GLM 4.5 Air (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 96000, - } satisfies Model<"openai-completions">, "z-ai/glm-4.5v": { id: "z-ai/glm-4.5v", name: "Z.ai: GLM 4.5V", @@ -13036,11 +12817,11 @@ export const MODELS = { cost: { input: 0.3, output: 0.8999999999999999, - cacheRead: 0.049999999999999996, + cacheRead: 0.055, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 24000, + maxTokens: 32768, } satisfies Model<"openai-completions">, "z-ai/glm-4.7": { id: "z-ai/glm-4.7", @@ -13107,7 +12888,7 @@ export const MODELS = { cacheRead: 0.24, cacheWrite: 0, }, - contextWindow: 202752, + contextWindow: 262144, maxTokens: 131072, } satisfies Model<"openai-completions">, "z-ai/glm-5.1": { @@ -13127,23 +12908,6 @@ export const MODELS = { contextWindow: 202752, maxTokens: 4096, } satisfies Model<"openai-completions">, - "z-ai/glm-5v-turbo": { - id: "z-ai/glm-5v-turbo", - name: "Z.ai: GLM 5V Turbo", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.2, - output: 4, - cacheRead: 0.24, - cacheWrite: 0, - }, - contextWindow: 202752, - maxTokens: 131072, - } satisfies Model<"openai-completions">, "~anthropic/claude-fable-latest": { id: "~anthropic/claude-fable-latest", name: "Anthropic: Claude Fable Latest", @@ -13299,25 +13063,6 @@ export const MODELS = { } satisfies Model<"openai-completions">, }, "together": { - "MiniMaxAI/MiniMax-M2.5": { - id: "MiniMaxAI/MiniMax-M2.5", - name: "MiniMax-M2.5", - api: "openai-completions", - provider: "together", - baseUrl: "https://api.together.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, - reasoning: true, - thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, - input: ["text"], - cost: { - input: 0.3, - output: 1.2, - cacheRead: 0.06, - cacheWrite: 0, - }, - contextWindow: 204800, - maxTokens: 131072, - } satisfies Model<"openai-completions">, "MiniMaxAI/MiniMax-M2.7": { id: "MiniMaxAI/MiniMax-M2.7", name: "MiniMax-M2.7", @@ -13337,28 +13082,9 @@ export const MODELS = { contextWindow: 202752, maxTokens: 131072, } satisfies Model<"openai-completions">, - "Qwen/Qwen3-235B-A22B-Instruct-2507-tput": { - id: "Qwen/Qwen3-235B-A22B-Instruct-2507-tput", - name: "Qwen3 235B A22B Instruct 2507 FP8", - api: "openai-completions", - provider: "together", - baseUrl: "https://api.together.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, - reasoning: true, - thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, - input: ["text"], - cost: { - input: 0.2, - output: 0.6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 262144, - } satisfies Model<"openai-completions">, - "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": { - id: "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8", - name: "Qwen3 Coder 480B A35B Instruct", + "Qwen/Qwen2.5-7B-Instruct-Turbo": { + id: "Qwen/Qwen2.5-7B-Instruct-Turbo", + name: "Qwen 2.5 7B Instruct Turbo", api: "openai-completions", provider: "together", baseUrl: "https://api.together.ai/v1", @@ -13366,27 +13092,26 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 2, - output: 2, + input: 0.3, + output: 0.3, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 262144, - maxTokens: 262144, + contextWindow: 32768, + maxTokens: 32768, } satisfies Model<"openai-completions">, - "Qwen/Qwen3-Coder-Next-FP8": { - id: "Qwen/Qwen3-Coder-Next-FP8", - name: "Qwen3 Coder Next FP8", + "Qwen/Qwen3-235B-A22B-Instruct-2507-tput": { + id: "Qwen/Qwen3-235B-A22B-Instruct-2507-tput", + name: "Qwen3 235B A22B Instruct 2507 FP8", api: "openai-completions", provider: "together", baseUrl: "https://api.together.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, - reasoning: true, - thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: false, input: ["text"], cost: { - input: 0.5, - output: 1.2, + input: 0.2, + output: 0.6, cacheRead: 0, cacheWrite: 0, }, @@ -13412,6 +13137,25 @@ export const MODELS = { contextWindow: 262144, maxTokens: 130000, } satisfies Model<"openai-completions">, + "Qwen/Qwen3.5-9B": { + id: "Qwen/Qwen3.5-9B", + name: "Qwen3.5 9B", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text", "image"], + cost: { + input: 0.17, + output: 0.25, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "Qwen/Qwen3.6-Plus": { id: "Qwen/Qwen3.6-Plus", name: "Qwen3.6 Plus", @@ -13437,9 +13181,8 @@ export const MODELS = { api: "openai-completions", provider: "together", baseUrl: "https://api.together.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, - reasoning: true, - thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: false, input: ["text"], cost: { input: 2.5, @@ -13450,44 +13193,6 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 500000, } satisfies Model<"openai-completions">, - "deepseek-ai/DeepSeek-V3": { - id: "deepseek-ai/DeepSeek-V3", - name: "DeepSeek-V3", - api: "openai-completions", - provider: "together", - baseUrl: "https://api.together.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, - reasoning: true, - thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, - input: ["text"], - cost: { - input: 1.25, - output: 1.25, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"openai-completions">, - "deepseek-ai/DeepSeek-V3-1": { - id: "deepseek-ai/DeepSeek-V3-1", - name: "DeepSeek V3.1", - api: "openai-completions", - provider: "together", - baseUrl: "https://api.together.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, - reasoning: true, - thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, - input: ["text"], - cost: { - input: 0.6, - output: 1.7, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"openai-completions">, "deepseek-ai/DeepSeek-V4-Pro": { id: "deepseek-ai/DeepSeek-V4-Pro", name: "DeepSeek V4 Pro", @@ -13499,8 +13204,8 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null}, input: ["text"], cost: { - input: 2.1, - output: 4.4, + input: 1.74, + output: 3.48, cacheRead: 0.2, cacheWrite: 0, }, @@ -13536,8 +13241,8 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, input: ["text", "image"], cost: { - input: 0.2, - output: 0.5, + input: 0.39, + output: 0.97, cacheRead: 0, cacheWrite: 0, }, @@ -13562,25 +13267,6 @@ export const MODELS = { contextWindow: 131072, maxTokens: 131072, } satisfies Model<"openai-completions">, - "moonshotai/Kimi-K2.5": { - id: "moonshotai/Kimi-K2.5", - name: "Kimi K2.5", - api: "openai-completions", - provider: "together", - baseUrl: "https://api.together.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, - reasoning: true, - thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, - input: ["text", "image"], - cost: { - input: 0.5, - output: 2.8, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 262144, - } satisfies Model<"openai-completions">, "moonshotai/Kimi-K2.6": { id: "moonshotai/Kimi-K2.6", name: "Kimi K2.6", @@ -13638,6 +13324,44 @@ export const MODELS = { contextWindow: 131072, maxTokens: 131072, } satisfies Model<"openai-completions">, + "openai/gpt-oss-20b": { + id: "openai/gpt-oss-20b", + name: "GPT OSS 20B", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"openai"}, + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null}, + input: ["text"], + cost: { + input: 0.05, + output: 0.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "zai-org/GLM-5": { + id: "zai-org/GLM-5", + name: "GLM-5", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text"], + cost: { + input: 1, + output: 3.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 202752, + maxTokens: 131072, + } satisfies Model<"openai-completions">, "zai-org/GLM-5.1": { id: "zai-org/GLM-5.1", name: "GLM-5.1", @@ -14075,7 +13799,7 @@ export const MODELS = { baseUrl: "https://ai-gateway.vercel.sh", compat: {"forceAdaptiveThinking":true}, reasoning: true, - thinkingLevelMap: {"xhigh":"xhigh"}, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], cost: { input: 10, @@ -15215,40 +14939,6 @@ export const MODELS = { contextWindow: 262114, maxTokens: 262114, } satisfies Model<"anthropic-messages">, - "moonshotai/kimi-k2-thinking-turbo": { - id: "moonshotai/kimi-k2-thinking-turbo", - name: "Kimi K2 Thinking Turbo", - api: "anthropic-messages", - provider: "vercel-ai-gateway", - baseUrl: "https://ai-gateway.vercel.sh", - reasoning: true, - input: ["text"], - cost: { - input: 1.15, - output: 8, - cacheRead: 0.15, - cacheWrite: 0, - }, - contextWindow: 262114, - maxTokens: 262114, - } satisfies Model<"anthropic-messages">, - "moonshotai/kimi-k2-turbo": { - id: "moonshotai/kimi-k2-turbo", - name: "Kimi K2 Turbo", - api: "anthropic-messages", - provider: "vercel-ai-gateway", - baseUrl: "https://ai-gateway.vercel.sh", - reasoning: false, - input: ["text"], - cost: { - input: 1.15, - output: 8, - cacheRead: 0.15, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 16384, - } satisfies Model<"anthropic-messages">, "moonshotai/kimi-k2.5": { id: "moonshotai/kimi-k2.5", name: "Kimi K2.5", @@ -15283,6 +14973,23 @@ export const MODELS = { contextWindow: 262000, maxTokens: 262000, } satisfies Model<"anthropic-messages">, + "moonshotai/kimi-k2.7-code": { + id: "moonshotai/kimi-k2.7-code", + name: "Kimi K2.7 Code", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.95, + output: 4, + cacheRead: 0.19, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 32768, + } satisfies Model<"anthropic-messages">, "nvidia/nemotron-3-super-120b-a12b": { id: "nvidia/nemotron-3-super-120b-a12b", name: "NVIDIA Nemotron 3 Super 120B A12B", @@ -16749,6 +16456,24 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 131072, } satisfies Model<"openai-completions">, + "mimo-v2.5-pro-ultraspeed": { + id: "mimo-v2.5-pro-ultraspeed", + name: "MiMo-V2.5-Pro-UltraSpeed", + api: "openai-completions", + provider: "xiaomi", + baseUrl: "https://api.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text"], + cost: { + input: 1.305, + output: 2.61, + cacheRead: 0.0108, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-completions">, }, "xiaomi-token-plan-ams": { "mimo-v2-omni": { @@ -16823,6 +16548,24 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 131072, } satisfies Model<"openai-completions">, + "mimo-v2.5-pro-ultraspeed": { + id: "mimo-v2.5-pro-ultraspeed", + name: "MiMo-V2.5-Pro-UltraSpeed", + api: "openai-completions", + provider: "xiaomi-token-plan-ams", + baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text"], + cost: { + input: 1.305, + output: 2.61, + cacheRead: 0.0108, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-completions">, }, "xiaomi-token-plan-cn": { "mimo-v2-omni": { @@ -16897,6 +16640,24 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 131072, } satisfies Model<"openai-completions">, + "mimo-v2.5-pro-ultraspeed": { + id: "mimo-v2.5-pro-ultraspeed", + name: "MiMo-V2.5-Pro-UltraSpeed", + api: "openai-completions", + provider: "xiaomi-token-plan-cn", + baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text"], + cost: { + input: 1.305, + output: 2.61, + cacheRead: 0.0108, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-completions">, }, "xiaomi-token-plan-sgp": { "mimo-v2-omni": { @@ -16971,6 +16732,24 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 131072, } satisfies Model<"openai-completions">, + "mimo-v2.5-pro-ultraspeed": { + id: "mimo-v2.5-pro-ultraspeed", + name: "MiMo-V2.5-Pro-UltraSpeed", + api: "openai-completions", + provider: "xiaomi-token-plan-sgp", + baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text"], + cost: { + input: 1.305, + output: 2.61, + cacheRead: 0.0108, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-completions">, }, "zai": { "glm-4.5-air": { diff --git a/packages/ai/test/openai-model-metadata.test.ts b/packages/ai/test/openai-model-metadata.test.ts new file mode 100644 index 00000000..64c593e1 --- /dev/null +++ b/packages/ai/test/openai-model-metadata.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { getModel } from "../src/models.ts"; + +describe("OpenAI model metadata", () => { + it("uses current GPT-5.4 and GPT-5.5 API context windows", () => { + expect(getModel("openai", "gpt-5.4").contextWindow).toBe(1050000); + expect(getModel("openai", "gpt-5.5").contextWindow).toBe(1050000); + }); + + it("uses current OpenAI Codex context windows", () => { + expect(getModel("openai-codex", "gpt-5.4").contextWindow).toBe(1000000); + expect(getModel("openai-codex", "gpt-5.4-mini").contextWindow).toBe(400000); + expect(getModel("openai-codex", "gpt-5.5").contextWindow).toBe(400000); + }); +}); From b4bff7f0d0eb074e56fbf2665a7bc6798567d701 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 12 Jun 2026 23:37:16 +0200 Subject: [PATCH 239/352] fix(coding-agent): avoid project trust prompt for update (#5674) --- packages/coding-agent/CHANGELOG.md | 2 + packages/coding-agent/README.md | 8 +- packages/coding-agent/docs/security.md | 14 ++- packages/coding-agent/docs/settings.md | 6 +- packages/coding-agent/docs/usage.md | 8 +- .../coding-agent/src/core/project-trust.ts | 4 +- .../coding-agent/src/core/trust-manager.ts | 41 ++++--- packages/coding-agent/src/index.ts | 2 +- packages/coding-agent/src/main.ts | 14 ++- .../interactive/components/trust-selector.ts | 14 ++- .../src/modes/interactive/interactive-mode.ts | 6 +- .../coding-agent/src/package-manager-cli.ts | 15 ++- .../test/package-command-paths.test.ts | 66 ++++++++++- .../coding-agent/test/resource-loader.test.ts | 2 +- .../coding-agent/test/trust-manager.test.ts | 108 ++++++------------ 15 files changed, 185 insertions(+), 125 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e770ebdb..1322e247 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,7 +8,9 @@ ### Fixed +- Fixed project trust detection to ignore global `~/.pi/agent` state when running from `$HOME`, and made `pi update` use only saved or explicit project trust without prompting ([#5619](https://github.com/earendil-works/pi/issues/5619)). - Fixed inherited user-message transcript rendering so standalone `+` messages no longer render as `-` ([#5657](https://github.com/earendil-works/pi/issues/5657)). +>>>>>>> main - Fixed `--model` resolution for authenticated custom model IDs whose slash prefix matches an unauthenticated built-in provider ([#5643](https://github.com/earendil-works/pi/issues/5643)). - Fixed `/fork` to keep session parent chains connected when the forked path contains labels ([#5669](https://github.com/earendil-works/pi/issues/5669)). - Fixed `/share` and `/export` HTML exports to use the active fallback theme when the configured custom theme no longer exists ([#5596](https://github.com/earendil-works/pi/issues/5596)). diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 6e5c7059..41a7bbc2 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -291,15 +291,15 @@ See [docs/settings.md](docs/settings.md) for all options. ### Project Trust -On interactive startup, pi asks before trusting a project folder that contains project-local extensions or settings and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. +On interactive startup, pi asks before trusting a project folder that contains project-local settings, resources, or project `.agents/skills` and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. Before the trust decision, pi loads only context files, user/global extensions, and CLI `-e` extensions so they can handle the `project_trust` event. Project-local extensions, project package-managed extensions, and project settings are loaded only after the project is trusted. This split also applies when switching to a session from a different cwd whose trust has not been resolved in the current process. -Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore trust-gated project inputs, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. +Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore those project resources, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. If no extension or saved decision applies, `defaultProjectTrust` controls the fallback behavior. Set it to `"ask"`, `"always"`, or `"never"` in `~/.pi/agent/settings.json`, or change it with `/settings`. -`pi config` and package commands use the same project trust flow. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them. +`pi config` and package commands use the same project trust flow, except `pi update` never prompts. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them. Use `/trust` in interactive mode to save a project trust decision for future sessions, including trust for the immediate parent folder. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect. @@ -527,7 +527,7 @@ pi list # List installed packages pi config # Enable/disable package resources ``` -`pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command. +`pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command. `pi update` never prompts for project trust. ### Modes diff --git a/packages/coding-agent/docs/security.md b/packages/coding-agent/docs/security.md index 0c6d387a..3a268a8a 100644 --- a/packages/coding-agent/docs/security.md +++ b/packages/coding-agent/docs/security.md @@ -6,14 +6,18 @@ Pi is a local coding agent. It runs with the permissions of the user account tha Project trust controls whether pi loads project-local settings, resources, packages, and extensions. It is not a sandbox and it does not restrict what the model can ask tools to do after you start working in a directory. -Pi considers a project to have trust inputs when it finds any of these from the current working directory: +Pi considers a project to have resources that require trust when it finds any of these from the current working directory: -- `.pi/` in the current directory -- `.agents/skills` in the current directory or an ancestor directory +- `.pi/settings.json` +- `.pi/extensions`, `.pi/skills`, `.pi/prompts`, or `.pi/themes` +- `.pi/SYSTEM.md` or `.pi/APPEND_SYSTEM.md` +- project `.agents/skills` in the current directory or an ancestor directory -When an interactive session starts in a project with configs in `.pi` or `.agents/skills` and no saved decision for the current directory or a parent directory, pi follows `defaultProjectTrust` from global settings. The default value is `"ask"`, which asks whether to trust the project when UI is available. Saved decisions are stored by canonical directory in `~/.pi/agent/trust.json`, and the closest saved decision on the current or parent path applies before the global default. +A bare `.pi` directory does not count as a project resource that requires trust. -Trusting a project allows pi to load trust-gated project inputs, including: +When an interactive session starts in a project with resources that require trust and no saved decision for the current directory or a parent directory, pi follows `defaultProjectTrust` from global settings. The default value is `"ask"`, which asks whether to trust the project when UI is available. Saved decisions are stored by canonical directory in `~/.pi/agent/trust.json`, and the closest saved decision on the current or parent path applies before the global default. + +Trusting a project allows pi to load project resources that require trust, including: - `.pi/settings.json` - `.pi` resources such as extensions, skills, prompt templates, themes, and system prompt files diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 2cf843d1..fccfab06 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -11,13 +11,13 @@ Edit directly or use `/settings` for common options. ## Project Trust -On interactive startup, pi asks before trusting a project folder that contains trust-gated project inputs and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. +On interactive startup, pi asks before trusting a project folder that contains project-local settings, resources, or project `.agents/skills` and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. -Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore trust-gated project inputs, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. +Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore those project resources, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. If no extension or saved decision applies, `defaultProjectTrust` controls the fallback behavior. Set it to `"ask"`, `"always"`, or `"never"` in `~/.pi/agent/settings.json`, or change it with `/settings`. -`pi config` and package commands use the same project trust flow. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them. +`pi config` and package commands use the same project trust flow, except `pi update` never prompts. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them. Use `/trust` in interactive mode to save a project trust decision for future sessions, including trust for the immediate parent folder. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect. diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index bb8a4c25..4f8f5954 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -112,15 +112,15 @@ Append to the default prompt without replacing it with `APPEND_SYSTEM.md` in eit ### Project Trust -On interactive startup, pi asks before trusting a project folder that contains project-local extensions or settings and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. +On interactive startup, pi asks before trusting a project folder that contains project-local settings, resources, or project `.agents/skills` and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. Before the trust decision, pi loads only context files, user/global extensions, and CLI `-e` extensions so they can handle the `project_trust` event. Project-local extensions, project package-managed extensions, and project settings are loaded only after the project is trusted. This split also applies when switching to a session from a different cwd whose trust has not been resolved in the current process. -Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore trust-gated project inputs, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. +Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore those project resources, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. If no extension or saved decision applies, `defaultProjectTrust` controls the fallback behavior. Set it to `"ask"`, `"always"`, or `"never"` in `~/.pi/agent/settings.json`, or change it with `/settings`. -`pi config` and package commands use the same project trust flow. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them. +`pi config` and package commands use the same project trust flow, except `pi update` never prompts. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them. Use `/trust` in interactive mode to save a project trust decision for future sessions, including trust for the immediate parent folder. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect. @@ -153,7 +153,7 @@ pi list # List installed packages pi config # Enable/disable package resources ``` -These commands manage pi packages, not the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). `pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command. +These commands manage pi packages, not the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). `pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command. `pi update` never prompts for project trust. See [Pi Packages](packages.md) for package sources and security notes. diff --git a/packages/coding-agent/src/core/project-trust.ts b/packages/coding-agent/src/core/project-trust.ts index c8b57250..c0892439 100644 --- a/packages/coding-agent/src/core/project-trust.ts +++ b/packages/coding-agent/src/core/project-trust.ts @@ -3,7 +3,7 @@ import type { LoadExtensionsResult, ProjectTrustContext } from "./extensions/typ import type { DefaultProjectTrust } from "./settings-manager.ts"; import { getProjectTrustOptions, - hasProjectTrustInputs, + hasTrustRequiringProjectResources, type ProjectTrustOption, type ProjectTrustStore, } from "./trust-manager.ts"; @@ -46,7 +46,7 @@ export async function resolveProjectTrusted(options: ResolveProjectTrustedOption if (options.trustOverride !== undefined) { return options.trustOverride; } - if (!hasProjectTrustInputs(options.cwd)) { + if (!hasTrustRequiringProjectResources(options.cwd)) { return true; } diff --git a/packages/coding-agent/src/core/trust-manager.ts b/packages/coding-agent/src/core/trust-manager.ts index 69f616ae..9c494b47 100644 --- a/packages/coding-agent/src/core/trust-manager.ts +++ b/packages/coding-agent/src/core/trust-manager.ts @@ -1,4 +1,5 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; import { dirname, join } from "node:path"; import lockfile from "proper-lockfile"; import { CONFIG_DIR_NAME } from "../config.ts"; @@ -25,6 +26,16 @@ export interface ProjectTrustOption { type TrustFile = Record; +const TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES = [ + "settings.json", + "extensions", + "skills", + "prompts", + "themes", + "SYSTEM.md", + "APPEND_SYSTEM.md", +] as const; + function normalizeCwd(cwd: string): string { return canonicalizePath(resolvePath(cwd)); } @@ -45,18 +56,14 @@ function findNearestTrustEntry(data: TrustFile, cwd: string): ProjectTrustStoreE } } -export function getProjectTrustPath(cwd: string): string { - return normalizeCwd(cwd); -} - export function getProjectTrustParentPath(cwd: string): string | undefined { - const trustPath = getProjectTrustPath(cwd); + const trustPath = normalizeCwd(cwd); const parentDir = dirname(trustPath); return parentDir === trustPath ? undefined : parentDir; } export function getProjectTrustOptions(cwd: string, options?: { includeSessionOnly?: boolean }): ProjectTrustOption[] { - const trustPath = getProjectTrustPath(cwd); + const trustPath = normalizeCwd(cwd); const trustOptions: ProjectTrustOption[] = [ { label: "Trust", trusted: true, updates: [{ path: trustPath, decision: true }], savedPath: trustPath }, ]; @@ -167,18 +174,26 @@ function withTrustFileLock(path: string, fn: () => T): T { } } -export function hasProjectConfigDir(cwd: string): boolean { - return existsSync(join(canonicalizePath(resolvePath(cwd)), CONFIG_DIR_NAME)); -} - -export function hasProjectTrustInputs(cwd: string): boolean { +/** + * Returns true when cwd has project-local resources that must be gated by + * project trust: trust-requiring entries under cwd/.pi, or .agents/skills in + * cwd or one of its ancestors. Returns false when no such project resources + * exist. The user/global ~/.agents/skills directory is always treated as a + * trusted user resource and is ignored here, even when cwd is $HOME. + */ +export function hasTrustRequiringProjectResources(cwd: string): boolean { + const homeDir = canonicalizePath(resolvePath(process.env.HOME || homedir())); + const userAgentsSkillsDir = join(homeDir, ".agents", "skills"); let currentDir = canonicalizePath(resolvePath(cwd)); - if (hasProjectConfigDir(currentDir)) { + + const configDir = join(currentDir, CONFIG_DIR_NAME); + if (TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES.some((entry) => existsSync(join(configDir, entry)))) { return true; } while (true) { - if (existsSync(join(currentDir, ".agents", "skills"))) { + const agentsSkillsDir = join(currentDir, ".agents", "skills"); + if (agentsSkillsDir !== userAgentsSkillsDir && existsSync(agentsSkillsDir)) { return true; } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 958c7ebb..3176a167 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -289,7 +289,7 @@ export { withFileMutationQueue, } from "./core/tools/index.ts"; export { - hasProjectTrustInputs, + hasTrustRequiringProjectResources, type ProjectTrustDecision, ProjectTrustStore, type ProjectTrustStoreEntry, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 0bc24685..63d6ec1e 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -41,7 +41,7 @@ import { import { assertValidSessionId, SessionManager } from "./core/session-manager.ts"; import { SettingsManager } from "./core/settings-manager.ts"; import { printTimings, resetTimings, time } from "./core/timings.ts"; -import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts"; +import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts"; import { runMigrations, showDeprecationWarnings } from "./migrations.ts"; import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts"; import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts"; @@ -572,7 +572,9 @@ export async function main(args: string[], options?: MainOptions) { const trustStore = new ProjectTrustStore(agentDir); const sessionCwd = sessionManager.getCwd(); const autoTrustOnReloadCwd = - parsed.projectTrustOverride === undefined && !hasProjectTrustInputs(sessionCwd) ? sessionCwd : undefined; + parsed.projectTrustOverride === undefined && !hasTrustRequiringProjectResources(sessionCwd) + ? sessionCwd + : undefined; const trustPromptMode: AppMode = parsed.help || parsed.listModels !== undefined ? "print" : appMode; const projectTrustByCwd = new Map(); @@ -591,12 +593,14 @@ export async function main(args: string[], options?: MainOptions) { const isInitialRuntime = sessionStartEvent === undefined; const projectTrustDiagnostics: AgentSessionRuntimeDiagnostic[] = []; const cachedProjectTrust = projectTrustByCwd.get(cwd); - const hasTrustInputs = hasProjectTrustInputs(cwd); + const hasTrustRequiringResources = hasTrustRequiringProjectResources(cwd); const shouldResolveProjectTrust = - parsed.projectTrustOverride === undefined && cachedProjectTrust === undefined && hasTrustInputs; + parsed.projectTrustOverride === undefined && cachedProjectTrust === undefined && hasTrustRequiringResources; const projectTrusted = shouldResolveProjectTrust ? false - : (cachedProjectTrust ?? parsed.projectTrustOverride ?? (!hasTrustInputs || trustStore.get(cwd) === true)); + : (cachedProjectTrust ?? + parsed.projectTrustOverride ?? + (!hasTrustRequiringResources || trustStore.get(cwd) === true)); const runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted }); const services = await createAgentSessionServices({ cwd, diff --git a/packages/coding-agent/src/modes/interactive/components/trust-selector.ts b/packages/coding-agent/src/modes/interactive/components/trust-selector.ts index b7b1fe00..92c23288 100644 --- a/packages/coding-agent/src/modes/interactive/components/trust-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/trust-selector.ts @@ -1,7 +1,6 @@ import { Container, getKeybindings, Spacer, Text } from "@earendil-works/pi-tui"; import { getProjectTrustOptions, - getProjectTrustPath, type ProjectTrustOption, type ProjectTrustStoreEntry, } from "../../../core/trust-manager.ts"; @@ -19,12 +18,12 @@ export interface TrustSelectorOptions { onCancel: () => void; } -function formatDecision(cwd: string, decision: ProjectTrustStoreEntry | null): string { +function formatDecision(trustPath: string | undefined, decision: ProjectTrustStoreEntry | null): string { if (decision === null) { return "none"; } const label = decision.decision ? "trusted" : "untrusted"; - if (decision.path !== getProjectTrustPath(cwd)) { + if (trustPath !== undefined && decision.path !== trustPath) { return `${label} (inherited from ${decision.path})`; } return `${label} (${decision.path})`; @@ -56,7 +55,14 @@ export class TrustSelectorComponent extends Container { this.addChild(new Text(theme.fg("muted", options.cwd), 1, 0)); this.addChild(new Spacer(1)); this.addChild( - new Text(theme.fg("muted", `Saved decision: ${formatDecision(options.cwd, options.savedDecision)}`), 1, 0), + new Text( + theme.fg( + "muted", + `Saved decision: ${formatDecision(this.trustOptions[0]?.savedPath, options.savedDecision)}`, + ), + 1, + 0, + ), ); this.addChild( new Text(theme.fg("muted", `Current session: ${options.projectTrusted ? "trusted" : "untrusted"}`), 1, 0), diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index d50611af..fc25792e 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -86,7 +86,7 @@ import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts"; import type { SourceInfo } from "../../core/source-info.ts"; import { isInstallTelemetryEnabled } from "../../core/telemetry.ts"; import type { TruncationResult } from "../../core/tools/truncate.ts"; -import { hasProjectConfigDir, hasProjectTrustInputs, ProjectTrustStore } from "../../core/trust-manager.ts"; +import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts"; import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts"; import { copyToClipboard } from "../../utils/clipboard.ts"; import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts"; @@ -3271,7 +3271,7 @@ export class InteractiveMode { } private renderProjectTrustWarningIfNeeded(): void { - if (this.settingsManager.isProjectTrusted() || !hasProjectTrustInputs(this.sessionManager.getCwd())) { + if (this.settingsManager.isProjectTrusted() || !hasTrustRequiringProjectResources(this.sessionManager.getCwd())) { return; } @@ -4198,7 +4198,7 @@ export class InteractiveMode { if (this.autoTrustOnReloadCwd !== cwd) { return false; } - if (!this.settingsManager.isProjectTrusted() || !hasProjectConfigDir(cwd)) { + if (!this.settingsManager.isProjectTrusted() || !hasTrustRequiringProjectResources(cwd)) { return false; } diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index 94dbff85..0dbe750c 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -18,7 +18,7 @@ import { DefaultPackageManager } from "./core/package-manager.ts"; import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts"; import { DefaultResourceLoader } from "./core/resource-loader.ts"; import { SettingsManager } from "./core/settings-manager.ts"; -import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts"; +import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts"; import { spawnProcess } from "./utils/child-process.ts"; import { getLatestPiRelease, isNewerPackageVersion } from "./utils/version-check.ts"; import { @@ -452,13 +452,21 @@ async function createCommandSettingsManager(options: { cwd: string; agentDir: string; projectTrustOverride?: boolean; + useSavedProjectTrustOnly?: boolean; extensionFactories?: ExtensionFactory[]; }): Promise { const settingsManager = SettingsManager.create(options.cwd, options.agentDir, { projectTrusted: false }); const projectTrustWarnings: string[] = []; + const trustStore = new ProjectTrustStore(options.agentDir); + if (options.useSavedProjectTrustOnly) { + const savedProjectTrusted = trustStore.get(options.cwd) === true; + settingsManager.setProjectTrusted(options.projectTrustOverride ?? savedProjectTrusted); + return { settingsManager, projectTrustWarnings }; + } + const appMode = getCommandAppMode(); const extensionsResult = - options.projectTrustOverride === undefined && hasProjectTrustInputs(options.cwd) + options.projectTrustOverride === undefined && hasTrustRequiringProjectResources(options.cwd) ? await new DefaultResourceLoader({ cwd: options.cwd, agentDir: options.agentDir, @@ -472,7 +480,7 @@ async function createCommandSettingsManager(options: { const projectTrusted = await resolveProjectTrusted({ cwd: options.cwd, - trustStore: new ProjectTrustStore(options.agentDir), + trustStore, trustOverride: options.projectTrustOverride, defaultProjectTrust: settingsManager.getDefaultProjectTrust(), extensionsResult, @@ -576,6 +584,7 @@ export async function handlePackageCommand( cwd, agentDir, projectTrustOverride: options.projectTrustOverride, + useSavedProjectTrustOnly: options.command === "update", extensionFactories: runtimeOptions.extensionFactories, }); reportProjectTrustWarnings(projectTrustWarnings); diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index 25a55b35..1cff83f6 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -202,6 +202,69 @@ describe("package commands", () => { } }); + it("does not prompt or ask extensions for project trust during update", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" })); + const fakeNpmPath = join(tempDir, "fake-project-npm.cjs"); + const recordPath = join(tempDir, "project-update.json"); + writeFileSync( + fakeNpmPath, + `const fs=require("node:fs");fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(process.argv.slice(2)));`, + ); + writeFileSync( + join(projectDir, ".pi", "settings.json"), + JSON.stringify({ packages: ["npm:fake-package"], npmCommand: [originalExecPath, fakeNpmPath] }), + ); + let projectTrustCalled = false; + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect( + main(["update", "--extensions"], { + extensionFactories: [ + (pi) => { + pi.on("project_trust", () => { + projectTrustCalled = true; + return { trusted: "yes" }; + }); + }, + ], + }), + ).resolves.toBeUndefined(); + + expect(projectTrustCalled).toBe(false); + expect(existsSync(recordPath)).toBe(false); + expect(process.exitCode).toBeUndefined(); + } finally { + logSpy.mockRestore(); + } + }); + + it("uses saved project trust during update", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + const fakeNpmPath = join(tempDir, "fake-trusted-project-npm.cjs"); + const recordPath = join(tempDir, "trusted-project-update.json"); + writeFileSync( + fakeNpmPath, + `const fs=require("node:fs");fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(process.argv.slice(2)));`, + ); + writeFileSync( + join(projectDir, ".pi", "settings.json"), + JSON.stringify({ packages: ["npm:fake-package"], npmCommand: [originalExecPath, fakeNpmPath] }), + ); + new ProjectTrustStore(agentDir).set(projectDir, true); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect(main(["update", "--extensions"])).resolves.toBeUndefined(); + + expect(existsSync(recordPath)).toBe(true); + expect(process.exitCode).toBeUndefined(); + } finally { + logSpy.mockRestore(); + } + }); + it("lets trust.json override default project trust", async () => { mkdirSync(join(projectDir, ".pi"), { recursive: true }); writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" })); @@ -223,6 +286,7 @@ describe("package commands", () => { it("blocks local package changes when project is untrusted", async () => { mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(projectDir, ".pi", "settings.json"), "{}"); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { diff --git a/packages/coding-agent/test/resource-loader.test.ts b/packages/coding-agent/test/resource-loader.test.ts index 7258b121..72cee79a 100644 --- a/packages/coding-agent/test/resource-loader.test.ts +++ b/packages/coding-agent/test/resource-loader.test.ts @@ -376,7 +376,7 @@ Content`, expect(loader.getSystemPrompt()).toBe("You are a helpful assistant."); }); - it("should skip trust-gated project resources when project is not trusted", async () => { + it("should skip project resources that require trust when project is not trusted", async () => { const piDir = join(cwd, ".pi"); const extensionsDir = join(piDir, "extensions"); const skillDir = join(piDir, "skills", "project-skill"); diff --git a/packages/coding-agent/test/trust-manager.test.ts b/packages/coding-agent/test/trust-manager.test.ts index 2716da36..01500bac 100644 --- a/packages/coding-agent/test/trust-manager.test.ts +++ b/packages/coding-agent/test/trust-manager.test.ts @@ -1,13 +1,8 @@ -import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - getProjectTrustPath, - hasProjectConfigDir, - hasProjectTrustInputs, - ProjectTrustStore, -} from "../src/core/trust-manager.ts"; +import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../src/core/trust-manager.ts"; describe("ProjectTrustStore", () => { let tempDir: string; @@ -26,86 +21,47 @@ describe("ProjectTrustStore", () => { rmSync(tempDir, { recursive: true, force: true }); }); - it("stores decisions per cwd", () => { - const store = new ProjectTrustStore(agentDir); - - expect(store.get(cwd)).toBeNull(); - expect(store.getEntry(cwd)).toBeNull(); - store.set(cwd, true); - expect(store.get(cwd)).toBe(true); - expect(store.getEntry(cwd)).toEqual({ path: getProjectTrustPath(cwd), decision: true }); - store.set(cwd, false); - expect(store.get(cwd)).toBe(false); - expect(store.getEntry(cwd)).toEqual({ path: getProjectTrustPath(cwd), decision: false }); - store.set(cwd, null); - expect(store.get(cwd)).toBeNull(); - expect(store.getEntry(cwd)).toBeNull(); - }); - - it("inherits the closest saved decision from parent directories", () => { - const store = new ProjectTrustStore(agentDir); - const parentDir = join(tempDir, "trusted-parent"); - const childDir = join(parentDir, "project"); - const grandchildDir = join(childDir, "nested"); - mkdirSync(grandchildDir, { recursive: true }); - - store.set(parentDir, true); - expect(store.get(childDir)).toBe(true); - expect(store.getEntry(childDir)).toEqual({ path: getProjectTrustPath(parentDir), decision: true }); - expect(store.get(grandchildDir)).toBe(true); - expect(store.getEntry(grandchildDir)).toEqual({ path: getProjectTrustPath(parentDir), decision: true }); - - store.set(childDir, false); - expect(store.get(grandchildDir)).toBe(false); - expect(store.getEntry(grandchildDir)).toEqual({ path: getProjectTrustPath(childDir), decision: false }); - }); - - it("can clear a child override to inherit parent trust", () => { + it("stores decisions and inherits from parent directories", () => { const store = new ProjectTrustStore(agentDir); const parentDir = join(tempDir, "trusted-parent"); const childDir = join(parentDir, "project"); mkdirSync(childDir, { recursive: true }); + expect(store.get(childDir)).toBeNull(); store.set(parentDir, true); - store.set(childDir, false); - expect(store.getEntry(childDir)).toEqual({ path: getProjectTrustPath(childDir), decision: false }); - - store.setMany([ - { path: parentDir, decision: true }, - { path: childDir, decision: null }, - ]); expect(store.get(childDir)).toBe(true); - expect(store.getEntry(childDir)).toEqual({ path: getProjectTrustPath(parentDir), decision: true }); + store.set(childDir, false); + expect(store.get(childDir)).toBe(false); + store.set(childDir, null); + expect(store.get(childDir)).toBe(true); }); - it("fails loudly without overwriting malformed trust stores", () => { - const trustPath = join(agentDir, "trust.json"); - writeFileSync(trustPath, "{not json", "utf-8"); - const store = new ProjectTrustStore(agentDir); + it("detects trust-requiring project resources", () => { + const originalHome = process.env.HOME; + process.env.HOME = tempDir; + try { + mkdirSync(join(tempDir, ".pi", "agent"), { recursive: true }); + mkdirSync(join(tempDir, ".agents", "skills"), { recursive: true }); + expect(hasTrustRequiringProjectResources(tempDir)).toBe(false); + expect(hasTrustRequiringProjectResources(cwd)).toBe(false); - expect(() => store.get(cwd)).toThrow(/Failed to read trust store/); - expect(() => store.set(cwd, true)).toThrow(/Failed to read trust store/); - expect(readFileSync(trustPath, "utf-8")).toBe("{not json"); - }); + writeFileSync(join(tempDir, ".pi", "settings.json"), "{}"); + expect(hasTrustRequiringProjectResources(tempDir)).toBe(true); + rmSync(join(tempDir, ".pi", "settings.json"), { force: true }); - it("detects project trust inputs", () => { - expect(hasProjectConfigDir(cwd)).toBe(false); - expect(hasProjectTrustInputs(cwd)).toBe(false); + mkdirSync(join(cwd, ".pi"), { recursive: true }); + writeFileSync(join(cwd, ".pi", "settings.json"), "{}"); + expect(hasTrustRequiringProjectResources(cwd)).toBe(true); - mkdirSync(join(cwd, ".pi"), { recursive: true }); - expect(hasProjectConfigDir(cwd)).toBe(true); - expect(hasProjectTrustInputs(cwd)).toBe(true); - rmSync(join(cwd, ".pi"), { recursive: true, force: true }); - - writeFileSync(join(cwd, "AGENTS.md"), "Project instructions"); - expect(hasProjectTrustInputs(cwd)).toBe(false); - rmSync(join(cwd, "AGENTS.md"), { force: true }); - - writeFileSync(join(cwd, "CLAUDE.md"), "Legacy project instructions"); - expect(hasProjectTrustInputs(cwd)).toBe(false); - rmSync(join(cwd, "CLAUDE.md"), { force: true }); - - mkdirSync(join(cwd, ".agents", "skills"), { recursive: true }); - expect(hasProjectTrustInputs(cwd)).toBe(true); + rmSync(join(cwd, ".pi"), { recursive: true, force: true }); + mkdirSync(join(cwd, ".agents", "skills"), { recursive: true }); + expect(hasTrustRequiringProjectResources(cwd)).toBe(true); + } finally { + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + } }); }); From 7a3cb6312e94c9276546d93b65b2524fc513e7ff Mon Sep 17 00:00:00 2001 From: Michael Yu Date: Sat, 13 Jun 2026 06:01:04 +0800 Subject: [PATCH 240/352] fix(ai): normalize generated model costs (#5634) --- packages/ai/scripts/generate-models.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index a3892cf1..c74a6afc 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -372,6 +372,10 @@ function normalizeNvidiaModelId(modelId: string): string { return modelId.toLowerCase().replaceAll("_", "."); } +function roundCost(value: number): number { + return Number(value.toFixed(6)); +} + async function fetchNvidiaNimModelIds(): Promise> { try { console.log("Fetching models from NVIDIA NIM API..."); @@ -417,10 +421,10 @@ async function fetchOpenRouterModels(): Promise[]> { } // Convert pricing from $/token to $/million tokens - const inputCost = parseFloat(model.pricing?.prompt || "0") * 1_000_000; - const outputCost = parseFloat(model.pricing?.completion || "0") * 1_000_000; - const cacheReadCost = parseFloat(model.pricing?.input_cache_read || "0") * 1_000_000; - const cacheWriteCost = parseFloat(model.pricing?.input_cache_write || "0") * 1_000_000; + const inputCost = roundCost(parseFloat(model.pricing?.prompt || "0") * 1_000_000); + const outputCost = roundCost(parseFloat(model.pricing?.completion || "0") * 1_000_000); + const cacheReadCost = roundCost(parseFloat(model.pricing?.input_cache_read || "0") * 1_000_000); + const cacheWriteCost = roundCost(parseFloat(model.pricing?.input_cache_write || "0") * 1_000_000); const normalizedModel: Model = { id: modelKey, @@ -476,10 +480,10 @@ async function fetchAiGatewayModels(): Promise[]> { input.push("image"); } - const inputCost = toNumber(model.pricing?.input) * 1_000_000; - const outputCost = toNumber(model.pricing?.output) * 1_000_000; - const cacheReadCost = toNumber(model.pricing?.input_cache_read) * 1_000_000; - const cacheWriteCost = toNumber(model.pricing?.input_cache_write) * 1_000_000; + const inputCost = roundCost(toNumber(model.pricing?.input) * 1_000_000); + const outputCost = roundCost(toNumber(model.pricing?.output) * 1_000_000); + const cacheReadCost = roundCost(toNumber(model.pricing?.input_cache_read) * 1_000_000); + const cacheWriteCost = roundCost(toNumber(model.pricing?.input_cache_write) * 1_000_000); models.push({ id: model.id, From 121f0edbf48ca0a93fb0941fb1d5ed2ce6f81407 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 13 Jun 2026 00:01:14 +0200 Subject: [PATCH 241/352] fix(ai): detect parenthesized context overflow errors closes #5677 --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/utils/overflow.ts | 5 +++-- packages/ai/test/overflow.test.ts | 7 +++++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b7b1112d..167908b6 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -6,6 +6,7 @@ ### Fixed +- Fixed OpenAI-compatible context overflow detection for parenthesized `maximum context length (N)` errors ([#5677](https://github.com/earendil-works/pi/issues/5677)). - Fixed OpenAI GPT-5.4/GPT-5.5 and OpenAI Codex GPT-5.4/GPT-5.4 mini/GPT-5.5 context window metadata to match current OpenAI limits ([#5644](https://github.com/earendil-works/pi/issues/5644)). - Increased the OpenAI Codex Responses SSE response-header timeout to 20 seconds to reduce false-positive stalls while retaining the bounded wait introduced for zero-event hangs ([#4945](https://github.com/earendil-works/pi/issues/4945)). - Fixed Claude Fable 5 thinking-off requests to omit Anthropic's unsupported `thinking.type: "disabled"` payload ([#5567](https://github.com/earendil-works/pi/pull/5567) by [@tmustier](https://github.com/tmustier)). diff --git a/packages/ai/src/utils/overflow.ts b/packages/ai/src/utils/overflow.ts index e3a9b37a..623b873a 100644 --- a/packages/ai/src/utils/overflow.ts +++ b/packages/ai/src/utils/overflow.ts @@ -12,6 +12,7 @@ import type { AssistantMessage } from "../types.ts"; * - Anthropic: "413 {\"error\":{\"type\":\"request_too_large\",\"message\":\"Request exceeds the maximum size\"}}" * - OpenAI: "Your input exceeds the context window of this model" * - OpenAI/LiteLLM: "Requested token count exceeds the model's maximum context length of 131072 tokens" + * - OpenAI-compatible: "Input length (265330) exceeds model's maximum context length (262144)." * - Google: "The input token count (1196265) exceeds the maximum number of tokens allowed (1048575)" * - xAI: "This model's maximum prompt length is 131072 but the request contains 537812 tokens" * - Groq: "Please reduce the length of the messages or completion" @@ -36,7 +37,7 @@ const OVERFLOW_PATTERNS = [ /request_too_large/i, // Anthropic request byte-size overflow (HTTP 413) /input is too long for requested model/i, // Amazon Bedrock /exceeds the context window/i, // OpenAI (Completions & Responses API) - /exceeds (?:the )?(?:model'?s )?maximum context length of [\d,]+ tokens?/i, // OpenAI-compatible proxies (LiteLLM) + /exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i, // OpenAI-compatible proxies (LiteLLM) /input token count.*exceeds the maximum/i, // Google (Gemini) /maximum prompt length is \d+/i, // xAI (Grok) /reduce the length of the messages/i, // Groq @@ -85,7 +86,7 @@ const NON_OVERFLOW_PATTERNS = [ * * **Reliable detection (returns error with detectable message):** * - Anthropic: "prompt is too long: X tokens > Y maximum" or "request_too_large" - * - OpenAI (Completions & Responses): "exceeds the context window" or "exceeds the model's maximum context length of X tokens" + * - OpenAI (Completions & Responses): "exceeds the context window", "exceeds the model's maximum context length of X tokens", or "exceeds model's maximum context length (X)" * - Google Gemini: "input token count exceeds the maximum" * - xAI (Grok): "maximum prompt length is X but request contains Y" * - Groq: "reduce the length of the messages" diff --git a/packages/ai/test/overflow.test.ts b/packages/ai/test/overflow.test.ts index 346d2006..62108911 100644 --- a/packages/ai/test/overflow.test.ts +++ b/packages/ai/test/overflow.test.ts @@ -49,6 +49,13 @@ describe("isContextOverflow", () => { expect(isContextOverflow(message, 131072)).toBe(true); }); + it("detects OpenAI-compatible parenthesized maximum context length errors", () => { + const message = createErrorMessage( + "Error: 400 Input length (265330) exceeds model's maximum context length (262144).", + ); + expect(isContextOverflow(message, 262144)).toBe(true); + }); + it("detects OpenRouter Poolside maximum allowed input length errors", () => { const message = createErrorMessage( "Provider returned error: Input length 131393 exceeds the maximum allowed input length of 131040 tokens.", From e320f09618ab3ddbb0f5f9d57769c9e458582e1d Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 13 Jun 2026 00:20:01 +0200 Subject: [PATCH 242/352] docs: update unreleased changelogs --- packages/ai/CHANGELOG.md | 5 ++++- packages/coding-agent/CHANGELOG.md | 21 ++++++++++++++++++--- packages/tui/CHANGELOG.md | 2 ++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 167908b6..7c6f84bf 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,13 +2,16 @@ ## [Unreleased] -- When Amazon Bedrock rejects an unsupported data retention mode, the error now links the AWS data retention documentation ([#5561](https://github.com/earendil-works/pi/pull/5561) by [@unexge](https://github.com/unexge)). +### Added + +- Added AWS data retention documentation links to Amazon Bedrock unsupported data retention mode validation errors ([#5561](https://github.com/earendil-works/pi/pull/5561) by [@unexge](https://github.com/unexge)). ### Fixed - Fixed OpenAI-compatible context overflow detection for parenthesized `maximum context length (N)` errors ([#5677](https://github.com/earendil-works/pi/issues/5677)). - Fixed OpenAI GPT-5.4/GPT-5.5 and OpenAI Codex GPT-5.4/GPT-5.4 mini/GPT-5.5 context window metadata to match current OpenAI limits ([#5644](https://github.com/earendil-works/pi/issues/5644)). - Increased the OpenAI Codex Responses SSE response-header timeout to 20 seconds to reduce false-positive stalls while retaining the bounded wait introduced for zero-event hangs ([#4945](https://github.com/earendil-works/pi/issues/4945)). +- Fixed Anthropic refusal stops to preserve provider `stop_details` explanations in error messages ([#5666](https://github.com/earendil-works/pi/pull/5666) by [@rwachtler](https://github.com/rwachtler)). - Fixed Claude Fable 5 thinking-off requests to omit Anthropic's unsupported `thinking.type: "disabled"` payload ([#5567](https://github.com/earendil-works/pi/pull/5567) by [@tmustier](https://github.com/tmustier)). ## [0.79.1] - 2026-06-09 diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1322e247..c6c5a1c4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,19 +2,34 @@ ## [Unreleased] +### New Features + +- **Clearer Bedrock validation guidance** - Amazon Bedrock data retention validation errors now link to AWS data retention documentation. See [Amazon Bedrock](docs/providers.md#amazon-bedrock). + ### Added -- Added an experimental first-time setup flow behind `PI_EXPERIMENTAL=1` that asks for a dark/light theme choice (preselecting the detected appearance) and opt-in analytics data sharing on first launch with the default agent directory; opting in stores a `trackingId` in `settings.json`. +- Added an experimental first-time setup flow behind `PI_EXPERIMENTAL=1` that asks for a dark/light theme choice (preselecting the detected appearance) and opt-in analytics data sharing on first launch with the default agent directory; opting in stores a `trackingId` in `settings.json` ([#5587](https://github.com/earendil-works/pi/pull/5587) by [@vegarsti](https://github.com/vegarsti)). +- Added AWS data retention documentation links to inherited Amazon Bedrock unsupported data retention mode validation errors ([#5561](https://github.com/earendil-works/pi/pull/5561) by [@unexge](https://github.com/unexge)). ### Fixed - Fixed project trust detection to ignore global `~/.pi/agent` state when running from `$HOME`, and made `pi update` use only saved or explicit project trust without prompting ([#5619](https://github.com/earendil-works/pi/issues/5619)). +- Fixed experimental first-time setup to skip forked sessions instead of rerunning the setup prompts ([#5627](https://github.com/earendil-works/pi/pull/5627) by [@vegarsti](https://github.com/vegarsti)). +- Fixed inherited OpenAI-compatible context overflow detection for parenthesized `maximum context length (N)` errors ([#5677](https://github.com/earendil-works/pi/issues/5677)). +- Fixed inherited OpenAI GPT-5.4/GPT-5.5 and OpenAI Codex GPT-5.4/GPT-5.4 mini/GPT-5.5 context window metadata to match current OpenAI limits ([#5644](https://github.com/earendil-works/pi/issues/5644)). +- Fixed inherited Anthropic refusal stops to preserve provider `stop_details` explanations in error messages ([#5666](https://github.com/earendil-works/pi/pull/5666) by [@rwachtler](https://github.com/rwachtler)). +- Increased the inherited OpenAI Codex Responses SSE response-header timeout to 20 seconds to reduce false-positive stalls while retaining the bounded wait introduced for zero-event hangs ([#4945](https://github.com/earendil-works/pi/issues/4945)). +- Fixed inherited Claude Fable 5 thinking-off requests to omit Anthropic's unsupported `thinking.type: "disabled"` payload ([#5567](https://github.com/earendil-works/pi/pull/5567) by [@tmustier](https://github.com/tmustier)). +- Fixed inherited late tool progress callbacks after tool settlement to be ignored instead of emitting stale `tool_execution_update` events ([#5573](https://github.com/earendil-works/pi/issues/5573)). - Fixed inherited user-message transcript rendering so standalone `+` messages no longer render as `-` ([#5657](https://github.com/earendil-works/pi/issues/5657)). ->>>>>>> main +- Fixed inherited slash-separated fuzzy queries so provider/model completions remain matchable after insertion. +- Fixed inherited WezTerm inline Kitty image rendering so reserved row clears do not erase all but the top strip of tool image previews ([#5618](https://github.com/earendil-works/pi/issues/5618)). +- Fixed inherited editor wrapping for CJK text to break at character boundaries instead of leaving large trailing gaps ([#5585](https://github.com/earendil-works/pi/pull/5585) by [@haoqixu](https://github.com/haoqixu)). +- Fixed inherited loose Markdown list rendering to preserve blank-line separation between list items ([#5562](https://github.com/earendil-works/pi/pull/5562) by [@Perlence](https://github.com/Perlence)). - Fixed `--model` resolution for authenticated custom model IDs whose slash prefix matches an unauthenticated built-in provider ([#5643](https://github.com/earendil-works/pi/issues/5643)). - Fixed `/fork` to keep session parent chains connected when the forked path contains labels ([#5669](https://github.com/earendil-works/pi/issues/5669)). - Fixed `/share` and `/export` HTML exports to use the active fallback theme when the configured custom theme no longer exists ([#5596](https://github.com/earendil-works/pi/issues/5596)). -- Fixed custom fallback model IDs with `:` suffixes to preserve the requested thinking level when the provider template model does not advertise reasoning ([#5552](https://github.com/earendil-works/pi/issues/5552)). +- Fixed custom fallback model IDs with `:` suffixes to preserve the requested thinking level when the provider template model does not advertise reasoning ([#5560](https://github.com/earendil-works/pi/pull/5560) by [@haoqixu](https://github.com/haoqixu)). ## [0.79.1] - 2026-06-09 diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index b5e277a9..edec3f28 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -7,6 +7,8 @@ - Fixed Markdown source list marker preservation to include unordered markers, so standalone `+` user messages no longer render as `-` ([#5657](https://github.com/earendil-works/pi/issues/5657)). - Fixed slash-separated fuzzy queries so provider/model completions remain matchable after insertion. - Fixed WezTerm inline Kitty image rendering so reserved row clears do not erase all but the top strip of tool image previews ([#5618](https://github.com/earendil-works/pi/issues/5618)). +- Fixed editor wrapping for CJK text to break at character boundaries instead of leaving large trailing gaps ([#5585](https://github.com/earendil-works/pi/pull/5585) by [@haoqixu](https://github.com/haoqixu)). +- Fixed loose Markdown list rendering to preserve blank-line separation between list items ([#5562](https://github.com/earendil-works/pi/pull/5562) by [@Perlence](https://github.com/Perlence)). ## [0.79.1] - 2026-06-09 From f21f3c4bbdd3868ce2a7a68019d7920b838f663b Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 13 Jun 2026 00:25:15 +0200 Subject: [PATCH 243/352] Release v0.79.2 --- package-lock.json | 26 +- packages/agent/CHANGELOG.md | 2 +- packages/agent/package.json | 4 +- packages/ai/CHANGELOG.md | 2 +- packages/ai/package.json | 2 +- packages/ai/src/models.generated.ts | 380 +++++++++--------- 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 +- packages/coding-agent/npm-shrinkwrap.json | 24 +- packages/coding-agent/package.json | 8 +- packages/tui/CHANGELOG.md | 2 +- packages/tui/package.json | 2 +- 20 files changed, 240 insertions(+), 240 deletions(-) diff --git a/package-lock.json b/package-lock.json index b32c59fe..5cdd129d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6170,10 +6170,10 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.79.1", + "version": "0.79.2", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.79.1", + "@earendil-works/pi-ai": "^0.79.2", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -6207,7 +6207,7 @@ }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.79.1", + "version": "0.79.2", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -6252,12 +6252,12 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "0.79.1", + "version": "0.79.2", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.1", - "@earendil-works/pi-ai": "^0.79.1", - "@earendil-works/pi-tui": "^0.79.1", + "@earendil-works/pi-agent-core": "^0.79.2", + "@earendil-works/pi-ai": "^0.79.2", + "@earendil-works/pi-tui": "^0.79.2", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -6296,32 +6296,32 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.79.1", + "version": "0.79.2", "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.79.1" + "version": "0.79.2" }, "packages/coding-agent/examples/extensions/gondolin": { "name": "pi-extension-gondolin", - "version": "0.79.1", + "version": "0.79.2", "dependencies": { "@earendil-works/gondolin": "0.12.0" } }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.9.1", + "version": "1.9.2", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.79.1", + "version": "0.79.2", "dependencies": { "ms": "2.1.3" }, @@ -6357,7 +6357,7 @@ }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.79.1", + "version": "0.79.2", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 287f07c3..feba85dc 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.2] - 2026-06-12 ### Fixed diff --git a/packages/agent/package.json b/packages/agent/package.json index afd0368c..0c94a352 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "0.79.1", + "version": "0.79.2", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -29,7 +29,7 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "^0.79.1", + "@earendil-works/pi-ai": "^0.79.2", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 7c6f84bf..eadcd674 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.2] - 2026-06-12 ### Added diff --git a/packages/ai/package.json b/packages/ai/package.json index 9a54de2f..bbbddd43 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.79.1", + "version": "0.79.2", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index eeed2e92..aa587f10 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -8830,8 +8830,8 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.7999999999999999, - output: 3.1999999999999997, + input: 0.8, + output: 3.2, cacheRead: 0, cacheWrite: 0, }, @@ -8864,7 +8864,7 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.7999999999999999, + input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1, @@ -8900,7 +8900,7 @@ export const MODELS = { cost: { input: 1, output: 5, - cacheRead: 0.09999999999999999, + cacheRead: 0.1, cacheWrite: 1.25, }, contextWindow: 200000, @@ -9244,8 +9244,8 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.09999999999999999, - output: 0.39999999999999997, + input: 0.1, + output: 0.4, cacheRead: 0, cacheWrite: 0, }, @@ -9295,8 +9295,8 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.20020000000000002, - output: 0.8000999999999999, + input: 0.2002, + output: 0.8001, cacheRead: 0, cacheWrite: 0, }, @@ -9312,7 +9312,7 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.19999999999999998, + input: 0.2, output: 0.77, cacheRead: 0.135, cacheWrite: 0, @@ -9330,7 +9330,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.21, - output: 0.7899999999999999, + output: 0.79, cacheRead: 0.13, cacheWrite: 0, }, @@ -9364,7 +9364,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.5, - output: 2.1500000000000004, + output: 2.15, cacheRead: 0.35, cacheWrite: 0, }, @@ -9489,7 +9489,7 @@ export const MODELS = { input: 0.3, output: 2.5, cacheRead: 0.03, - cacheWrite: 0.08333333333333334, + cacheWrite: 0.083333, }, contextWindow: 1048576, maxTokens: 65535, @@ -9503,10 +9503,10 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.09999999999999999, - output: 0.39999999999999997, + input: 0.1, + output: 0.4, cacheRead: 0.01, - cacheWrite: 0.08333333333333334, + cacheWrite: 0.083333, }, contextWindow: 1048576, maxTokens: 65535, @@ -9520,10 +9520,10 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.09999999999999999, - output: 0.39999999999999997, + input: 0.1, + output: 0.4, cacheRead: 0.01, - cacheWrite: 0.08333333333333334, + cacheWrite: 0.083333, }, contextWindow: 1048576, maxTokens: 65535, @@ -9590,8 +9590,8 @@ export const MODELS = { cost: { input: 0.5, output: 3, - cacheRead: 0.049999999999999996, - cacheWrite: 0.08333333333333334, + cacheRead: 0.05, + cacheWrite: 0.083333, }, contextWindow: 1048576, maxTokens: 65536, @@ -9607,8 +9607,8 @@ export const MODELS = { cost: { input: 0.25, output: 1.5, - cacheRead: 0.024999999999999998, - cacheWrite: 0.08333333333333334, + cacheRead: 0.025, + cacheWrite: 0.083333, }, contextWindow: 1048576, maxTokens: 65536, @@ -9624,8 +9624,8 @@ export const MODELS = { cost: { input: 0.25, output: 1.5, - cacheRead: 0.024999999999999998, - cacheWrite: 0.08333333333333334, + cacheRead: 0.025, + cacheWrite: 0.083333, }, contextWindow: 1048576, maxTokens: 65536, @@ -9641,7 +9641,7 @@ export const MODELS = { cost: { input: 2, output: 12, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0.375, }, contextWindow: 1048576, @@ -9658,7 +9658,7 @@ export const MODELS = { cost: { input: 2, output: 12, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0.375, }, contextWindow: 1048756, @@ -9676,7 +9676,7 @@ export const MODELS = { input: 1.5, output: 9, cacheRead: 0.15, - cacheWrite: 0.08333333333333334, + cacheWrite: 0.083333, }, contextWindow: 1048576, maxTokens: 65536, @@ -9690,7 +9690,7 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.049999999999999996, + input: 0.05, output: 0.15, cacheRead: 0, cacheWrite: 0, @@ -9792,9 +9792,9 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.049999999999999996, - output: 0.09999999999999999, - cacheRead: 0.049999999999999996, + input: 0.05, + output: 0.1, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 131072, @@ -9812,7 +9812,7 @@ export const MODELS = { cost: { input: 0.25, output: 0.75, - cacheRead: 0.024999999999999998, + cacheRead: 0.025, cacheWrite: 0, }, contextWindow: 128000, @@ -9895,8 +9895,8 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.39999999999999997, - output: 0.39999999999999997, + input: 0.4, + output: 0.4, cacheRead: 0, cacheWrite: 0, }, @@ -9929,7 +9929,7 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.09999999999999999, + input: 0.1, output: 0.32, cacheRead: 0, cacheWrite: 0, @@ -9980,7 +9980,7 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.09999999999999999, + input: 0.1, output: 0.3, cacheRead: 0, cacheWrite: 0, @@ -9997,7 +9997,7 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.39999999999999997, + input: 0.4, output: 2.2, cacheRead: 0, cacheWrite: 0, @@ -10049,8 +10049,8 @@ export const MODELS = { input: ["text"], cost: { input: 0.15, - output: 0.8999999999999999, - cacheRead: 0.049999999999999996, + output: 0.9, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 204800, @@ -10067,7 +10067,7 @@ export const MODELS = { cost: { input: 0.25, output: 1, - cacheRead: 0.049999999999999996, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 204800, @@ -10100,7 +10100,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.3, - output: 0.8999999999999999, + output: 0.9, cacheRead: 0.03, cacheWrite: 0, }, @@ -10116,7 +10116,7 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.39999999999999997, + input: 0.4, output: 2, cacheRead: 0.04, cacheWrite: 0, @@ -10133,8 +10133,8 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.19999999999999998, - output: 0.19999999999999998, + input: 0.2, + output: 0.2, cacheRead: 0.02, cacheWrite: 0, }, @@ -10150,8 +10150,8 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.09999999999999999, - output: 0.09999999999999999, + input: 0.1, + output: 0.1, cacheRead: 0.01, cacheWrite: 0, }, @@ -10186,7 +10186,7 @@ export const MODELS = { cost: { input: 2, output: 6, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 128000, @@ -10203,7 +10203,7 @@ export const MODELS = { cost: { input: 2, output: 6, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 131072, @@ -10220,7 +10220,7 @@ export const MODELS = { cost: { input: 0.5, output: 1.5, - cacheRead: 0.049999999999999996, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 262144, @@ -10235,7 +10235,7 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.39999999999999997, + input: 0.4, output: 2, cacheRead: 0.04, cacheWrite: 0, @@ -10269,7 +10269,7 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.39999999999999997, + input: 0.4, output: 2, cacheRead: 0.04, cacheWrite: 0, @@ -10303,7 +10303,7 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.19999999999999998, + input: 0.2, output: 0.6, cacheRead: 0.02, cacheWrite: 0, @@ -10338,7 +10338,7 @@ export const MODELS = { input: ["text", "image"], cost: { input: 0.075, - output: 0.19999999999999998, + output: 0.2, cacheRead: 0, cacheWrite: 0, }, @@ -10356,7 +10356,7 @@ export const MODELS = { cost: { input: 2, output: 6, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 65536, @@ -10371,7 +10371,7 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.09999999999999999, + input: 0.1, output: 0.3, cacheRead: 0.01, cacheWrite: 0, @@ -10388,7 +10388,7 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.5700000000000001, + input: 0.57, output: 2.3, cacheRead: 0, cacheWrite: 0, @@ -10457,9 +10457,9 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.6799999999999999, + input: 0.68, output: 3.41, - cacheRead: 0.33999999999999997, + cacheRead: 0.34, cacheWrite: 0, }, contextWindow: 262144, @@ -10508,8 +10508,8 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.39999999999999997, - output: 0.39999999999999997, + input: 0.4, + output: 0.4, cacheRead: 0, cacheWrite: 0, }, @@ -10525,8 +10525,8 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.049999999999999996, - output: 0.19999999999999998, + input: 0.05, + output: 0.2, cacheRead: 0, cacheWrite: 0, }, @@ -10577,7 +10577,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.09, - output: 0.44999999999999996, + output: 0.45, cacheRead: 0, cacheWrite: 0, }, @@ -10797,9 +10797,9 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.39999999999999997, - output: 1.5999999999999999, - cacheRead: 0.09999999999999999, + input: 0.4, + output: 1.6, + cacheRead: 0.1, cacheWrite: 0, }, contextWindow: 1047576, @@ -10814,9 +10814,9 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.09999999999999999, - output: 0.39999999999999997, - cacheRead: 0.024999999999999998, + input: 0.1, + output: 0.4, + cacheRead: 0.025, cacheWrite: 0, }, contextWindow: 1047576, @@ -10969,7 +10969,7 @@ export const MODELS = { cost: { input: 0.25, output: 2, - cacheRead: 0.024999999999999998, + cacheRead: 0.025, cacheWrite: 0, }, contextWindow: 400000, @@ -10984,8 +10984,8 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.049999999999999996, - output: 0.39999999999999997, + input: 0.05, + output: 0.4, cacheRead: 0.01, cacheWrite: 0, }, @@ -11088,7 +11088,7 @@ export const MODELS = { cost: { input: 0.25, output: 2, - cacheRead: 0.024999999999999998, + cacheRead: 0.025, cacheWrite: 0, }, contextWindow: 400000, @@ -11248,7 +11248,7 @@ export const MODELS = { thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], cost: { - input: 0.19999999999999998, + input: 0.2, output: 1.25, cacheRead: 0.02, cacheWrite: 0, @@ -11693,7 +11693,7 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.19999999999999998, + input: 0.2, output: 1.1, cacheRead: 0, cacheWrite: 0, @@ -11711,7 +11711,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.36, - output: 0.39999999999999997, + output: 0.4, cacheRead: 0, cacheWrite: 0, }, @@ -11729,7 +11729,7 @@ export const MODELS = { cost: { input: 0.26, output: 0.78, - cacheRead: 0.052000000000000005, + cacheRead: 0.052, cacheWrite: 0.325, }, contextWindow: 1000000, @@ -11778,7 +11778,7 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.09999999999999999, + input: 0.1, output: 0.24, cacheRead: 0, cacheWrite: 0, @@ -11795,8 +11795,8 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.45499999999999996, - output: 1.8199999999999998, + input: 0.455, + output: 1.82, cacheRead: 0, cacheWrite: 0, }, @@ -11813,7 +11813,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.09, - output: 0.09999999999999999, + output: 0.1, cacheRead: 0, cacheWrite: 0, }, @@ -11829,9 +11829,9 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.09999999999999999, - output: 0.09999999999999999, - cacheRead: 0.09999999999999999, + input: 0.1, + output: 0.1, + cacheRead: 0.1, cacheWrite: 0, }, contextWindow: 262144, @@ -11881,7 +11881,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.08, - output: 0.39999999999999997, + output: 0.4, cacheRead: 0.08, cacheWrite: 0, }, @@ -11914,9 +11914,9 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.049999999999999996, - output: 0.39999999999999997, - cacheRead: 0.049999999999999996, + input: 0.05, + output: 0.4, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 131072, @@ -11932,7 +11932,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.22, - output: 1.7999999999999998, + output: 1.8, cacheRead: 0, cacheWrite: 0, }, @@ -11983,7 +11983,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.11, - output: 0.7999999999999999, + output: 0.8, cacheRead: 0.07, cacheWrite: 0, }, @@ -12118,7 +12118,7 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.19999999999999998, + input: 0.2, output: 0.88, cacheRead: 0.11, cacheWrite: 0, @@ -12186,8 +12186,8 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.10400000000000001, - output: 0.41600000000000004, + input: 0.104, + output: 0.416, cacheRead: 0, cacheWrite: 0, }, @@ -12273,7 +12273,7 @@ export const MODELS = { cost: { input: 0.14, output: 1, - cacheRead: 0.049999999999999996, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 262144, @@ -12305,7 +12305,7 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.09999999999999999, + input: 0.1, output: 0.15, cacheRead: 0, cacheWrite: 0, @@ -12357,7 +12357,7 @@ export const MODELS = { input: ["text", "image"], cost: { input: 0.3, - output: 1.7999999999999998, + output: 1.8, cacheRead: 0, cacheWrite: 0.375, }, @@ -12373,7 +12373,7 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.28850000000000003, + input: 0.2885, output: 3.17, cacheRead: 0, cacheWrite: 0, @@ -12392,7 +12392,7 @@ export const MODELS = { cost: { input: 0.15, output: 1, - cacheRead: 0.049999999999999996, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 262144, @@ -12478,7 +12478,7 @@ export const MODELS = { input: 0.32, output: 1.28, cacheRead: 0.064, - cacheWrite: 0.39999999999999997, + cacheWrite: 0.4, }, contextWindow: 1000000, maxTokens: 65536, @@ -12492,8 +12492,8 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.09999999999999999, - output: 0.09999999999999999, + input: 0.1, + output: 0.1, cacheRead: 0, cacheWrite: 0, }, @@ -12560,7 +12560,7 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.19999999999999998, + input: 0.2, output: 1.15, cacheRead: 0.04, cacheWrite: 0, @@ -12579,7 +12579,7 @@ export const MODELS = { cost: { input: 0.063, output: 0.21, - cacheRead: 0.020999999999999998, + cacheRead: 0.021, cacheWrite: 0, }, contextWindow: 262144, @@ -12594,7 +12594,7 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.16999999999999998, + input: 0.17, output: 0.43, cacheRead: 0, cacheWrite: 0, @@ -12611,8 +12611,8 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.39999999999999997, - output: 0.39999999999999997, + input: 0.4, + output: 0.4, cacheRead: 0, cacheWrite: 0, }, @@ -12647,7 +12647,7 @@ export const MODELS = { cost: { input: 1.25, output: 2.5, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 2000000, @@ -12664,7 +12664,7 @@ export const MODELS = { cost: { input: 1.25, output: 2.5, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 1000000, @@ -12681,7 +12681,7 @@ export const MODELS = { cost: { input: 1, output: 2, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 256000, @@ -12696,7 +12696,7 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.09999999999999999, + input: 0.1, output: 0.3, cacheRead: 0.01, cacheWrite: 0, @@ -12782,7 +12782,7 @@ export const MODELS = { input: ["text", "image"], cost: { input: 0.6, - output: 1.7999999999999998, + output: 1.8, cacheRead: 0.11, cacheWrite: 0, }, @@ -12816,7 +12816,7 @@ export const MODELS = { input: ["text", "image"], cost: { input: 0.3, - output: 0.8999999999999999, + output: 0.9, cacheRead: 0.055, cacheWrite: 0, }, @@ -12832,7 +12832,7 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.39999999999999997, + input: 0.4, output: 1.75, cacheRead: 0.08, cacheWrite: 0, @@ -12850,7 +12850,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.06, - output: 0.39999999999999997, + output: 0.4, cacheRead: 0.01, cacheWrite: 0, }, @@ -12936,7 +12936,7 @@ export const MODELS = { cost: { input: 1, output: 5, - cacheRead: 0.09999999999999999, + cacheRead: 0.1, cacheWrite: 1.25, }, contextWindow: 200000, @@ -12988,7 +12988,7 @@ export const MODELS = { input: 1.5, output: 9, cacheRead: 0.15, - cacheWrite: 0.08333333333333334, + cacheWrite: 0.083333, }, contextWindow: 1048576, maxTokens: 65536, @@ -13004,7 +13004,7 @@ export const MODELS = { cost: { input: 2, output: 12, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0.375, }, contextWindow: 1048576, @@ -13019,9 +13019,9 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.6799999999999999, + input: 0.68, output: 3.41, - cacheRead: 0.33999999999999997, + cacheRead: 0.34, cacheWrite: 0, }, contextWindow: 262144, @@ -13477,7 +13477,7 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.39999999999999997, + input: 0.4, output: 4, cacheRead: 0, cacheWrite: 0, @@ -13547,7 +13547,7 @@ export const MODELS = { cost: { input: 1, output: 5, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 1000000, @@ -13647,7 +13647,7 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.39999999999999997, + input: 0.4, output: 4, cacheRead: 0, cacheWrite: 0, @@ -13664,8 +13664,8 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.09999999999999999, - output: 0.39999999999999997, + input: 0.1, + output: 0.4, cacheRead: 0.001, cacheWrite: 0.125, }, @@ -13681,7 +13681,7 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.39999999999999997, + input: 0.4, output: 2.4, cacheRead: 0.04, cacheWrite: 0.5, @@ -13699,7 +13699,7 @@ export const MODELS = { input: ["text", "image"], cost: { input: 0.6, - output: 3.5999999999999996, + output: 3.6, cacheRead: 0, cacheWrite: 0, }, @@ -13717,7 +13717,7 @@ export const MODELS = { cost: { input: 0.5, output: 3, - cacheRead: 0.09999999999999999, + cacheRead: 0.1, cacheWrite: 0.625, }, contextWindow: 1000000, @@ -13749,8 +13749,8 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.39999999999999997, - output: 1.5999999999999999, + input: 0.4, + output: 1.6, cacheRead: 0.08, cacheWrite: 0.5, }, @@ -13783,7 +13783,7 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.7999999999999999, + input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1, @@ -13821,7 +13821,7 @@ export const MODELS = { cost: { input: 1, output: 5, - cacheRead: 0.09999999999999999, + cacheRead: 0.1, cacheWrite: 1.25, }, contextWindow: 200000, @@ -14014,7 +14014,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.25, - output: 0.8999999999999999, + output: 0.9, cacheRead: 0, cacheWrite: 0, }, @@ -14032,7 +14032,7 @@ export const MODELS = { cost: { input: 0.25, output: 2, - cacheRead: 0.049999999999999996, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 256000, @@ -14217,8 +14217,8 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.09999999999999999, - output: 0.39999999999999997, + input: 0.1, + output: 0.4, cacheRead: 0.01, cacheWrite: 0, }, @@ -14253,7 +14253,7 @@ export const MODELS = { cost: { input: 0.5, output: 3, - cacheRead: 0.049999999999999996, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 1000000, @@ -14270,7 +14270,7 @@ export const MODELS = { cost: { input: 2, output: 12, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 1000000, @@ -14321,7 +14321,7 @@ export const MODELS = { cost: { input: 2, output: 12, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 1000000, @@ -14371,7 +14371,7 @@ export const MODELS = { input: ["text", "image"], cost: { input: 0.14, - output: 0.39999999999999997, + output: 0.4, cacheRead: 0, cacheWrite: 0, }, @@ -14389,7 +14389,7 @@ export const MODELS = { cost: { input: 0.25, output: 0.75, - cacheRead: 0.024999999999999998, + cacheRead: 0.025, cacheWrite: 0, }, contextWindow: 128000, @@ -14541,7 +14541,7 @@ export const MODELS = { input: ["text", "image"], cost: { input: 0.24, - output: 0.9700000000000001, + output: 0.97, cacheRead: 0, cacheWrite: 0, }, @@ -14557,7 +14557,7 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.16999999999999998, + input: 0.17, output: 0.66, cacheRead: 0, cacheWrite: 0, @@ -14711,7 +14711,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.3, - output: 0.8999999999999999, + output: 0.9, cacheRead: 0, cacheWrite: 0, }, @@ -14727,7 +14727,7 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.39999999999999997, + input: 0.4, output: 2, cacheRead: 0, cacheWrite: 0, @@ -14744,7 +14744,7 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.09999999999999999, + input: 0.1, output: 0.3, cacheRead: 0, cacheWrite: 0, @@ -14761,7 +14761,7 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.09999999999999999, + input: 0.1, output: 0.3, cacheRead: 0, cacheWrite: 0, @@ -14778,8 +14778,8 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.09999999999999999, - output: 0.09999999999999999, + input: 0.1, + output: 0.1, cacheRead: 0, cacheWrite: 0, }, @@ -14812,7 +14812,7 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.39999999999999997, + input: 0.4, output: 2, cacheRead: 0, cacheWrite: 0, @@ -14863,7 +14863,7 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.09999999999999999, + input: 0.1, output: 0.3, cacheRead: 0, cacheWrite: 0, @@ -14914,7 +14914,7 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.5700000000000001, + input: 0.57, output: 2.3, cacheRead: 0, cacheWrite: 0, @@ -14950,7 +14950,7 @@ export const MODELS = { cost: { input: 0.6, output: 3, - cacheRead: 0.09999999999999999, + cacheRead: 0.1, cacheWrite: 0, }, contextWindow: 262114, @@ -15033,7 +15033,7 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.19999999999999998, + input: 0.2, output: 0.6, cacheRead: 0, cacheWrite: 0, @@ -15051,7 +15051,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.06, - output: 0.22999999999999998, + output: 0.23, cacheRead: 0, cacheWrite: 0, }, @@ -15101,9 +15101,9 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.39999999999999997, - output: 1.5999999999999999, - cacheRead: 0.09999999999999999, + input: 0.4, + output: 1.6, + cacheRead: 0.1, cacheWrite: 0, }, contextWindow: 1047576, @@ -15118,9 +15118,9 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.09999999999999999, - output: 0.39999999999999997, - cacheRead: 0.024999999999999998, + input: 0.1, + output: 0.4, + cacheRead: 0.025, cacheWrite: 0, }, contextWindow: 1047576, @@ -15222,7 +15222,7 @@ export const MODELS = { cost: { input: 0.25, output: 2, - cacheRead: 0.024999999999999998, + cacheRead: 0.025, cacheWrite: 0, }, contextWindow: 400000, @@ -15237,8 +15237,8 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.049999999999999996, - output: 0.39999999999999997, + input: 0.05, + output: 0.4, cacheRead: 0.005, cacheWrite: 0, }, @@ -15307,7 +15307,7 @@ export const MODELS = { cost: { input: 0.25, output: 2, - cacheRead: 0.024999999999999998, + cacheRead: 0.025, cacheWrite: 0, }, contextWindow: 400000, @@ -15501,7 +15501,7 @@ export const MODELS = { thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], cost: { - input: 0.19999999999999998, + input: 0.2, output: 1.25, cacheRead: 0.02, cacheWrite: 0, @@ -15589,8 +15589,8 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.049999999999999996, - output: 0.19999999999999998, + input: 0.05, + output: 0.2, cacheRead: 0, cacheWrite: 0, }, @@ -15776,7 +15776,7 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.19999999999999998, + input: 0.2, output: 1.15, cacheRead: 0.04, cacheWrite: 0, @@ -15793,9 +15793,9 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.19999999999999998, + input: 0.2, output: 0.5, - cacheRead: 0.049999999999999996, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 1000000, @@ -15810,9 +15810,9 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.19999999999999998, + input: 0.2, output: 0.5, - cacheRead: 0.049999999999999996, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 1000000, @@ -15829,7 +15829,7 @@ export const MODELS = { cost: { input: 1.25, output: 2.5, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 2000000, @@ -15846,7 +15846,7 @@ export const MODELS = { cost: { input: 1.25, output: 2.5, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 2000000, @@ -15863,7 +15863,7 @@ export const MODELS = { cost: { input: 1.25, output: 2.5, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 2000000, @@ -15880,7 +15880,7 @@ export const MODELS = { cost: { input: 1.25, output: 2.5, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 2000000, @@ -15897,7 +15897,7 @@ export const MODELS = { cost: { input: 1.25, output: 2.5, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 2000000, @@ -15914,7 +15914,7 @@ export const MODELS = { cost: { input: 1.25, output: 2.5, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 2000000, @@ -15931,7 +15931,7 @@ export const MODELS = { cost: { input: 1.25, output: 2.5, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 1000000, @@ -15948,7 +15948,7 @@ export const MODELS = { cost: { input: 1, output: 2, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 256000, @@ -15963,7 +15963,7 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.09999999999999999, + input: 0.1, output: 0.3, cacheRead: 0.01, cacheWrite: 0, @@ -15982,7 +15982,7 @@ export const MODELS = { cost: { input: 1, output: 3, - cacheRead: 0.19999999999999998, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 1000000, @@ -16048,7 +16048,7 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.19999999999999998, + input: 0.2, output: 1.1, cacheRead: 0.03, cacheWrite: 0, @@ -16066,7 +16066,7 @@ export const MODELS = { input: ["text", "image"], cost: { input: 0.6, - output: 1.7999999999999998, + output: 1.8, cacheRead: 0.11, cacheWrite: 0, }, @@ -16100,8 +16100,8 @@ export const MODELS = { input: ["text", "image"], cost: { input: 0.3, - output: 0.8999999999999999, - cacheRead: 0.049999999999999996, + output: 0.9, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 128000, @@ -16151,7 +16151,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.07, - output: 0.39999999999999997, + output: 0.4, cacheRead: 0, cacheWrite: 0, }, @@ -16168,7 +16168,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.06, - output: 0.39999999999999997, + output: 0.4, cacheRead: 0.01, cacheWrite: 0, }, @@ -16185,8 +16185,8 @@ export const MODELS = { input: ["text"], cost: { input: 1, - output: 3.1999999999999997, - cacheRead: 0.19999999999999998, + output: 3.2, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 202800, diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c6c5a1c4..dc0f9fa0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.2] - 2026-06-12 ### 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 dc287898..8ad2516c 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.79.1", + "version": "0.79.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "0.79.1", + "version": "0.79.2", "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 4b1dd16b..c898bca6 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.79.1", + "version": "0.79.2", "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 fba74f25..cff3a11f 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.79.1", + "version": "0.79.2", "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 b71b386c..96e72e79 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.79.1", + "version": "0.79.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-gondolin", - "version": "0.79.1", + "version": "0.79.2", "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 51448000..416616c4 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.79.1", + "version": "0.79.2", "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 360c0def..936fc58b 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.9.1", + "version": "1.9.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.9.1", + "version": "1.9.2", "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 f285b6ce..4d8f4398 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.9.1", + "version": "1.9.2", "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 ee75d24f..4d58693f 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.79.1", + "version": "0.79.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "0.79.1", + "version": "0.79.2", "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 fab9f145..1c39c1eb 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.79.1", + "version": "0.79.2", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 9132d489..3718cee9 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -1,17 +1,17 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.79.1", + "version": "0.79.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent", - "version": "0.79.1", + "version": "0.79.2", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.1", - "@earendil-works/pi-ai": "^0.79.1", - "@earendil-works/pi-tui": "^0.79.1", + "@earendil-works/pi-agent-core": "^0.79.2", + "@earendil-works/pi-ai": "^0.79.2", + "@earendil-works/pi-tui": "^0.79.2", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -473,11 +473,11 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.79.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.1.tgz", + "version": "0.79.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.2.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.79.1", + "@earendil-works/pi-ai": "^0.79.2", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -487,8 +487,8 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.79.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.1.tgz", + "version": "0.79.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.2.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -510,8 +510,8 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.79.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.1.tgz", + "version": "0.79.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.2.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 b9381465..73774712 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.79.1", + "version": "0.79.2", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -36,9 +36,9 @@ "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" }, "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.1", - "@earendil-works/pi-ai": "^0.79.1", - "@earendil-works/pi-tui": "^0.79.1", + "@earendil-works/pi-agent-core": "^0.79.2", + "@earendil-works/pi-ai": "^0.79.2", + "@earendil-works/pi-tui": "^0.79.2", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index edec3f28..2b348d0b 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.2] - 2026-06-12 ### Fixed diff --git a/packages/tui/package.json b/packages/tui/package.json index 94ba86ad..0b1d76a3 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-tui", - "version": "0.79.1", + "version": "0.79.2", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", From 032c01c1e11d5171c6ceb6afb83a4715bfa6e4ae Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 13 Jun 2026 00:25:18 +0200 Subject: [PATCH 244/352] Add [Unreleased] section for next cycle --- packages/agent/CHANGELOG.md | 2 ++ packages/ai/CHANGELOG.md | 2 ++ packages/coding-agent/CHANGELOG.md | 2 ++ packages/tui/CHANGELOG.md | 2 ++ 4 files changed, 8 insertions(+) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index feba85dc..ec983000 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.2] - 2026-06-12 ### Fixed diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index eadcd674..754e1edd 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.2] - 2026-06-12 ### Added diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index dc0f9fa0..4fa1b007 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.2] - 2026-06-12 ### New Features diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 2b348d0b..1edd68d0 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.2] - 2026-06-12 ### Fixed From aa3a5233adad808309bdfe3d54ef94bbd6c80168 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 13 Jun 2026 11:02:38 +0200 Subject: [PATCH 245/352] fix(ai): restore Codex context limits --- packages/ai/CHANGELOG.md | 4 + packages/ai/scripts/generate-models.ts | 14 +- packages/ai/src/models.generated.ts | 151 +++++++++--------- .../ai/test/openai-model-metadata.test.ts | 15 -- 4 files changed, 86 insertions(+), 98 deletions(-) delete mode 100644 packages/ai/test/openai-model-metadata.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 754e1edd..0b0e1e16 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Restored OpenAI GPT-5.4/GPT-5.5 and OpenAI Codex GPT-5.4/GPT-5.4 mini/GPT-5.5 context window metadata to the observed 272k-token Codex backend limit, avoiding a billing hazard from sending prompts above Codex's accepted limit (reported by [@trethore](https://github.com/trethore)). + ## [0.79.2] - 2026-06-12 ### Added diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index c74a6afc..1eaf9fe1 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -1367,7 +1367,7 @@ async function generateModels() { candidate.maxTokens = 128000; } if (candidate.provider === "openai" && (candidate.id === "gpt-5.4" || candidate.id === "gpt-5.5")) { - candidate.contextWindow = 1050000; + candidate.contextWindow = 272000; candidate.maxTokens = 128000; } // models.dev reports gpt-5-pro output as 272000 (a duplicate of the input sub-limit); @@ -1634,7 +1634,7 @@ async function generateModels() { cacheRead: 0.25, cacheWrite: 0, }, - contextWindow: 1050000, + contextWindow: 272000, maxTokens: 128000, }); } @@ -1761,9 +1761,9 @@ async function generateModels() { // OpenAI Codex (ChatGPT OAuth) models // NOTE: These are not fetched from models.dev; we keep a small, explicit list to avoid aliases. + // Context window is based on observed server limits (400s above ~272k), not marketing numbers. const CODEX_BASE_URL = "https://chatgpt.com/backend-api"; - const CODEX_GPT_54_CONTEXT = 1000000; - const CODEX_STANDARD_CONTEXT = 400000; + const CODEX_CONTEXT = 272000; const CODEX_SPARK_CONTEXT = 128000; const CODEX_MAX_TOKENS = 128000; const codexModels: Model<"openai-codex-responses">[] = [ @@ -1788,7 +1788,7 @@ async function generateModels() { reasoning: true, input: ["text", "image"], cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 }, - contextWindow: CODEX_GPT_54_CONTEXT, + contextWindow: CODEX_CONTEXT, maxTokens: CODEX_MAX_TOKENS, }, { @@ -1800,7 +1800,7 @@ async function generateModels() { reasoning: true, input: ["text", "image"], cost: { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 }, - contextWindow: CODEX_STANDARD_CONTEXT, + contextWindow: CODEX_CONTEXT, maxTokens: CODEX_MAX_TOKENS, }, { @@ -1812,7 +1812,7 @@ async function generateModels() { reasoning: true, input: ["text", "image"], cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, - contextWindow: CODEX_STANDARD_CONTEXT, + contextWindow: CODEX_CONTEXT, maxTokens: CODEX_MAX_TOKENS, }, ]; diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index aa587f10..0b59d572 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -3945,24 +3945,6 @@ export const MODELS = { contextWindow: 131072, maxTokens: 32768, } satisfies Model<"anthropic-messages">, - "accounts/fireworks/models/kimi-k2p5": { - id: "accounts/fireworks/models/kimi-k2p5", - name: "Kimi K2.5", - api: "anthropic-messages", - provider: "fireworks", - baseUrl: "https://api.fireworks.ai/inference", - compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.6, - output: 3, - cacheRead: 0.1, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 256000, - } satisfies Model<"anthropic-messages">, "accounts/fireworks/models/kimi-k2p6": { id: "accounts/fireworks/models/kimi-k2p6", name: "Kimi K2.6", @@ -3981,23 +3963,23 @@ export const MODELS = { contextWindow: 262000, maxTokens: 262000, } satisfies Model<"anthropic-messages">, - "accounts/fireworks/models/minimax-m2p5": { - id: "accounts/fireworks/models/minimax-m2p5", - name: "MiniMax-M2.5", + "accounts/fireworks/models/kimi-k2p7-code": { + id: "accounts/fireworks/models/kimi-k2p7-code", + name: "Kimi K2.7 Code", api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, reasoning: true, - input: ["text"], + input: ["text", "image"], cost: { - input: 0.3, - output: 1.2, - cacheRead: 0.03, + input: 0.95, + output: 4, + cacheRead: 0.19, cacheWrite: 0, }, - contextWindow: 196608, - maxTokens: 196608, + contextWindow: 262000, + maxTokens: 262000, } satisfies Model<"anthropic-messages">, "accounts/fireworks/models/minimax-m2p7": { id: "accounts/fireworks/models/minimax-m2p7", @@ -4017,9 +3999,27 @@ export const MODELS = { contextWindow: 196608, maxTokens: 196608, } satisfies Model<"anthropic-messages">, - "accounts/fireworks/models/qwen3p6-plus": { - id: "accounts/fireworks/models/qwen3p6-plus", - name: "Qwen 3.6 Plus", + "accounts/fireworks/models/minimax-m3": { + id: "accounts/fireworks/models/minimax-m3", + name: "MiniMax-M3", + api: "anthropic-messages", + provider: "fireworks", + baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0, + }, + contextWindow: 512000, + maxTokens: 512000, + } satisfies Model<"anthropic-messages">, + "accounts/fireworks/models/qwen3p7-plus": { + id: "accounts/fireworks/models/qwen3p7-plus", + name: "Qwen 3.7 Plus", api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", @@ -4027,9 +4027,9 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.5, - output: 3, - cacheRead: 0.1, + input: 0.4, + output: 1.6, + cacheRead: 0.08, cacheWrite: 0, }, contextWindow: 262144, @@ -4089,6 +4089,24 @@ export const MODELS = { contextWindow: 262000, maxTokens: 262000, } satisfies Model<"anthropic-messages">, + "accounts/fireworks/routers/kimi-k2p7-code-fast": { + id: "accounts/fireworks/routers/kimi-k2p7-code-fast", + name: "Kimi K2.7 Code Fast", + api: "anthropic-messages", + provider: "fireworks", + baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 8, + cacheRead: 0.38, + cacheWrite: 0, + }, + contextWindow: 262000, + maxTokens: 262000, + } satisfies Model<"anthropic-messages">, }, "github-copilot": { "claude-fable-5": { @@ -7382,7 +7400,7 @@ export const MODELS = { cacheRead: 0.25, cacheWrite: 0, }, - contextWindow: 1050000, + contextWindow: 272000, maxTokens: 128000, } satisfies Model<"openai-responses">, "gpt-5.4-mini": { @@ -7454,7 +7472,7 @@ export const MODELS = { cacheRead: 0.5, cacheWrite: 0, }, - contextWindow: 1050000, + contextWindow: 272000, maxTokens: 128000, } satisfies Model<"openai-responses">, "gpt-5.5-pro": { @@ -7646,7 +7664,7 @@ export const MODELS = { cacheRead: 0.25, cacheWrite: 0, }, - contextWindow: 1000000, + contextWindow: 272000, maxTokens: 128000, } satisfies Model<"openai-codex-responses">, "gpt-5.4-mini": { @@ -7664,7 +7682,7 @@ export const MODELS = { cacheRead: 0.075, cacheWrite: 0, }, - contextWindow: 400000, + contextWindow: 272000, maxTokens: 128000, } satisfies Model<"openai-codex-responses">, "gpt-5.5": { @@ -7682,7 +7700,7 @@ export const MODELS = { cacheRead: 0.5, cacheWrite: 0, }, - contextWindow: 400000, + contextWindow: 272000, maxTokens: 128000, } satisfies Model<"openai-codex-responses">, }, @@ -7705,25 +7723,6 @@ export const MODELS = { contextWindow: 200000, maxTokens: 32000, } satisfies Model<"openai-completions">, - "claude-fable-5": { - id: "claude-fable-5", - name: "Claude Fable 5", - api: "anthropic-messages", - provider: "opencode", - baseUrl: "https://opencode.ai/zen", - compat: {"forceAdaptiveThinking":true}, - reasoning: true, - thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, - input: ["text", "image"], - cost: { - input: 10, - output: 50, - cacheRead: 1, - cacheWrite: 12.5, - }, - contextWindow: 1000000, - maxTokens: 128000, - } satisfies Model<"anthropic-messages">, "claude-haiku-4-5": { id: "claude-haiku-4-5", name: "Claude Haiku 4.5", @@ -13082,6 +13081,25 @@ export const MODELS = { contextWindow: 202752, maxTokens: 131072, } satisfies Model<"openai-completions">, + "MiniMaxAI/MiniMax-M3": { + id: "MiniMaxAI/MiniMax-M3", + name: "MiniMax-M3", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text", "image"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0, + }, + contextWindow: 524288, + maxTokens: 250000, + } satisfies Model<"openai-completions">, "Qwen/Qwen2.5-7B-Instruct-Turbo": { id: "Qwen/Qwen2.5-7B-Instruct-Turbo", name: "Qwen 2.5 7B Instruct Turbo", @@ -13791,25 +13809,6 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } satisfies Model<"anthropic-messages">, - "anthropic/claude-fable-5": { - id: "anthropic/claude-fable-5", - name: "Claude Fable 5", - api: "anthropic-messages", - provider: "vercel-ai-gateway", - baseUrl: "https://ai-gateway.vercel.sh", - compat: {"forceAdaptiveThinking":true}, - reasoning: true, - thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, - input: ["text", "image"], - cost: { - input: 10, - output: 50, - cacheRead: 1, - cacheWrite: 12.5, - }, - contextWindow: 1000000, - maxTokens: 128000, - } satisfies Model<"anthropic-messages">, "anthropic/claude-haiku-4.5": { id: "anthropic/claude-haiku-4.5", name: "Claude Haiku 4.5", diff --git a/packages/ai/test/openai-model-metadata.test.ts b/packages/ai/test/openai-model-metadata.test.ts deleted file mode 100644 index 64c593e1..00000000 --- a/packages/ai/test/openai-model-metadata.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.ts"; - -describe("OpenAI model metadata", () => { - it("uses current GPT-5.4 and GPT-5.5 API context windows", () => { - expect(getModel("openai", "gpt-5.4").contextWindow).toBe(1050000); - expect(getModel("openai", "gpt-5.5").contextWindow).toBe(1050000); - }); - - it("uses current OpenAI Codex context windows", () => { - expect(getModel("openai-codex", "gpt-5.4").contextWindow).toBe(1000000); - expect(getModel("openai-codex", "gpt-5.4-mini").contextWindow).toBe(400000); - expect(getModel("openai-codex", "gpt-5.5").contextWindow).toBe(400000); - }); -}); From 57b6bdce715119934926210de2815dd986cb3fe0 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 13 Jun 2026 11:07:31 +0200 Subject: [PATCH 246/352] docs(coding-agent): update Codex context limit changelog --- packages/coding-agent/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4fa1b007..0fc04af7 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed inherited OpenAI GPT-5.4/GPT-5.5 and OpenAI Codex GPT-5.4/GPT-5.4 mini/GPT-5.5 context window metadata to use the observed 272k-token Codex backend limit, avoiding a billing hazard from prompts above Codex's accepted limit (reported by [@trethore](https://github.com/trethore)). + ## [0.79.2] - 2026-06-12 ### New Features From f2585c4c824149ed953f7fcda16b34c9d25fb433 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 13 Jun 2026 11:11:11 +0200 Subject: [PATCH 247/352] Release v0.79.3 --- package-lock.json | 26 +++++++++---------- packages/agent/CHANGELOG.md | 2 +- packages/agent/package.json | 4 +-- packages/ai/CHANGELOG.md | 2 +- packages/ai/package.json | 2 +- packages/coding-agent/CHANGELOG.md | 2 +- .../package-lock.json | 4 +-- .../custom-provider-anthropic/package.json | 2 +- .../custom-provider-gitlab-duo/package.json | 2 +- .../extensions/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 +- packages/coding-agent/npm-shrinkwrap.json | 24 ++++++++--------- packages/coding-agent/package.json | 8 +++--- packages/tui/CHANGELOG.md | 2 +- packages/tui/package.json | 2 +- 19 files changed, 50 insertions(+), 50 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5cdd129d..11022066 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6170,10 +6170,10 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.79.2", + "version": "0.79.3", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.79.2", + "@earendil-works/pi-ai": "^0.79.3", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -6207,7 +6207,7 @@ }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.79.2", + "version": "0.79.3", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -6252,12 +6252,12 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "0.79.2", + "version": "0.79.3", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.2", - "@earendil-works/pi-ai": "^0.79.2", - "@earendil-works/pi-tui": "^0.79.2", + "@earendil-works/pi-agent-core": "^0.79.3", + "@earendil-works/pi-ai": "^0.79.3", + "@earendil-works/pi-tui": "^0.79.3", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -6296,32 +6296,32 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.79.2", + "version": "0.79.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.79.2" + "version": "0.79.3" }, "packages/coding-agent/examples/extensions/gondolin": { "name": "pi-extension-gondolin", - "version": "0.79.2", + "version": "0.79.3", "dependencies": { "@earendil-works/gondolin": "0.12.0" } }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.9.2", + "version": "1.9.3", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.79.2", + "version": "0.79.3", "dependencies": { "ms": "2.1.3" }, @@ -6357,7 +6357,7 @@ }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.79.2", + "version": "0.79.3", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index ec983000..2c73f2d6 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.3] - 2026-06-13 ## [0.79.2] - 2026-06-12 diff --git a/packages/agent/package.json b/packages/agent/package.json index 0c94a352..b8a17c29 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "0.79.2", + "version": "0.79.3", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -29,7 +29,7 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "^0.79.2", + "@earendil-works/pi-ai": "^0.79.3", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 0b0e1e16..b332dc01 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.3] - 2026-06-13 ### Fixed diff --git a/packages/ai/package.json b/packages/ai/package.json index bbbddd43..806d19ca 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.79.2", + "version": "0.79.3", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0fc04af7..36e685c5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.3] - 2026-06-13 ### Fixed 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 8ad2516c..906cc651 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.79.2", + "version": "0.79.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "0.79.2", + "version": "0.79.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 c898bca6..b2082217 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.79.2", + "version": "0.79.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 cff3a11f..3e80ae34 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.79.2", + "version": "0.79.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 96e72e79..cbd6cfec 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.79.2", + "version": "0.79.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-gondolin", - "version": "0.79.2", + "version": "0.79.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 416616c4..a339c43a 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.79.2", + "version": "0.79.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 936fc58b..04da2c15 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.9.2", + "version": "1.9.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.9.2", + "version": "1.9.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 4d8f4398..60573f2a 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.9.2", + "version": "1.9.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 4d58693f..df12962d 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.79.2", + "version": "0.79.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "0.79.2", + "version": "0.79.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 1c39c1eb..b47c9e68 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.79.2", + "version": "0.79.3", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 3718cee9..e660e9d7 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -1,17 +1,17 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.79.2", + "version": "0.79.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent", - "version": "0.79.2", + "version": "0.79.3", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.2", - "@earendil-works/pi-ai": "^0.79.2", - "@earendil-works/pi-tui": "^0.79.2", + "@earendil-works/pi-agent-core": "^0.79.3", + "@earendil-works/pi-ai": "^0.79.3", + "@earendil-works/pi-tui": "^0.79.3", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -473,11 +473,11 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.79.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.2.tgz", + "version": "0.79.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.3.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.79.2", + "@earendil-works/pi-ai": "^0.79.3", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -487,8 +487,8 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.79.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.2.tgz", + "version": "0.79.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.3.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -510,8 +510,8 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.79.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.2.tgz", + "version": "0.79.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.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 73774712..851b80a3 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.79.2", + "version": "0.79.3", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -36,9 +36,9 @@ "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" }, "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.2", - "@earendil-works/pi-ai": "^0.79.2", - "@earendil-works/pi-tui": "^0.79.2", + "@earendil-works/pi-agent-core": "^0.79.3", + "@earendil-works/pi-ai": "^0.79.3", + "@earendil-works/pi-tui": "^0.79.3", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 1edd68d0..da464e1c 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.3] - 2026-06-13 ## [0.79.2] - 2026-06-12 diff --git a/packages/tui/package.json b/packages/tui/package.json index 0b1d76a3..a808a640 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-tui", - "version": "0.79.2", + "version": "0.79.3", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", From b15148fe376ca79fe66c1c22a64542b9a9d2a2b5 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 13 Jun 2026 11:11:14 +0200 Subject: [PATCH 248/352] Add [Unreleased] section for next cycle --- packages/agent/CHANGELOG.md | 2 ++ packages/ai/CHANGELOG.md | 2 ++ packages/coding-agent/CHANGELOG.md | 2 ++ packages/tui/CHANGELOG.md | 2 ++ 4 files changed, 8 insertions(+) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 2c73f2d6..f176ad06 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.3] - 2026-06-13 ## [0.79.2] - 2026-06-12 diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b332dc01..03604c9b 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.3] - 2026-06-13 ### Fixed diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 36e685c5..7f677ad9 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.3] - 2026-06-13 ### Fixed diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index da464e1c..69b0529a 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.3] - 2026-06-13 ## [0.79.2] - 2026-06-12 From 6f29450e1339b37582796775749de3957936cd8d Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 13 Jun 2026 11:17:38 +0200 Subject: [PATCH 249/352] fix(ai): update adaptive thinking model expectations --- packages/ai/test/anthropic-adaptive-thinking-models.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/ai/test/anthropic-adaptive-thinking-models.test.ts b/packages/ai/test/anthropic-adaptive-thinking-models.test.ts index da042e2f..18023e11 100644 --- a/packages/ai/test/anthropic-adaptive-thinking-models.test.ts +++ b/packages/ai/test/anthropic-adaptive-thinking-models.test.ts @@ -5,9 +5,8 @@ import type { Api, Model } from "../src/types.ts"; const EXPECTED_CURRENT_ADAPTIVE_THINKING_MODELS = [ "anthropic/claude-fable-5", "anthropic/claude-opus-4-8", - "opencode/claude-fable-5", + "cloudflare-ai-gateway/claude-fable-5", "opencode/claude-opus-4-8", - "vercel-ai-gateway/anthropic/claude-fable-5", "vercel-ai-gateway/anthropic/claude-opus-4.8", ]; From f315d81491813682d02d659e55788fe227b3db2c Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 13 Jun 2026 15:43:00 +0200 Subject: [PATCH 250/352] meta: Update weekend policy in contributing to be less confusing --- CONTRIBUTING.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 24f0f825..d2c79b9f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ If you use an agent, run it from the `pi-mono` root directory so it picks up `AG All issues and PRs from new contributors are auto-closed by default. -Issues submitted Friday through Sunday are not reviewed. If something is urgent, ask on Discord: https://discord.com/invite/3cU7Bz4UPx +Issues submitted Friday through Sunday are not guaranteed to be reviewed. If something is urgent, ask on Discord: https://discord.com/invite/3cU7Bz4UPx Maintainers review auto-closed issues daily and reopen worthwhile ones. Issues that do not meet the quality bar below will not be reopened or receive a reply. @@ -76,9 +76,9 @@ Ask on [Discord](https://discord.com/invite/nKXTsAcmbT). pi receives more issues than the maintainers can responsibly review in real time. Many reports do not meet the quality bar in this guide or do not follow CONTRIBUTING.md. Some are slung at the repository mindlessly via an agent instead of being reviewed and shaped by the person submitting them. Auto-closing creates a buffer so maintainers can review the tracker on their own schedule and reopen the issues that meet the quality bar. -### Why are weekend issues not reviewed? +### Why are weekend issues lower priority? -Maintainers need uninterrupted time away from the issue tracker. Issues submitted Friday through Sunday are auto-closed and are not part of the Monday review queue. If a problem is urgent, ask on Discord and include the short version, a repro, and the relevant logs. +We triage the tracker during working hours. That means more issues can accumulate over the weekend. Anything submitted Friday through Sunday may be missed or given lower priority in the Monday review queue. If a problem is urgent, ask on Discord and include the short version, a repro, and the relevant logs. ### Why do some issues get no reply? From 9e9fc7947871a913946f727854ae0a57fbce1863 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 13 Jun 2026 21:24:22 +0200 Subject: [PATCH 251/352] fix(coding-agent): treat uppercase config values as literals closes #5661 --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/docs/models.md | 4 +- packages/coding-agent/docs/providers.md | 4 +- .../coding-agent/src/core/model-registry.ts | 80 +--------- .../src/core/resolve-config-value.ts | 5 - packages/coding-agent/src/migrations.ts | 139 +--------------- .../test/config-value-migration.test.ts | 150 ++++++++++-------- .../coding-agent/test/model-registry.test.ts | 52 ++++-- .../5661-uppercase-header-values.test.ts | 91 +++++++++++ 9 files changed, 226 insertions(+), 303 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/5661-uppercase-header-values.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7f677ad9..6b17d85a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed custom provider config so plain uppercase API key and header values remain literals instead of being treated as legacy environment references; use explicit `$ENV_VAR` syntax for environment variables ([#5661](https://github.com/earendil-works/pi/issues/5661)). + ## [0.79.3] - 2026-06-13 ### Fixed diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index 39b56aa1..830eaacd 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -161,13 +161,11 @@ The `apiKey` and `headers` fields support command execution, environment interpo "apiKey": "$$literal-dollar-prefix" "apiKey": "$!literal-bang-prefix" ``` -- **Literal value:** Used directly +- **Literal value:** Used directly. Plain uppercase strings such as `MY_API_KEY` are literals; use `$MY_API_KEY` for environment variables. ```json "apiKey": "sk-..." ``` -Legacy uppercase env-var-like values such as `MY_API_KEY` are migrated to `$MY_API_KEY` on startup. - For `models.json`, shell commands are resolved at request time. pi intentionally does not apply built-in TTL, stale reuse, or recovery logic for arbitrary commands. Different commands need different caching and failure strategies, and pi cannot infer the right one. If your command is slow, expensive, rate-limited, or should keep using a previous value on transient failures, wrap it in your own script or command that implements the caching or TTL behavior you want. diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index 185405cd..f139aa30 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -124,13 +124,13 @@ The `key` field supports command execution, environment interpolation, and liter { "type": "api_key", "key": "$$literal-dollar-prefix" } { "type": "api_key", "key": "$!literal-bang-prefix" } ``` -- **Literal value:** Used directly +- **Literal value:** Used directly. Plain uppercase strings such as `MY_API_KEY` are literals; use `$MY_API_KEY` for environment variables. ```json { "type": "api_key", "key": "sk-ant-..." } { "type": "api_key", "key": "public" } ``` -Legacy uppercase env-var-like values such as `MY_API_KEY` are migrated to `$MY_API_KEY` on startup. OAuth credentials are also stored here after `/login` and managed automatically. +OAuth credentials are also stored here after `/login` and managed automatically. ## Cloud Providers diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index 00c81576..ff172966 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -25,7 +25,6 @@ import { type Static, Type } from "typebox"; import { Compile } from "typebox/compile"; import type { TLocalizedValidationError } from "typebox/error"; import { getAgentDir } from "../config.ts"; -import { warnDeprecation } from "../utils/deprecation.ts"; import { stripJsonComments } from "../utils/json.ts"; import { normalizePath } from "../utils/paths.ts"; import type { AuthStatus, AuthStorage } from "./auth-storage.ts"; @@ -35,7 +34,6 @@ import { getConfigValueEnvVarNames, isCommandConfigValue, isConfigValueConfigured, - isLegacyEnvVarNameConfigValue, resolveConfigValueOrThrow, resolveConfigValueUncached, resolveHeadersOrThrow, @@ -237,77 +235,6 @@ interface ProviderRequestConfig { authHeader?: boolean; } -function migrateLegacyRegisterProviderConfigValue(providerName: string, field: string, value: string): string { - if (!isLegacyEnvVarNameConfigValue(value)) return value; - warnDeprecation( - `registerProvider("${providerName}") ${field} value "${value}" is treated as a legacy environment variable reference. This will no longer be detected as an environment variable reference in a future release. Pass "$${value}" instead.`, - ); - return `$${value}`; -} - -function migrateLegacyRegisterProviderHeaders( - providerName: string, - field: string, - headers: Record | undefined, -): Record | undefined { - if (!headers) return undefined; - let migratedHeaders: Record | undefined; - for (const [key, value] of Object.entries(headers)) { - const migratedValue = migrateLegacyRegisterProviderConfigValue(providerName, `${field} header "${key}"`, value); - if (migratedValue === value) continue; - migratedHeaders ??= { ...headers }; - migratedHeaders[key] = migratedValue; - } - return migratedHeaders ?? headers; -} - -function migrateLegacyRegisterProviderConfigValues( - providerName: string, - config: ProviderConfigInput, -): ProviderConfigInput { - let migratedConfig: ProviderConfigInput | undefined; - - const setMigratedConfigValue = ( - key: TKey, - value: ProviderConfigInput[TKey], - ) => { - migratedConfig ??= { ...config }; - migratedConfig[key] = value; - }; - - if (config.apiKey) { - const apiKey = migrateLegacyRegisterProviderConfigValue(providerName, "apiKey", config.apiKey); - if (apiKey !== config.apiKey) { - setMigratedConfigValue("apiKey", apiKey); - } - } - - const headers = migrateLegacyRegisterProviderHeaders(providerName, "headers", config.headers); - if (headers !== config.headers) { - setMigratedConfigValue("headers", headers); - } - - if (config.models) { - let models: ProviderConfigInput["models"] | undefined; - for (let index = 0; index < config.models.length; index++) { - const model = config.models[index]; - const modelHeaders = migrateLegacyRegisterProviderHeaders( - providerName, - `model "${model.id}" headers`, - model.headers, - ); - if (modelHeaders === model.headers) continue; - models ??= [...config.models]; - models[index] = { ...model, headers: modelHeaders }; - } - if (models) { - setMigratedConfigValue("models", models); - } - } - - return migratedConfig ?? config; -} - export type ResolvedRequestAuth = | { ok: true; @@ -869,10 +796,9 @@ export class ModelRegistry { * If provider has oauth: registers OAuth provider for /login support. */ registerProvider(providerName: string, config: ProviderConfigInput): void { - const migratedConfig = migrateLegacyRegisterProviderConfigValues(providerName, config); - this.validateProviderConfig(providerName, migratedConfig); - this.applyProviderConfig(providerName, migratedConfig); - this.upsertRegisteredProvider(providerName, migratedConfig); + this.validateProviderConfig(providerName, config); + this.applyProviderConfig(providerName, config); + this.upsertRegisteredProvider(providerName, config); } /** diff --git a/packages/coding-agent/src/core/resolve-config-value.ts b/packages/coding-agent/src/core/resolve-config-value.ts index 119e379d..6c2263f6 100644 --- a/packages/coding-agent/src/core/resolve-config-value.ts +++ b/packages/coding-agent/src/core/resolve-config-value.ts @@ -10,7 +10,6 @@ import { getShellConfig } from "../utils/shell.ts"; const commandResultCache = new Map(); const ENV_VAR_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; const ENV_VAR_NAME_PREFIX_RE = /^[A-Za-z_][A-Za-z0-9_]*/; -const LEGACY_ENV_VAR_NAME_RE = /^[A-Z_][A-Z0-9_]*$/; type TemplatePart = { type: "literal"; value: string } | { type: "env"; name: string }; @@ -136,10 +135,6 @@ export function isConfigValueConfigured(config: string): boolean { return getMissingConfigValueEnvVarNames(config).length === 0; } -export function isLegacyEnvVarNameConfigValue(config: string): boolean { - return LEGACY_ENV_VAR_NAME_RE.test(config); -} - /** * Resolve a config value (API key, header value, etc.) to an actual value. * - If starts with "!", executes the rest as a shell command and uses stdout (cached) diff --git a/packages/coding-agent/src/migrations.ts b/packages/coding-agent/src/migrations.ts index 5cce43b8..39aeea04 100644 --- a/packages/coding-agent/src/migrations.ts +++ b/packages/coding-agent/src/migrations.ts @@ -3,12 +3,10 @@ */ import chalk from "chalk"; -import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs"; +import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs"; import { dirname, join } from "path"; import { CONFIG_DIR_NAME, getAgentDir, getBinDir } from "./config.ts"; import { migrateKeybindingsConfig } from "./core/keybindings.ts"; -import { isLegacyEnvVarNameConfigValue } from "./core/resolve-config-value.ts"; -import { stripJsonComments } from "./utils/json.ts"; const MIGRATION_GUIDE_URL = "https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/CHANGELOG.md#extensions-migration"; @@ -74,140 +72,6 @@ export function migrateAuthToAuthJson(): string[] { return providers; } -interface ConfigValueMigration { - location: string; - from: string; - to: string; -} - -function migrateLegacyEnvVarString(value: string): string | undefined { - return isLegacyEnvVarNameConfigValue(value) ? `$${value}` : undefined; -} - -function migrateStringProperty( - record: Record, - key: string, - location: string, - migrations: ConfigValueMigration[], -): boolean { - const value = record[key]; - if (typeof value !== "string") return false; - const migrated = migrateLegacyEnvVarString(value); - if (migrated === undefined) return false; - record[key] = migrated; - migrations.push({ location, from: value, to: migrated }); - return true; -} - -function migrateHeadersConfig(headers: unknown, location: string, migrations: ConfigValueMigration[]): boolean { - if (typeof headers !== "object" || headers === null || Array.isArray(headers)) return false; - const headerRecord = headers as Record; - let migrated = false; - for (const [key, value] of Object.entries(headerRecord)) { - if (typeof value !== "string") continue; - const migratedValue = migrateLegacyEnvVarString(value); - if (migratedValue === undefined) continue; - headerRecord[key] = migratedValue; - migrations.push({ location: `${location}[${JSON.stringify(key)}]`, from: value, to: migratedValue }); - migrated = true; - } - return migrated; -} - -function migrateAuthJsonConfigValues(agentDir: string): ConfigValueMigration[] { - const authPath = join(agentDir, "auth.json"); - if (!existsSync(authPath)) return []; - - try { - const parsed = JSON.parse(readFileSync(authPath, "utf-8")) as unknown; - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return []; - const authData = parsed as Record; - - const migrations: ConfigValueMigration[] = []; - for (const [provider, credential] of Object.entries(authData)) { - if (typeof credential !== "object" || credential === null || Array.isArray(credential)) continue; - const credentialRecord = credential as Record; - if (credentialRecord.type !== "api_key") continue; - migrateStringProperty(credentialRecord, "key", `auth.json[${JSON.stringify(provider)}].key`, migrations); - } - - if (migrations.length === 0) return []; - writeFileSync(authPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf-8"); - chmodSync(authPath, 0o600); - return migrations; - } catch { - return []; - } -} - -function migrateModelsJsonConfigValues(agentDir: string): ConfigValueMigration[] { - const modelsPath = join(agentDir, "models.json"); - if (!existsSync(modelsPath)) return []; - - try { - const parsed = JSON.parse(stripJsonComments(readFileSync(modelsPath, "utf-8"))) as unknown; - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return []; - const modelsData = parsed as Record; - const providers = modelsData.providers; - if (typeof providers !== "object" || providers === null || Array.isArray(providers)) return []; - - const migrations: ConfigValueMigration[] = []; - for (const [provider, providerConfig] of Object.entries(providers)) { - if (typeof providerConfig !== "object" || providerConfig === null || Array.isArray(providerConfig)) continue; - const providerRecord = providerConfig as Record; - const providerLocation = `models.json.providers[${JSON.stringify(provider)}]`; - migrateStringProperty(providerRecord, "apiKey", `${providerLocation}.apiKey`, migrations); - migrateHeadersConfig(providerRecord.headers, `${providerLocation}.headers`, migrations); - - if (Array.isArray(providerRecord.models)) { - for (let index = 0; index < providerRecord.models.length; index++) { - const modelConfig = providerRecord.models[index]; - if (typeof modelConfig !== "object" || modelConfig === null || Array.isArray(modelConfig)) continue; - const modelRecord = modelConfig as Record; - const modelKey = typeof modelRecord.id === "string" ? JSON.stringify(modelRecord.id) : String(index); - migrateHeadersConfig(modelRecord.headers, `${providerLocation}.models[${modelKey}].headers`, migrations); - } - } - - const modelOverrides = providerRecord.modelOverrides; - if (typeof modelOverrides === "object" && modelOverrides !== null && !Array.isArray(modelOverrides)) { - for (const [modelId, modelOverride] of Object.entries(modelOverrides)) { - if (typeof modelOverride !== "object" || modelOverride === null || Array.isArray(modelOverride)) - continue; - const modelOverrideRecord = modelOverride as Record; - migrateHeadersConfig( - modelOverrideRecord.headers, - `${providerLocation}.modelOverrides[${JSON.stringify(modelId)}].headers`, - migrations, - ); - } - } - } - - if (migrations.length === 0) return []; - writeFileSync(modelsPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf-8"); - return migrations; - } catch { - return []; - } -} - -function migrateExplicitEnvVarConfigValues(): void { - const agentDir = getAgentDir(); - const migrations = [...migrateAuthJsonConfigValues(agentDir), ...migrateModelsJsonConfigValues(agentDir)]; - if (migrations.length === 0) return; - - const details = migrations.map((migration) => ` - ${migration.location}: ${migration.from} -> ${migration.to}`); - console.log( - chalk.yellow( - [ - "Warning: Migrated API key/header environment references to explicit $ENV_VAR syntax. Plain strings will be treated as literals.", - ...details, - ].join("\n"), - ), - ); -} - /** * Migrate sessions from ~/.pi/agent/*.jsonl to proper session directories. * @@ -443,7 +307,6 @@ export function runMigrations(cwd: string): { deprecationWarnings: string[]; } { const migratedAuthProviders = migrateAuthToAuthJson(); - migrateExplicitEnvVarConfigValues(); migrateSessionsFromAgentRoot(); migrateToolsToBin(); migrateKeybindingsConfigFile(); diff --git a/packages/coding-agent/test/config-value-migration.test.ts b/packages/coding-agent/test/config-value-migration.test.ts index d0bb8125..8a273b53 100644 --- a/packages/coding-agent/test/config-value-migration.test.ts +++ b/packages/coding-agent/test/config-value-migration.test.ts @@ -37,7 +37,7 @@ describe("config value env var syntax migration", () => { } } - it("rewrites legacy uppercase auth.json API key values to explicit env references", () => { + it("leaves uppercase auth.json API key values unchanged", () => { const agentDir = createAgentDir(); fs.writeFileSync( path.join(agentDir, "auth.json"), @@ -61,19 +61,17 @@ describe("config value env var syntax migration", () => { string, Record >; - expect(migrated.anthropic.key).toBe("$ANTHROPIC_API_KEY"); + expect(migrated.anthropic.key).toBe("ANTHROPIC_API_KEY"); expect(migrated.openai.key).toBe("$OPENAI_API_KEY"); expect(migrated.opencode.key).toBe("public"); expect(migrated.github.access).toBe("ACCESS_TOKEN"); - const logMessage = String(logSpy.mock.calls[0]?.[0] ?? ""); - expect(logMessage).toContain("explicit $ENV_VAR syntax"); - expect(logMessage).toContain('auth.json["anthropic"].key: ANTHROPIC_API_KEY -> $ANTHROPIC_API_KEY'); + expect(logSpy).not.toHaveBeenCalled(); }); it.each([ ["malformed", '{\n "providers": {\n'], ["blank", ""], - ])("does not throw on %s models.json during config migration", (_name, content) => { + ])("does not throw on %s models.json during migrations", (_name, content) => { const agentDir = createAgentDir(); const modelsPath = path.join(agentDir, "models.json"); fs.writeFileSync(modelsPath, content, "utf-8"); @@ -87,71 +85,93 @@ describe("config value env var syntax migration", () => { expect(loadError).toContain(`File: ${modelsPath}`); }); - it("rewrites legacy uppercase models.json API key and header values", () => { + it("leaves uppercase models.json API key and header values unchanged", async () => { const agentDir = createAgentDir(); - fs.writeFileSync( - path.join(agentDir, "models.json"), - `${JSON.stringify( - { - providers: { - "custom-provider": { - baseUrl: "https://example.com/v1", - apiKey: "CUSTOM_API_KEY", - api: "openai-completions", - headers: { - "x-api-key": "HEADER_API_KEY", - "x-literal": "literal", - }, - models: [ - { - id: "model-a", - headers: { "x-model-key": "MODEL_API_KEY" }, + const envKeys = ["CUSTOM_API_KEY", "HEADER_API_KEY", "MODEL_API_KEY", "OVERRIDE_API_KEY"]; + const savedEnv: Record = {}; + for (const key of envKeys) { + savedEnv[key] = process.env[key]; + process.env[key] = `env-${key}`; + } + + try { + fs.writeFileSync( + path.join(agentDir, "models.json"), + `${JSON.stringify( + { + providers: { + "custom-provider": { + baseUrl: "https://example.com/v1", + apiKey: "CUSTOM_API_KEY", + api: "openai-completions", + headers: { + "x-api-key": "HEADER_API_KEY", + "x-literal": "literal", + }, + models: [ + { + id: "model-a", + headers: { "x-model-key": "MODEL_API_KEY" }, + }, + ], + modelOverrides: { + "model-b": { headers: { "x-override-key": "OVERRIDE_API_KEY" } }, }, - ], - modelOverrides: { - "model-b": { headers: { "x-override-key": "OVERRIDE_API_KEY" } }, }, }, }, + null, + 2, + )}\n`, + "utf-8", + ); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + withAgentDir(agentDir, () => runMigrations(agentDir)); + + const migrated = JSON.parse(fs.readFileSync(path.join(agentDir, "models.json"), "utf-8")) as { + providers: Record< + string, + { + apiKey?: string; + headers?: Record; + models?: Array<{ headers?: Record }>; + modelOverrides?: Record }>; + } + >; + }; + const provider = migrated.providers["custom-provider"]!; + expect(provider.apiKey).toBe("CUSTOM_API_KEY"); + expect(provider.headers?.["x-api-key"]).toBe("HEADER_API_KEY"); + expect(provider.headers?.["x-literal"]).toBe("literal"); + expect(provider.models?.[0]?.headers?.["x-model-key"]).toBe("MODEL_API_KEY"); + expect(provider.modelOverrides?.["model-b"]?.headers?.["x-override-key"]).toBe("OVERRIDE_API_KEY"); + expect(logSpy).not.toHaveBeenCalled(); + + const registry = ModelRegistry.create( + AuthStorage.create(path.join(agentDir, "auth.json")), + path.join(agentDir, "models.json"), + ); + const model = registry.find("custom-provider", "model-a"); + expect(model).toBeDefined(); + expect(await registry.getApiKeyForProvider("custom-provider")).toBe("CUSTOM_API_KEY"); + expect(await registry.getApiKeyAndHeaders(model!)).toMatchObject({ + ok: true, + apiKey: "CUSTOM_API_KEY", + headers: { + "x-api-key": "HEADER_API_KEY", + "x-literal": "literal", + "x-model-key": "MODEL_API_KEY", }, - null, - 2, - )}\n`, - "utf-8", - ); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - - withAgentDir(agentDir, () => runMigrations(agentDir)); - - const migrated = JSON.parse(fs.readFileSync(path.join(agentDir, "models.json"), "utf-8")) as { - providers: Record< - string, - { - apiKey?: string; - headers?: Record; - models?: Array<{ headers?: Record }>; - modelOverrides?: Record }>; + }); + } finally { + for (const key of envKeys) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; } - >; - }; - const provider = migrated.providers["custom-provider"]!; - expect(provider.apiKey).toBe("$CUSTOM_API_KEY"); - expect(provider.headers?.["x-api-key"]).toBe("$HEADER_API_KEY"); - expect(provider.headers?.["x-literal"]).toBe("literal"); - expect(provider.models?.[0]?.headers?.["x-model-key"]).toBe("$MODEL_API_KEY"); - expect(provider.modelOverrides?.["model-b"]?.headers?.["x-override-key"]).toBe("$OVERRIDE_API_KEY"); - const logMessage = String(logSpy.mock.calls[0]?.[0] ?? ""); - expect(logMessage).toContain( - 'models.json.providers["custom-provider"].apiKey: CUSTOM_API_KEY -> $CUSTOM_API_KEY', - ); - expect(logMessage).toContain( - 'models.json.providers["custom-provider"].headers["x-api-key"]: HEADER_API_KEY -> $HEADER_API_KEY', - ); - expect(logMessage).toContain( - 'models.json.providers["custom-provider"].models["model-a"].headers["x-model-key"]: MODEL_API_KEY -> $MODEL_API_KEY', - ); - expect(logMessage).toContain( - 'models.json.providers["custom-provider"].modelOverrides["model-b"].headers["x-override-key"]: OVERRIDE_API_KEY -> $OVERRIDE_API_KEY', - ); + } + } }); }); diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 95b70fbd..3cba5264 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -7,7 +7,6 @@ import { getOAuthProvider } from "@earendil-works/pi-ai/oauth"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.ts"; import { clearApiKeyCache, ModelRegistry, type ProviderConfigInput } from "../src/core/model-registry.ts"; -import { clearDeprecationWarningsForTests } from "../src/utils/deprecation.ts"; describe("ModelRegistry", () => { let tempDir: string; @@ -19,7 +18,6 @@ describe("ModelRegistry", () => { mkdirSync(tempDir, { recursive: true }); modelsJsonPath = join(tempDir, "models.json"); authStorage = AuthStorage.create(join(tempDir, "auth.json")); - clearDeprecationWarningsForTests(); }); afterEach(() => { @@ -27,7 +25,6 @@ describe("ModelRegistry", () => { rmSync(tempDir, { recursive: true }); } clearApiKeyCache(); - clearDeprecationWarningsForTests(); vi.restoreAllMocks(); }); @@ -896,26 +893,55 @@ describe("ModelRegistry", () => { expect(registry.getProviderDisplayName("oauth-provider")).toBe("OAuth Provider"); }); - test("registerProvider warns and temporarily treats uppercase apiKey as an env reference", async () => { - const originalEnv = process.env.CUSTOM_NAME; - process.env.CUSTOM_NAME = "legacy-env-key"; + test("registerProvider treats uppercase apiKey and headers as literals", async () => { + const envKeys = ["CUSTOM_NAME", "BEARER", "MODEL_TOKEN"]; + const savedEnv: Record = {}; + for (const key of envKeys) { + savedEnv[key] = process.env[key]; + process.env[key] = `env-${key}`; + } const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); try { const registry = ModelRegistry.create(authStorage, modelsJsonPath); - registry.registerProvider("legacy-provider", { + registry.registerProvider("literal-provider", { ...providerConfig("https://provider.test/v1", [{ id: "demo-model" }], "openai-completions"), apiKey: "CUSTOM_NAME", + headers: { Authorization: "BEARER" }, + models: [ + { + id: "demo-model", + name: "demo-model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 100000, + maxTokens: 8000, + headers: { "x-model-token": "MODEL_TOKEN" }, + }, + ], }); - expect(await registry.getApiKeyForProvider("legacy-provider")).toBe("legacy-env-key"); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Pass "$CUSTOM_NAME" instead')); + expect(await registry.getApiKeyForProvider("literal-provider")).toBe("CUSTOM_NAME"); + const model = registry.find("literal-provider", "demo-model"); + expect(model).toBeDefined(); + expect(await registry.getApiKeyAndHeaders(model!)).toMatchObject({ + ok: true, + apiKey: "CUSTOM_NAME", + headers: { + Authorization: "BEARER", + "x-model-token": "MODEL_TOKEN", + }, + }); + expect(warnSpy).not.toHaveBeenCalled(); } finally { - if (originalEnv === undefined) { - delete process.env.CUSTOM_NAME; - } else { - process.env.CUSTOM_NAME = originalEnv; + for (const key of envKeys) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } } } }); diff --git a/packages/coding-agent/test/suite/regressions/5661-uppercase-header-values.test.ts b/packages/coding-agent/test/suite/regressions/5661-uppercase-header-values.test.ts new file mode 100644 index 00000000..e625a3c6 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/5661-uppercase-header-values.test.ts @@ -0,0 +1,91 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { ENV_AGENT_DIR } from "../../../src/config.ts"; +import { AuthStorage } from "../../../src/core/auth-storage.ts"; +import { ModelRegistry } from "../../../src/core/model-registry.ts"; +import { runMigrations } from "../../../src/migrations.ts"; +import { createHarness } from "../harness.ts"; + +describe("regression #5661: uppercase models.json header values", () => { + const cleanups: Array<() => void> = []; + + afterEach(() => { + while (cleanups.length > 0) { + cleanups.pop()?.(); + } + }); + + function withAgentDir(agentDir: string, fn: () => void): void { + const previousAgentDir = process.env[ENV_AGENT_DIR]; + process.env[ENV_AGENT_DIR] = agentDir; + try { + fn(); + } finally { + if (previousAgentDir === undefined) { + delete process.env[ENV_AGENT_DIR]; + } else { + process.env[ENV_AGENT_DIR] = previousAgentDir; + } + } + } + + it("keeps uppercase header strings as literals during startup migrations", async () => { + const harness = await createHarness({ withConfiguredAuth: false }); + cleanups.push(harness.cleanup); + + const envKeys = ["CUSTOM_API_KEY", "BEARER"]; + const savedEnv: Record = {}; + for (const key of envKeys) { + savedEnv[key] = process.env[key]; + process.env[key] = `env-${key}`; + } + cleanups.push(() => { + for (const key of envKeys) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + }); + + const modelsPath = join(harness.tempDir, "models.json"); + writeFileSync( + modelsPath, + `${JSON.stringify( + { + providers: { + "my-provider": { + baseUrl: "https://example.com/v1", + apiKey: "CUSTOM_API_KEY", + api: "openai-completions", + headers: { Authorization: "BEARER" }, + models: [{ id: "my-model" }], + }, + }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + + withAgentDir(harness.tempDir, () => runMigrations(harness.tempDir)); + + const migrated = JSON.parse(readFileSync(modelsPath, "utf-8")) as { + providers: Record }>; + }; + expect(migrated.providers["my-provider"]?.apiKey).toBe("CUSTOM_API_KEY"); + expect(migrated.providers["my-provider"]?.headers?.Authorization).toBe("BEARER"); + + const registry = ModelRegistry.create(AuthStorage.create(join(harness.tempDir, "auth.json")), modelsPath); + const model = registry.find("my-provider", "my-model"); + expect(model).toBeDefined(); + expect(await registry.getApiKeyAndHeaders(model!)).toMatchObject({ + ok: true, + apiKey: "CUSTOM_API_KEY", + headers: { Authorization: "BEARER" }, + }); + }); +}); From 21a904f4b7a117b253dac05f27e39724ad9e2385 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 13 Jun 2026 23:59:45 +0200 Subject: [PATCH 252/352] fix(ai): disable OpenCode long cache retention for rejecting routes closes #5702 --- packages/ai/CHANGELOG.md | 4 ++ packages/ai/scripts/generate-models.ts | 16 +++++++ packages/ai/src/models.generated.ts | 56 +++++++++++++++++++----- packages/ai/test/cache-retention.test.ts | 40 +++++++++++++++++ packages/coding-agent/CHANGELOG.md | 1 + 5 files changed, 107 insertions(+), 10 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 03604c9b..71da18c3 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed OpenCode/OpenCode Go completion models that reject `prompt_cache_retention` to omit long-retention cache fields when `cacheRetention` is `long` ([#5702](https://github.com/earendil-works/pi/issues/5702)). + ## [0.79.3] - 2026-06-13 ### Fixed diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 1eaf9fe1..63c54a56 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -187,6 +187,15 @@ const OPENAI_RESPONSES_NONE_REASONING_MODELS = new Set([ "gpt-5.5", ]); +const OPENCODE_OPENAI_COMPLETIONS_LONG_CACHE_RETENTION_UNSUPPORTED_MODELS = new Set([ + "opencode:deepseek-v4-flash", + "opencode:deepseek-v4-pro", + "opencode:kimi-k2.5", + "opencode:kimi-k2.6", + "opencode:minimax-m2.7", + "opencode-go:kimi-k2.6", +]); + function mergeThinkingLevelMap(model: Model, map: NonNullable["thinkingLevelMap"]>): void { model.thinkingLevelMap = { ...model.thinkingLevelMap, ...map }; } @@ -1070,6 +1079,13 @@ async function loadModelsDevData(): Promise[]> { if (api === "openai-completions") { compat = { ...(compat ?? {}), maxTokensField: "max_tokens" }; + if ( + OPENCODE_OPENAI_COMPLETIONS_LONG_CACHE_RETENTION_UNSUPPORTED_MODELS.has( + `${variant.provider}:${modelId}`, + ) + ) { + compat = { ...compat, supportsLongCacheRetention: false }; + } } models.push({ diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 0b59d572..35295e5d 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -7889,7 +7889,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", - compat: {"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + compat: {"maxTokensField":"max_tokens","supportsLongCacheRetention":false,"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, input: ["text"], @@ -7927,7 +7927,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", - compat: {"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + compat: {"maxTokensField":"max_tokens","supportsLongCacheRetention":false,"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, input: ["text"], @@ -8343,7 +8343,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", - compat: {"maxTokensField":"max_tokens"}, + compat: {"maxTokensField":"max_tokens","supportsLongCacheRetention":false}, reasoning: true, input: ["text", "image"], cost: { @@ -8361,7 +8361,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", - compat: {"thinkingFormat":"deepseek","supportsReasoningEffort":false,"maxTokensField":"max_tokens"}, + compat: {"thinkingFormat":"deepseek","supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsLongCacheRetention":false}, reasoning: true, input: ["text", "image"], cost: { @@ -8415,7 +8415,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", - compat: {"maxTokensField":"max_tokens"}, + compat: {"maxTokensField":"max_tokens","supportsLongCacheRetention":false}, reasoning: true, input: ["text"], cost: { @@ -8579,7 +8579,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", - compat: {"thinkingFormat":"deepseek","supportsReasoningEffort":false,"maxTokensField":"max_tokens"}, + compat: {"thinkingFormat":"deepseek","supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsLongCacheRetention":false}, reasoning: true, thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, input: ["text", "image"], @@ -10473,13 +10473,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.95, - output: 4, - cacheRead: 0.19, + input: 0.75, + output: 3.5, + cacheRead: 0.16, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 4096, + maxTokens: 262144, } satisfies Model<"openai-completions">, "nex-agi/nex-n2-pro:free": { id: "nex-agi/nex-n2-pro:free", @@ -16823,6 +16823,24 @@ export const MODELS = { contextWindow: 200000, maxTokens: 131072, } satisfies Model<"openai-completions">, + "glm-5.2": { + id: "glm-5.2", + name: "GLM-5.2", + api: "openai-completions", + provider: "zai", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","zaiToolStream":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 131072, + } satisfies Model<"openai-completions">, "glm-5v-turbo": { id: "glm-5v-turbo", name: "GLM-5V-Turbo", @@ -16915,6 +16933,24 @@ export const MODELS = { contextWindow: 200000, maxTokens: 131072, } satisfies Model<"openai-completions">, + "glm-5.2": { + id: "glm-5.2", + name: "GLM-5.2", + api: "openai-completions", + provider: "zai-coding-cn", + baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", + compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","zaiToolStream":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 131072, + } satisfies Model<"openai-completions">, "glm-5v-turbo": { id: "glm-5v-turbo", name: "GLM-5V-Turbo", diff --git a/packages/ai/test/cache-retention.test.ts b/packages/ai/test/cache-retention.test.ts index 8828ba86..6e2c1a5b 100644 --- a/packages/ai/test/cache-retention.test.ts +++ b/packages/ai/test/cache-retention.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { MODELS } from "../src/models.generated.ts"; import { getModel } from "../src/models.ts"; import { streamAnthropic } from "../src/providers/anthropic.ts"; import { streamOpenAICompletions } from "../src/providers/openai-completions.ts"; @@ -13,6 +14,11 @@ class PayloadCaptured extends Error { } } +interface OpenAICompletionsCachePayload { + prompt_cache_key?: string; + prompt_cache_retention?: string; +} + function stopAfterPayload(capture: (payload: TPayload) => void): (payload: unknown) => never { return (payload: unknown): never => { capture(payload as TPayload); @@ -455,5 +461,39 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { expect(capturedPayload.prompt_cache_key).toBeUndefined(); expect(capturedPayload.prompt_cache_retention).toBeUndefined(); }); + + it.each([ + MODELS.opencode["deepseek-v4-flash"], + MODELS.opencode["deepseek-v4-pro"], + MODELS.opencode["kimi-k2.5"], + MODELS.opencode["kimi-k2.6"], + MODELS.opencode["minimax-m2.7"], + MODELS["opencode-go"]["kimi-k2.6"], + ] as const)("should omit long cache retention for $provider/$id", async (metadata) => { + const model = metadata as Model<"openai-completions">; + let capturedPayload: OpenAICompletionsCachePayload | undefined; + + try { + const s = streamOpenAICompletions(model, context, { + apiKey: "fake-key", + cacheRetention: "long", + sessionId: "session-opencode-long-cache-unsupported", + onPayload: stopAfterPayload((payload) => { + capturedPayload = payload; + }), + }); + + for await (const event of s) { + if (event.type === "error") break; + } + } catch { + // Expected to fail + } + + expect(model.compat?.supportsLongCacheRetention).toBe(false); + expect(capturedPayload).toBeDefined(); + expect(capturedPayload?.prompt_cache_key).toBeUndefined(); + expect(capturedPayload?.prompt_cache_retention).toBeUndefined(); + }); }); }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6b17d85a..3cb2c685 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed inherited OpenCode/OpenCode Go completion model metadata to omit long-retention cache fields for routes that reject `prompt_cache_retention` ([#5702](https://github.com/earendil-works/pi/issues/5702)). - Fixed custom provider config so plain uppercase API key and header values remain literals instead of being treated as legacy environment references; use explicit `$ENV_VAR` syntax for environment variables ([#5661](https://github.com/earendil-works/pi/issues/5661)). ## [0.79.3] - 2026-06-13 From 5be8c31fa7a26f26cdc78b021e64ff0b7541d57a Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 14 Jun 2026 00:57:56 +0200 Subject: [PATCH 253/352] meta: add extension disclaimer to bug reporting --- .github/ISSUE_TEMPLATE/bug.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index cf50ffde..43cb73c5 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -11,6 +11,8 @@ body: Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice. + **Important:** before reporting an issue in core, please validate first with `pi -ne` that this is not caused by an extension you loaded. + - type: textarea id: description attributes: From 2fbdff9dab1a1652aa9f97e9508b90b4ae44a7ce Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 14 Jun 2026 01:31:32 +0200 Subject: [PATCH 254/352] fix(coding-agent): fix pnpm self-update bin-dir closes #5689 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/config.ts | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3cb2c685..152ee9f0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed `pi update` for pnpm global installs whose configured `global-bin-dir` no longer matches the active pnpm home ([#5689](https://github.com/earendil-works/pi/issues/5689)). - Fixed inherited OpenCode/OpenCode Go completion model metadata to omit long-retention cache fields for routes that reject `prompt_cache_retention` ([#5702](https://github.com/earendil-works/pi/issues/5702)). - Fixed custom provider config so plain uppercase API key and header values remain literals instead of being treated as legacy environment references; use explicit `$ENV_VAR` syntax for environment variables ([#5661](https://github.com/earendil-works/pi/issues/5661)). diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 65fb220e..75b2efaf 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -109,19 +109,27 @@ function getSelfUpdateCommandForMethod( switch (method) { case "bun-binary": return undefined; - case "pnpm": + case "pnpm": { + const match = readCommandOutput("pnpm", ["root", "-g"]) + ? undefined + : /^(.*[\\/]global[\\/][^\\/]+)[\\/]\.pnpm[\\/]/.exec(getPackageDir()); + const binDirArgs = match + ? [`--config.global-bin-dir=${process.env.PNPM_HOME || dirname(dirname(match[1]))}`] + : []; return makeSelfUpdateCommand( makeSelfUpdateCommandStep("pnpm", [ "install", "-g", "--ignore-scripts", "--config.minimumReleaseAge=0", + ...binDirArgs, updatePackageName, ]), updatePackageName === installedPackageName ? undefined - : makeSelfUpdateCommandStep("pnpm", ["remove", "-g", installedPackageName]), + : makeSelfUpdateCommandStep("pnpm", ["remove", "-g", ...binDirArgs, installedPackageName]), ); + } case "yarn": return makeSelfUpdateCommand( makeSelfUpdateCommandStep("yarn", ["global", "add", "--ignore-scripts", updatePackageName]), @@ -205,7 +213,9 @@ function getGlobalPackageRoots(method: InstallMethod, _packageName: string, npmC } case "pnpm": { const root = readCommandOutput("pnpm", ["root", "-g"]); - return root ? [root, dirname(root)] : []; + if (root) return [root, dirname(root)]; + const match = /^(.*[\\/]global[\\/][^\\/]+)[\\/]\.pnpm[\\/]/.exec(getPackageDir()); + return match ? [match[1]] : []; } case "yarn": { const dir = readCommandOutput("yarn", ["global", "dir"]); From c48f656f88697b23c65cce1e658893eda02c4c12 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 14 Jun 2026 01:37:58 +0200 Subject: [PATCH 255/352] fix(coding-agent): handle npm package semver ranges closes #5695 --- package-lock.json | 10 +++- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/npm-shrinkwrap.json | 13 +++++ packages/coding-agent/package.json | 2 + .../coding-agent/src/core/package-manager.ts | 53 ++++++++++++------- .../coding-agent/src/utils/version-check.ts | 34 ++---------- .../coding-agent/test/package-manager.test.ts | 38 ++++++++----- .../coding-agent/test/version-check.test.ts | 1 + 8 files changed, 90 insertions(+), 62 deletions(-) diff --git a/package-lock.json b/package-lock.json index 11022066..4ee5233f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2337,6 +2337,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript/native-preview": { "version": "7.0.0-dev.20260120.1", "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260120.1.tgz", @@ -4700,7 +4707,6 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -6269,6 +6275,7 @@ "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", + "semver": "7.8.0", "typebox": "1.1.38", "undici": "8.3.0", "yaml": "2.9.0" @@ -6283,6 +6290,7 @@ "@types/ms": "2.1.0", "@types/node": "24.12.4", "@types/proper-lockfile": "4.1.4", + "@types/semver": "7.7.1", "shx": "0.4.0", "typescript": "5.9.3", "vitest": "3.2.4" diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 152ee9f0..71de722b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed `pi update` for pnpm global installs whose configured `global-bin-dir` no longer matches the active pnpm home ([#5689](https://github.com/earendil-works/pi/issues/5689)). +- Fixed npm package specs that use ranges or tags (for example `@^1.2.7`) so installed package resources still load instead of being treated as mismatched exact pins ([#5695](https://github.com/earendil-works/pi/issues/5695)). - Fixed inherited OpenCode/OpenCode Go completion model metadata to omit long-retention cache fields for routes that reject `prompt_cache_retention` ([#5702](https://github.com/earendil-works/pi/issues/5702)). - Fixed custom provider config so plain uppercase API key and header values remain literals instead of being treated as legacy environment references; use explicit `$ENV_VAR` syntax for environment variables ([#5661](https://github.com/earendil-works/pi/issues/5661)). diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index e660e9d7..74eff303 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -23,6 +23,7 @@ "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", + "semver": "7.8.0", "typebox": "1.1.38", "undici": "8.3.0", "yaml": "2.9.0" @@ -1636,6 +1637,18 @@ } ] }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 851b80a3..ff4522ce 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -50,6 +50,7 @@ "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", + "semver": "7.8.0", "typebox": "1.1.38", "undici": "8.3.0", "yaml": "2.9.0" @@ -70,6 +71,7 @@ "@types/ms": "2.1.0", "@types/node": "24.12.4", "@types/proper-lockfile": "4.1.4", + "@types/semver": "7.7.1", "shx": "0.4.0", "typescript": "5.9.3", "vitest": "3.2.4" diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 5120c9ad..ebdf974b 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -27,6 +27,7 @@ import type { Readable } from "node:stream"; import { globSync } from "glob"; import ignore from "ignore"; import { minimatch } from "minimatch"; +import { maxSatisfying, rcompare, satisfies, valid, validRange } from "semver"; import { CONFIG_DIR_NAME } from "../config.ts"; import { spawnProcess, spawnProcessSync } from "../utils/child-process.ts"; import { type GitSource, parseGitUrl } from "../utils/git.ts"; @@ -44,6 +45,14 @@ function isOfflineModeEnabled(): boolean { return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes"; } +function isExactNpmVersion(version: string | undefined): boolean { + return valid(version ?? "") !== null; +} + +function getNpmVersionRange(version: string | undefined): string | undefined { + return version ? (validRange(version) ?? undefined) : undefined; +} + export interface PathMetadata { source: string; scope: SourceScope; @@ -119,6 +128,8 @@ type NpmSource = { type: "npm"; spec: string; name: string; + version?: string; + range?: string; pinned: boolean; }; @@ -1113,8 +1124,8 @@ export class DefaultPackageManager implements PackageManager { } try { - const latestVersion = await this.getLatestNpmVersion(source.name); - return latestVersion !== installedVersion; + const targetVersion = await this.getLatestNpmVersion(source.version ? source.spec : source.name, source.range); + return targetVersion !== installedVersion; } catch { // Preserve existing update behavior when version lookup fails. return true; @@ -1128,7 +1139,7 @@ export class DefaultPackageManager implements PackageManager { const sourceLabel = sources.length === 1 ? sources[0].source : `${scope} npm packages`; const message = sources.length === 1 ? `Updating ${sources[0].source}...` : `Updating ${scope} npm packages...`; - const specs = sources.map((entry) => `${entry.parsed.name}@latest`); + const specs = sources.map((entry) => (entry.parsed.version ? entry.parsed.spec : `${entry.parsed.name}@latest`)); await this.withProgress("update", sourceLabel, message, async () => { await this.installNpmBatch(specs, scope); @@ -1241,8 +1252,7 @@ export class DefaultPackageManager implements PackageManager { if (parsed.type === "npm") { let installedPath = this.getNpmInstallPath(parsed, scope); const needsInstall = - !existsSync(installedPath) || - (parsed.pinned && !(await this.installedNpmMatchesPinnedVersion(parsed, installedPath))); + !existsSync(installedPath) || !(await this.installedNpmMatchesConfiguredVersion(parsed, installedPath)); if (needsInstall) { const installed = await installMissing(); if (!installed) continue; @@ -1394,7 +1404,9 @@ export class DefaultPackageManager implements PackageManager { type: "npm", spec, name, - pinned: Boolean(version), + version, + range: getNpmVersionRange(version), + pinned: isExactNpmVersion(version), }; } @@ -1411,18 +1423,12 @@ export class DefaultPackageManager implements PackageManager { return { type: "local", path: source }; } - private async installedNpmMatchesPinnedVersion(source: NpmSource, installedPath: string): Promise { + private async installedNpmMatchesConfiguredVersion(source: NpmSource, installedPath: string): Promise { const installedVersion = this.getInstalledNpmVersion(installedPath); if (!installedVersion) { return false; } - - const { version: pinnedVersion } = this.parseNpmSpec(source.spec); - if (!pinnedVersion) { - return true; - } - - return installedVersion === pinnedVersion; + return source.range ? satisfies(installedVersion, source.range) : true; } private async npmHasAvailableUpdate(source: NpmSource, installedPath: string): Promise { @@ -1436,8 +1442,8 @@ export class DefaultPackageManager implements PackageManager { } try { - const latestVersion = await this.getLatestNpmVersion(source.name); - return latestVersion !== installedVersion; + const targetVersion = await this.getLatestNpmVersion(source.version ? source.spec : source.name, source.range); + return targetVersion !== installedVersion; } catch { return false; } @@ -1455,16 +1461,25 @@ export class DefaultPackageManager implements PackageManager { } } - private async getLatestNpmVersion(packageName: string): Promise { + private async getLatestNpmVersion(packageSpec: string, range?: string): Promise { const npmCommand = this.getNpmCommand(); const stdout = await this.runCommandCapture( npmCommand.command, - [...npmCommand.args, "view", packageName, "version", "--json"], + [...npmCommand.args, "view", packageSpec, "version", "--json"], { cwd: this.cwd, timeoutMs: NETWORK_TIMEOUT_MS }, ); const raw = stdout.trim(); if (!raw) throw new Error("Empty response from npm view"); - return JSON.parse(raw); + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed === "string") { + return parsed; + } + if (Array.isArray(parsed)) { + const versions = parsed.filter((value): value is string => typeof value === "string" && value.length > 0); + const latest = range ? maxSatisfying(versions, range) : [...versions].sort(rcompare)[0]; + if (latest) return latest; + } + throw new Error("Unexpected response from npm view"); } private async gitHasAvailableUpdate(installedPath: string): Promise { diff --git a/packages/coding-agent/src/utils/version-check.ts b/packages/coding-agent/src/utils/version-check.ts index aec25bdb..a6a4f469 100644 --- a/packages/coding-agent/src/utils/version-check.ts +++ b/packages/coding-agent/src/utils/version-check.ts @@ -1,3 +1,4 @@ +import { compare, valid } from "semver"; import { getPiUserAgent } from "./pi-user-agent.ts"; const LATEST_VERSION_URL = "https://pi.dev/api/latest-version"; @@ -9,40 +10,13 @@ export interface LatestPiRelease { note?: string; } -interface ParsedVersion { - major: number; - minor: number; - patch: number; - prerelease?: string; -} - -function parsePackageVersion(version: string): ParsedVersion | undefined { - const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/); - if (!match) { - return undefined; - } - return { - major: Number.parseInt(match[1], 10), - minor: Number.parseInt(match[2], 10), - patch: Number.parseInt(match[3], 10), - prerelease: match[4], - }; -} - export function comparePackageVersions(leftVersion: string, rightVersion: string): number | undefined { - const left = parsePackageVersion(leftVersion); - const right = parsePackageVersion(rightVersion); + const left = valid(leftVersion.trim()); + const right = valid(rightVersion.trim()); if (!left || !right) { return undefined; } - - if (left.major !== right.major) return left.major - right.major; - if (left.minor !== right.minor) return left.minor - right.minor; - if (left.patch !== right.patch) return left.patch - right.patch; - if (left.prerelease === right.prerelease) return 0; - if (!left.prerelease) return 1; - if (!right.prerelease) return -1; - return left.prerelease.localeCompare(right.prerelease); + return compare(left, right); } export function isNewerPackageVersion(candidateVersion: string, currentVersion: string): boolean { diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index cd1f4532..9b1b0d7f 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -1128,8 +1128,17 @@ Content`, }); it("should parse package source types from docs examples", () => { - expect((packageManager as any).parseSource("npm:@scope/pkg@1.2.3").type).toBe("npm"); - expect((packageManager as any).parseSource("npm:pkg").type).toBe("npm"); + const parseNpm = (source: string) => { + const parsed = (packageManager as any).parseSource(source); + if (parsed.type !== "npm") { + throw new Error(`Expected npm source: ${source}`); + } + return parsed; + }; + + expect(parseNpm("npm:@scope/pkg@1.2.3").pinned).toBe(true); + expect(parseNpm("npm:@scope/pkg@^1.2.3").pinned).toBe(false); + expect(parseNpm("npm:pkg").pinned).toBe(false); expect((packageManager as any).parseSource("git:github.com/user/repo@v1").type).toBe("git"); expect((packageManager as any).parseSource("https://github.com/user/repo@v1").type).toBe("git"); @@ -2052,25 +2061,27 @@ export default function(api) { api.registerTool({ name: "test", description: "te }); describe("offline mode and network timeouts", () => { - it("should update project npm packages using @latest when newer version is available", async () => { + it("should update npm range packages using the configured spec", async () => { const installedPath = join(tempDir, ".pi", "npm", "node_modules", "example"); mkdirSync(installedPath, { recursive: true }); writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "1.0.0" })); - settingsManager.setProjectPackages(["npm:example"]); + settingsManager.setProjectPackages(["npm:example@^1.0.0"]); - const runCommandCaptureSpy = vi.spyOn(packageManager as any, "runCommandCapture").mockResolvedValue('"1.2.3"'); + const runCommandCaptureSpy = vi + .spyOn(packageManager as any, "runCommandCapture") + .mockResolvedValue('["1.0.0","1.2.0"]'); const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockResolvedValue(undefined); await packageManager.update("npm:example"); expect(runCommandCaptureSpy).toHaveBeenCalledWith( "npm", - ["view", "example", "version", "--json"], + ["view", "example@^1.0.0", "version", "--json"], expect.objectContaining({ cwd: tempDir, timeoutMs: expect.any(Number) }), ); expect(runCommandSpy).toHaveBeenCalledWith( "npm", - ["install", "example@latest", "--prefix", join(tempDir, ".pi", "npm"), "--legacy-peer-deps"], + ["install", "example@^1.0.0", "--prefix", join(tempDir, ".pi", "npm"), "--legacy-peer-deps"], undefined, ); }); @@ -2078,17 +2089,19 @@ export default function(api) { api.registerTool({ name: "test", description: "te it("should skip project npm update when installed version matches latest", async () => { const installedPath = join(tempDir, ".pi", "npm", "node_modules", "example"); mkdirSync(installedPath, { recursive: true }); - writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "1.2.3" })); - settingsManager.setProjectPackages(["npm:example"]); + writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "1.3.1" })); + settingsManager.setProjectPackages(["npm:example@^1.0.0"]); - const runCommandCaptureSpy = vi.spyOn(packageManager as any, "runCommandCapture").mockResolvedValue('"1.2.3"'); + const runCommandCaptureSpy = vi + .spyOn(packageManager as any, "runCommandCapture") + .mockResolvedValue('["1.0.0","1.3.1","1.0.2"]'); const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockResolvedValue(undefined); await packageManager.update("npm:example"); expect(runCommandCaptureSpy).toHaveBeenCalledWith( "npm", - ["view", "example", "version", "--json"], + ["view", "example@^1.0.0", "version", "--json"], expect.objectContaining({ cwd: tempDir, timeoutMs: expect.any(Number) }), ); expect(runCommandSpy).not.toHaveBeenCalled(); @@ -2298,11 +2311,12 @@ export default function(api) { api.registerTool({ name: "test", description: "te }); it("should not run npm view during resolve for installed unpinned packages", async () => { + process.env.PI_OFFLINE = "1"; const installedPath = join(tempDir, ".pi", "npm", "node_modules", "example"); mkdirSync(join(installedPath, "extensions"), { recursive: true }); writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "1.0.0" })); writeFileSync(join(installedPath, "extensions", "index.ts"), "export default function() {};"); - settingsManager.setProjectPackages(["npm:example"]); + settingsManager.setProjectPackages(["npm:example@^1.0.0"]); const runCommandCaptureSpy = vi.spyOn(packageManager as any, "runCommandCapture"); diff --git a/packages/coding-agent/test/version-check.test.ts b/packages/coding-agent/test/version-check.test.ts index 70197fa5..b294c627 100644 --- a/packages/coding-agent/test/version-check.test.ts +++ b/packages/coding-agent/test/version-check.test.ts @@ -29,6 +29,7 @@ describe("version checks", () => { expect(comparePackageVersions("0.70.6", "0.70.5")).toBeGreaterThan(0); expect(comparePackageVersions("0.70.5", "0.70.5")).toBe(0); expect(comparePackageVersions("0.70.4", "0.70.5")).toBeLessThan(0); + expect(comparePackageVersions("5.0.0-beta.20", "5.0.0-beta.9")).toBeGreaterThan(0); expect(isNewerPackageVersion("0.70.5", "0.70.5")).toBe(false); expect(isNewerPackageVersion("0.70.6", "0.70.5")).toBe(true); }); From 3fcfb7abf77784bfe52567f7670870834efb65d1 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 14 Jun 2026 02:05:11 +0200 Subject: [PATCH 256/352] docs(coding-agent): document extension resource lifecycle --- packages/coding-agent/docs/extensions.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index cb02960f..7038351d 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -216,6 +216,12 @@ export default async function (pi: ExtensionAPI) { This pattern makes the fetched models available during normal startup and to `pi --list-models`. +### Long-lived resources and shutdown + +Extension factories may run in invocations that never start a session. Do not start background resources such as processes, sockets, file watchers, or timers from the factory. + +Defer background resource startup until `session_start` or the command/tool/event that needs the resource. Register an idempotent `session_shutdown` handler to close any session-scoped resources you start. + ### Extension Styles **Single file** - simplest, for small extensions: @@ -471,7 +477,7 @@ pi.on("session_tree", async (event, ctx) => { #### session_shutdown -Fired before an extension runtime is torn down. +Fired before a started session runtime is torn down. Use this to clean up resources opened from `session_start` or other session-scoped hooks. ```typescript pi.on("session_shutdown", async (event, ctx) => { From f0989800cbd577979b6f38c3cc87bdc0514bff39 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Sun, 14 Jun 2026 07:14:42 +0200 Subject: [PATCH 257/352] feat: detect first-run terminal theme (#5385) --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/src/cli/startup-ui.ts | 34 +-- .../src/modes/interactive/interactive-mode.ts | 59 +++-- .../src/modes/interactive/theme/theme.ts | 85 +++---- .../coding-agent/test/theme-detection.test.ts | 86 +++++-- packages/tui/CHANGELOG.md | 4 + packages/tui/src/index.ts | 2 + packages/tui/src/terminal-colors.ts | 62 +++++ packages/tui/src/tui.ts | 64 +++++ packages/tui/test/terminal-colors.test.ts | 233 ++++++++++++++++++ 10 files changed, 532 insertions(+), 101 deletions(-) create mode 100644 packages/tui/src/terminal-colors.ts create mode 100644 packages/tui/test/terminal-colors.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 71de722b..23d7b939 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added first-run interactive theme detection from the terminal background. + ### Fixed - Fixed `pi update` for pnpm global installs whose configured `global-bin-dir` no longer matches the active pnpm home ([#5689](https://github.com/earendil-works/pi/issues/5689)). diff --git a/packages/coding-agent/src/cli/startup-ui.ts b/packages/coding-agent/src/cli/startup-ui.ts index ec7aa5e7..93841304 100644 --- a/packages/coding-agent/src/cli/startup-ui.ts +++ b/packages/coding-agent/src/cli/startup-ui.ts @@ -10,7 +10,7 @@ import { FirstTimeSetupComponent, type FirstTimeSetupResult, } from "../modes/interactive/components/first-time-setup.ts"; -import { detectTerminalBackground, initTheme, setTheme } from "../modes/interactive/theme/theme.ts"; +import { detectTerminalBackgroundTheme, initTheme, setTheme } from "../modes/interactive/theme/theme.ts"; const OFFICIAL_PACKAGE_NAME = "@earendil-works/pi-coding-agent"; const OFFICIAL_APP_NAME = "pi"; @@ -123,19 +123,25 @@ export async function showFirstTimeSetup(settingsManager: SettingsManager): Prom resolve(); }; - const component = new FirstTimeSetupComponent({ - detectedTheme: detectTerminalBackground().theme, - onThemePreview: (themeName) => { - setTheme(themeName); - ui.invalidate(); - ui.requestRender(); - }, - onSubmit: (result) => void finish(result), - onCancel: () => void finish(undefined), - }); - ui.addChild(component); - ui.setFocus(component); - ui.start(); + const showSetup = async () => { + ui.start(); + const detection = await detectTerminalBackgroundTheme({ ui, timeoutMs: 100 }); + setTheme(detection.theme); + const component = new FirstTimeSetupComponent({ + detectedTheme: detection.theme, + onThemePreview: (themeName) => { + setTheme(themeName); + ui.requestRender(); + }, + onSubmit: (result) => void finish(result), + onCancel: () => void finish(undefined), + }); + ui.addChild(component); + ui.setFocus(component); + ui.requestRender(); + }; + + void showSetup(); }); } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index fc25792e..b3424a15 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -126,6 +126,7 @@ import { TrustSelectorComponent } from "./components/trust-selector.ts"; import { UserMessageComponent } from "./components/user-message.ts"; import { UserMessageSelectorComponent } from "./components/user-message-selector.ts"; import { + detectTerminalBackgroundTheme, getAvailableThemes, getAvailableThemesWithPaths, getEditorTheme, @@ -428,6 +429,25 @@ export class InteractiveMode { initTheme(this.settingsManager.getTheme(), true); } + private async detectThemeIfUnset(): Promise { + if (this.settingsManager.getTheme()) { + return; + } + + const detection = await detectTerminalBackgroundTheme({ ui: this.ui, timeoutMs: 100 }); + const result = setTheme(detection.theme, true); + if (!result.success) { + return; + } + + if (detection.confidence === "high") { + this.settingsManager.setTheme(detection.theme); + await this.settingsManager.flush(); + } + this.updateEditorBorderColor(); + this.ui.requestRender(); + } + private getAutocompleteSourceTag(sourceInfo?: SourceInfo): string | undefined { if (!sourceInfo) { return undefined; @@ -629,9 +649,28 @@ export class InteractiveMode { console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`)); } - // Add header container as first child + // Add header container as first child. Populate it after detectThemeIfUnset. this.ui.addChild(this.headerContainer); + this.ui.addChild(this.chatContainer); + this.ui.addChild(this.pendingMessagesContainer); + this.ui.addChild(this.statusContainer); + this.renderWidgets(); // Initialize with default spacer + this.ui.addChild(this.widgetContainerAbove); + this.ui.addChild(this.editorContainer); + this.ui.addChild(this.widgetContainerBelow); + this.ui.addChild(this.footer); + this.ui.setFocus(this.editor); + + this.setupKeyHandlers(); + this.setupEditorSubmitHandler(); + + // Start the UI before initializing extensions so session_start handlers can use interactive dialogs + this.ui.start(); + this.isInitialized = true; + + await this.detectThemeIfUnset(); + // Add header with keybindings from config (unless silenced) if (this.options.verbose || !this.settingsManager.getQuietStartup()) { const logo = theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`); @@ -692,23 +731,7 @@ export class InteractiveMode { this.builtInHeader = new Text("", 0, 0); this.headerContainer.addChild(this.builtInHeader); } - - this.ui.addChild(this.chatContainer); - this.ui.addChild(this.pendingMessagesContainer); - this.ui.addChild(this.statusContainer); - this.renderWidgets(); // Initialize with default spacer - this.ui.addChild(this.widgetContainerAbove); - this.ui.addChild(this.editorContainer); - this.ui.addChild(this.widgetContainerBelow); - this.ui.addChild(this.footer); - this.ui.setFocus(this.editor); - - this.setupKeyHandlers(); - this.setupEditorSubmitHandler(); - - // Start the UI before initializing extensions so session_start handlers can use interactive dialogs - this.ui.start(); - this.isInitialized = true; + this.ui.requestRender(); // Initialize extensions first so resources are shown before messages await this.rebindCurrentSession(); diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index 8bdf4816..778f8028 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -4,6 +4,7 @@ import { type EditorTheme, getCapabilities, type MarkdownTheme, + type RgbColor, type SelectListTheme, type SettingsListTheme, } from "@earendil-works/pi-tui"; @@ -624,12 +625,6 @@ export function getThemeByName(name: string): Theme | undefined { export type TerminalTheme = "dark" | "light"; -export interface RgbColor { - r: number; - g: number; - b: number; -} - export interface TerminalThemeDetection { theme: TerminalTheme; source: "terminal background" | "COLORFGBG" | "fallback"; @@ -641,6 +636,15 @@ export interface TerminalThemeDetectionOptions { env?: NodeJS.ProcessEnv; } +export interface TerminalBackgroundThemeDetector { + queryTerminalBackgroundColor({ timeoutMs }: { timeoutMs: number }): Promise; +} + +export interface TerminalBackgroundThemeDetectionOptions extends TerminalThemeDetectionOptions { + ui: TerminalBackgroundThemeDetector; + timeoutMs: number; +} + function getColorFgBgBackgroundIndex(colorfgbg: string): number | undefined { const parts = colorfgbg.split(";"); for (let i = parts.length - 1; i >= 0; i--) { @@ -668,50 +672,7 @@ export function getThemeForRgbColor(rgb: RgbColor): TerminalTheme { return getRgbColorLuminance(rgb) >= 0.5 ? "light" : "dark"; } -function parseOscHexChannel(channel: string): number | undefined { - if (!/^[0-9a-f]+$/i.test(channel)) { - return undefined; - } - const max = 16 ** channel.length - 1; - if (max <= 0) { - return undefined; - } - return Math.round((parseInt(channel, 16) / max) * 255); -} - -export function parseOsc11BackgroundColor(data: string): RgbColor | undefined { - const match = data.match(/^\x1b\]11;([^\x07\x1b]*)(?:\x07|\x1b\\)$/i); - if (!match) { - return undefined; - } - - const value = match[1].trim(); - if (value.startsWith("#")) { - const hex = value.slice(1); - if (/^[0-9a-f]{6}$/i.test(hex)) { - return hexToRgb(value); - } - if (/^[0-9a-f]{12}$/i.test(hex)) { - const r = parseOscHexChannel(hex.slice(0, 4)); - const g = parseOscHexChannel(hex.slice(4, 8)); - const b = parseOscHexChannel(hex.slice(8, 12)); - return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined; - } - return undefined; - } - - const rgbValue = value.replace(/^rgba?:/i, ""); - const [red, green, blue] = rgbValue.split("/"); - if (red === undefined || green === undefined || blue === undefined) { - return undefined; - } - const r = parseOscHexChannel(red); - const g = parseOscHexChannel(green); - const b = parseOscHexChannel(blue); - return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined; -} - -export function detectTerminalBackground(options: TerminalThemeDetectionOptions = {}): TerminalThemeDetection { +export function detectTerminalBackgroundFromEnv(options: TerminalThemeDetectionOptions = {}): TerminalThemeDetection { const env = options.env ?? process.env; const colorfgbg = env.COLORFGBG || ""; const bg = getColorFgBgBackgroundIndex(colorfgbg); @@ -732,8 +693,30 @@ export function detectTerminalBackground(options: TerminalThemeDetectionOptions }; } +export async function detectTerminalBackgroundTheme({ + ui, + timeoutMs, + env, +}: TerminalBackgroundThemeDetectionOptions): Promise { + try { + const rgb = await ui.queryTerminalBackgroundColor({ timeoutMs }); + if (rgb) { + return { + theme: getThemeForRgbColor(rgb), + source: "terminal background", + detail: `OSC 11 background rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`, + confidence: "high", + }; + } + } catch { + // Fall back to environment-based detection when the terminal query fails. + } + + return detectTerminalBackgroundFromEnv({ env }); +} + export function getDefaultTheme(): string { - return detectTerminalBackground().theme; + return detectTerminalBackgroundFromEnv().theme; } // ============================================================================ diff --git a/packages/coding-agent/test/theme-detection.test.ts b/packages/coding-agent/test/theme-detection.test.ts index 7bb280b2..4ad0cf56 100644 --- a/packages/coding-agent/test/theme-detection.test.ts +++ b/packages/coding-agent/test/theme-detection.test.ts @@ -1,24 +1,24 @@ -import { resetCapabilitiesCache, setCapabilities } from "@earendil-works/pi-tui"; +import { type RgbColor, resetCapabilitiesCache, setCapabilities } from "@earendil-works/pi-tui"; import { afterEach, describe, expect, it } from "vitest"; import { - detectTerminalBackground, + detectTerminalBackgroundFromEnv, + detectTerminalBackgroundTheme, getThemeByName, getThemeForRgbColor, - parseOsc11BackgroundColor, } from "../src/modes/interactive/theme/theme.ts"; afterEach(() => { resetCapabilitiesCache(); }); -describe("detectTerminalBackground", () => { +describe("detectTerminalBackgroundFromEnv", () => { it("uses the COLORFGBG background color index", () => { - expect(detectTerminalBackground({ env: { COLORFGBG: "0;15" } })).toMatchObject({ + expect(detectTerminalBackgroundFromEnv({ env: { COLORFGBG: "0;15" } })).toMatchObject({ theme: "light", source: "COLORFGBG", confidence: "high", }); - expect(detectTerminalBackground({ env: { COLORFGBG: "15;0" } })).toMatchObject({ + expect(detectTerminalBackgroundFromEnv({ env: { COLORFGBG: "15;0" } })).toMatchObject({ theme: "dark", source: "COLORFGBG", confidence: "high", @@ -26,11 +26,11 @@ describe("detectTerminalBackground", () => { }); it("uses the last COLORFGBG field as the background", () => { - expect(detectTerminalBackground({ env: { COLORFGBG: "0;7;15" } }).theme).toBe("light"); + expect(detectTerminalBackgroundFromEnv({ env: { COLORFGBG: "0;7;15" } }).theme).toBe("light"); }); it("defaults to dark without terminal background hints", () => { - expect(detectTerminalBackground({ env: {} })).toMatchObject({ + expect(detectTerminalBackgroundFromEnv({ env: {} })).toMatchObject({ theme: "dark", source: "fallback", confidence: "low", @@ -38,6 +38,65 @@ describe("detectTerminalBackground", () => { }); }); +describe("detectTerminalBackgroundTheme", () => { + it("uses the queried terminal background before environment hints", async () => { + let queriedTimeoutMs: number | undefined; + const detection = await detectTerminalBackgroundTheme({ + env: { COLORFGBG: "15;0" }, + timeoutMs: 250, + ui: { + async queryTerminalBackgroundColor({ timeoutMs }: { timeoutMs: number }): Promise { + queriedTimeoutMs = timeoutMs; + return { r: 250, g: 250, b: 250 }; + }, + }, + }); + + expect(queriedTimeoutMs).toBe(250); + expect(detection).toMatchObject({ + theme: "light", + source: "terminal background", + confidence: "high", + }); + }); + + it("falls back to environment hints when the terminal query returns no color", async () => { + const detection = await detectTerminalBackgroundTheme({ + env: { COLORFGBG: "15;0" }, + timeoutMs: 250, + ui: { + async queryTerminalBackgroundColor(): Promise { + return undefined; + }, + }, + }); + + expect(detection).toMatchObject({ + theme: "dark", + source: "COLORFGBG", + confidence: "high", + }); + }); + + it("falls back to environment hints when the terminal query fails", async () => { + const detection = await detectTerminalBackgroundTheme({ + env: { COLORFGBG: "0;15" }, + timeoutMs: 250, + ui: { + async queryTerminalBackgroundColor(): Promise { + throw new Error("terminal write failed"); + }, + }, + }); + + expect(detection).toMatchObject({ + theme: "light", + source: "COLORFGBG", + confidence: "high", + }); + }); +}); + describe("theme color mode", () => { it("uses terminal capabilities", () => { setCapabilities({ images: null, trueColor: false, hyperlinks: false }); @@ -54,16 +113,7 @@ describe("theme color mode", () => { }); }); -describe("parseOsc11BackgroundColor", () => { - it("parses 16-bit OSC 11 rgb responses", () => { - expect(parseOsc11BackgroundColor("\x1b]11;rgb:0000/8000/ffff\x07")).toEqual({ r: 0, g: 128, b: 255 }); - }); - - it("parses OSC 11 hex responses", () => { - expect(parseOsc11BackgroundColor("\x1b]11;#ffffff\x1b\\")).toEqual({ r: 255, g: 255, b: 255 }); - expect(parseOsc11BackgroundColor("\x1b]11;#000000\x07")).toEqual({ r: 0, g: 0, b: 0 }); - }); - +describe("theme detection from RGB", () => { it("classifies RGB colors by luminance", () => { expect(getThemeForRgbColor({ r: 8, g: 8, b: 8 })).toBe("dark"); expect(getThemeForRgbColor({ r: 250, g: 250, b: 250 })).toBe("light"); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 69b0529a..b1108dee 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added terminal background color query support for OSC 11 replies. + ## [0.79.3] - 2026-06-13 ## [0.79.2] - 2026-06-12 diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index fe0fed00..47bb5240 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -61,6 +61,8 @@ export { export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.ts"; // Terminal interface and implementations export { ProcessTerminal, type Terminal } from "./terminal.ts"; +// Terminal colors +export { parseOsc11BackgroundColor, type RgbColor } from "./terminal-colors.ts"; // Terminal image support export { allocateImageId, diff --git a/packages/tui/src/terminal-colors.ts b/packages/tui/src/terminal-colors.ts new file mode 100644 index 00000000..1c383a51 --- /dev/null +++ b/packages/tui/src/terminal-colors.ts @@ -0,0 +1,62 @@ +export interface RgbColor { + r: number; + g: number; + b: number; +} + +function hexToRgb(hex: string): RgbColor { + const normalized = hex.startsWith("#") ? hex.slice(1) : hex; + const r = parseInt(normalized.slice(0, 2), 16); + const g = parseInt(normalized.slice(2, 4), 16); + const b = parseInt(normalized.slice(4, 6), 16); + return { r, g, b }; +} + +function parseOscHexChannel(channel: string): number | undefined { + if (!/^[0-9a-f]+$/i.test(channel)) { + return undefined; + } + const max = 16 ** channel.length - 1; + if (max <= 0) { + return undefined; + } + return Math.round((parseInt(channel, 16) / max) * 255); +} + +const OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN = /^\x1b\]11;([^\x07\x1b]*)(?:\x07|\x1b\\)$/i; + +export function isOsc11BackgroundColorResponse(data: string): boolean { + return OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN.test(data); +} + +export function parseOsc11BackgroundColor(data: string): RgbColor | undefined { + const match = data.match(OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN); + if (!match) { + return undefined; + } + + const value = match[1].trim(); + if (value.startsWith("#")) { + const hex = value.slice(1); + if (/^[0-9a-f]{6}$/i.test(hex)) { + return hexToRgb(value); + } + if (/^[0-9a-f]{12}$/i.test(hex)) { + const r = parseOscHexChannel(hex.slice(0, 4)); + const g = parseOscHexChannel(hex.slice(4, 8)); + const b = parseOscHexChannel(hex.slice(8, 12)); + return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined; + } + return undefined; + } + + const rgbValue = value.replace(/^rgba?:/i, ""); + const [red, green, blue] = rgbValue.split("/"); + if (red === undefined || green === undefined || blue === undefined) { + return undefined; + } + const r = parseOscHexChannel(red); + const g = parseOscHexChannel(green); + const b = parseOscHexChannel(blue); + return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined; +} diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 2399564d..2b5d05cd 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -8,6 +8,7 @@ import * as path from "node:path"; import { performance } from "node:perf_hooks"; import { isKeyRelease, matchesKey } from "./keys.ts"; import type { Terminal } from "./terminal.ts"; +import { isOsc11BackgroundColorResponse, parseOsc11BackgroundColor, type RgbColor } from "./terminal-colors.ts"; import { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.ts"; import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.ts"; @@ -82,6 +83,11 @@ export interface Component { type InputListenerResult = { consume?: boolean; data?: string } | undefined; type InputListener = (data: string) => InputListenerResult; +type PendingOsc11BackgroundQuery = { + settled: boolean; + resolve: ((rgb: RgbColor | undefined) => void) | undefined; + timer: NodeJS.Timeout | undefined; +}; /** * Interface for components that can receive focus and display a hardware cursor. @@ -303,6 +309,8 @@ export class TUI extends Container { private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves private fullRedrawCount = 0; private stopped = false; + private pendingOsc11BackgroundReplies = 0; + private pendingOsc11BackgroundQueries: PendingOsc11BackgroundQuery[] = []; // Overlay stack for modal components rendered on top of base content private focusOrderCounter = 0; @@ -720,6 +728,10 @@ export class TUI extends Container { } private handleInput(data: string): void { + if (this.consumeOsc11BackgroundResponse(data)) { + return; + } + if (this.inputListeners.size > 0) { let current = data; for (const listener of this.inputListeners) { @@ -788,6 +800,30 @@ export class TUI extends Container { } } + private consumeOsc11BackgroundResponse(data: string): boolean { + if (this.pendingOsc11BackgroundReplies <= 0) { + return false; + } + + if (!isOsc11BackgroundColorResponse(data)) { + return false; + } + + const rgb = parseOsc11BackgroundColor(data); + this.pendingOsc11BackgroundReplies -= 1; + const query = this.pendingOsc11BackgroundQueries.shift(); + if (query && !query.settled) { + query.settled = true; + if (query.timer) { + clearTimeout(query.timer); + query.timer = undefined; + } + query.resolve?.(rgb); + query.resolve = undefined; + } + return true; + } + private consumeCellSizeResponse(data: string): boolean { // Response format: ESC [ 6 ; height ; width t const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/); @@ -1561,4 +1597,32 @@ export class TUI extends Container { this.terminal.hideCursor(); } } + + /** + * Query the terminal's default background color with OSC 11 (`ESC ] 11 ; ? BEL`). + * @param timeoutMs Query timeout in milliseconds. + * @returns Promise containing the parsed RGB color, or undefined if it times out or fails to parse. + */ + queryTerminalBackgroundColor({ timeoutMs }: { timeoutMs: number }): Promise { + return new Promise((resolve) => { + const query: PendingOsc11BackgroundQuery = { + settled: false, + resolve, + timer: undefined, + }; + + query.timer = setTimeout(() => { + if (query.settled) { + return; + } + query.settled = true; + query.timer = undefined; + query.resolve?.(undefined); + query.resolve = undefined; + }, timeoutMs); + this.pendingOsc11BackgroundQueries.push(query); + this.pendingOsc11BackgroundReplies += 1; + this.terminal.write("\x1b]11;?\x07"); + }); + } } diff --git a/packages/tui/test/terminal-colors.test.ts b/packages/tui/test/terminal-colors.test.ts new file mode 100644 index 00000000..9ac2a4ee --- /dev/null +++ b/packages/tui/test/terminal-colors.test.ts @@ -0,0 +1,233 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { type Component, parseOsc11BackgroundColor, type Terminal, TUI } from "../src/index.ts"; + +class TestTerminal implements Terminal { + private inputHandler?: (data: string) => void; + private resizeHandler?: () => void; + private readonly columnCount: number; + private readonly rowCount: number; + readonly writes: string[] = []; + + constructor(columnCount = 80, rowCount = 24) { + this.columnCount = columnCount; + this.rowCount = rowCount; + } + + start(onInput: (data: string) => void, onResize: () => void): void { + this.inputHandler = onInput; + this.resizeHandler = onResize; + } + + stop(): void { + this.inputHandler = undefined; + this.resizeHandler = undefined; + } + + async drainInput(_maxMs?: number, _idleMs?: number): Promise {} + + write(data: string): void { + this.writes.push(data); + } + + get columns(): number { + return this.columnCount; + } + + get rows(): number { + return this.rowCount; + } + + get kittyProtocolActive(): boolean { + return false; + } + + moveBy(_lines: number): void {} + + hideCursor(): void {} + + showCursor(): void {} + + clearLine(): void {} + + clearFromCursor(): void {} + + clearScreen(): void {} + + setTitle(_title: string): void {} + + setProgress(_active: boolean): void {} + + sendInput(data: string): void { + this.inputHandler?.(data); + } + + sendResize(): void { + this.resizeHandler?.(); + } +} + +class InputRecorder implements Component { + readonly inputs: string[] = []; + + render(_width: number): string[] { + return []; + } + + handleInput(data: string): void { + this.inputs.push(data); + } + + invalidate(): void {} +} + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +describe("parseOsc11BackgroundColor", () => { + it("parses 16-bit OSC 11 rgb responses", () => { + assert.deepStrictEqual(parseOsc11BackgroundColor("\x1b]11;rgb:0000/8000/ffff\x07"), { + r: 0, + g: 128, + b: 255, + }); + }); + + it("parses OSC 11 hex responses", () => { + assert.deepStrictEqual(parseOsc11BackgroundColor("\x1b]11;#ffffff\x1b\\"), { r: 255, g: 255, b: 255 }); + assert.deepStrictEqual(parseOsc11BackgroundColor("\x1b]11;#000000\x07"), { r: 0, g: 0, b: 0 }); + }); + + it("rejects non-strict OSC 11 responses", () => { + assert.strictEqual(parseOsc11BackgroundColor(`x\x1b]11;#ffffff\x07`), undefined); + assert.strictEqual(parseOsc11BackgroundColor("\x1b]10;#ffffff\x07"), undefined); + assert.strictEqual(parseOsc11BackgroundColor("\x1b]11;#ffffff\x07x"), undefined); + }); +}); + +describe("TUI.queryTerminalBackgroundColor", () => { + it("writes OSC 11 query and resolves with the parsed RGB reply", async () => { + const terminal = new TestTerminal(); + const tui = new TUI(terminal); + tui.start(); + try { + const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 }); + assert.ok(terminal.writes.includes("\x1b]11;?\x07")); + + terminal.sendInput("\x1b]11;#ffffff\x07"); + + assert.deepStrictEqual(await query, { r: 255, g: 255, b: 255 }); + } finally { + tui.stop(); + } + }); + + it("consumes OSC 11 replies before input listeners and focused component dispatch", async () => { + const terminal = new TestTerminal(); + const tui = new TUI(terminal); + const component = new InputRecorder(); + const listenerInputs: string[] = []; + tui.addChild(component); + tui.setFocus(component); + tui.addInputListener((data) => { + listenerInputs.push(data); + return undefined; + }); + tui.start(); + try { + const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 }); + + terminal.sendInput("\x1b]11;#000000\x07"); + + assert.deepStrictEqual(await query, { r: 0, g: 0, b: 0 }); + assert.deepStrictEqual(listenerInputs, []); + assert.deepStrictEqual(component.inputs, []); + } finally { + tui.stop(); + } + }); + + it("consumes unparseable strict OSC 11 replies and resolves undefined", async () => { + const terminal = new TestTerminal(); + const tui = new TUI(terminal); + const component = new InputRecorder(); + const listenerInputs: string[] = []; + tui.addChild(component); + tui.setFocus(component); + tui.addInputListener((data) => { + listenerInputs.push(data); + return undefined; + }); + tui.start(); + try { + const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 }); + + terminal.sendInput("\x1b]11;not-a-color\x07"); + + assert.strictEqual(await query, undefined); + assert.deepStrictEqual(listenerInputs, []); + assert.deepStrictEqual(component.inputs, []); + } finally { + tui.stop(); + } + }); + + it("dispatches non-matching input normally while waiting for an OSC 11 reply", async () => { + const terminal = new TestTerminal(); + const tui = new TUI(terminal); + const component = new InputRecorder(); + const listenerInputs: string[] = []; + tui.addChild(component); + tui.setFocus(component); + tui.addInputListener((data) => { + listenerInputs.push(data); + return undefined; + }); + tui.start(); + try { + let settled = false; + const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 }).then((rgb) => { + settled = true; + return rgb; + }); + + terminal.sendInput("x"); + await Promise.resolve(); + + assert.strictEqual(settled, false); + assert.deepStrictEqual(listenerInputs, ["x"]); + assert.deepStrictEqual(component.inputs, ["x"]); + + terminal.sendInput("\x1b]11;#ffffff\x07"); + assert.deepStrictEqual(await query, { r: 255, g: 255, b: 255 }); + } finally { + tui.stop(); + } + }); + + it("keeps consuming a late OSC 11 reply after timeout", async () => { + const terminal = new TestTerminal(); + const tui = new TUI(terminal); + const component = new InputRecorder(); + const listenerInputs: string[] = []; + tui.addChild(component); + tui.setFocus(component); + tui.addInputListener((data) => { + listenerInputs.push(data); + return undefined; + }); + tui.start(); + try { + const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1 }); + await wait(5); + + assert.strictEqual(await query, undefined); + + terminal.sendInput("\x1b]11;#ffffff\x07"); + + assert.deepStrictEqual(listenerInputs, []); + assert.deepStrictEqual(component.inputs, []); + } finally { + tui.stop(); + } + }); +}); From 11b5403fade1502a9a58a9cd4e9f983a3d1d734e Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 14 Jun 2026 10:52:31 +0200 Subject: [PATCH 258/352] fix(coding-agent): exit after package commands closes #5687 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/main.ts | 1 + packages/coding-agent/test/package-command-paths.test.ts | 9 +++++++++ 3 files changed, 11 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 23d7b939..6e63d2f2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed package commands such as `pi list`, `pi install`, and `pi update` to terminate after completing even if an extension leaves background handles open ([#5687](https://github.com/earendil-works/pi/issues/5687)). - Fixed `pi update` for pnpm global installs whose configured `global-bin-dir` no longer matches the active pnpm home ([#5689](https://github.com/earendil-works/pi/issues/5689)). - Fixed npm package specs that use ranges or tags (for example `@^1.2.7`) so installed package resources still load instead of being treated as mismatched exact pins ([#5695](https://github.com/earendil-works/pi/issues/5695)). - Fixed inherited OpenCode/OpenCode Go completion model metadata to omit long-retention cache fields for routes that reject `prompt_cache_retention` ([#5702](https://github.com/earendil-works/pi/issues/5702)). diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 63d6ec1e..00f3b208 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -467,6 +467,7 @@ export async function main(args: string[], options?: MainOptions) { } if (await handlePackageCommand(args, { extensionFactories: options?.extensionFactories })) { + process.exit(process.exitCode ?? 0); return; } diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index 1cff83f6..4cc1ce5e 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -37,12 +37,21 @@ describe("package commands", () => { originalExitCode = process.exitCode; originalExecPath = process.execPath; process.exitCode = undefined; + vi.spyOn(process, "exit").mockImplementation(((code?: string | number | null) => { + if (code === undefined || code === null || Number(code) === 0) { + process.exitCode = undefined; + } else { + process.exitCode = code; + } + return undefined as never; + }) as typeof process.exit); process.env[ENV_AGENT_DIR] = agentDir; process.chdir(projectDir); }); afterEach(() => { vi.unstubAllGlobals(); + vi.restoreAllMocks(); process.chdir(originalCwd); process.exitCode = originalExitCode; if (originalAgentDir === undefined) { From 6b40c99a5982b206d2acbaacc5d7ef641c2a98c3 Mon Sep 17 00:00:00 2001 From: Alexey Zaytsev Date: Sun, 14 Jun 2026 08:02:58 -0500 Subject: [PATCH 259/352] feat(examples): Wrap question extension text instead of truncating (#5708) --- .../examples/extensions/question.ts | 57 +++++++++----- .../examples/extensions/questionnaire.ts | 77 ++++++++++++------- 2 files changed, 88 insertions(+), 46 deletions(-) diff --git a/packages/coding-agent/examples/extensions/question.ts b/packages/coding-agent/examples/extensions/question.ts index eb1af51a..1192b490 100644 --- a/packages/coding-agent/examples/extensions/question.ts +++ b/packages/coding-agent/examples/extensions/question.ts @@ -5,7 +5,15 @@ */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui"; +import { + Editor, + type EditorTheme, + Key, + matchesKey, + Text, + visibleWidth, + wrapTextWithAnsi, +} from "@earendil-works/pi-tui"; import { Type } from "typebox"; interface OptionWithDesc { @@ -139,10 +147,27 @@ export default function question(pi: ExtensionAPI) { if (cachedLines) return cachedLines; const lines: string[] = []; - const add = (s: string) => lines.push(truncateToWidth(s, width)); + const renderWidth = Math.max(1, width); - add(theme.fg("accent", "─".repeat(width))); - add(theme.fg("text", ` ${params.question}`)); + function addWrapped(text: string) { + lines.push(...wrapTextWithAnsi(text, renderWidth)); + } + + function addWrappedWithPrefix(prefix: string, text: string) { + const prefixWidth = visibleWidth(prefix); + if (prefixWidth >= renderWidth) { + addWrapped(prefix + text); + return; + } + const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth); + const continuationPrefix = " ".repeat(prefixWidth); + for (let i = 0; i < wrapped.length; i++) { + lines.push(`${i === 0 ? prefix : continuationPrefix}${wrapped[i]}`); + } + } + + lines.push(theme.fg("accent", "─".repeat(renderWidth))); + addWrappedWithPrefix(" ", theme.fg("text", params.question)); lines.push(""); for (let i = 0; i < allOptions.length; i++) { @@ -150,36 +175,32 @@ export default function question(pi: ExtensionAPI) { const selected = i === optionIndex; const isOther = opt.isOther === true; const prefix = selected ? theme.fg("accent", "> ") : " "; + const label = `${i + 1}. ${opt.label}${isOther && editMode ? " ✎" : ""}`; + const color = selected || (isOther && editMode) ? "accent" : "text"; - if (isOther && editMode) { - add(prefix + theme.fg("accent", `${i + 1}. ${opt.label} ✎`)); - } else if (selected) { - add(prefix + theme.fg("accent", `${i + 1}. ${opt.label}`)); - } else { - add(` ${theme.fg("text", `${i + 1}. ${opt.label}`)}`); - } + addWrappedWithPrefix(prefix, theme.fg(color, label)); // Show description if present if (opt.description) { - add(` ${theme.fg("muted", opt.description)}`); + addWrappedWithPrefix(" ", theme.fg("muted", opt.description)); } } if (editMode) { lines.push(""); - add(theme.fg("muted", " Your answer:")); - for (const line of editor.render(width - 2)) { - add(` ${line}`); + addWrappedWithPrefix(" ", theme.fg("muted", "Your answer:")); + for (const line of editor.render(Math.max(1, renderWidth - 2))) { + lines.push(` ${line}`); } } lines.push(""); if (editMode) { - add(theme.fg("dim", " Enter to submit • Esc to go back")); + addWrappedWithPrefix(" ", theme.fg("dim", "Enter to submit • Esc to go back")); } else { - add(theme.fg("dim", " ↑↓ navigate • Enter to select • Esc to cancel")); + addWrappedWithPrefix(" ", theme.fg("dim", "↑↓ navigate • Enter to select • Esc to cancel")); } - add(theme.fg("accent", "─".repeat(width))); + lines.push(theme.fg("accent", "─".repeat(renderWidth))); cachedLines = lines; return lines; diff --git a/packages/coding-agent/examples/extensions/questionnaire.ts b/packages/coding-agent/examples/extensions/questionnaire.ts index 653bd864..3a546ac3 100644 --- a/packages/coding-agent/examples/extensions/questionnaire.ts +++ b/packages/coding-agent/examples/extensions/questionnaire.ts @@ -6,7 +6,15 @@ */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui"; +import { + Editor, + type EditorTheme, + Key, + matchesKey, + Text, + visibleWidth, + wrapTextWithAnsi, +} from "@earendil-works/pi-tui"; import { Type } from "typebox"; // Types @@ -259,13 +267,28 @@ export default function questionnaire(pi: ExtensionAPI) { if (cachedLines) return cachedLines; const lines: string[] = []; + const renderWidth = Math.max(1, width); const q = currentQuestion(); const opts = currentOptions(); - // Helper to add truncated line - const add = (s: string) => lines.push(truncateToWidth(s, width)); + function addWrapped(text: string) { + lines.push(...wrapTextWithAnsi(text, renderWidth)); + } - add(theme.fg("accent", "─".repeat(width))); + function addWrappedWithPrefix(prefix: string, text: string) { + const prefixWidth = visibleWidth(prefix); + if (prefixWidth >= renderWidth) { + addWrapped(prefix + text); + return; + } + const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth); + const continuationPrefix = " ".repeat(prefixWidth); + for (let i = 0; i < wrapped.length; i++) { + lines.push(`${i === 0 ? prefix : continuationPrefix}${wrapped[i]}`); + } + } + + lines.push(theme.fg("accent", "─".repeat(renderWidth))); // Tab bar (multi-question only) if (isMulti) { @@ -287,7 +310,7 @@ export default function questionnaire(pi: ExtensionAPI) { ? theme.bg("selectedBg", theme.fg("text", submitText)) : theme.fg(canSubmit ? "success" : "dim", submitText); tabs.push(`${submitStyled} →`); - add(` ${tabs.join("")}`); + addWrappedWithPrefix(" ", tabs.join("")); lines.push(""); } @@ -298,54 +321,52 @@ export default function questionnaire(pi: ExtensionAPI) { const selected = i === optionIndex; const isOther = opt.isOther === true; const prefix = selected ? theme.fg("accent", "> ") : " "; - const color = selected ? "accent" : "text"; - // Mark "Type something" differently when in input mode - if (isOther && inputMode) { - add(prefix + theme.fg("accent", `${i + 1}. ${opt.label} ✎`)); - } else { - add(prefix + theme.fg(color, `${i + 1}. ${opt.label}`)); - } + const label = `${i + 1}. ${opt.label}${isOther && inputMode ? " ✎" : ""}`; + const color = selected || (isOther && inputMode) ? "accent" : "text"; + + addWrappedWithPrefix(prefix, theme.fg(color, label)); if (opt.description) { - add(` ${theme.fg("muted", opt.description)}`); + addWrappedWithPrefix(" ", theme.fg("muted", opt.description)); } } } // Content if (inputMode && q) { - add(theme.fg("text", ` ${q.prompt}`)); + addWrappedWithPrefix(" ", theme.fg("text", q.prompt)); lines.push(""); // Show options for reference renderOptions(); lines.push(""); - add(theme.fg("muted", " Your answer:")); - for (const line of editor.render(width - 2)) { - add(` ${line}`); + addWrappedWithPrefix(" ", theme.fg("muted", "Your answer:")); + for (const line of editor.render(Math.max(1, renderWidth - 2))) { + lines.push(` ${line}`); } lines.push(""); - add(theme.fg("dim", " Enter to submit • Esc to cancel")); + addWrappedWithPrefix(" ", theme.fg("dim", "Enter to submit • Esc to cancel")); } else if (currentTab === questions.length) { - add(theme.fg("accent", theme.bold(" Ready to submit"))); + addWrappedWithPrefix(" ", theme.fg("accent", theme.bold("Ready to submit"))); lines.push(""); for (const question of questions) { const answer = answers.get(question.id); if (answer) { const prefix = answer.wasCustom ? "(wrote) " : ""; - add(`${theme.fg("muted", ` ${question.label}: `)}${theme.fg("text", prefix + answer.label)}`); + const summary = `${theme.fg("muted", `${question.label}: `)}${theme.fg("text", prefix + answer.label)}`; + addWrappedWithPrefix(" ", summary); } } lines.push(""); if (allAnswered()) { - add(theme.fg("success", " Press Enter to submit")); + addWrappedWithPrefix(" ", theme.fg("success", "Press Enter to submit")); } else { const missing = questions .filter((q) => !answers.has(q.id)) .map((q) => q.label) .join(", "); - add(theme.fg("warning", ` Unanswered: ${missing}`)); + addWrappedWithPrefix(" ", theme.fg("warning", `Unanswered: ${missing}`)); } } else if (q) { - add(theme.fg("text", ` ${q.prompt}`)); + addWrappedWithPrefix(" ", theme.fg("text", q.prompt)); lines.push(""); renderOptions(); } @@ -353,11 +374,11 @@ export default function questionnaire(pi: ExtensionAPI) { lines.push(""); if (!inputMode) { const help = isMulti - ? " Tab/←→ navigate • ↑↓ select • Enter confirm • Esc cancel" - : " ↑↓ navigate • Enter select • Esc cancel"; - add(theme.fg("dim", help)); + ? "Tab/←→ navigate • ↑↓ select • Enter confirm • Esc cancel" + : "↑↓ navigate • Enter select • Esc cancel"; + addWrappedWithPrefix(" ", theme.fg("dim", help)); } - add(theme.fg("accent", "─".repeat(width))); + lines.push(theme.fg("accent", "─".repeat(renderWidth))); cachedLines = lines; return lines; @@ -400,7 +421,7 @@ export default function questionnaire(pi: ExtensionAPI) { let text = theme.fg("toolTitle", theme.bold("questionnaire ")); text += theme.fg("muted", `${count} question${count !== 1 ? "s" : ""}`); if (labels) { - text += theme.fg("dim", ` (${truncateToWidth(labels, 40)})`); + text += theme.fg("dim", ` (${labels})`); } return new Text(text, 0, 0); }, From d683a581b74734306196a249af7afa991e240707 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 14 Jun 2026 16:11:31 +0200 Subject: [PATCH 260/352] meta: Update CONTRIBUTING.md for clearer language --- CONTRIBUTING.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d2c79b9f..7a5c57a8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,13 +2,21 @@ This guide exists to save both sides time. +## Philosophy + +First things first: **pi's core is minimal**. + +If your feature does not belong in the core, it should be an extension. PRs that bloat the core will likely be rejected. + +Pi's core exists to be minimal and to be extensible so that it can be influenced and manipulated by extensions. Even hook points for extensions however should be well considered and discussed to avoid adding unmaintainable bloat and complex interactions. + ## The One Rule **You must understand your code.** If you cannot explain what your changes do and how they interact with the rest of the system, your PR will be closed. Using AI to write code is fine. Submitting AI-generated slop without understanding it is not. -If you use an agent, run it from the `pi-mono` root directory so it picks up `AGENTS.md` automatically. Your agent must follow the rules and guidelines in that file. +If you use an agent, run it from the `pi` root directory so it picks up `AGENTS.md` automatically. Your agent must follow the rules and guidelines in that file. ## Contribution Gate @@ -32,7 +40,7 @@ If you open an issue, you must use one of the two GitHub issue templates. If you open an issue, keep it short, concrete, and worth reading. - Keep it concise. If it does not fit on one screen, it is too long. -- Write in your own voice. +- Write in your own voice (do not use an LLM to generate text, if you must, follow up with a clearly AI labeled comment). - State the bug or request clearly. - Explain why it matters. - If you want to implement the change yourself, say so. @@ -62,10 +70,6 @@ Do not edit `CHANGELOG.md`. Changelog entries are added by maintainers. If you are adding a new provider to `packages/ai`, see `AGENTS.md` for required tests. -## Philosophy - -pi's core is minimal. If your feature does not belong in the core, it should be an extension. PRs that bloat the core will likely be rejected. - ## Questions? Ask on [Discord](https://discord.com/invite/nKXTsAcmbT). From 93b3b7c1fe8e10795dd2171db04e3a5eb4c84360 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 14 Jun 2026 23:16:59 +0200 Subject: [PATCH 261/352] fix(tui): preserve WezTerm Kitty images on full redraw Reserve visible Kitty image rows before drawing placements in full redraws, while preserving the first-row path for taller-than-viewport images so #4415 stays fixed. Closes #5618. --- packages/tui/CHANGELOG.md | 4 ++ packages/tui/src/tui.ts | 15 ++++- packages/tui/test/tui-render.test.ts | 89 ++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index b1108dee..91b52474 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -6,6 +6,10 @@ - Added terminal background color query support for OSC 11 replies. +### Fixed + +- Fixed WezTerm inline Kitty image rendering during full redraw fallbacks so image padding rows are reserved before the placement is drawn without regressing tall-image placement ([#5618](https://github.com/earendil-works/pi/issues/5618), [#4415](https://github.com/earendil-works/pi/issues/4415)). + ## [0.79.3] - 2026-06-13 ## [0.79.2] - 2026-06-12 diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 2b5d05cd..1fff6545 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1244,7 +1244,20 @@ export class TUI extends Container { } for (let i = 0; i < newLines.length; i++) { if (i > 0) buffer += "\r\n"; - buffer += newLines[i]; + const line = newLines[i]; + const isImage = isImageLine(line); + const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i) : 1; + if (imageReservedRows > 1 && imageReservedRows <= height) { + for (let row = 1; row < imageReservedRows; row++) { + buffer += "\r\n"; + } + buffer += `\x1b[${imageReservedRows - 1}A`; + buffer += line; + buffer += `\x1b[${imageReservedRows - 1}B`; + i += imageReservedRows - 1; + continue; + } + buffer += line; } buffer += "\x1b[?2026l"; // End synchronized output this.terminal.write(buffer); diff --git a/packages/tui/test/tui-render.test.ts b/packages/tui/test/tui-render.test.ts index 4e31e8f4..ab038ac7 100644 --- a/packages/tui/test/tui-render.test.ts +++ b/packages/tui/test/tui-render.test.ts @@ -152,6 +152,95 @@ describe("TUI Kitty image cleanup", () => { } }); + it("reserves Kitty image rows before drawing during full redraw fallbacks", async () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + setCellDimensions({ widthPx: 10, heightPx: 10 }); + try { + const terminal = new LoggingVirtualTerminal(40, 5); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = ["l0", "l1", "l2", "l3", "l4"]; + tui.start(); + await terminal.waitForRender(); + const redrawsBeforeImage = tui.fullRedraws; + terminal.clearWrites(); + + const image = new Image( + "AAAA", + "image/png", + { fallbackColor: (value) => value }, + { maxWidthCells: 3 }, + { widthPx: 30, heightPx: 30 }, + ); + const imageLines = image.render(40); + const imageSequence = imageLines[0]; + component.lines = ["l0", "l1", "l2", "l3", "l4", ...imageLines, "after"]; + tui.requestRender(); + await terminal.waitForRender(); + + const writes = terminal.getWrites(); + assert.ok(tui.fullRedraws > redrawsBeforeImage, "scrolling image append should force a full redraw"); + assert.ok( + writes.includes(`\r\n\r\n\x1b[2A${imageSequence}\x1b[2B`), + "full redraw should reserve visible image rows before drawing the placement", + ); + assert.ok( + !writes.includes(`${imageSequence}\r\n\x1b[0m`), + "full redraw must not write reserved padding rows after drawing the placement", + ); + + tui.stop(); + } finally { + resetCapabilitiesCache(); + setCellDimensions({ widthPx: 9, heightPx: 18 }); + } + }); + + it("does not use cursor-up placement for Kitty images taller than the viewport", async () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + setCellDimensions({ widthPx: 10, heightPx: 10 }); + try { + const terminal = new LoggingVirtualTerminal(40, 5); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = ["before"]; + tui.start(); + await terminal.waitForRender(); + terminal.clearWrites(); + + const image = new Image( + "AAAA", + "image/png", + { fallbackColor: (value) => value }, + { maxWidthCells: 6 }, + { widthPx: 60, heightPx: 60 }, + ); + const imageLines = image.render(40); + const imageSequence = imageLines[0]; + assert.ok(imageLines.length > terminal.rows, "test image should exceed the viewport height"); + + component.lines = ["before", ...imageLines, "after"]; + tui.requestRender(true); + await terminal.waitForRender(); + + const writes = terminal.getWrites(); + assert.ok(writes.includes(imageSequence), "image placement should be drawn"); + assert.ok( + !writes.includes(`\x1b[${imageLines.length - 1}A${imageSequence}`), + "taller-than-viewport images must keep the #4461 first-row placement path", + ); + + tui.stop(); + } finally { + resetCapabilitiesCache(); + setCellDimensions({ widthPx: 9, heightPx: 18 }); + } + }); + it("deletes changed image ids before drawing moved placements", async () => { const terminal = new LoggingVirtualTerminal(40, 10); const tui = new TUI(terminal); From b5e13bcd8ab8f436f751684817201f1e4a326223 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 15 Jun 2026 00:02:31 +0200 Subject: [PATCH 262/352] docs(coding-agent): clarify active tools docs closes #5729 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/extensions.md | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6e63d2f2..6afe9ad9 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed extensions documentation to clarify that `pi.getActiveTools()` returns active tool names while `pi.getAllTools()` returns tool metadata ([#5729](https://github.com/earendil-works/pi/issues/5729)). - Fixed package commands such as `pi list`, `pi install`, and `pi update` to terminate after completing even if an extension leaves background handles open ([#5687](https://github.com/earendil-works/pi/issues/5687)). - Fixed `pi update` for pnpm global installs whose configured `global-bin-dir` no longer matches the active pnpm home ([#5689](https://github.com/earendil-works/pi/issues/5689)). - Fixed npm package specs that use ranges or tags (for example `@^1.2.7`) so installed package resources still load instead of being treated as mismatched exact pins ([#5695](https://github.com/earendil-works/pi/issues/5695)). diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 7038351d..ab3188ce 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -1534,21 +1534,21 @@ const result = await pi.exec("git", ["status"], { signal, timeout: 5000 }); ### pi.getActiveTools() / pi.getAllTools() / pi.setActiveTools(names) -Manage active tools. This works for both built-in tools and dynamically registered tools. +Manage active tools. This works for both built-in tools and dynamically registered tools. `pi.getActiveTools()` returns the active tool names as `string[]`; `pi.getAllTools()` returns metadata for all configured tools. ```typescript -const active = pi.getActiveTools(); +const active = pi.getActiveTools(); // ["read", "bash", ...] const all = pi.getAllTools(); -// [{ +// all = [{ // name: "read", // description: "Read file contents...", // parameters: ..., // promptGuidelines: ["Use read to examine files instead of cat or sed."], // sourceInfo: { path: "", source: "builtin", scope: "temporary", origin: "top-level" } // }, ...] -const names = all.map(t => t.name); const builtinTools = all.filter((t) => t.sourceInfo.source === "builtin"); const extensionTools = all.filter((t) => t.sourceInfo.source !== "builtin" && t.sourceInfo.source !== "sdk"); +pi.setActiveTools([...new Set([...active, "my_custom_tool"])]); // Keep current tools and enable my_custom_tool pi.setActiveTools(["read", "bash"]); // Switch to read-only ``` From ba0ec61563b731ecf28d01f9d6395e568ffe28c9 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 15 Jun 2026 00:47:35 +0200 Subject: [PATCH 263/352] fix(coding-agent): restore terminal on SIGTERM closes #5724 --- packages/coding-agent/CHANGELOG.md | 1 + .../src/modes/interactive/interactive-mode.ts | 6 +- ...-signal-shutdown-extension-cleanup.test.ts | 1 - .../5724-sigterm-signal-exit.test.ts | 92 +++++++++++++++++++ 4 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/5724-sigterm-signal-exit.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6afe9ad9..e697d433 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed SIGTERM/SIGHUP interactive shutdown to keep signal handlers installed until terminal cleanup completes, preventing `signal-exit` from re-sending the signal and leaving the terminal in raw/Kitty keyboard mode ([#5724](https://github.com/earendil-works/pi/issues/5724)). - Fixed extensions documentation to clarify that `pi.getActiveTools()` returns active tool names while `pi.getAllTools()` returns tool metadata ([#5729](https://github.com/earendil-works/pi/issues/5729)). - Fixed package commands such as `pi list`, `pi install`, and `pi update` to terminate after completing even if an extension leaves background handles open ([#5687](https://github.com/earendil-works/pi/issues/5687)). - Fixed `pi update` for pnpm global installs whose configured `global-bin-dir` no longer matches the active pnpm home ([#5689](https://github.com/earendil-works/pi/issues/5689)). diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index b3424a15..56ef9098 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -3362,7 +3362,9 @@ export class InteractiveMode { private async shutdown(options?: { fromSignal?: boolean }): Promise { if (this.isShuttingDown) return; this.isShuttingDown = true; - this.unregisterSignalHandlers(); + // Keep signal handlers registered until terminal cleanup has completed. + // `signal-exit` checks the listener list during the same SIGTERM/SIGHUP + // dispatch and re-sends the signal if only its own listeners remain. if (options?.fromSignal) { // Signal-triggered shutdown (SIGTERM/SIGHUP). Emit extension cleanup @@ -5742,7 +5744,6 @@ export class InteractiveMode { } stop(): void { - this.unregisterSignalHandlers(); if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(false); } @@ -5760,5 +5761,6 @@ export class InteractiveMode { this.ui.stop(); this.isInitialized = false; } + this.unregisterSignalHandlers(); } } diff --git a/packages/coding-agent/test/suite/regressions/5080-signal-shutdown-extension-cleanup.test.ts b/packages/coding-agent/test/suite/regressions/5080-signal-shutdown-extension-cleanup.test.ts index 30810fe8..33a960e2 100644 --- a/packages/coding-agent/test/suite/regressions/5080-signal-shutdown-extension-cleanup.test.ts +++ b/packages/coding-agent/test/suite/regressions/5080-signal-shutdown-extension-cleanup.test.ts @@ -116,7 +116,6 @@ describe("InteractiveMode.shutdown ordering (#5080)", () => { expect(order).toEqual(["dispose", "drainInput", "stop"]); expect(context.isShuttingDown).toBe(true); - expect(context.unregisterSignalHandlers).toHaveBeenCalledTimes(1); }); test("interactive quit stops the TUI before emitting session_shutdown", async () => { diff --git a/packages/coding-agent/test/suite/regressions/5724-sigterm-signal-exit.test.ts b/packages/coding-agent/test/suite/regressions/5724-sigterm-signal-exit.test.ts new file mode 100644 index 00000000..d170f1cf --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/5724-sigterm-signal-exit.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts"; + +// Regression for https://github.com/earendil-works/pi/issues/5724 +// +// `proper-lockfile` installs `signal-exit`, whose signal listener re-sends +// SIGTERM/SIGHUP when it observes no other process listeners during the same +// signal dispatch. InteractiveMode must therefore keep its signal handlers +// registered until async terminal cleanup has completed. + +type ShutdownThis = { + isShuttingDown: boolean; + unregisterSignalHandlers: () => void; + runtimeHost: { dispose: () => Promise }; + ui: { terminal: { drainInput: (ms: number) => Promise } }; + stop: () => void; +}; + +type InteractiveModePrototypeWithShutdown = { + shutdown(this: ShutdownThis, options?: { fromSignal?: boolean }): Promise; +}; + +const interactiveModePrototype = InteractiveMode.prototype as unknown; + +class ProcessExitError extends Error {} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve: (() => void) | undefined; + const promise = new Promise((res) => { + resolve = res; + }); + return { + promise, + resolve: () => resolve?.(), + }; +} + +async function callShutdown(context: ShutdownThis, options?: { fromSignal?: boolean }): Promise { + try { + await (interactiveModePrototype as InteractiveModePrototypeWithShutdown).shutdown.call(context, options); + } catch (error) { + if (!(error instanceof ProcessExitError)) throw error; + } +} + +describe("InteractiveMode SIGTERM shutdown with signal-exit (#5724)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("keeps signal handlers registered while signal-triggered cleanup is pending", async () => { + vi.spyOn(process, "exit").mockImplementation((() => { + throw new ProcessExitError(); + }) as typeof process.exit); + + const order: string[] = []; + const dispose = deferred(); + const context: ShutdownThis = { + isShuttingDown: false, + unregisterSignalHandlers: vi.fn(() => { + order.push("unregister"); + }), + runtimeHost: { + dispose: vi.fn(() => { + order.push("dispose"); + return dispose.promise; + }), + }, + ui: { + terminal: { + drainInput: vi.fn(async () => { + order.push("drainInput"); + }), + }, + }, + stop: vi.fn(() => { + order.push("stop"); + }), + }; + + const shutdownPromise = callShutdown(context, { fromSignal: true }); + await Promise.resolve(); + + expect(order).toEqual(["dispose"]); + expect(context.unregisterSignalHandlers).not.toHaveBeenCalled(); + + dispose.resolve(); + await shutdownPromise; + + expect(order).toEqual(["dispose", "drainInput", "stop"]); + }); +}); From 5b6058c3a7e9669650925683ce4c0380f652bd4d Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 15 Jun 2026 01:06:15 +0200 Subject: [PATCH 264/352] fix(tui): align overlays over CJK wide cells closes #5297 --- packages/tui/CHANGELOG.md | 1 + packages/tui/src/utils.ts | 2 +- .../regression-overlay-cjk-boundary.test.ts | 68 +++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 packages/tui/test/regression-overlay-cjk-boundary.test.ts diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 91b52474..9ab6749b 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed overlay compositing over CJK wide characters so borders stay aligned when an overlay starts inside a full-width cell ([#5297](https://github.com/earendil-works/pi/issues/5297)). - Fixed WezTerm inline Kitty image rendering during full redraw fallbacks so image padding rows are reserved before the placement is drawn without regressing tall-image placement ([#5618](https://github.com/earendil-works/pi/issues/5618), [#4415](https://github.com/earendil-works/pi/issues/4415)). ## [0.79.3] - 2026-06-13 diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index bf228ce0..b074a3a2 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -1156,7 +1156,7 @@ export function extractSegments( for (const { segment } of graphemeSegmenter.segment(line.slice(i, textEnd))) { const w = graphemeWidth(segment); - if (currentCol < beforeEnd) { + if (currentCol < beforeEnd && currentCol + w <= beforeEnd) { if (pendingAnsiBefore) { before += pendingAnsiBefore; pendingAnsiBefore = ""; diff --git a/packages/tui/test/regression-overlay-cjk-boundary.test.ts b/packages/tui/test/regression-overlay-cjk-boundary.test.ts new file mode 100644 index 00000000..2ae1f5ed --- /dev/null +++ b/packages/tui/test/regression-overlay-cjk-boundary.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { TUI } from "../src/tui.ts"; +import { extractSegments, sliceByColumn, visibleWidth } from "../src/utils.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; + +type TuiComposite = { + compositeLineAt( + baseLine: string, + overlayLine: string, + startCol: number, + overlayWidth: number, + totalWidth: number, + ): string; +}; + +function compositeLineAt( + baseLine: string, + overlayLine: string, + startCol: number, + overlayWidth: number, + totalWidth: number, +): string { + const tui = new TUI(new VirtualTerminal(totalWidth, 10)) as unknown as TuiComposite; + return tui.compositeLineAt(baseLine, overlayLine, startCol, overlayWidth, totalWidth); +} + +describe("overlay CJK boundary regression", () => { + it("excludes a wide grapheme from before when overlay starts inside it", () => { + const segments = extractSegments("abcd让EFGH", 5, 9, 11, true); + + assert.strictEqual(segments.before, "abcd"); + assert.strictEqual(segments.beforeWidth, 4); + assert.strictEqual(visibleWidth(segments.before), segments.beforeWidth); + assert.strictEqual(segments.after, "H"); + assert.strictEqual(segments.afterWidth, 1); + }); + + it("keeps ASCII before-segment behavior at the same boundary", () => { + const segments = extractSegments("abcdG EFGH", 5, 9, 11, true); + + assert.strictEqual(segments.before, "abcdG"); + assert.strictEqual(segments.beforeWidth, 5); + assert.strictEqual(visibleWidth(segments.before), segments.beforeWidth); + }); + + it("composites an overlay at the requested column when it starts inside a wide grapheme", () => { + const out = compositeLineAt("abcd让EFGH", "│XX│", 5, 4, 20); + const prefix = sliceByColumn(out, 0, 5, true); + const overlay = sliceByColumn(out, 5, 4, true); + + assert.strictEqual(out.includes("让"), false); + assert.strictEqual(visibleWidth(out), 20); + assert.strictEqual(visibleWidth(prefix), 5); + assert.strictEqual(visibleWidth(overlay), 4); + assert.strictEqual(overlay.includes("│XX│"), true); + }); + + it("composites an overlay when it starts at a wide grapheme boundary", () => { + const out = compositeLineAt("abcd让EFGH", "│XX│", 4, 4, 20); + const overlay = sliceByColumn(out, 4, 4, true); + + assert.strictEqual(out.includes("让"), false); + assert.strictEqual(visibleWidth(out), 20); + assert.strictEqual(visibleWidth(overlay), 4); + assert.strictEqual(overlay.includes("│XX│"), true); + }); +}); From 24053eab6681be055fce2663b1956bc74413caa7 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 15 Jun 2026 01:12:40 +0200 Subject: [PATCH 265/352] fix(tui): update tab overlay boundary expectation --- packages/tui/test/tab-width.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/tui/test/tab-width.test.ts b/packages/tui/test/tab-width.test.ts index 2797f89f..427a77db 100644 --- a/packages/tui/test/tab-width.test.ts +++ b/packages/tui/test/tab-width.test.ts @@ -16,8 +16,13 @@ describe("tab width accounting", () => { const text = "out 192M\t.pi/skill-tests/results-ha"; const segments = extractSegments(text, 10, 13, 10, true); - assert.strictEqual(segments.before, "out 192M\t"); - assert.strictEqual(segments.beforeWidth, 11); + assert.strictEqual(segments.before, "out 192M"); + assert.strictEqual(segments.beforeWidth, 8); assert.strictEqual(visibleWidth(segments.before), segments.beforeWidth); + + const tabFits = extractSegments(text, 11, 13, 10, true); + assert.strictEqual(tabFits.before, "out 192M\t"); + assert.strictEqual(tabFits.beforeWidth, 11); + assert.strictEqual(visibleWidth(tabFits.before), tabFits.beforeWidth); }); }); From bb959aae017eedc8edaa91d01d0475d483ea9371 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 15 Jun 2026 01:25:27 +0200 Subject: [PATCH 266/352] fix(coding-agent): wrap tree help on narrow terminals closes #5055 --- packages/coding-agent/CHANGELOG.md | 1 + .../interactive/components/tree-selector.ts | 118 ++++++++++++++---- .../coding-agent/test/tree-selector.test.ts | 26 +++- 3 files changed, 123 insertions(+), 22 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e697d433..39db7fdd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed `/tree` help rendering to show compact wrapped controls instead of truncating them on narrow terminals ([#5055](https://github.com/earendil-works/pi/issues/5055)). - Fixed SIGTERM/SIGHUP interactive shutdown to keep signal handlers installed until terminal cleanup completes, preventing `signal-exit` from re-sending the signal and leaving the terminal in raw/Kitty keyboard mode ([#5724](https://github.com/earendil-works/pi/issues/5724)). - Fixed extensions documentation to clarify that `pi.getActiveTools()` returns active tool names while `pi.getAllTools()` returns tool metadata ([#5729](https://github.com/earendil-works/pi/issues/5729)). - Fixed package commands such as `pi list`, `pi install`, and `pi update` to terminate after completing even if an extension leaves background handles open ([#5687](https://github.com/earendil-works/pi/issues/5687)). diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 990e705d..7c2cbf3e 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -4,15 +4,17 @@ import { type Focusable, getKeybindings, Input, + type Keybinding, Spacer, Text, - TruncatedText, truncateToWidth, + visibleWidth, + wrapTextWithAnsi, } from "@earendil-works/pi-tui"; import type { SessionTreeNode } from "../../../core/session-manager.ts"; import { theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; -import { keyHint, keyText } from "./keybinding-hints.ts"; +import { formatKeyText, keyHint } from "./keybinding-hints.ts"; /** Gutter info: position (displayIndent where connector was) and whether to show │ */ interface GutterInfo { @@ -1075,6 +1077,98 @@ class SearchLine implements Component { handleInput(_keyData: string): void {} } +/** Component that renders tree help as semantic rows with chunk-aware wrapping */ +class TreeHelp implements Component { + invalidate(): void {} + + render(width: number): string[] { + const items = TREE_HELP_ITEMS.map(({ keys, label, labelFirst }) => { + const text = formatHelpKeys(keys); + if (!text) return label; + return labelFirst ? `${label} ${text}` : `${text} ${label}`; + }); + + const availableWidth = Math.max(1, width); + const indent = " "; + const separator = " · "; + const lines: string[] = []; + let currentLine = ""; + + for (const item of items) { + const candidate = currentLine + ? `${currentLine}${separator}${item}` + : visibleWidth(`${indent}${item}`) <= availableWidth + ? `${indent}${item}` + : item; + if (!currentLine || visibleWidth(candidate) <= availableWidth) { + currentLine = candidate; + continue; + } + + lines.push(...wrapTextWithAnsi(currentLine.trimEnd(), availableWidth)); + currentLine = visibleWidth(`${indent}${item}`) <= availableWidth ? `${indent}${item}` : item; + } + + if (currentLine) { + lines.push(...wrapTextWithAnsi(currentLine.trimEnd(), availableWidth)); + } + + return lines.map((line) => theme.fg("muted", line)); + } +} + +const TREE_HELP_ITEMS: Array<{ keys: Keybinding[]; label: string; labelFirst?: boolean }> = [ + { keys: ["tui.select.up", "tui.select.down"], label: "move" }, + { keys: ["tui.editor.cursorLeft", "tui.editor.cursorRight"], label: "page" }, + { keys: ["app.tree.foldOrUp", "app.tree.unfoldOrDown"], label: "branch" }, + { keys: ["app.tree.editLabel"], label: "label" }, + { keys: ["app.tree.toggleLabelTimestamp"], label: "label time" }, + { + keys: [ + "app.tree.filter.default", + "app.tree.filter.noTools", + "app.tree.filter.userOnly", + "app.tree.filter.labeledOnly", + "app.tree.filter.all", + ], + label: "filters", + labelFirst: true, + }, + { keys: ["app.tree.filter.cycleForward", "app.tree.filter.cycleBackward"], label: "cycle", labelFirst: true }, +]; + +function formatHelpKeys(keybindings: Keybinding[]): string { + const keys: string[] = []; + for (const keybinding of keybindings) { + const key = getKeybindings().getKeys(keybinding)[0]; + if (key !== undefined) keys.push(key); + } + if (keys.length === 0) return ""; + + return formatKeyText(compactRawKeys(keys)) + .replace(/\bpageUp\b/g, "pgup") + .replace(/\bpageDown\b/g, "pgdn") + .replace(/\bup\b/g, "↑") + .replace(/\bdown\b/g, "↓") + .replace(/\bleft\b/g, "←") + .replace(/\bright\b/g, "→"); +} + +function compactRawKeys(keys: string[]): string { + if (keys.length === 1) return keys[0]!; + + const parts = keys.map((key) => { + const separatorIndex = key.lastIndexOf("+"); + return separatorIndex === -1 + ? { prefix: "", suffix: key } + : { prefix: key.slice(0, separatorIndex + 1), suffix: key.slice(separatorIndex + 1) }; + }); + const prefix = parts[0]!.prefix; + return prefix && parts.every((part) => part.prefix === prefix) + ? `${prefix}${parts.map((part) => part.suffix).join("/")}` + : keys.join("/"); +} + /** Label input component shown when editing a label */ class LabelInput implements Component, Focusable { private input: Input; @@ -1181,25 +1275,7 @@ export class TreeSelectorComponent extends Container implements Focusable { this.addChild(new Spacer(1)); this.addChild(new DynamicBorder()); this.addChild(new Text(theme.bold(" Session Tree"), 1, 0)); - const filterKeys = [ - keyText("app.tree.filter.default"), - keyText("app.tree.filter.noTools"), - keyText("app.tree.filter.userOnly"), - keyText("app.tree.filter.labeledOnly"), - keyText("app.tree.filter.all"), - ].join("/"); - const cycleKeys = `${keyText("app.tree.filter.cycleForward")}/${keyText("app.tree.filter.cycleBackward")}`; - const branchKeys = `${keyText("app.tree.foldOrUp")}/${keyText("app.tree.unfoldOrDown")}`; - this.addChild( - new TruncatedText( - theme.fg( - "muted", - ` ↑/↓: move. ←/→: page. ${branchKeys}: fold/branch. ${keyText("app.tree.editLabel")}: label. ${filterKeys}: filters (${cycleKeys} cycle). ${keyText("app.tree.toggleLabelTimestamp")}: label time`, - ), - 0, - 0, - ), - ); + this.addChild(new TreeHelp()); this.addChild(new SearchLine(this.treeList)); this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); diff --git a/packages/coding-agent/test/tree-selector.test.ts b/packages/coding-agent/test/tree-selector.test.ts index 8f423dc5..4281b986 100644 --- a/packages/coding-agent/test/tree-selector.test.ts +++ b/packages/coding-agent/test/tree-selector.test.ts @@ -1,4 +1,5 @@ -import { setKeybindings } from "@earendil-works/pi-tui"; +import { stripVTControlCharacters } from "node:util"; +import { setKeybindings, visibleWidth } from "@earendil-works/pi-tui"; import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { KeybindingsManager } from "../src/core/keybindings.ts"; import type { @@ -248,6 +249,29 @@ describe("TreeSelectorComponent", () => { }); }); + describe("help", () => { + test("renders semantic help rows without truncating narrow terminal controls", () => { + const entries = [userMessage("user-1", null, "hello"), assistantMessage("asst-1", "user-1", "hi")]; + const tree = buildTree(entries); + const selector = new TreeSelectorComponent( + tree, + "asst-1", + 24, + () => {}, + () => {}, + ); + + const plainLines = selector.render(30).map(stripVTControlCharacters); + const plain = plainLines.join("\n"); + expect(plain).toContain("branch"); + expect(plain).toContain("filters"); + expect(plain).toContain("cycle"); + expect(plain).toContain("label time"); + expect(plain).not.toContain("..."); + expect(plainLines.every((line) => visibleWidth(line) <= 30)).toBe(true); + }); + }); + describe("label timestamps", () => { test("toggles label timestamps for labeled nodes", () => { const entries = [userMessage("user-1", null, "hello"), assistantMessage("asst-1", "user-1", "hi")]; From a85196817051df96f8f63aed4799371270cd1ef1 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Mon, 15 Jun 2026 09:16:13 +0200 Subject: [PATCH 267/352] docs(coding-agent): reorder containerization patterns --- README.md | 2 +- .../coding-agent/docs/containerization.md | 70 +++++++++---------- packages/coding-agent/docs/index.md | 2 +- packages/coding-agent/docs/security.md | 2 +- 4 files changed, 38 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 563b6858..295dce24 100644 --- a/README.md +++ b/README.md @@ -62,9 +62,9 @@ Pi does not include a built-in permission system for restricting filesystem, pro If you need stronger boundaries, containerize or sandbox Pi. See [packages/coding-agent/docs/containerization.md](packages/coding-agent/docs/containerization.md) for three patterns: -- **OpenShell**: run the whole `pi` process in a policy-controlled sandbox. - **Gondolin extension**: keep `pi` and provider auth on the host while routing built-in tools and `!` commands into a local Linux micro-VM. - **Plain Docker**: run the whole `pi` process in a local container for simple isolation. +- **OpenShell**: run the whole `pi` process in a policy-controlled sandbox. ## Contributing diff --git a/packages/coding-agent/docs/containerization.md b/packages/coding-agent/docs/containerization.md index a3a96bee..33f2df3d 100644 --- a/packages/coding-agent/docs/containerization.md +++ b/packages/coding-agent/docs/containerization.md @@ -10,46 +10,12 @@ There are two general options. You can either | Pattern | What is isolated | Best for | Notes | | --- | --- | --- | --- | -| OpenShell | Whole `pi` process in a policy-controlled sandbox | Local or remote managed sandbox | Requires an OpenShell gateway | | Gondolin extension | Built-in tools and `!` commands | Local micro-VM isolation while keeping auth on host | See [`examples/extensions/gondolin/`](../examples/extensions/gondolin/). | | Plain Docker | Whole `pi` process in a local container | Simple local isolation | Provider API keys enter the container. | +| OpenShell | Whole `pi` process in a policy-controlled sandbox | Local or remote managed sandbox | Requires an OpenShell gateway | Extensions run wherever the `pi` process runs. If you run host `pi` with a tool-routing extension, other custom extension tools still run on the host unless they also delegate their operations. -## OpenShell - -Use [NVIDIA OpenShell](https://docs.nvidia.com/openshell/about/overview) when you want a policy-controlled sandbox with filesystem, process, network, credential, and inference controls. -OpenShell can run sandboxes through a local gateway backed by Docker, Podman, or a VM runtime, or through a remote Kubernetes gateway. - -Every sandbox requires an active gateway. -Register and select one before creating a sandbox: - -```bash -openshell gateway add --name -openshell gateway select -``` - -Launch `pi` inside an OpenShell sandbox: - -```bash -openshell sandbox create --name pi-sandbox --from pi -- pi -``` - -In this pattern, the whole `pi` process runs inside the sandbox. -Built-in tools, `!` commands, and extension tools execute inside the OpenShell boundary. - -If the gateway is remote, project files are not bind-mounted from the host, meaning writes in the sandbox are not reflected on your machine. -Clone the repository inside the sandbox or use OpenShell file transfer commands: - -```bash -openshell sandbox upload pi-sandbox ./repo /workspace -openshell sandbox download pi-sandbox /workspace/repo ./repo-out -``` - -OpenShell providers can keep raw model API keys outside the sandbox. -When inference routing is configured, code inside the sandbox can call `https://inference.local`, and the gateway injects the configured provider credentials upstream. -Configure Pi to use the corresponding OpenAI-compatible or Anthropic-compatible endpoint if you want model traffic to use this route. - ## Gondolin [Gondolin](https://github.com/earendil-works/gondolin) is a local Linux micro-VM. @@ -109,3 +75,37 @@ docker run --rm -it \ The `-v "$PWD:/workspace"` mounts your current directory into the container at /workspace such that reads and writes in `/workspace` inside Docker directly affect your host files, like in the Gondolin example. Use a named volume for `/root/.pi/agent` if you want container-local settings and sessions. Mounting your host `~/.pi/agent` exposes host auth and session files to the container. + +## OpenShell + +Use [NVIDIA OpenShell](https://docs.nvidia.com/openshell/about/overview) when you want a policy-controlled sandbox with filesystem, process, network, credential, and inference controls. +OpenShell can run sandboxes through a local gateway backed by Docker, Podman, or a VM runtime, or through a remote Kubernetes gateway. + +Every sandbox requires an active gateway. +Register and select one before creating a sandbox: + +```bash +openshell gateway add --name +openshell gateway select +``` + +Launch `pi` inside an OpenShell sandbox: + +```bash +openshell sandbox create --name pi-sandbox --from pi -- pi +``` + +In this pattern, the whole `pi` process runs inside the sandbox. +Built-in tools, `!` commands, and extension tools execute inside the OpenShell boundary. + +If the gateway is remote, project files are not bind-mounted from the host, meaning writes in the sandbox are not reflected on your machine. +Clone the repository inside the sandbox or use OpenShell file transfer commands: + +```bash +openshell sandbox upload pi-sandbox ./repo /workspace +openshell sandbox download pi-sandbox /workspace/repo ./repo-out +``` + +OpenShell providers can keep raw model API keys outside the sandbox. +When inference routing is configured, code inside the sandbox can call `https://inference.local`, and the gateway injects the configured provider credentials upstream. +Configure Pi to use the corresponding OpenAI-compatible or Anthropic-compatible endpoint if you want model traffic to use this route. diff --git a/packages/coding-agent/docs/index.md b/packages/coding-agent/docs/index.md index 71995831..b5ee017e 100644 --- a/packages/coding-agent/docs/index.md +++ b/packages/coding-agent/docs/index.md @@ -42,7 +42,7 @@ For the full first-run flow, see [Quickstart](quickstart.md). - [Using Pi](usage.md) - interactive mode, slash commands, context files, and CLI reference. - [Providers](providers.md) - subscription and API-key setup for built-in providers. - [Security](security.md) - project trust, sandbox boundaries, and vulnerability reporting. -- [Containerization](containerization.md) - sandbox pi with OpenShell, Gondolin, or Docker. +- [Containerization](containerization.md) - sandbox pi with Gondolin, Docker, or OpenShell. - [Settings](settings.md) - global and project settings. - [Keybindings](keybindings.md) - default shortcuts and custom keybindings. - [Sessions](sessions.md) - session management, branching, and tree navigation. diff --git a/packages/coding-agent/docs/security.md b/packages/coding-agent/docs/security.md index 3a268a8a..29828e03 100644 --- a/packages/coding-agent/docs/security.md +++ b/packages/coding-agent/docs/security.md @@ -42,7 +42,7 @@ For untrusted repositories, generated code you do not intend to monitor closely, Common patterns are documented in [Containerization](containerization.md): -- run the whole `pi` process inside OpenShell or Docker +- run the whole `pi` process inside a container/sandbox - run host pi while routing built-in tool execution into a Gondolin micro-VM - mount only the workspace paths the agent should access - avoid mounting host `~/.pi/agent` unless the container should access host sessions, settings, and credentials From 0be5bb6c968ded1698793d0a3e7e60a0294050e2 Mon Sep 17 00:00:00 2001 From: Bucky <16464422+theBucky@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:21:14 +0800 Subject: [PATCH 268/352] fix(ai): price anthropic 1h cache writes at 2x input (#5738) anthropic 1h cache writes were billed at the 5m rate; split the bucket and price the 1h portion correctly --- packages/ai/src/models.ts | 5 +- packages/ai/src/providers/anthropic.ts | 1 + packages/ai/src/types.ts | 2 + .../anthropic-cache-write-1h-cost.test.ts | 86 +++++++++++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 packages/ai/test/anthropic-cache-write-1h-cost.test.ts diff --git a/packages/ai/src/models.ts b/packages/ai/src/models.ts index e14a6c2f..9d9fa519 100644 --- a/packages/ai/src/models.ts +++ b/packages/ai/src/models.ts @@ -37,10 +37,13 @@ export function getModels( } export function calculateCost(model: Model, usage: Usage): Usage["cost"] { + // Anthropic charges 2x base input for 1h cache writes. + const longWrite = usage.cacheWrite1h ?? 0; + const shortWrite = usage.cacheWrite - longWrite; usage.cost.input = (model.cost.input / 1000000) * usage.input; usage.cost.output = (model.cost.output / 1000000) * usage.output; usage.cost.cacheRead = (model.cost.cacheRead / 1000000) * usage.cacheRead; - usage.cost.cacheWrite = (model.cost.cacheWrite / 1000000) * usage.cacheWrite; + usage.cost.cacheWrite = (model.cost.cacheWrite * shortWrite + model.cost.input * 2 * longWrite) / 1000000; usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite; return usage.cost; } diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 90b46146..9a64cbd8 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -535,6 +535,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti output.usage.output = event.message.usage.output_tokens || 0; output.usage.cacheRead = event.message.usage.cache_read_input_tokens || 0; output.usage.cacheWrite = event.message.usage.cache_creation_input_tokens || 0; + output.usage.cacheWrite1h = event.message.usage.cache_creation?.ephemeral_1h_input_tokens || 0; // Anthropic doesn't provide total_tokens, compute from components output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 897e87d9..b7d0fda0 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -267,6 +267,8 @@ export interface Usage { output: number; cacheRead: number; cacheWrite: number; + /** Subset of `cacheWrite` written with 1h retention. Only Anthropic reports this split. */ + cacheWrite1h?: number; totalTokens: number; cost: { input: number; diff --git a/packages/ai/test/anthropic-cache-write-1h-cost.test.ts b/packages/ai/test/anthropic-cache-write-1h-cost.test.ts new file mode 100644 index 00000000..f9523b40 --- /dev/null +++ b/packages/ai/test/anthropic-cache-write-1h-cost.test.ts @@ -0,0 +1,86 @@ +import type Anthropic from "@anthropic-ai/sdk"; +import { describe, expect, it } from "vitest"; +import { getModel } from "../src/models.ts"; +import { streamAnthropic } from "../src/providers/anthropic.ts"; +import type { Context } from "../src/types.ts"; + +function createSseResponse(events: Array<{ event: string; data: string }>): Response { + const body = events.map(({ event, data }) => `event: ${event}\ndata: ${data}\n`).join("\n"); + return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }); +} + +function createFakeAnthropicClient(response: Response): Anthropic { + return { + messages: { create: () => ({ asResponse: async () => response }) }, + } as unknown as Anthropic; +} + +function eventsWithCacheCreation( + cacheCreation: Record | undefined, +): Array<{ event: string; data: string }> { + const startUsage: Record = { + input_tokens: 100, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 1_000_000, + }; + if (cacheCreation) startUsage.cache_creation = cacheCreation; + return [ + { + event: "message_start", + data: JSON.stringify({ type: "message_start", message: { id: "msg_test", usage: startUsage } }), + }, + { + event: "content_block_start", + data: JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }), + }, + { + event: "content_block_delta", + data: JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hi" } }), + }, + { event: "content_block_stop", data: JSON.stringify({ type: "content_block_stop", index: 0 }) }, + { + event: "message_delta", + data: JSON.stringify({ + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { + input_tokens: 100, + output_tokens: 5, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 1_000_000, + }, + }), + }, + { event: "message_stop", data: JSON.stringify({ type: "message_stop" }) }, + ]; +} + +// claude-opus-4-8: input 5, cacheWrite (5m) 6.25 per Mtok. 1h write = 2x input = 10. +const context: Context = { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }; + +describe("Anthropic 1h cache write cost", () => { + it("prices the 1h portion at 2x input and the rest at the 5m rate", async () => { + const model = getModel("anthropic", "claude-opus-4-8"); + const response = createSseResponse( + eventsWithCacheCreation({ ephemeral_5m_input_tokens: 600_000, ephemeral_1h_input_tokens: 400_000 }), + ); + const result = await streamAnthropic(model, context, { client: createFakeAnthropicClient(response) }).result(); + + expect(result.usage.cacheWrite).toBe(1_000_000); + expect(result.usage.cacheWrite1h).toBe(400_000); + // 600k * 6.25/Mtok + 400k * 10/Mtok = 3.75 + 4.0 = 7.75 + expect(result.usage.cost.cacheWrite).toBeCloseTo(7.75, 10); + }); + + it("falls back to the 5m rate when no breakdown is reported", async () => { + const model = getModel("anthropic", "claude-opus-4-8"); + const response = createSseResponse(eventsWithCacheCreation(undefined)); + const result = await streamAnthropic(model, context, { client: createFakeAnthropicClient(response) }).result(); + + expect(result.usage.cacheWrite).toBe(1_000_000); + expect(result.usage.cacheWrite1h ?? 0).toBe(0); + // 1M * 6.25/Mtok = 6.25 + expect(result.usage.cost.cacheWrite).toBeCloseTo(6.25, 10); + }); +}); From 28b3af5d0fd3c688706ad718979726a9d290967c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 15 Jun 2026 07:25:09 +0000 Subject: [PATCH 269/352] chore: approve contributor Mearman --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 0513093f..f38c892d 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -237,3 +237,5 @@ davidlifschitz pr vdxz pr dangooddd pr + +Mearman pr From 408ac103ec698a60aa84524ea4b028f1660e98d1 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 15 Jun 2026 09:46:59 +0200 Subject: [PATCH 270/352] fix(ai): update Copilot Claude thinking metadata closes #4637 --- packages/ai/CHANGELOG.md | 1 + packages/ai/scripts/generate-models.ts | 14 ++++ packages/ai/src/models.generated.ts | 66 +++++++++++++++++-- .../ai/test/github-copilot-anthropic.test.ts | 12 +++- 4 files changed, 87 insertions(+), 6 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 71da18c3..b73499de 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed GitHub Copilot Claude adaptive-thinking effort metadata to match manually checked Copilot model capabilities ([#4637](https://github.com/earendil-works/pi/issues/4637)). - Fixed OpenCode/OpenCode Go completion models that reject `prompt_cache_retention` to omit long-retention cache fields when `cacheRetention` is `long` ([#5702](https://github.com/earendil-works/pi/issues/5702)). ## [0.79.3] - 2026-06-13 diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 63c54a56..dfb8f061 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -196,6 +196,14 @@ const OPENCODE_OPENAI_COMPLETIONS_LONG_CACHE_RETENTION_UNSUPPORTED_MODELS = new "opencode-go:kimi-k2.6", ]); +// Checked manually against the authenticated GitHub Copilot /models endpoint on 2026-06-15. +// Keep this to narrow corrections over models.dev metadata instead of snapshotting Copilot's catalog. +const GITHUB_COPILOT_THINKING_LEVEL_OVERRIDES = { + "claude-opus-4.7": { minimal: "low" }, + "claude-opus-4.8": { minimal: "low" }, + "claude-sonnet-4.6": { minimal: "low", xhigh: "max" }, +} satisfies Record["thinkingLevelMap"]>>; + function mergeThinkingLevelMap(model: Model, map: NonNullable["thinkingLevelMap"]>): void { model.thinkingLevelMap = { ...model.thinkingLevelMap, ...map }; } @@ -358,6 +366,12 @@ function applyThinkingLevelMetadata(model: Model): void { // Ring reasons by default. Only high/xhigh have documented explicit effort controls. mergeThinkingLevelMap(model, ANT_LING_RING_THINKING_LEVEL_MAP); } + if (model.provider === "github-copilot") { + const override = GITHUB_COPILOT_THINKING_LEVEL_OVERRIDES[model.id]; + if (override) { + mergeThinkingLevelMap(model, override); + } + } } function getAnthropicMessagesCompat(provider: string, modelId: string): AnthropicMessagesCompat | undefined { diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 35295e5d..b1d877fa 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -4194,7 +4194,7 @@ export const MODELS = { headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, compat: {"forceAdaptiveThinking":true,"supportsTemperature":false}, reasoning: true, - thinkingLevelMap: {"xhigh":"xhigh"}, + thinkingLevelMap: {"xhigh":"xhigh","minimal":"low"}, input: ["text", "image"], cost: { input: 5, @@ -4214,7 +4214,7 @@ export const MODELS = { headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, compat: {"forceAdaptiveThinking":true,"supportsTemperature":false}, reasoning: true, - thinkingLevelMap: {"xhigh":"xhigh"}, + thinkingLevelMap: {"xhigh":"xhigh","minimal":"low"}, input: ["text", "image"], cost: { input: 5, @@ -4272,6 +4272,7 @@ export const MODELS = { headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, compat: {"forceAdaptiveThinking":true}, reasoning: true, + thinkingLevelMap: {"minimal":"low","xhigh":"max"}, input: ["text", "image"], cost: { input: 3, @@ -4831,6 +4832,42 @@ export const MODELS = { contextWindow: 262144, maxTokens: 32768, } satisfies Model<"google-generative-ai">, + "gemma-4-E2B-it": { + id: "gemma-4-E2B-it", + name: "Gemma 4 E2B IT", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":"MINIMAL","low":null,"medium":null,"high":"HIGH"}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"google-generative-ai">, + "gemma-4-E4B-it": { + id: "gemma-4-E4B-it", + name: "Gemma 4 E4B IT", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":"MINIMAL","low":null,"medium":null,"high":"HIGH"}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"google-generative-ai">, }, "google-vertex": { "gemini-1.5-flash": { @@ -9432,13 +9469,13 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh"}, input: ["text"], cost: { - input: 0.098, - output: 0.196, + input: 0.09, + output: 0.18, cacheRead: 0.02, cacheWrite: 0, }, contextWindow: 1048576, - maxTokens: 4096, + maxTokens: 65536, } satisfies Model<"openai-completions">, "deepseek/deepseek-v4-pro": { id: "deepseek/deepseek-v4-pro", @@ -13304,6 +13341,25 @@ export const MODELS = { contextWindow: 262144, maxTokens: 131000, } satisfies Model<"openai-completions">, + "moonshotai/Kimi-K2.7-Code": { + id: "moonshotai/Kimi-K2.7-Code", + name: "Kimi K2.7 Code", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text"], + cost: { + input: 0.95, + output: 4, + cacheRead: 0.19, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 131072, + } satisfies Model<"openai-completions">, "nvidia/nemotron-3-ultra-550b-a55b": { id: "nvidia/nemotron-3-ultra-550b-a55b", name: "Nemotron 3 Ultra 550B A55B", diff --git a/packages/ai/test/github-copilot-anthropic.test.ts b/packages/ai/test/github-copilot-anthropic.test.ts index ee3de3e8..ace2bd8f 100644 --- a/packages/ai/test/github-copilot-anthropic.test.ts +++ b/packages/ai/test/github-copilot-anthropic.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { getModel } from "../src/models.ts"; +import { getModel, getSupportedThinkingLevels } from "../src/models.ts"; import { streamAnthropic } from "../src/providers/anthropic.ts"; import type { Context } from "../src/types.ts"; @@ -54,6 +54,16 @@ describe("Copilot Claude via Anthropic Messages", () => { messages: [{ role: "user", content: "Hello", timestamp: Date.now() }], }; + it("applies Copilot-specific adaptive thinking effort overrides", () => { + const opus47 = getModel("github-copilot", "claude-opus-4.7"); + expect(opus47.thinkingLevelMap).toMatchObject({ minimal: "low", xhigh: "xhigh" }); + expect(getSupportedThinkingLevels(opus47)).toContain("xhigh"); + + const sonnet46 = getModel("github-copilot", "claude-sonnet-4.6"); + expect(sonnet46.thinkingLevelMap).toMatchObject({ minimal: "low", xhigh: "max" }); + expect(getSupportedThinkingLevels(sonnet46)).toContain("xhigh"); + }); + it("uses Bearer auth, Copilot headers, and valid Anthropic Messages payload", async () => { const model = getModel("github-copilot", "claude-sonnet-4.6"); expect(model.api).toBe("anthropic-messages"); From 3fa4095629164a71f517a68fe5fa56662355bfcf Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 15 Jun 2026 08:55:45 +0100 Subject: [PATCH 271/352] fix: drain stdout before resolving when a child holds the pipe past exit (#5753) --- packages/coding-agent/src/core/tools/bash.ts | 3 + .../coding-agent/src/utils/child-process.ts | 28 +++++-- .../regressions/5208-late-bash-output.test.ts | 29 +++++++ .../5303-bash-output-truncation.test.ts | 79 +++++++++++++++++++ 4 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/5208-late-bash-output.test.ts create mode 100644 packages/coding-agent/test/suite/regressions/5303-bash-output-truncation.test.ts diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index d2bfacb9..e56291bb 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -289,6 +289,7 @@ export function createBashToolDefinition( const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command; const spawnContext = resolveSpawnContext(resolvedCommand, cwd, spawnHook); const output = new OutputAccumulator({ tempFilePrefix: "pi-bash" }); + let acceptingOutput = true; let updateTimer: NodeJS.Timeout | undefined; let updateDirty = false; let lastUpdateAt = 0; @@ -334,11 +335,13 @@ export function createBashToolDefinition( } const handleData = (data: Buffer) => { + if (!acceptingOutput) return; output.append(data); scheduleOutputUpdate(); }; const finishOutput = async () => { + acceptingOutput = false; output.finish(); clearUpdateTimer(); emitOutputUpdate(); diff --git a/packages/coding-agent/src/utils/child-process.ts b/packages/coding-agent/src/utils/child-process.ts index 3d97d3da..b152444d 100644 --- a/packages/coding-agent/src/utils/child-process.ts +++ b/packages/coding-agent/src/utils/child-process.ts @@ -38,10 +38,13 @@ export function spawnProcessSync( /** * Wait for a child process to terminate without hanging on inherited stdio handles. * - * On Windows, daemonized descendants can inherit the child's stdout/stderr pipe - * handles. In that case the child emits `exit`, but `close` can hang forever even - * though the original process is already gone. We wait briefly for stdio to end, - * then forcibly stop tracking the inherited handles. + * A short-lived child can `exit` while a detached descendant keeps its stdout/stderr + * pipe open. We must not resolve and destroy the streams on a fixed deadline measured + * from `exit`, or output still being written past that deadline is silently lost + * (earendil-works/pi#5303). Instead, after `exit` we wait for the pipes to fall idle: + * the grace timer is re-armed on every chunk, so an actively writing descendant keeps + * us reading, while a quiet inherited handle (e.g. a Windows daemonized descendant + * that never lets `close` fire) still releases us after the grace elapses. */ export function waitForChildProcess(child: ChildProcess): Promise { return new Promise((resolve, reject) => { @@ -62,6 +65,8 @@ export function waitForChildProcess(child: ChildProcess): Promise child.removeListener("close", onClose); child.stdout?.removeListener("end", onStdoutEnd); child.stderr?.removeListener("end", onStderrEnd); + child.stdout?.removeListener("data", onData); + child.stderr?.removeListener("data", onData); }; const finalize = (code: number | null) => { @@ -80,6 +85,17 @@ export function waitForChildProcess(child: ChildProcess): Promise } }; + const armIdleTimer = () => { + if (postExitTimer) clearTimeout(postExitTimer); + postExitTimer = setTimeout(() => finalize(exitCode), EXIT_STDIO_GRACE_MS); + }; + + const onData = () => { + // Output is still arriving after exit; defer finalizing so we don't + // destroy the stream mid-write and truncate the tail. + if (exited && !settled) armIdleTimer(); + }; + const onStdoutEnd = () => { stdoutEnded = true; maybeFinalizeAfterExit(); @@ -102,7 +118,7 @@ export function waitForChildProcess(child: ChildProcess): Promise exitCode = code; maybeFinalizeAfterExit(); if (!settled) { - postExitTimer = setTimeout(() => finalize(code), EXIT_STDIO_GRACE_MS); + armIdleTimer(); } }; @@ -112,6 +128,8 @@ export function waitForChildProcess(child: ChildProcess): Promise child.stdout?.once("end", onStdoutEnd); child.stderr?.once("end", onStderrEnd); + child.stdout?.on("data", onData); + child.stderr?.on("data", onData); child.once("error", onError); child.once("exit", onExit); child.once("close", onClose); diff --git a/packages/coding-agent/test/suite/regressions/5208-late-bash-output.test.ts b/packages/coding-agent/test/suite/regressions/5208-late-bash-output.test.ts new file mode 100644 index 00000000..cb78a7f6 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/5208-late-bash-output.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { type BashOperations, createBashTool } from "../../../src/core/tools/bash.ts"; + +function getTextOutput(result: { content?: Array<{ type: string; text?: string }> }): string { + return ( + result.content + ?.filter((block) => block.type === "text") + .map((block) => block.text ?? "") + .join("\n") ?? "" + ); +} + +describe("regression #5208: late bash output callbacks", () => { + it("ignores output callbacks after bash operations resolve", async () => { + const operations: BashOperations = { + exec: async (_command, _cwd, { onData }) => { + onData(Buffer.from("before\n", "utf-8")); + setTimeout(() => onData(Buffer.from("late\n", "utf-8")), 0); + return { exitCode: 0 }; + }, + }; + const bash = createBashTool(process.cwd(), { operations }); + + const result = await bash.execute("test-call-late-output", { command: "late-output" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(getTextOutput(result).trim()).toBe("before"); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/5303-bash-output-truncation.test.ts b/packages/coding-agent/test/suite/regressions/5303-bash-output-truncation.test.ts new file mode 100644 index 00000000..aeaa49fc --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/5303-bash-output-truncation.test.ts @@ -0,0 +1,79 @@ +import type { ChildProcessByStdio } from "node:child_process"; +import type { Readable } from "node:stream"; +import { afterEach, describe, expect, it } from "vitest"; +import { spawnProcess, waitForChildProcess } from "../../../src/utils/child-process.ts"; + +/** + * Regression test for https://github.com/earendil-works/pi/issues/5303 + * + * waitForChildProcess armed a fixed 100ms timer on `exit` and destroyed the + * stdio streams when it fired. When a short-lived detached descendant kept the + * stdout pipe open, `close` never fired, so that timer was the only thing that + * resolved the wait, and any output written more than 100ms after exit was + * binned. In practice every git commit whose pre-commit hook runs lint-staged + * came back truncated mid-listr2 output, read by the model as a hang. + * + * The fix re-arms the grace on each chunk, so an actively writing pipe keeps us + * reading while a genuinely idle held-open handle still releases after the + * grace elapses. Both behaviours are covered below. + */ +describe.skipIf(process.platform === "win32")("issue #5303 bash output truncation past exit", () => { + let child: ChildProcessByStdio | undefined; + + afterEach(() => { + if (child?.pid) { + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + // Already gone. + } + } + child = undefined; + }); + + it("captures output emitted after exit while a detached child holds stdout open", async () => { + // The shell exits immediately, but a backgrounded subshell keeps the stdout + // pipe open and emits ticks every 50ms, the last well past the 100ms grace. + const command = 'printf "HEAD\\n"; ( for i in 1 2 3 4 5 6; do sleep 0.05; printf "TICK$i\\n"; done ) &'; + child = spawnProcess("/bin/sh", ["-c", command], { + stdio: ["ignore", "pipe", "pipe"], + detached: true, + }) as ChildProcessByStdio; + + let output = ""; + child.stdout.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + + const exitCode = await waitForChildProcess(child); + + expect(exitCode).toBe(0); + expect(output).toContain("HEAD"); + expect(output).toContain("TICK6"); + }); + + it("resolves promptly when a detached child holds stdout open but stays quiet", async () => { + // The shell exits, but a backgrounded sleeper inherits the stdout pipe and + // keeps it open for a long time without writing. `close` never fires, so we + // must still release via the idle grace rather than hang on the open handle. + const command = 'printf "DONE\\n"; ( sleep 30 ) &'; + child = spawnProcess("/bin/sh", ["-c", command], { + stdio: ["ignore", "pipe", "pipe"], + detached: true, + }) as ChildProcessByStdio; + + let output = ""; + child.stdout.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + + const start = Date.now(); + const exitCode = await waitForChildProcess(child); + const elapsed = Date.now() - start; + + expect(exitCode).toBe(0); + expect(output).toContain("DONE"); + // Must not wait for the 30s sleeper; the idle grace releases us in well under a second. + expect(elapsed).toBeLessThan(2000); + }); +}); From 8a7ad60f03766e685aa73a0203c64804020b9c2c Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 15 Jun 2026 10:11:22 +0200 Subject: [PATCH 272/352] feat(coding-agent): add binary release checksums closes #5739 --- .github/workflows/build-binaries.yml | 27 +++++++++++++-------------- packages/coding-agent/CHANGELOG.md | 1 + 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 4a081581..355ad1cd 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -59,28 +59,27 @@ jobs: run: | cd packages/coding-agent/binaries + release_assets=( + pi-darwin-arm64.tar.gz + pi-darwin-x64.tar.gz + pi-linux-x64.tar.gz + pi-linux-arm64.tar.gz + pi-windows-x64.zip + pi-windows-arm64.zip + ) + sha256sum "${release_assets[@]}" > SHA256SUMS + release_assets+=(SHA256SUMS) + if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then gh release edit "${RELEASE_TAG}" \ --title "${RELEASE_TAG}" \ --notes-file /tmp/release-notes.md - gh release upload "${RELEASE_TAG}" \ - pi-darwin-arm64.tar.gz \ - pi-darwin-x64.tar.gz \ - pi-linux-x64.tar.gz \ - pi-linux-arm64.tar.gz \ - pi-windows-x64.zip \ - pi-windows-arm64.zip \ - --clobber + gh release upload "${RELEASE_TAG}" "${release_assets[@]}" --clobber else gh release create "${RELEASE_TAG}" \ --title "${RELEASE_TAG}" \ --notes-file /tmp/release-notes.md \ - pi-darwin-arm64.tar.gz \ - pi-darwin-x64.tar.gz \ - pi-linux-x64.tar.gz \ - pi-linux-arm64.tar.gz \ - pi-windows-x64.zip \ - pi-windows-arm64.zip + "${release_assets[@]}" fi publish-npm: diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 39db7fdd..594e4fd0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added `SHA256SUMS` integrity files to standalone binary GitHub release assets ([#5739](https://github.com/earendil-works/pi/issues/5739)). - Added first-run interactive theme detection from the terminal background. ### Fixed From b1ad469be4239d2c970d413bab98df76d59f3ed4 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 15 Jun 2026 10:23:19 +0200 Subject: [PATCH 273/352] docs: audit changelog entries --- packages/ai/CHANGELOG.md | 1 + packages/coding-agent/CHANGELOG.md | 13 ++++++++++++- packages/tui/CHANGELOG.md | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b73499de..19766189 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed Anthropic 1-hour prompt-cache write cost accounting to price 1-hour cache writes at 2x input instead of the 5-minute cache-write rate ([#5738](https://github.com/earendil-works/pi/pull/5738) by [@theBucky](https://github.com/theBucky)). - Fixed GitHub Copilot Claude adaptive-thinking effort metadata to match manually checked Copilot model capabilities ([#4637](https://github.com/earendil-works/pi/issues/4637)). - Fixed OpenCode/OpenCode Go completion models that reject `prompt_cache_retention` to omit long-retention cache fields when `cacheRetention` is `long` ([#5702](https://github.com/earendil-works/pi/issues/5702)). diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 594e4fd0..ac772d06 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,20 +2,31 @@ ## [Unreleased] +### New Features + +- **Automatic first-run theme selection** - pi detects the terminal background on first run and defaults to the `dark` or `light` theme. See [Selecting a Theme](docs/themes.md#selecting-a-theme). +- **Standalone binary integrity checksums** - GitHub release assets now include `SHA256SUMS` files for verifying standalone binary downloads. See [Quickstart Install](docs/quickstart.md#install). + ### Added - Added `SHA256SUMS` integrity files to standalone binary GitHub release assets ([#5739](https://github.com/earendil-works/pi/issues/5739)). -- Added first-run interactive theme detection from the terminal background. +- Added first-run interactive theme detection from the terminal background ([#5385](https://github.com/earendil-works/pi/pull/5385) by [@vegarsti](https://github.com/vegarsti)). ### Fixed +- Fixed bash tool output collection to keep draining stdout/stderr after the child exits while descendants still write, avoiding truncated late output ([#5753](https://github.com/earendil-works/pi/pull/5753) by [@Mearman](https://github.com/Mearman)). - Fixed `/tree` help rendering to show compact wrapped controls instead of truncating them on narrow terminals ([#5055](https://github.com/earendil-works/pi/issues/5055)). - Fixed SIGTERM/SIGHUP interactive shutdown to keep signal handlers installed until terminal cleanup completes, preventing `signal-exit` from re-sending the signal and leaving the terminal in raw/Kitty keyboard mode ([#5724](https://github.com/earendil-works/pi/issues/5724)). - Fixed extensions documentation to clarify that `pi.getActiveTools()` returns active tool names while `pi.getAllTools()` returns tool metadata ([#5729](https://github.com/earendil-works/pi/issues/5729)). +- Fixed question and questionnaire extension examples to wrap long prompt, option, and help text instead of truncating it ([#5708](https://github.com/earendil-works/pi/pull/5708) by [@xl0](https://github.com/xl0)). - Fixed package commands such as `pi list`, `pi install`, and `pi update` to terminate after completing even if an extension leaves background handles open ([#5687](https://github.com/earendil-works/pi/issues/5687)). - Fixed `pi update` for pnpm global installs whose configured `global-bin-dir` no longer matches the active pnpm home ([#5689](https://github.com/earendil-works/pi/issues/5689)). - Fixed npm package specs that use ranges or tags (for example `@^1.2.7`) so installed package resources still load instead of being treated as mismatched exact pins ([#5695](https://github.com/earendil-works/pi/issues/5695)). +- Fixed inherited Anthropic 1-hour prompt-cache write cost accounting to price 1-hour cache writes at 2x input instead of the 5-minute cache-write rate ([#5738](https://github.com/earendil-works/pi/pull/5738) by [@theBucky](https://github.com/theBucky)). +- Fixed inherited GitHub Copilot Claude adaptive-thinking effort metadata to match manually checked Copilot model capabilities ([#4637](https://github.com/earendil-works/pi/issues/4637)). - Fixed inherited OpenCode/OpenCode Go completion model metadata to omit long-retention cache fields for routes that reject `prompt_cache_retention` ([#5702](https://github.com/earendil-works/pi/issues/5702)). +- Fixed inherited overlay compositing over CJK wide characters so borders stay aligned when an overlay starts inside a full-width cell ([#5297](https://github.com/earendil-works/pi/issues/5297)). +- Fixed inherited WezTerm inline Kitty image rendering during full redraw fallbacks so image padding rows are reserved before the placement is drawn without regressing tall-image placement ([#5618](https://github.com/earendil-works/pi/issues/5618), [#4415](https://github.com/earendil-works/pi/issues/4415)). - Fixed custom provider config so plain uppercase API key and header values remain literals instead of being treated as legacy environment references; use explicit `$ENV_VAR` syntax for environment variables ([#5661](https://github.com/earendil-works/pi/issues/5661)). ## [0.79.3] - 2026-06-13 diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 9ab6749b..1de1cd15 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Added terminal background color query support for OSC 11 replies. +- Added terminal background color query support for OSC 11 replies ([#5385](https://github.com/earendil-works/pi/pull/5385) by [@vegarsti](https://github.com/vegarsti)). ### Fixed From bba6af2c71c5004546007cde59995d15efe8465b Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 15 Jun 2026 10:28:21 +0200 Subject: [PATCH 274/352] Release v0.79.4 --- package-lock.json | 26 +++++++++---------- packages/agent/CHANGELOG.md | 2 +- packages/agent/package.json | 4 +-- packages/ai/CHANGELOG.md | 2 +- packages/ai/package.json | 2 +- packages/coding-agent/CHANGELOG.md | 2 +- .../package-lock.json | 4 +-- .../custom-provider-anthropic/package.json | 2 +- .../custom-provider-gitlab-duo/package.json | 2 +- .../extensions/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 +- packages/coding-agent/npm-shrinkwrap.json | 24 ++++++++--------- packages/coding-agent/package.json | 8 +++--- packages/tui/CHANGELOG.md | 2 +- packages/tui/package.json | 2 +- 19 files changed, 50 insertions(+), 50 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4ee5233f..4063babb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6176,10 +6176,10 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.79.3", + "version": "0.79.4", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.79.3", + "@earendil-works/pi-ai": "^0.79.4", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -6213,7 +6213,7 @@ }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.79.3", + "version": "0.79.4", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -6258,12 +6258,12 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "0.79.3", + "version": "0.79.4", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.3", - "@earendil-works/pi-ai": "^0.79.3", - "@earendil-works/pi-tui": "^0.79.3", + "@earendil-works/pi-agent-core": "^0.79.4", + "@earendil-works/pi-ai": "^0.79.4", + "@earendil-works/pi-tui": "^0.79.4", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -6304,32 +6304,32 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.79.3", + "version": "0.79.4", "dependencies": { "@anthropic-ai/sdk": "0.52.0" } }, "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": { "name": "pi-extension-custom-provider-gitlab-duo", - "version": "0.79.3" + "version": "0.79.4" }, "packages/coding-agent/examples/extensions/gondolin": { "name": "pi-extension-gondolin", - "version": "0.79.3", + "version": "0.79.4", "dependencies": { "@earendil-works/gondolin": "0.12.0" } }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.9.3", + "version": "1.9.4", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.79.3", + "version": "0.79.4", "dependencies": { "ms": "2.1.3" }, @@ -6365,7 +6365,7 @@ }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.79.3", + "version": "0.79.4", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index f176ad06..4699f57f 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.4] - 2026-06-15 ## [0.79.3] - 2026-06-13 diff --git a/packages/agent/package.json b/packages/agent/package.json index b8a17c29..36812b0f 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "0.79.3", + "version": "0.79.4", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -29,7 +29,7 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "^0.79.3", + "@earendil-works/pi-ai": "^0.79.4", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 19766189..6f46bf15 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.4] - 2026-06-15 ### Fixed diff --git a/packages/ai/package.json b/packages/ai/package.json index 806d19ca..8278c9b9 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.79.3", + "version": "0.79.4", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ac772d06..ca4276b9 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.4] - 2026-06-15 ### 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 906cc651..5d512449 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.79.3", + "version": "0.79.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "0.79.3", + "version": "0.79.4", "dependencies": { "@anthropic-ai/sdk": "^0.52.0" } diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json index b2082217..404ac822 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.79.3", + "version": "0.79.4", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json index 3e80ae34..f5e5cdeb 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.79.3", + "version": "0.79.4", "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 cbd6cfec..c7cae39d 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.79.3", + "version": "0.79.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-gondolin", - "version": "0.79.3", + "version": "0.79.4", "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 a339c43a..ba925197 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.79.3", + "version": "0.79.4", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/sandbox/package-lock.json b/packages/coding-agent/examples/extensions/sandbox/package-lock.json index 04da2c15..84bc5c66 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.9.3", + "version": "1.9.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.9.3", + "version": "1.9.4", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.26" } diff --git a/packages/coding-agent/examples/extensions/sandbox/package.json b/packages/coding-agent/examples/extensions/sandbox/package.json index 60573f2a..e2962554 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.9.3", + "version": "1.9.4", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/with-deps/package-lock.json b/packages/coding-agent/examples/extensions/with-deps/package-lock.json index df12962d..555b18ba 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.79.3", + "version": "0.79.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "0.79.3", + "version": "0.79.4", "dependencies": { "ms": "^2.1.3" }, diff --git a/packages/coding-agent/examples/extensions/with-deps/package.json b/packages/coding-agent/examples/extensions/with-deps/package.json index b47c9e68..db2ac17e 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.79.3", + "version": "0.79.4", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 74eff303..438d1319 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -1,17 +1,17 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.79.3", + "version": "0.79.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent", - "version": "0.79.3", + "version": "0.79.4", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.3", - "@earendil-works/pi-ai": "^0.79.3", - "@earendil-works/pi-tui": "^0.79.3", + "@earendil-works/pi-agent-core": "^0.79.4", + "@earendil-works/pi-ai": "^0.79.4", + "@earendil-works/pi-tui": "^0.79.4", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -474,11 +474,11 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.79.3", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.3.tgz", + "version": "0.79.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.4.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.79.3", + "@earendil-works/pi-ai": "^0.79.4", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -488,8 +488,8 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.79.3", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.3.tgz", + "version": "0.79.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.4.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -511,8 +511,8 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.79.3", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.3.tgz", + "version": "0.79.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.4.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index ff4522ce..e67d13dd 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.79.3", + "version": "0.79.4", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -36,9 +36,9 @@ "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" }, "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.3", - "@earendil-works/pi-ai": "^0.79.3", - "@earendil-works/pi-tui": "^0.79.3", + "@earendil-works/pi-agent-core": "^0.79.4", + "@earendil-works/pi-ai": "^0.79.4", + "@earendil-works/pi-tui": "^0.79.4", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 1de1cd15..89e3e742 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.4] - 2026-06-15 ### Added diff --git a/packages/tui/package.json b/packages/tui/package.json index a808a640..749a3933 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-tui", - "version": "0.79.3", + "version": "0.79.4", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", From 1aa3c02d56635ec40e7c8448d7eff35022e95740 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 15 Jun 2026 10:28:24 +0200 Subject: [PATCH 275/352] Add [Unreleased] section for next cycle --- packages/agent/CHANGELOG.md | 2 ++ packages/ai/CHANGELOG.md | 2 ++ packages/coding-agent/CHANGELOG.md | 2 ++ packages/tui/CHANGELOG.md | 2 ++ 4 files changed, 8 insertions(+) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 4699f57f..64f79b1b 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.4] - 2026-06-15 ## [0.79.3] - 2026-06-13 diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 6f46bf15..ff08f5b7 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.4] - 2026-06-15 ### Fixed diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ca4276b9..621b0fe1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.4] - 2026-06-15 ### New Features diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 89e3e742..5998b2b1 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.4] - 2026-06-15 ### Added From 0369bdb8f4f3ec3b9c67342c9bdd14f92fe87dd3 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 15 Jun 2026 17:08:59 +0200 Subject: [PATCH 276/352] fix(ai): add Moonshot CN Kimi K2.7 metadata closes #5760 --- packages/ai/CHANGELOG.md | 4 ++ packages/ai/scripts/generate-models.ts | 34 +++++++++++-- packages/ai/src/models.generated.ts | 20 ++++++++ .../ai/src/providers/openai-completions.ts | 6 ++- .../openai-completions-tool-choice.test.ts | 48 +++++++++++++++++++ packages/ai/test/supports-xhigh.test.ts | 9 ++++ packages/coding-agent/CHANGELOG.md | 4 ++ 7 files changed, 120 insertions(+), 5 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index ff08f5b7..243b729f 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Moonshot AI China model metadata to include Kimi K2.7 Code, and omitted unsupported thinking-off payloads for Kimi K2.7 Code models ([#5760](https://github.com/earendil-works/pi/issues/5760)). + ## [0.79.4] - 2026-06-15 ### Fixed diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index dfb8f061..07d0559d 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -68,6 +68,8 @@ const KIMI_STATIC_HEADERS = { "User-Agent": "KimiCLI/1.5", } as const; +const MOONSHOT_CN_MIRRORED_MODEL_IDS = new Set(["kimi-k2.7-code", "kimi-k2.7-code-highspeed"]); + const TOGETHER_BASE_URL = "https://api.together.ai/v1"; const TOGETHER_BASE_COMPAT: OpenAICompletionsCompat = { supportsStore: false, @@ -347,6 +349,15 @@ function applyThinkingLevelMetadata(model: Model): void { if (model.provider === "openai-codex" && supportsOpenAiXhigh(model.id)) { mergeThinkingLevelMap(model, { minimal: "low" }); } + if ( + (model.provider === "moonshotai" || model.provider === "moonshotai-cn") && + (model.id === "kimi-k2.7-code" || model.id === "kimi-k2.7-code-highspeed") + ) { + // Kimi K2.7 Code is always-thinking. Official docs say + // `thinking: { type: "disabled" }` is rejected, and callers can omit + // the thinking parameter to use the enabled default. + mergeThinkingLevelMap(model, { off: null }); + } if (model.provider === "openrouter" && model.id.startsWith("inception/mercury-2")) { // Mercury 2 in instant mode (reasoning_effort: "none") disables tool calling. // Mark "off" unsupported so the openai-completions provider omits the reasoning param @@ -1262,12 +1273,27 @@ async function loadModelsDevData(): Promise[]> { supportsStrictMode: false, thinkingFormat: "deepseek", }; + const getMoonshotProviderModels = (key: "moonshotai" | "moonshotai-cn"): Record => { + const providerModels = data[key]?.models as Record | undefined; + return providerModels ? { ...providerModels } : {}; + }; + const moonshotModels = { + moonshotai: getMoonshotProviderModels("moonshotai"), + "moonshotai-cn": getMoonshotProviderModels("moonshotai-cn"), + }; + + // models.dev can lag the CN catalog while the global Moonshot catalog already + // has the model. Mirror selected current model IDs into moonshotai-cn until + // upstream CN metadata catches up. + for (const modelId of MOONSHOT_CN_MIRRORED_MODEL_IDS) { + const model = moonshotModels.moonshotai[modelId]; + if (model && !moonshotModels["moonshotai-cn"][modelId]) { + moonshotModels["moonshotai-cn"][modelId] = model; + } + } for (const { key, provider, baseUrl } of moonshotVariants) { - if (!data[key]?.models) continue; - - for (const [modelId, model] of Object.entries(data[key].models)) { - const m = model as ModelsDevModel; + for (const [modelId, m] of Object.entries(moonshotModels[key])) { if (m.tool_call !== true) continue; models.push({ diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index b1d877fa..cf18edc2 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -6425,6 +6425,7 @@ export const MODELS = { baseUrl: "https://api.moonshot.ai/v1", compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: true, + thinkingLevelMap: {"off":null}, input: ["text", "image"], cost: { input: 0.95, @@ -6563,6 +6564,25 @@ export const MODELS = { contextWindow: 262144, maxTokens: 262144, } satisfies Model<"openai-completions">, + "kimi-k2.7-code": { + id: "kimi-k2.7-code", + name: "Kimi K2.7 Code", + api: "openai-completions", + provider: "moonshotai-cn", + baseUrl: "https://api.moonshot.cn/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 0.95, + output: 4, + cacheRead: 0.19, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, }, "nvidia": { "meta/llama-3.1-70b-instruct": { diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 0f87c897..140726ed 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -564,7 +564,11 @@ function buildParams( preserve_thinking: true, }; } else if (compat.thinkingFormat === "deepseek" && model.reasoning) { - (params as any).thinking = { type: options?.reasoningEffort ? "enabled" : "disabled" }; + if (options?.reasoningEffort) { + (params as any).thinking = { type: "enabled" }; + } else if (model.thinkingLevelMap?.off !== null) { + (params as any).thinking = { type: "disabled" }; + } if (options?.reasoningEffort && compat.supportsReasoningEffort) { (params as any).reasoning_effort = model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort; diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index 28c91795..9cf84496 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -1118,6 +1118,54 @@ describe("openai-completions tool_choice", () => { expect(params.reasoning_effort).toBeUndefined(); }); + it("omits disabled thinking for Moonshot Kimi K2.7 Code models", async () => { + const cases = [getModel("moonshotai", "kimi-k2.7-code"), getModel("moonshotai-cn", "kimi-k2.7-code")]; + + for (const model of cases) { + expect(model).toBeDefined(); + let payload: unknown; + + await streamSimple( + model!, + { + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = (payload ?? mockState.lastParams) as { thinking?: unknown; reasoning_effort?: string }; + expect(params.thinking).toBeUndefined(); + expect(params.reasoning_effort).toBeUndefined(); + } + }); + + it("keeps disabled thinking for Moonshot Kimi K2.6 when thinking is off", async () => { + const model = getModel("moonshotai-cn", "kimi-k2.6")!; + let payload: unknown; + + await streamSimple( + model, + { + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = (payload ?? mockState.lastParams) as { thinking?: unknown; reasoning_effort?: string }; + expect(params.thinking).toEqual({ type: "disabled" }); + expect(params.reasoning_effort).toBeUndefined(); + }); + it("sends max_tokens for OpenCode completions models", async () => { const cases = [getModel("opencode-go", "kimi-k2.6")!, getModel("opencode", "grok-build-0.1")!] as const; diff --git a/packages/ai/test/supports-xhigh.test.ts b/packages/ai/test/supports-xhigh.test.ts index ec6dc87b..9f5363cb 100644 --- a/packages/ai/test/supports-xhigh.test.ts +++ b/packages/ai/test/supports-xhigh.test.ts @@ -69,6 +69,15 @@ describe("getSupportedThinkingLevels", () => { expect(getSupportedThinkingLevels(model!)).toEqual(["off", "high"]); }); + it("excludes thinking off for Moonshot Kimi K2.7 Code models", () => { + const cases = [getModel("moonshotai", "kimi-k2.7-code"), getModel("moonshotai-cn", "kimi-k2.7-code")]; + + for (const model of cases) { + expect(model).toBeDefined(); + expect(getSupportedThinkingLevels(model!)).toEqual(["minimal", "low", "medium", "high"]); + } + }); + it("includes only high for OpenCode Grok Build", () => { const model = getModel("opencode", "grok-build-0.1"); expect(model).toBeDefined(); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 621b0fe1..05e39136 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed inherited Moonshot AI China model metadata to include Kimi K2.7 Code, and omitted unsupported thinking-off payloads for Kimi K2.7 Code models ([#5760](https://github.com/earendil-works/pi/issues/5760)). + ## [0.79.4] - 2026-06-15 ### New Features From 431d88f4fcd4d5c897a2e99c14deef0716139ff9 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 15 Jun 2026 17:36:39 +0200 Subject: [PATCH 277/352] meta: Link to rfcs --- CONTRIBUTING.md | 5 +++++ README.md | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7a5c57a8..b0c38322 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -95,3 +95,8 @@ AI can help group duplicates, summarize reports, and spot missing information. I ### Is this hostile to contributors? No. It is a guardrail against burnout and tracker spam. Short, concrete, reproducible issues are welcome. Thoughtful contributions are welcome. Automated slop, entitlement, and large volumes of low-effort reports are not. + +## Where can I learn about plans? + +Earendil uses RFCs to discuss larger changes. Not all of them are public, but +quite a few are. They can be found at [rfc.earendil.com](https://rfc.earendil.com/keyword/pi/). diff --git a/README.md b/README.md index 295dce24..dc31b930 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ If you need stronger boundaries, containerize or sandbox Pi. See [packages/codin ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines and [AGENTS.md](AGENTS.md) for project-specific rules (for both humans and agents). +See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines and [AGENTS.md](AGENTS.md) for project-specific rules (for both humans and agents). Longer term plans for Pi can also be found in [RFCs](https://rfc.earendil.com/keyword/pi/). ## Development From bee8e9c80529a0424fcffb64c3a3052895f2aac6 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 15 Jun 2026 23:21:44 +0200 Subject: [PATCH 278/352] feat(coding-agent): mark experimental sessions in footer --- .../coding-agent/src/modes/interactive/components/footer.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts index f108ae5e..aa537127 100644 --- a/packages/coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -1,6 +1,7 @@ import { isAbsolute, relative, resolve, sep } from "node:path"; import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import type { AgentSession } from "../../../core/agent-session.ts"; +import { areExperimentalFeaturesEnabled } from "../../../core/experimental.ts"; import type { ReadonlyFooterDataProvider } from "../../../core/footer-data-provider.ts"; import { theme } from "../theme/theme.ts"; @@ -159,6 +160,9 @@ export class FooterComponent implements Component { contextPercentStr = contextPercentDisplay; } statsParts.push(contextPercentStr); + if (areExperimentalFeaturesEnabled()) { + statsParts.push(`${theme.fg("dim", "•")} ${theme.bold(theme.fg("warning", "xp"))}`); + } let statsLeft = statsParts.join(" "); From 7cfd1af395a1454c960c3a5178d9c0c58bbaafa6 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 16 Jun 2026 00:39:24 +0200 Subject: [PATCH 279/352] fix(coding-agent): keep empty session selector open closes #5747 --- packages/coding-agent/CHANGELOG.md | 1 + .../src/modes/interactive/components/session-selector.ts | 6 ------ 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 05e39136..72a9b722 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed the session selector to stay open and show the all-sessions empty state when both current-folder and all-scope session lists are empty ([#5747](https://github.com/earendil-works/pi/issues/5747)). - Fixed inherited Moonshot AI China model metadata to include Kimi K2.7 Code, and omitted unsupported thinking-off payloads for Kimi K2.7 Code models ([#5760](https://github.com/earendil-works/pi/issues/5760)). ## [0.79.4] - 2026-06-15 diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector.ts b/packages/coding-agent/src/modes/interactive/components/session-selector.ts index 74141e5e..a92f0762 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts @@ -694,7 +694,6 @@ export class SessionSelectorComponent extends Container implements Focusable { private allSessions: SessionInfo[] | null = null; private currentSessionsLoader: SessionsLoader; private allSessionsLoader: SessionsLoader; - private onCancel: () => void; private requestRender: () => void; private renameSession?: (sessionPath: string, currentName: string | undefined) => Promise; private currentLoading = false; @@ -751,7 +750,6 @@ export class SessionSelectorComponent extends Container implements Focusable { this.keybindings = options?.keybindings ?? KeybindingsManager.create(); this.currentSessionsLoader = currentSessionsLoader; this.allSessionsLoader = allSessionsLoader; - this.onCancel = onCancel; this.requestRender = requestRender; this.header = new SessionSelectorHeader(this.scope, this.sortMode, this.nameFilter, this.requestRender); const renameSession = options?.renameSession; @@ -948,10 +946,6 @@ export class SessionSelectorComponent extends Container implements Focusable { this.header.setLoading(false); this.sessionList.setSessions(sessions, showCwd); this.requestRender(); - - if (scope === "all" && sessions.length === 0 && (this.currentSessions?.length ?? 0) === 0) { - this.onCancel(); - } } catch (err) { if (scope === "current") { this.currentLoading = false; From b0c8f65ffa832cefc73a5db96378fe1337f6665e Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 16 Jun 2026 01:03:12 +0200 Subject: [PATCH 280/352] fix(ai): update Google Vertex Gemini models closes #5761 --- packages/ai/CHANGELOG.md | 1 + packages/ai/scripts/generate-models.ts | 229 ++++++--------------- packages/ai/src/models.generated.ts | 223 +++++++++----------- packages/ai/src/providers/google-vertex.ts | 3 +- packages/ai/src/providers/google.ts | 3 +- packages/coding-agent/CHANGELOG.md | 1 + 6 files changed, 162 insertions(+), 298 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 243b729f..3203ecfe 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed Google and `google-vertex` Gemini model metadata to map `latest` aliases to the current models, add Gemini 3.5 Flash for Vertex, correct Gemini 2.5 Flash Vertex cache pricing, and remove shut-down Vertex preview models ([#5761](https://github.com/earendil-works/pi/issues/5761)). - Fixed Moonshot AI China model metadata to include Kimi K2.7 Code, and omitted unsupported thinking-off payloads for Kimi K2.7 Code models ([#5760](https://github.com/earendil-works/pi/issues/5760)). ## [0.79.4] - 2026-06-15 diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 07d0559d..26e581bb 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -123,6 +123,7 @@ const TOGETHER_TOGGLE_REASONING_LEVEL_MAP = { const AI_GATEWAY_MODELS_URL = "https://ai-gateway.vercel.sh/v1"; const AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh"; +const VERTEX_BASE_URL = "https://{location}-aiplatform.googleapis.com"; const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1"; const NVIDIA_HEADERS = { "NVCF-POLL-SECONDS": "3600", @@ -270,7 +271,8 @@ function isGemini3ProModel(modelId: string): boolean { } function isGemini3FlashModel(modelId: string): boolean { - return /gemini-3(?:\.\d+)?-flash/.test(modelId.toLowerCase()); + const id = modelId.toLowerCase(); + return /gemini-3(?:\.\d+)?-flash/.test(id) || id === "gemini-flash-latest" || id === "gemini-flash-lite-latest"; } function isGemma4Model(modelId: string): boolean { @@ -624,6 +626,13 @@ async function loadModelsDevData(): Promise[]> { for (const [modelId, model] of Object.entries(data.google.models)) { const m = model as ModelsDevModel; if (m.tool_call !== true) continue; + let source = m; + if (modelId === "gemini-flash-latest") { + source = (data.google.models["gemini-3.5-flash"] as ModelsDevModel | undefined) ?? m; + } + if (modelId === "gemini-flash-lite-latest") { + source = (data.google.models["gemini-3.1-flash-lite"] as ModelsDevModel | undefined) ?? m; + } models.push({ id: modelId, @@ -631,16 +640,57 @@ async function loadModelsDevData(): Promise[]> { api: "google-generative-ai", provider: "google", baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: m.reasoning === true, - input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], + reasoning: source.reasoning === true, + input: source.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], cost: { - input: m.cost?.input || 0, - output: m.cost?.output || 0, - cacheRead: m.cost?.cache_read || 0, - cacheWrite: m.cost?.cache_write || 0, + input: source.cost?.input || 0, + output: source.cost?.output || 0, + cacheRead: source.cost?.cache_read || 0, + cacheWrite: source.cost?.cache_write || 0, }, - contextWindow: m.limit?.context || 4096, - maxTokens: m.limit?.output || 4096, + contextWindow: source.limit?.context || 4096, + maxTokens: source.limit?.output || 4096, + }); + } + } + + // Process Google Vertex Gemini models. The google-vertex models.dev catalog also includes + // Claude, OpenAI, and other MaaS models that do not use the @google/genai Gemini streaming + // path implemented by our google-vertex provider. + if (data["google-vertex"]?.models) { + for (const [modelId, model] of Object.entries(data["google-vertex"].models)) { + const m = model as ModelsDevModel; + if (m.tool_call !== true) continue; + if (!modelId.startsWith("gemini-")) continue; + if (modelId === "gemini-3.1-flash-lite-preview") continue; + let source = m; + if (modelId === "gemini-flash-latest") { + source = (data["google-vertex"].models["gemini-3.5-flash"] as ModelsDevModel | undefined) ?? m; + } + if (modelId === "gemini-flash-lite-latest") { + source = (data["google-vertex"].models["gemini-3.1-flash-lite"] as ModelsDevModel | undefined) ?? m; + } + + // models.dev reports Vertex cache_read/cache_write values for Gemini 2.5 Flash that + // do not match the official Gemini API standard pricing table. pi only accounts + // cachedContentTokenCount as cacheRead. + const cacheRead = modelId === "gemini-2.5-flash" ? 0.03 : source.cost?.cache_read || 0; + models.push({ + id: modelId, + name: m.name || modelId, + api: "google-vertex", + provider: "google-vertex", + baseUrl: VERTEX_BASE_URL, + reasoning: source.reasoning === true, + input: source.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], + cost: { + input: source.cost?.input || 0, + output: source.cost?.output || 0, + cacheRead, + cacheWrite: 0, + }, + contextWindow: source.limit?.context || 4096, + maxTokens: source.limit?.output || 4096, }); } } @@ -1968,167 +2018,6 @@ async function generateModels() { }); } - const VERTEX_BASE_URL = "https://{location}-aiplatform.googleapis.com"; - const vertexModels: Model<"google-vertex">[] = [ - { - id: "gemini-3-pro-preview", - name: "Gemini 3 Pro Preview (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 }, - contextWindow: 1000000, - maxTokens: 64000, - }, - { - id: "gemini-3.1-pro-preview", - name: "Gemini 3.1 Pro Preview (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65536, - }, - { - id: "gemini-3.1-pro-preview-customtools", - name: "Gemini 3.1 Pro Preview Custom Tools (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65536, - }, - { - id: "gemini-3-flash-preview", - name: "Gemini 3 Flash Preview (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65536, - }, - { - id: "gemini-2.0-flash", - name: "Gemini 2.0 Flash (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: false, - input: ["text", "image"], - cost: { input: 0.15, output: 0.6, cacheRead: 0.0375, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 8192, - }, - { - id: "gemini-2.0-flash-lite", - name: "Gemini 2.0 Flash Lite (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 0.075, output: 0.3, cacheRead: 0.01875, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65536, - }, - { - id: "gemini-2.5-pro", - name: "Gemini 2.5 Pro (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65536, - }, - { - id: "gemini-2.5-flash", - name: "Gemini 2.5 Flash (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 0.3, output: 2.5, cacheRead: 0.03, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65536, - }, - { - id: "gemini-2.5-flash-lite-preview-09-2025", - name: "Gemini 2.5 Flash Lite Preview 09-25 (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 0.1, output: 0.4, cacheRead: 0.01, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65536, - }, - { - id: "gemini-2.5-flash-lite", - name: "Gemini 2.5 Flash Lite (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 0.1, output: 0.4, cacheRead: 0.01, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65536, - }, - { - id: "gemini-1.5-pro", - name: "Gemini 1.5 Pro (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: false, - input: ["text", "image"], - cost: { input: 1.25, output: 5, cacheRead: 0.3125, cacheWrite: 0 }, - contextWindow: 1000000, - maxTokens: 8192, - }, - { - id: "gemini-1.5-flash", - name: "Gemini 1.5 Flash (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: false, - input: ["text", "image"], - cost: { input: 0.075, output: 0.3, cacheRead: 0.01875, cacheWrite: 0 }, - contextWindow: 1000000, - maxTokens: 8192, - }, - { - id: "gemini-1.5-flash-8b", - name: "Gemini 1.5 Flash-8B (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: false, - input: ["text", "image"], - cost: { input: 0.0375, output: 0.15, cacheRead: 0.01, cacheWrite: 0 }, - contextWindow: 1000000, - maxTokens: 8192, - }, - ]; - allModels.push(...vertexModels); - // Azure Foundry deploys these with larger context windows than OpenAI's own API, // which caps gpt-5.4/gpt-5.5 at 272k. See models-sold-directly-by-azure docs. const AZURE_CONTEXT_WINDOW_OVERRIDES: Record = { diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index cf18edc2..34575eb0 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -4769,11 +4769,12 @@ export const MODELS = { provider: "google", baseUrl: "https://generativelanguage.googleapis.com/v1beta", reasoning: true, + thinkingLevelMap: {"off":null}, input: ["text", "image"], cost: { - input: 0.3, - output: 2.5, - cacheRead: 0.075, + input: 1.5, + output: 9, + cacheRead: 0.15, cacheWrite: 0, }, contextWindow: 1048576, @@ -4786,10 +4787,11 @@ export const MODELS = { provider: "google", baseUrl: "https://generativelanguage.googleapis.com/v1beta", reasoning: true, + thinkingLevelMap: {"off":null}, input: ["text", "image"], cost: { - input: 0.1, - output: 0.4, + input: 0.25, + output: 1.5, cacheRead: 0.025, cacheWrite: 0, }, @@ -4870,94 +4872,9 @@ export const MODELS = { } satisfies Model<"google-generative-ai">, }, "google-vertex": { - "gemini-1.5-flash": { - id: "gemini-1.5-flash", - name: "Gemini 1.5 Flash (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: "https://{location}-aiplatform.googleapis.com", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.075, - output: 0.3, - cacheRead: 0.01875, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 8192, - } satisfies Model<"google-vertex">, - "gemini-1.5-flash-8b": { - id: "gemini-1.5-flash-8b", - name: "Gemini 1.5 Flash-8B (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: "https://{location}-aiplatform.googleapis.com", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.0375, - output: 0.15, - cacheRead: 0.01, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 8192, - } satisfies Model<"google-vertex">, - "gemini-1.5-pro": { - id: "gemini-1.5-pro", - name: "Gemini 1.5 Pro (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: "https://{location}-aiplatform.googleapis.com", - reasoning: false, - input: ["text", "image"], - cost: { - input: 1.25, - output: 5, - cacheRead: 0.3125, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 8192, - } satisfies Model<"google-vertex">, - "gemini-2.0-flash": { - id: "gemini-2.0-flash", - name: "Gemini 2.0 Flash (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: "https://{location}-aiplatform.googleapis.com", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.15, - output: 0.6, - cacheRead: 0.0375, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 8192, - } satisfies Model<"google-vertex">, - "gemini-2.0-flash-lite": { - id: "gemini-2.0-flash-lite", - name: "Gemini 2.0 Flash Lite (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: "https://{location}-aiplatform.googleapis.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.075, - output: 0.3, - cacheRead: 0.01875, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-vertex">, "gemini-2.5-flash": { id: "gemini-2.5-flash", - name: "Gemini 2.5 Flash (Vertex)", + name: "Gemini 2.5 Flash", api: "google-vertex", provider: "google-vertex", baseUrl: "https://{location}-aiplatform.googleapis.com", @@ -4974,24 +4891,7 @@ export const MODELS = { } satisfies Model<"google-vertex">, "gemini-2.5-flash-lite": { id: "gemini-2.5-flash-lite", - name: "Gemini 2.5 Flash Lite (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: "https://{location}-aiplatform.googleapis.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.1, - output: 0.4, - cacheRead: 0.01, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-vertex">, - "gemini-2.5-flash-lite-preview-09-2025": { - id: "gemini-2.5-flash-lite-preview-09-2025", - name: "Gemini 2.5 Flash Lite Preview 09-25 (Vertex)", + name: "Gemini 2.5 Flash-Lite", api: "google-vertex", provider: "google-vertex", baseUrl: "https://{location}-aiplatform.googleapis.com", @@ -5008,7 +4908,7 @@ export const MODELS = { } satisfies Model<"google-vertex">, "gemini-2.5-pro": { id: "gemini-2.5-pro", - name: "Gemini 2.5 Pro (Vertex)", + name: "Gemini 2.5 Pro", api: "google-vertex", provider: "google-vertex", baseUrl: "https://{location}-aiplatform.googleapis.com", @@ -5025,7 +4925,7 @@ export const MODELS = { } satisfies Model<"google-vertex">, "gemini-3-flash-preview": { id: "gemini-3-flash-preview", - name: "Gemini 3 Flash Preview (Vertex)", + name: "Gemini 3 Flash Preview", api: "google-vertex", provider: "google-vertex", baseUrl: "https://{location}-aiplatform.googleapis.com", @@ -5041,27 +4941,27 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 65536, } satisfies Model<"google-vertex">, - "gemini-3-pro-preview": { - id: "gemini-3-pro-preview", - name: "Gemini 3 Pro Preview (Vertex)", + "gemini-3.1-flash-lite": { + id: "gemini-3.1-flash-lite", + name: "Gemini 3.1 Flash Lite", api: "google-vertex", provider: "google-vertex", baseUrl: "https://{location}-aiplatform.googleapis.com", reasoning: true, - thinkingLevelMap: {"off":null,"minimal":null,"low":"LOW","medium":null,"high":"HIGH"}, + thinkingLevelMap: {"off":null}, input: ["text", "image"], cost: { - input: 2, - output: 12, - cacheRead: 0.2, + input: 0.25, + output: 1.5, + cacheRead: 0.025, cacheWrite: 0, }, - contextWindow: 1000000, - maxTokens: 64000, + contextWindow: 1048576, + maxTokens: 65536, } satisfies Model<"google-vertex">, "gemini-3.1-pro-preview": { id: "gemini-3.1-pro-preview", - name: "Gemini 3.1 Pro Preview (Vertex)", + name: "Gemini 3.1 Pro Preview", api: "google-vertex", provider: "google-vertex", baseUrl: "https://{location}-aiplatform.googleapis.com", @@ -5079,7 +4979,7 @@ export const MODELS = { } satisfies Model<"google-vertex">, "gemini-3.1-pro-preview-customtools": { id: "gemini-3.1-pro-preview-customtools", - name: "Gemini 3.1 Pro Preview Custom Tools (Vertex)", + name: "Gemini 3.1 Pro Preview Custom Tools", api: "google-vertex", provider: "google-vertex", baseUrl: "https://{location}-aiplatform.googleapis.com", @@ -5095,6 +4995,60 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 65536, } satisfies Model<"google-vertex">, + "gemini-3.5-flash": { + id: "gemini-3.5-flash", + name: "Gemini 3.5 Flash", + api: "google-vertex", + provider: "google-vertex", + baseUrl: "https://{location}-aiplatform.googleapis.com", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.5, + output: 9, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-vertex">, + "gemini-flash-latest": { + id: "gemini-flash-latest", + name: "Gemini Flash Latest", + api: "google-vertex", + provider: "google-vertex", + baseUrl: "https://{location}-aiplatform.googleapis.com", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.5, + output: 9, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-vertex">, + "gemini-flash-lite-latest": { + id: "gemini-flash-lite-latest", + name: "Gemini Flash-Lite Latest", + api: "google-vertex", + provider: "google-vertex", + baseUrl: "https://{location}-aiplatform.googleapis.com", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 0.25, + output: 1.5, + cacheRead: 0.025, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-vertex">, }, "groq": { "llama-3.1-8b-instant": { @@ -9489,13 +9443,13 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh"}, input: ["text"], cost: { - input: 0.09, - output: 0.18, + input: 0.098, + output: 0.196, cacheRead: 0.02, cacheWrite: 0, }, contextWindow: 1048576, - maxTokens: 65536, + maxTokens: 4096, } satisfies Model<"openai-completions">, "deepseek/deepseek-v4-pro": { id: "deepseek/deepseek-v4-pro", @@ -12333,7 +12287,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 81920, } satisfies Model<"openai-completions">, "qwen/qwen3.5-397b-a17b": { id: "qwen/qwen3.5-397b-a17b", @@ -15065,6 +15019,23 @@ export const MODELS = { contextWindow: 256000, maxTokens: 32768, } satisfies Model<"anthropic-messages">, + "moonshotai/kimi-k2.7-code-highspeed": { + id: "moonshotai/kimi-k2.7-code-highspeed", + name: "Kimi K2.7 Code High Speed", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.9, + output: 8, + cacheRead: 0.38, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, + } satisfies Model<"anthropic-messages">, "nvidia/nemotron-3-super-120b-a12b": { id: "nvidia/nemotron-3-super-120b-a12b", name: "NVIDIA Nemotron 3 Super 120B A12B", diff --git a/packages/ai/src/providers/google-vertex.ts b/packages/ai/src/providers/google-vertex.ts index 6feeabb6..de476c00 100644 --- a/packages/ai/src/providers/google-vertex.ts +++ b/packages/ai/src/providers/google-vertex.ts @@ -490,7 +490,8 @@ function isGemini3ProModel(model: Model<"google-generative-ai">): boolean { } function isGemini3FlashModel(model: Model<"google-generative-ai">): boolean { - return /gemini-3(?:\.\d+)?-flash/.test(model.id.toLowerCase()); + const id = model.id.toLowerCase(); + return /gemini-3(?:\.\d+)?-flash/.test(id) || id === "gemini-flash-latest" || id === "gemini-flash-lite-latest"; } function getDisabledThinkingConfig(model: Model<"google-vertex">): ThinkingConfig { diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index d3f6f623..a270792a 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -406,7 +406,8 @@ function isGemini3ProModel(model: Model<"google-generative-ai">): boolean { } function isGemini3FlashModel(model: Model<"google-generative-ai">): boolean { - return /gemini-3(?:\.\d+)?-flash/.test(model.id.toLowerCase()); + const id = model.id.toLowerCase(); + return /gemini-3(?:\.\d+)?-flash/.test(id) || id === "gemini-flash-latest" || id === "gemini-flash-lite-latest"; } function getDisabledThinkingConfig(model: Model<"google-generative-ai">): ThinkingConfig { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 72a9b722..dfcf6226 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed inherited Google and `google-vertex` Gemini model metadata to map `latest` aliases to the current models, add Gemini 3.5 Flash for Vertex, correct Gemini 2.5 Flash Vertex cache pricing, and remove shut-down Vertex preview models ([#5761](https://github.com/earendil-works/pi/issues/5761)). - Fixed the session selector to stay open and show the all-sessions empty state when both current-folder and all-scope session lists are empty ([#5747](https://github.com/earendil-works/pi/issues/5747)). - Fixed inherited Moonshot AI China model metadata to include Kimi K2.7 Code, and omitted unsupported thinking-off payloads for Kimi K2.7 Code models ([#5760](https://github.com/earendil-works/pi/issues/5760)). From f8a77f47f2499febe56a71c3563415a0cae2616c Mon Sep 17 00:00:00 2001 From: Ramiz Wachtler Date: Tue, 16 Jun 2026 09:37:37 +0200 Subject: [PATCH 281/352] feat(coding-agent): add Vercel AI Gateway attribution (#5798) --- .../coding-agent/src/core/provider-attribution.ts | 12 ++++++++++++ .../test/sdk-openrouter-attribution.test.ts | 7 +++++++ 2 files changed, 19 insertions(+) diff --git a/packages/coding-agent/src/core/provider-attribution.ts b/packages/coding-agent/src/core/provider-attribution.ts index 91265c92..97ad1cfa 100644 --- a/packages/coding-agent/src/core/provider-attribution.ts +++ b/packages/coding-agent/src/core/provider-attribution.ts @@ -7,6 +7,7 @@ const NVIDIA_NIM_HOST = "integrate.api.nvidia.com"; const CLOUDFLARE_API_HOST = "api.cloudflare.com"; const CLOUDFLARE_AI_GATEWAY_HOST = "gateway.ai.cloudflare.com"; const OPENCODE_HOST = "opencode.ai"; +const VERCEL_GATEWAY_HOST = "ai-gateway.vercel.sh"; function matchesHost(baseUrl: string, expectedHost: string): boolean { try { @@ -33,6 +34,10 @@ function isCloudflareModel(model: Model): boolean { ); } +function isVercelGatewayModel(model: Model): boolean { + return model.provider === "vercel-ai-gateway" || matchesHost(model.baseUrl, VERCEL_GATEWAY_HOST); +} + function getDefaultAttributionHeaders( model: Model, settingsManager: SettingsManager, @@ -61,6 +66,13 @@ function getDefaultAttributionHeaders( }; } + if (isVercelGatewayModel(model)) { + return { + "http-referer": "https://pi.dev", + "x-title": "pi", + }; + } + return undefined; } diff --git a/packages/coding-agent/test/sdk-openrouter-attribution.test.ts b/packages/coding-agent/test/sdk-openrouter-attribution.test.ts index 9f035189..b03e5bb6 100644 --- a/packages/coding-agent/test/sdk-openrouter-attribution.test.ts +++ b/packages/coding-agent/test/sdk-openrouter-attribution.test.ts @@ -196,6 +196,13 @@ describe("createAgentSession provider attribution headers", () => { expect(headers?.["X-OpenRouter-Categories"]).toBe("provider-category"); }); + it("adds default attribution headers for Vercel AI Gateway models", async () => { + const headers = await captureHeaders(createModel("vercel-ai-gateway", "https://ai-gateway.vercel.sh/v1")); + + expect(headers?.["http-referer"]).toBe("https://pi.dev"); + expect(headers?.["x-title"]).toBe("pi"); + }); + it("adds default attribution headers for direct NVIDIA NIM endpoints", async () => { const headers = await captureHeaders(createModel("custom-nim", "https://integrate.api.nvidia.com/v1")); From 75b0d723c0778a7c7d9543b5d960f3fc80c455fe Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 16 Jun 2026 14:29:07 +0200 Subject: [PATCH 282/352] fix(ai): support Z.AI GLM-5.2 effort levels closes #5770 --- packages/ai/CHANGELOG.md | 1 + packages/ai/scripts/generate-models.ts | 11 +++ packages/ai/src/models.generated.ts | 72 +++++++++++++---- .../ai/src/providers/openai-completions.ts | 12 ++- .../openai-completions-tool-choice.test.ts | 80 +++++++++++++++++++ 5 files changed, 159 insertions(+), 17 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 3203ecfe..76bbc3d7 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed Z.AI GLM-5.2 thinking requests to send `reasoning_effort` with the provider's `high`/`max` effort mapping ([#5770](https://github.com/earendil-works/pi/issues/5770)). - Fixed Google and `google-vertex` Gemini model metadata to map `latest` aliases to the current models, add Gemini 3.5 Flash for Vertex, correct Gemini 2.5 Flash Vertex cache pricing, and remove shut-down Vertex preview models ([#5761](https://github.com/earendil-works/pi/issues/5761)). - Fixed Moonshot AI China model metadata to include Kimi K2.7 Code, and omitted unsupported thinking-off payloads for Kimi K2.7 Code models ([#5760](https://github.com/earendil-works/pi/issues/5760)). diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 26e581bb..8cc890ee 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -157,6 +157,13 @@ const NVIDIA_NIM_UNSUPPORTED_MODELS = new Set([ "upstage/solar-10.7b-instruct", ]); const ZAI_TOOL_STREAM_UNSUPPORTED_MODELS = new Set(["glm-4.5", "glm-4.5-air", "glm-4.5-flash", "glm-4.5v"]); +const ZAI_GLM52_THINKING_LEVEL_MAP = { + minimal: null, + low: "high", + medium: "high", + high: "high", + xhigh: "max", +} as const; const EAGER_TOOL_INPUT_STREAMING_UNSUPPORTED_ANTHROPIC_MODELS = new Set([ "github-copilot:claude-haiku-4.5", "github-copilot:claude-sonnet-4", @@ -894,6 +901,8 @@ async function loadModelsDevData(): Promise[]> { if (m.tool_call !== true) continue; const supportsImage = m.modalities?.input?.includes("image"); + const isGlm52 = modelId === "glm-5.2"; + models.push({ id: modelId, name: m.name || modelId, @@ -901,6 +910,7 @@ async function loadModelsDevData(): Promise[]> { provider, baseUrl, reasoning: m.reasoning === true, + ...(isGlm52 ? { thinkingLevelMap: ZAI_GLM52_THINKING_LEVEL_MAP } : {}), input: supportsImage ? ["text", "image"] : ["text"], cost: { input: m.cost?.input || 0, @@ -911,6 +921,7 @@ async function loadModelsDevData(): Promise[]> { compat: { supportsDeveloperRole: false, thinkingFormat: "zai", + ...(isGlm52 ? { supportsReasoningEffort: true } : {}), ...(!ZAI_TOOL_STREAM_UNSUPPORTED_MODELS.has(modelId) ? { zaiToolStream: true } : {}), }, contextWindow: m.limit?.context || 4096, diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 34575eb0..d7e2f9f3 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -3921,7 +3921,7 @@ export const MODELS = { cost: { input: 0.15, output: 0.6, - cacheRead: 0.015, + cacheRead: 0.01, cacheWrite: 0, }, contextWindow: 131072, @@ -6390,6 +6390,25 @@ export const MODELS = { contextWindow: 262144, maxTokens: 262144, } satisfies Model<"openai-completions">, + "kimi-k2.7-code-highspeed": { + id: "kimi-k2.7-code-highspeed", + name: "Kimi K2.7 Code HighSpeed", + api: "openai-completions", + provider: "moonshotai", + baseUrl: "https://api.moonshot.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.9, + output: 8, + cacheRead: 0.38, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, }, "moonshotai-cn": { "kimi-k2-0711-preview": { @@ -6537,6 +6556,25 @@ export const MODELS = { contextWindow: 262144, maxTokens: 262144, } satisfies Model<"openai-completions">, + "kimi-k2.7-code-highspeed": { + id: "kimi-k2.7-code-highspeed", + name: "Kimi K2.7 Code HighSpeed", + api: "openai-completions", + provider: "moonshotai-cn", + baseUrl: "https://api.moonshot.cn/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.9, + output: 8, + cacheRead: 0.38, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, }, "nvidia": { "meta/llama-3.1-70b-instruct": { @@ -12298,13 +12336,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.39, - output: 2.34, + input: 0.385, + output: 2.45, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 262144, - maxTokens: 65536, + contextWindow: 256000, + maxTokens: 4096, } satisfies Model<"openai-completions">, "qwen/qwen3.5-9b": { id: "qwen/qwen3.5-9b", @@ -12587,13 +12625,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.063, - output: 0.21, - cacheRead: 0.021, + input: 0.066, + output: 0.26, + cacheRead: 0.029, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 4096, + maxTokens: 262144, } satisfies Model<"openai-completions">, "thedrummer/rocinante-12b": { id: "thedrummer/rocinante-12b", @@ -12774,13 +12812,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.125, + input: 0.13, output: 0.85, - cacheRead: 0.06, + cacheRead: 0.025, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 131070, + maxTokens: 98304, } satisfies Model<"openai-completions">, "z-ai/glm-4.5v": { id: "z-ai/glm-4.5v", @@ -13214,8 +13252,8 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 2.5, - output: 7.5, + input: 1.25, + output: 3.75, cacheRead: 0, cacheWrite: 0, }, @@ -16876,8 +16914,9 @@ export const MODELS = { api: "openai-completions", provider: "zai", baseUrl: "https://api.z.ai/api/coding/paas/v4", - compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","zaiToolStream":true}, + compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","supportsReasoningEffort":true,"zaiToolStream":true}, reasoning: true, + thinkingLevelMap: {"minimal":null,"low":"high","medium":"high","high":"high","xhigh":"max"}, input: ["text"], cost: { input: 0, @@ -16986,8 +17025,9 @@ export const MODELS = { api: "openai-completions", provider: "zai-coding-cn", baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", - compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","zaiToolStream":true}, + compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","supportsReasoningEffort":true,"zaiToolStream":true}, reasoning: true, + thinkingLevelMap: {"minimal":null,"low":"high","medium":"high","high":"high","xhigh":"max"}, input: ["text"], cost: { input: 0, diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 140726ed..c2d5cff9 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -554,8 +554,18 @@ function buildParams( } if (compat.thinkingFormat === "zai" && model.reasoning) { - const zaiParams = params as typeof params & { thinking?: { type: "enabled" | "disabled" } }; + const zaiParams = params as Omit & { + thinking?: { type: "enabled" | "disabled" }; + reasoning_effort?: string; + }; zaiParams.thinking = { type: options?.reasoningEffort ? "enabled" : "disabled" }; + if (options?.reasoningEffort && compat.supportsReasoningEffort) { + const mappedEffort = model.thinkingLevelMap?.[options.reasoningEffort]; + const effort = mappedEffort === undefined ? options.reasoningEffort : mappedEffort; + if (typeof effort === "string") { + zaiParams.reasoning_effort = effort; + } + } } else if (compat.thinkingFormat === "qwen" && model.reasoning) { (params as any).enable_thinking = !!options?.reasoningEffort; } else if (compat.thinkingFormat === "qwen-chat-template" && model.reasoning) { diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index 9cf84496..8ca86025 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -257,6 +257,86 @@ describe("openai-completions tool_choice", () => { expect(getModel("zai", "glm-4.5-air")?.compat?.zaiToolStream).toBeUndefined(); }); + it("stores z.ai GLM-5.2 effort metadata", () => { + for (const provider of ["zai", "zai-coding-cn"] as const) { + const model = getModel(provider, "glm-5.2")!; + expect(model.compat?.supportsReasoningEffort).toBe(true); + expect(model.thinkingLevelMap).toEqual({ + minimal: null, + low: "high", + medium: "high", + high: "high", + xhigh: "max", + }); + } + }); + + it("maps z.ai GLM-5.2 thinking levels to reasoning_effort", async () => { + const model = getModel("zai", "glm-5.2")!; + const cases = [ + { reasoning: "low", effort: "high" }, + { reasoning: "medium", effort: "high" }, + { reasoning: "high", effort: "high" }, + { reasoning: "xhigh", effort: "max" }, + ] as const; + + for (const testCase of cases) { + let payload: unknown; + + await streamSimple( + model, + { + messages: [ + { + role: "user", + content: "Hi", + timestamp: Date.now(), + }, + ], + }, + { + apiKey: "test", + reasoning: testCase.reasoning, + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = (payload ?? mockState.lastParams) as { thinking?: unknown; reasoning_effort?: string }; + expect(params.thinking).toEqual({ type: "enabled" }); + expect(params.reasoning_effort).toBe(testCase.effort); + } + }); + + it("omits z.ai GLM-5.2 reasoning_effort when thinking is off", async () => { + const model = getModel("zai", "glm-5.2")!; + let payload: unknown; + + await streamSimple( + model, + { + messages: [ + { + role: "user", + content: "Hi", + timestamp: Date.now(), + }, + ], + }, + { + apiKey: "test", + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = (payload ?? mockState.lastParams) as { thinking?: unknown; reasoning_effort?: string }; + expect(params.thinking).toEqual({ type: "disabled" }); + expect(params.reasoning_effort).toBeUndefined(); + }); + it("omits tool_stream for unsupported z.ai models", async () => { const model = getModel("zai", "glm-4.5-air")!; const tools: Tool[] = [ From 06d8c54de297d6b0ddb44c249eca9f988ddeaaee Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 16 Jun 2026 15:10:23 +0200 Subject: [PATCH 283/352] fix(coding-agent): avoid Windows pi update exit assertion closes #5805 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/main.ts | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index dfcf6226..11cb9900 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed successful `pi update` on Windows to exit naturally instead of calling `process.exit(0)`, avoiding a Node.js/libuv assertion after version-check network requests ([#5805](https://github.com/earendil-works/pi/issues/5805)). - Fixed inherited Google and `google-vertex` Gemini model metadata to map `latest` aliases to the current models, add Gemini 3.5 Flash for Vertex, correct Gemini 2.5 Flash Vertex cache pricing, and remove shut-down Vertex preview models ([#5761](https://github.com/earendil-works/pi/issues/5761)). - Fixed the session selector to stay open and show the all-sessions empty state when both current-folder and all-scope session lists are empty ([#5747](https://github.com/earendil-works/pi/issues/5747)). - Fixed inherited Moonshot AI China model metadata to include Kimi K2.7 Code, and omitted unsupported thinking-off payloads for Kimi K2.7 Code models ([#5760](https://github.com/earendil-works/pi/issues/5760)). diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 00f3b208..42ce1ce5 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -467,7 +467,15 @@ export async function main(args: string[], options?: MainOptions) { } if (await handlePackageCommand(args, { extensionFactories: options?.extensionFactories })) { - process.exit(process.exitCode ?? 0); + const exitCode = process.exitCode ?? 0; + if (process.platform === "win32" && exitCode === 0 && args[0] === "update") { + // We normally prefer process.exit(0) for package commands so bad extensions cannot keep + // one-shot commands alive. On Windows, Node can assert after fetch() if process.exit(0) + // runs during teardown; let successful `pi update` drain naturally instead. + // https://github.com/nodejs/node/issues/56645 + return; + } + process.exit(exitCode); return; } From 3039f3e17d09e23fb2b097b67cf2f5eb92e5aadb Mon Sep 17 00:00:00 2001 From: 4h9fbZ <176179231+4h9fbZ@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:14:17 +0200 Subject: [PATCH 284/352] fix(tui): restore cursorUp start-of-line jump when input is non-empty (#5789) --- packages/tui/src/components/editor.ts | 9 ++++++++- packages/tui/test/editor.test.ts | 11 ++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 724dd460..bedd0105 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -390,6 +390,10 @@ export class Editor implements Component, Focusable { } } + private isEditorEmpty(): boolean { + return this.state.lines.length === 1 && this.state.lines[0] === ""; + } + private isOnFirstVisualLine(): boolean { const visualLines = this.buildVisualLineMap(this.lastWidth); const currentVisualLine = this.findCurrentVisualLine(visualLines); @@ -803,7 +807,10 @@ export class Editor implements Component, Focusable { // Arrow key navigation (with history support) if (kb.matches(data, "tui.editor.cursorUp")) { - if (this.isOnFirstVisualLine() && this.history.length > 0) { + if ( + this.isOnFirstVisualLine() && + (this.isEditorEmpty() || this.historyIndex > -1 || this.state.cursorCol === 0) + ) { this.navigateHistory(-1); } else if (this.isOnFirstVisualLine()) { // Already at top - jump to start of line diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 2c8a7b62..0f33370e 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -79,7 +79,7 @@ describe("Editor component", () => { assert.strictEqual(editor.getText(), "first"); }); - it("restores draft on Down arrow after browsing history", () => { + it("jumps to start before entering history from a non-empty draft", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.addToHistory("prompt"); @@ -87,12 +87,16 @@ describe("Editor component", () => { editor.handleInput("\x1b[D"); editor.handleInput("\x1b[D"); - editor.handleInput("\x1b[A"); // Up - shows "prompt" + editor.handleInput("\x1b[A"); // Up - jumps to start before history browsing + assert.strictEqual(editor.getText(), "draft"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + editor.handleInput("\x1b[A"); // Up at start - shows "prompt" assert.strictEqual(editor.getText(), "prompt"); editor.handleInput("\x1b[B"); // Down - restores draft assert.strictEqual(editor.getText(), "draft"); - assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 }); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); }); it("navigates forward through history with Down arrow", () => { @@ -104,6 +108,7 @@ describe("Editor component", () => { editor.setText("draft"); // Go to oldest + editor.handleInput("\x1b[A"); // start of draft editor.handleInput("\x1b[A"); // third editor.handleInput("\x1b[A"); // second editor.handleInput("\x1b[A"); // first From 7f29e7a3697dc77416818faea4f4af29a13ce079 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 16 Jun 2026 17:19:08 +0200 Subject: [PATCH 285/352] feat: add provider-scoped environment overrides (#5807) --- packages/ai/CHANGELOG.md | 4 + packages/ai/README.md | 19 ++++ packages/ai/src/env-api-keys.ts | 90 ++++++------------ packages/ai/src/providers/amazon-bedrock.ts | 92 ++++++++++++------- packages/ai/src/providers/anthropic.ts | 17 ++-- .../src/providers/azure-openai-responses.ts | 15 ++- packages/ai/src/providers/cloudflare.ts | 9 +- packages/ai/src/providers/google-vertex.ts | 19 +++- .../src/providers/openai-codex-responses.ts | 39 ++++---- .../ai/src/providers/openai-completions.ts | 15 +-- packages/ai/src/providers/openai-responses.ts | 15 +-- packages/ai/src/providers/simple-options.ts | 1 + packages/ai/src/stream.ts | 2 +- packages/ai/src/types.ts | 9 ++ packages/ai/src/utils/node-http-proxy.ts | 55 +++++------ packages/ai/src/utils/oauth/anthropic.ts | 3 +- packages/ai/src/utils/oauth/openai-codex.ts | 3 +- packages/ai/src/utils/provider-env.ts | 52 +++++++++++ .../test/bedrock-endpoint-resolution.test.ts | 33 ++++++- packages/ai/test/node-http-proxy.test.ts | 11 +++ .../openai-completions-empty-tools.test.ts | 25 +++++ packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/docs/providers.md | 22 ++++- .../src/bun/restore-sandbox-env.ts | 4 + .../coding-agent/src/core/agent-session.ts | 17 +++- .../coding-agent/src/core/auth-storage.ts | 11 ++- .../core/compaction/branch-summarization.ts | 5 +- .../src/core/compaction/compaction.ts | 13 ++- .../coding-agent/src/core/model-registry.ts | 20 +++- .../src/core/resolve-config-value.ts | 40 ++++---- packages/coding-agent/src/core/sdk.ts | 2 + .../coding-agent/test/auth-storage.test.ts | 28 ++++++ .../coding-agent/test/model-registry.test.ts | 32 +++++++ 33 files changed, 511 insertions(+), 215 deletions(-) create mode 100644 packages/ai/src/utils/provider-env.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 76bbc3d7..e4d9ced0 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added provider-scoped `StreamOptions.env` overrides for provider configuration, including Cloudflare endpoint placeholders, Azure OpenAI, Google Vertex, Amazon Bedrock, cache retention, and proxy environment lookups ([#5728](https://github.com/earendil-works/pi/issues/5728)). + ### Fixed - Fixed Z.AI GLM-5.2 thinking requests to send `reasoning_effort` with the provider's `high`/`max` effort mapping ([#5770](https://github.com/earendil-works/pi/issues/5770)). diff --git a/packages/ai/README.md b/packages/ai/README.md index 4190fcb8..a7b8028e 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -38,6 +38,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an - [Browser Usage](#browser-usage) - [Browser Compatibility Notes](#browser-compatibility-notes) - [Environment Variables](#environment-variables-nodejs-only) + - [Provider-Scoped Environment Overrides](#provider-scoped-environment-overrides) - [Checking Environment Variables](#checking-environment-variables) - [OAuth Providers](#oauth-providers) - [Vertex AI](#vertex-ai) @@ -1145,6 +1146,24 @@ const response = await complete(model, context, { }); ``` +### Provider-Scoped Environment Overrides + +Pass `env` in stream options to scope provider configuration to a request. Values in `env` are used before process environment variables for API key discovery and provider configuration such as Cloudflare account IDs, Azure OpenAI settings, Vertex project/location, Bedrock settings, `PI_CACHE_RETENTION`, and `HTTP_PROXY`/`HTTPS_PROXY`. + +```typescript +const model = getModel('cloudflare-ai-gateway', 'workers-ai/@cf/moonshotai/kimi-k2.6'); + +const response = await complete(model, context, { + env: { + CLOUDFLARE_API_KEY: '...', + CLOUDFLARE_ACCOUNT_ID: 'account-id', + CLOUDFLARE_GATEWAY_ID: 'gateway-id' + } +}); +``` + +Use this when one process needs different provider settings per request, or when ambient environment variables should not leak into a provider call. + ### Checking Environment Variables ```typescript diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index 291d2288..7bd955e1 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -23,44 +23,17 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version }); } -import type { KnownProvider } from "./types.ts"; - -let _procEnvCache: Map | null = null; - -/** - * Fallback for https://github.com/oven-sh/bun/issues/27802 - * Bun compiled binaries have an empty `process.env` inside sandbox - * environments on Linux. We can recover the env from `/proc/self/environ`. - */ -function getProcEnv(key: string): string | undefined { - if (!process.versions?.bun) return undefined; - if (typeof process === "undefined") return undefined; - - // If process.env already has entries, the bug is not triggered. - if (Object.keys(process.env).length > 0) return undefined; - - if (_procEnvCache === null) { - _procEnvCache = new Map(); - try { - const { readFileSync } = require("node:fs") as typeof import("node:fs"); - const data = readFileSync("/proc/self/environ", "utf-8"); - for (const entry of data.split("\0")) { - const idx = entry.indexOf("="); - if (idx > 0) { - _procEnvCache.set(entry.slice(0, idx), entry.slice(idx + 1)); - } - } - } catch { - // /proc/self/environ may not be readable. - } - } - - return _procEnvCache.get(key); -} +import type { KnownProvider, ProviderEnv } from "./types.ts"; +import { getProviderEnvValue } from "./utils/provider-env.ts"; let cachedVertexAdcCredentialsExists: boolean | null = null; -function hasVertexAdcCredentials(): boolean { +function hasVertexAdcCredentials(env?: ProviderEnv): boolean { + const explicitCredentialsPath = env?.GOOGLE_APPLICATION_CREDENTIALS; + if (explicitCredentialsPath) { + return _existsSync ? _existsSync(explicitCredentialsPath) : false; + } + if (cachedVertexAdcCredentialsExists === null) { // If node modules haven't loaded yet (async import race at startup), // return false WITHOUT caching so the next call retries once they're ready. @@ -75,7 +48,7 @@ function hasVertexAdcCredentials(): boolean { } // Check GOOGLE_APPLICATION_CREDENTIALS env var first (standard way) - const gacPath = process.env.GOOGLE_APPLICATION_CREDENTIALS || getProcEnv("GOOGLE_APPLICATION_CREDENTIALS"); + const gacPath = getProviderEnvValue("GOOGLE_APPLICATION_CREDENTIALS", env); if (gacPath) { cachedVertexAdcCredentialsExists = _existsSync(gacPath); } else { @@ -143,13 +116,13 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined { * credential sources such as AWS profiles, AWS IAM credentials, and Google * Application Default Credentials. */ -export function findEnvKeys(provider: KnownProvider): string[] | undefined; -export function findEnvKeys(provider: string): string[] | undefined; -export function findEnvKeys(provider: string): string[] | undefined { +export function findEnvKeys(provider: KnownProvider, env?: ProviderEnv): string[] | undefined; +export function findEnvKeys(provider: string, env?: ProviderEnv): string[] | undefined; +export function findEnvKeys(provider: string, env?: ProviderEnv): string[] | undefined { const envVars = getApiKeyEnvVars(provider); if (!envVars) return undefined; - const found = envVars.filter((envVar) => !!process.env[envVar] || !!getProcEnv(envVar)); + const found = envVars.filter((envVar) => !!getProviderEnvValue(envVar, env)); return found.length > 0 ? found : undefined; } @@ -158,25 +131,22 @@ export function findEnvKeys(provider: string): string[] | undefined { * * Will not return API keys for providers that require OAuth tokens. */ -export function getEnvApiKey(provider: KnownProvider): string | undefined; -export function getEnvApiKey(provider: string): string | undefined; -export function getEnvApiKey(provider: string): string | undefined { - const envKeys = findEnvKeys(provider); +export function getEnvApiKey(provider: KnownProvider, env?: ProviderEnv): string | undefined; +export function getEnvApiKey(provider: string, env?: ProviderEnv): string | undefined; +export function getEnvApiKey(provider: string, env?: ProviderEnv): string | undefined { + const envKeys = findEnvKeys(provider, env); if (envKeys?.[0]) { - return process.env[envKeys[0]] || getProcEnv(envKeys[0]); + return getProviderEnvValue(envKeys[0], env); } // Vertex AI supports either an explicit API key or Application Default Credentials. // Auth is configured via `gcloud auth application-default login`. if (provider === "google-vertex") { - const hasCredentials = hasVertexAdcCredentials(); + const hasCredentials = hasVertexAdcCredentials(env); const hasProject = !!( - process.env.GOOGLE_CLOUD_PROJECT || - process.env.GCLOUD_PROJECT || - getProcEnv("GOOGLE_CLOUD_PROJECT") || - getProcEnv("GCLOUD_PROJECT") + getProviderEnvValue("GOOGLE_CLOUD_PROJECT", env) || getProviderEnvValue("GCLOUD_PROJECT", env) ); - const hasLocation = !!(process.env.GOOGLE_CLOUD_LOCATION || getProcEnv("GOOGLE_CLOUD_LOCATION")); + const hasLocation = !!getProviderEnvValue("GOOGLE_CLOUD_LOCATION", env); if (hasCredentials && hasProject && hasLocation) { return ""; @@ -192,18 +162,12 @@ export function getEnvApiKey(provider: string): string | undefined { // 5. AWS_CONTAINER_CREDENTIALS_FULL_URI - ECS task roles (full URI) // 6. AWS_WEB_IDENTITY_TOKEN_FILE - IRSA (IAM Roles for Service Accounts) if ( - process.env.AWS_PROFILE || - (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) || - process.env.AWS_BEARER_TOKEN_BEDROCK || - process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || - process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI || - process.env.AWS_WEB_IDENTITY_TOKEN_FILE || - getProcEnv("AWS_PROFILE") || - (getProcEnv("AWS_ACCESS_KEY_ID") && getProcEnv("AWS_SECRET_ACCESS_KEY")) || - getProcEnv("AWS_BEARER_TOKEN_BEDROCK") || - getProcEnv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") || - getProcEnv("AWS_CONTAINER_CREDENTIALS_FULL_URI") || - getProcEnv("AWS_WEB_IDENTITY_TOKEN_FILE") + getProviderEnvValue("AWS_PROFILE", env) || + (getProviderEnvValue("AWS_ACCESS_KEY_ID", env) && getProviderEnvValue("AWS_SECRET_ACCESS_KEY", env)) || + getProviderEnvValue("AWS_BEARER_TOKEN_BEDROCK", env) || + getProviderEnvValue("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", env) || + getProviderEnvValue("AWS_CONTAINER_CREDENTIALS_FULL_URI", env) || + getProviderEnvValue("AWS_WEB_IDENTITY_TOKEN_FILE", env) ) { return ""; } diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index a4ace1c2..5d327f4b 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -1,3 +1,4 @@ +import type { Agent as HttpsAgent } from "node:https"; import { BedrockRuntimeClient, type BedrockRuntimeClientConfig, @@ -23,6 +24,8 @@ import { } from "@aws-sdk/client-bedrock-runtime"; import { NodeHttpHandler } from "@smithy/node-http-handler"; import type { BuildMiddleware, DocumentType, MetadataBearer } from "@smithy/types"; +import { HttpProxyAgent } from "http-proxy-agent"; +import { HttpsProxyAgent } from "https-proxy-agent"; import { calculateCost } from "../models.ts"; import type { Api, @@ -31,6 +34,7 @@ import type { Context, ImageContent, Model, + ProviderEnv, SimpleStreamOptions, StopReason, StreamFunction, @@ -45,7 +49,8 @@ import type { } from "../types.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { parseStreamingJson } from "../utils/json-parse.ts"; -import { createHttpProxyAgentsForTarget } from "../utils/node-http-proxy.ts"; +import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts"; +import { getProviderEnvValue } from "../utils/provider-env.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; import { adjustMaxTokensForThinking, buildBaseOptions, clampReasoning } from "./simple-options.ts"; import { transformMessages } from "./transform-messages.ts"; @@ -119,18 +124,18 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt const blocks = output.content as Block[]; const config: BedrockRuntimeClientConfig = { - profile: options.profile, + profile: options.profile || getProviderEnvValue("AWS_PROFILE", options.env), }; const configuredRegion = getConfiguredBedrockRegion(options); - const hasConfiguredProfile = hasConfiguredBedrockProfile(); + const hasAmbientConfiguredProfile = Boolean(getProviderEnvValue("AWS_PROFILE")); const endpointRegion = getStandardBedrockEndpointRegion(model.baseUrl); const useExplicitEndpoint = shouldUseExplicitBedrockEndpoint( model.baseUrl, configuredRegion, - hasConfiguredProfile, + hasAmbientConfiguredProfile, ); - // Only pin standard AWS Bedrock runtime endpoints when no region/profile is configured. + // Only pin standard AWS Bedrock runtime endpoints when no region or ambient AWS_PROFILE is configured. // This preserves custom endpoints (VPC/proxy) from #3402 without forcing built-in // catalog defaults such as us-east-1 to override AWS_REGION/AWS_PROFILE. if (useExplicitEndpoint) { @@ -138,8 +143,10 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt } // Resolve bearer token for Bedrock API key auth. - const bearerToken = options.bearerToken || process.env.AWS_BEARER_TOKEN_BEDROCK || undefined; - const useBearerToken = bearerToken !== undefined && process.env.AWS_BEDROCK_SKIP_AUTH !== "1"; + const skipAuth = getProviderEnvValue("AWS_BEDROCK_SKIP_AUTH", options.env) === "1"; + const bearerToken = + options.bearerToken || getProviderEnvValue("AWS_BEARER_TOKEN_BEDROCK", options.env) || undefined; + const useBearerToken = bearerToken !== undefined && !skipAuth; // in Node.js/Bun environment only if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) { @@ -153,25 +160,33 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt config.region = configuredRegion; } else if (endpointRegion && useExplicitEndpoint) { config.region = endpointRegion; - } else if (!hasConfiguredProfile) { + } else if (!hasAmbientConfiguredProfile) { config.region = "us-east-1"; } // Support proxies that don't need authentication - if (process.env.AWS_BEDROCK_SKIP_AUTH === "1") { + if (skipAuth) { config.credentials = { accessKeyId: "dummy-access-key", secretAccessKey: "dummy-secret-key", }; } - const proxyAgents = createHttpProxyAgentsForTarget(model.baseUrl); - if (proxyAgents) { + const credentials = getConfiguredBedrockCredentials(options.env); + if (!skipAuth && credentials) { + config.credentials = credentials; + } + + const proxyUrl = resolveHttpProxyUrlForTarget(model.baseUrl, options.env); + if (proxyUrl) { // Bedrock runtime uses NodeHttp2Handler by default since v3.798.0, which is based // on `http2` module and has no support for http agent. // Use NodeHttpHandler to support HTTP(S) proxy agents. - config.requestHandler = new NodeHttpHandler(proxyAgents); - } else if (process.env.AWS_BEDROCK_FORCE_HTTP1 === "1") { + config.requestHandler = new NodeHttpHandler({ + httpAgent: new HttpProxyAgent(proxyUrl), + httpsAgent: new HttpsProxyAgent(proxyUrl) as unknown as HttpsAgent, + }); + } else if (getProviderEnvValue("AWS_BEDROCK_FORCE_HTTP1", options.env) === "1") { // Some custom endpoints require HTTP/1.1 instead of HTTP/2 config.requestHandler = new NodeHttpHandler(); } @@ -192,12 +207,12 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt if (options.headers && Object.keys(options.headers).length > 0) { addCustomHeadersMiddleware(client, options.headers); } - const cacheRetention = resolveCacheRetention(options.cacheRetention); + const cacheRetention = resolveCacheRetention(options.cacheRetention, options.env); const inferenceMaxTokens = options.maxTokens ?? (isAnthropicClaudeModel(model) ? model.maxTokens : undefined); let commandInput = { modelId: model.id, - messages: convertMessages(context, model, cacheRetention), - system: buildSystemPrompt(context.systemPrompt, model, cacheRetention), + messages: convertMessages(context, model, cacheRetention, options.env), + system: buildSystemPrompt(context.systemPrompt, model, cacheRetention, options.env), inferenceConfig: { ...(inferenceMaxTokens !== undefined && { maxTokens: inferenceMaxTokens }), ...(options.temperature !== undefined && { temperature: options.temperature }), @@ -578,11 +593,11 @@ function mapThinkingLevelToEffort( * Resolve cache retention preference. * Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility. */ -function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention { +function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention { if (cacheRetention) { return cacheRetention; } - if (typeof process !== "undefined" && process.env.PI_CACHE_RETENTION === "long") { + if (getProviderEnvValue("PI_CACHE_RETENTION", env) === "long") { return "long"; } return "short"; @@ -617,14 +632,14 @@ function isAnthropicClaudeModel(model: Model<"bedrock-converse-stream">): boolea * As a last resort, set AWS_BEDROCK_FORCE_CACHE=1 to enable cache points. * Amazon Nova models have automatic caching and don't need explicit cache points. */ -function supportsPromptCaching(model: Model<"bedrock-converse-stream">): boolean { +function supportsPromptCaching(model: Model<"bedrock-converse-stream">, env?: ProviderEnv): boolean { const candidates = getModelMatchCandidates(model.id, model.name); const hasClaudeRef = candidates.some((s) => s.includes("claude")); if (!hasClaudeRef) { // Application inference profiles don't contain the model name in the ARN. // Allow users to force cache points via environment variable. - if (typeof process !== "undefined" && process.env.AWS_BEDROCK_FORCE_CACHE === "1") return true; + if (getProviderEnvValue("AWS_BEDROCK_FORCE_CACHE", env) === "1") return true; return false; } // Claude 4.x models (opus-4, sonnet-4, haiku-4) @@ -652,13 +667,14 @@ function buildSystemPrompt( systemPrompt: string | undefined, model: Model<"bedrock-converse-stream">, cacheRetention: CacheRetention, + env?: ProviderEnv, ): SystemContentBlock[] | undefined { if (!systemPrompt) return undefined; const blocks: SystemContentBlock[] = [{ text: sanitizeSurrogates(systemPrompt) }]; // Add cache point for supported Claude models when caching is enabled - if (cacheRetention !== "none" && supportsPromptCaching(model)) { + if (cacheRetention !== "none" && supportsPromptCaching(model, env)) { blocks.push({ cachePoint: { type: CachePointType.DEFAULT, ...(cacheRetention === "long" ? { ttl: CacheTTL.ONE_HOUR } : {}) }, }); @@ -699,6 +715,7 @@ function convertMessages( context: Context, model: Model<"bedrock-converse-stream">, cacheRetention: CacheRetention, + env?: ProviderEnv, ): Message[] { const result: Message[] = []; const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId); @@ -844,7 +861,7 @@ function convertMessages( } // Add cache point to the last user message for supported Claude models when caching is enabled - if (cacheRetention !== "none" && supportsPromptCaching(model) && result.length > 0) { + if (cacheRetention !== "none" && supportsPromptCaching(model, env) && result.length > 0) { const lastMessage = result[result.length - 1]; if (lastMessage.role === ConversationRole.USER && lastMessage.content) { (lastMessage.content as ContentBlock[]).push({ @@ -906,19 +923,26 @@ function mapStopReason(reason: string | undefined): StopReason { } function getConfiguredBedrockRegion(options: BedrockOptions): string | undefined { - if (typeof process === "undefined") { - return options.region; - } - - return options.region || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || undefined; + return ( + options.region || + getProviderEnvValue("AWS_REGION", options.env) || + getProviderEnvValue("AWS_DEFAULT_REGION", options.env) || + undefined + ); } -function hasConfiguredBedrockProfile(): boolean { - if (typeof process === "undefined") { - return false; +function getConfiguredBedrockCredentials(env?: ProviderEnv): BedrockRuntimeClientConfig["credentials"] | undefined { + const accessKeyId = getProviderEnvValue("AWS_ACCESS_KEY_ID", env); + const secretAccessKey = getProviderEnvValue("AWS_SECRET_ACCESS_KEY", env); + if (!accessKeyId || !secretAccessKey) { + return undefined; } - - return Boolean(process.env.AWS_PROFILE); + const sessionToken = getProviderEnvValue("AWS_SESSION_TOKEN", env); + return { + accessKeyId, + secretAccessKey, + ...(sessionToken ? { sessionToken } : {}), + }; } function getStandardBedrockEndpointRegion(baseUrl: string | undefined): string | undefined { @@ -938,14 +962,14 @@ function getStandardBedrockEndpointRegion(baseUrl: string | undefined): string | function shouldUseExplicitBedrockEndpoint( baseUrl: string, configuredRegion: string | undefined, - hasConfiguredProfile: boolean, + hasAmbientConfiguredProfile: boolean, ): boolean { const endpointRegion = getStandardBedrockEndpointRegion(baseUrl); if (!endpointRegion) { return true; } - return !configuredRegion && !hasConfiguredProfile; + return !configuredRegion && !hasAmbientConfiguredProfile; } function isGovCloudBedrockTarget(model: Model<"bedrock-converse-stream">, options: BedrockOptions): boolean { diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 9a64cbd8..6ffab7cb 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -17,6 +17,7 @@ import type { ImageContent, Message, Model, + ProviderEnv, SimpleStreamOptions, StopReason, StreamFunction, @@ -30,6 +31,7 @@ import type { import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.ts"; +import { getProviderEnvValue } from "../utils/provider-env.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; import { resolveCloudflareBaseUrl } from "./cloudflare.ts"; @@ -41,11 +43,11 @@ import { transformMessages } from "./transform-messages.ts"; * Resolve cache retention preference. * Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility. */ -function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention { +function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention { if (cacheRetention) { return cacheRetention; } - if (typeof process !== "undefined" && process.env.PI_CACHE_RETENTION === "long") { + if (getProviderEnvValue("PI_CACHE_RETENTION", env) === "long") { return "long"; } return "short"; @@ -54,8 +56,9 @@ function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention function getCacheControl( model: Model<"anthropic-messages">, cacheRetention?: CacheRetention, + env?: ProviderEnv, ): { retention: CacheRetention; cacheControl?: CacheControlEphemeral } { - const retention = resolveCacheRetention(cacheRetention); + const retention = resolveCacheRetention(cacheRetention, env); if (retention === "none") { return { retention }; } @@ -494,7 +497,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti }); } - const cacheRetention = options?.cacheRetention ?? resolveCacheRetention(); + const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env); const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId; const created = createClient( @@ -505,6 +508,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti options?.headers, copilotDynamicHeaders, cacheSessionId, + options?.env, ); client = created.client; isOAuth = created.isOAuthToken; @@ -794,6 +798,7 @@ function createClient( optionsHeaders?: Record, dynamicHeaders?: Record, sessionId?: string, + env?: ProviderEnv, ): { client: Anthropic; isOAuthToken: boolean } { // Adaptive thinking models have interleaved thinking built in, so skip the beta header. const needsInterleavedBeta = interleavedThinking && model.compat?.forceAdaptiveThinking !== true; @@ -809,7 +814,7 @@ function createClient( const client = new Anthropic({ apiKey: null, authToken: null, - baseURL: resolveCloudflareBaseUrl(model), + baseURL: resolveCloudflareBaseUrl(model, env), dangerouslyAllowBrowser: true, defaultHeaders: mergeHeaders( { @@ -902,7 +907,7 @@ function buildParams( isOAuthToken: boolean, options?: AnthropicOptions, ): MessageCreateParamsStreaming { - const { cacheControl } = getCacheControl(model, options?.cacheRetention); + const { cacheControl } = getCacheControl(model, options?.cacheRetention, options?.env); const compat = getAnthropicCompat(model); const params: MessageCreateParamsStreaming = { model: model.id, diff --git a/packages/ai/src/providers/azure-openai-responses.ts b/packages/ai/src/providers/azure-openai-responses.ts index ecb4c7e6..db1d3fb7 100644 --- a/packages/ai/src/providers/azure-openai-responses.ts +++ b/packages/ai/src/providers/azure-openai-responses.ts @@ -12,6 +12,7 @@ import type { } from "../types.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; +import { getProviderEnvValue } from "../utils/provider-env.ts"; import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts"; import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts"; import { buildBaseOptions } from "./simple-options.ts"; @@ -36,7 +37,9 @@ function resolveDeploymentName(model: Model<"azure-openai-responses">, options?: if (options?.azureDeploymentName) { return options.azureDeploymentName; } - const mappedDeployment = parseDeploymentNameMap(process.env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP).get(model.id); + const mappedDeployment = parseDeploymentNameMap( + getProviderEnvValue("AZURE_OPENAI_DEPLOYMENT_NAME_MAP", options?.env), + ).get(model.id); return mappedDeployment || model.id; } @@ -198,10 +201,14 @@ function resolveAzureConfig( model: Model<"azure-openai-responses">, options?: AzureOpenAIResponsesOptions, ): { baseUrl: string; apiVersion: string } { - const apiVersion = options?.azureApiVersion || process.env.AZURE_OPENAI_API_VERSION || DEFAULT_AZURE_API_VERSION; + const apiVersion = + options?.azureApiVersion || + getProviderEnvValue("AZURE_OPENAI_API_VERSION", options?.env) || + DEFAULT_AZURE_API_VERSION; - const baseUrl = options?.azureBaseUrl?.trim() || process.env.AZURE_OPENAI_BASE_URL?.trim() || undefined; - const resourceName = options?.azureResourceName || process.env.AZURE_OPENAI_RESOURCE_NAME; + const baseUrl = + options?.azureBaseUrl?.trim() || getProviderEnvValue("AZURE_OPENAI_BASE_URL", options?.env)?.trim() || undefined; + const resourceName = options?.azureResourceName || getProviderEnvValue("AZURE_OPENAI_RESOURCE_NAME", options?.env); let resolvedBaseUrl = baseUrl; diff --git a/packages/ai/src/providers/cloudflare.ts b/packages/ai/src/providers/cloudflare.ts index cd0a8159..98546419 100644 --- a/packages/ai/src/providers/cloudflare.ts +++ b/packages/ai/src/providers/cloudflare.ts @@ -1,4 +1,5 @@ -import type { Api, Model } from "../types.ts"; +import type { Api, Model, ProviderEnv } from "../types.ts"; +import { getProviderEnvValue } from "../utils/provider-env.ts"; /** Workers AI direct endpoint. */ export const CLOUDFLARE_WORKERS_AI_BASE_URL = @@ -20,12 +21,12 @@ export function isCloudflareProvider(provider: string): boolean { return provider === "cloudflare-workers-ai" || provider === "cloudflare-ai-gateway"; } -/** Substitute `{VAR}` placeholders in a Cloudflare baseUrl from process.env. */ -export function resolveCloudflareBaseUrl(model: Model): string { +/** Substitute `{VAR}` placeholders in a Cloudflare baseUrl from provider env or process.env. */ +export function resolveCloudflareBaseUrl(model: Model, env?: ProviderEnv): string { const url = model.baseUrl; if (!url.includes("{")) return url; const baseUrl = url.replace(/\{([A-Z_][A-Z0-9_]*)\}/g, (_match, name: string) => { - const value = process.env[name]; + const value = getProviderEnvValue(name, env); if (!value) { throw new Error(`${name} is required for provider ${model.provider} but is not set.`); } diff --git a/packages/ai/src/providers/google-vertex.ts b/packages/ai/src/providers/google-vertex.ts index de476c00..aa971959 100644 --- a/packages/ai/src/providers/google-vertex.ts +++ b/packages/ai/src/providers/google-vertex.ts @@ -14,6 +14,7 @@ import type { Context, Model, ThinkingLevel as PiThinkingLevel, + ProviderEnv, SimpleStreamOptions, StreamFunction, StreamOptions, @@ -23,6 +24,7 @@ import type { ToolCall, } from "../types.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; +import { getProviderEnvValue } from "../utils/provider-env.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; import type { GoogleThinkingLevel } from "./google-shared.ts"; import { @@ -91,7 +93,7 @@ export const streamGoogleVertex: StreamFunction<"google-vertex", GoogleVertexOpt // Create the client using either a Vertex API key, if provided, or ADC with project and location const client = apiKey ? createClientWithApiKey(model, apiKey, options?.headers) - : createClient(model, resolveProject(options), resolveLocation(options), options?.headers); + : createClient(model, resolveProject(options), resolveLocation(options), options?.headers, options?.env); let params = buildParams(model, context, options); const nextParams = await options?.onPayload?.(params, model); if (nextParams !== undefined) { @@ -333,12 +335,15 @@ function createClient( project: string, location: string, optionsHeaders?: Record, + env?: ProviderEnv, ): GoogleGenAI { + const googleAuthOptions = buildGoogleAuthOptions(env); return new GoogleGenAI({ vertexai: true, project, location, apiVersion: API_VERSION, + ...(googleAuthOptions ? { googleAuthOptions } : {}), httpOptions: buildHttpOptions(model, optionsHeaders), }); } @@ -394,6 +399,11 @@ function baseUrlIncludesApiVersion(baseUrl: string): boolean { } } +function buildGoogleAuthOptions(env?: ProviderEnv): { keyFilename: string } | undefined { + const keyFilename = getProviderEnvValue("GOOGLE_APPLICATION_CREDENTIALS", env); + return keyFilename ? { keyFilename } : undefined; +} + function resolveApiKey(options?: GoogleVertexOptions): string | undefined { const apiKey = options?.apiKey?.trim(); if (!apiKey || apiKey === GCP_VERTEX_CREDENTIALS_MARKER || isPlaceholderApiKey(apiKey)) { @@ -407,7 +417,10 @@ function isPlaceholderApiKey(apiKey: string): boolean { } function resolveProject(options?: GoogleVertexOptions): string { - const project = options?.project || process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT; + const project = + options?.project || + getProviderEnvValue("GOOGLE_CLOUD_PROJECT", options?.env) || + getProviderEnvValue("GCLOUD_PROJECT", options?.env); if (!project) { throw new Error( "Vertex AI requires a project ID. Set GOOGLE_CLOUD_PROJECT/GCLOUD_PROJECT or pass project in options.", @@ -417,7 +430,7 @@ function resolveProject(options?: GoogleVertexOptions): string { } function resolveLocation(options?: GoogleVertexOptions): string { - const location = options?.location || process.env.GOOGLE_CLOUD_LOCATION; + const location = options?.location || getProviderEnvValue("GOOGLE_CLOUD_LOCATION", options?.env); if (!location) { throw new Error("Vertex AI requires a location. Set GOOGLE_CLOUD_LOCATION or pass location in options."); } diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 7fe9e256..19a2f5d7 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -27,6 +27,7 @@ import type { AssistantMessage, Context, Model, + ProviderEnv, SimpleStreamOptions, StreamFunction, StreamOptions, @@ -40,6 +41,7 @@ import { } from "../utils/diagnostics.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; +import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts"; import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts"; import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts"; import { buildBaseOptions } from "./simple-options.ts"; @@ -814,19 +816,13 @@ type WebSocketConstructor = new ( ) => WebSocketLike; let _cachedWebsocket: WebSocketConstructor | null = null; -async function getWebSocketConstructor(): Promise { - if (_cachedWebsocket) return _cachedWebsocket; +async function getWebSocketConstructor(env?: ProviderEnv): Promise { + if (!env && _cachedWebsocket) return _cachedWebsocket; // bun doesn't respect http proxy envs, ref: https://github.com/oven-sh/bun/issues/15489 // TODO: remove this when bun supports proxy envs in websocket. - if ( - process?.versions?.bun && - (process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy) - ) { - const m = await dynamicImport("proxy-from-env"); - const getProxyForUrl = (m as { getProxyForUrl: (url: string | object | URL) => string }).getProxyForUrl; - - _cachedWebsocket = class extends WebSocket { + if (typeof process !== "undefined" && process.versions?.bun) { + const WebSocketWithProxy = class extends WebSocket { constructor(url: string | URL, options?: string | string[] | Record) { let _opts: Record = {}; if (Array.isArray(options) || typeof options === "string") { @@ -835,11 +831,17 @@ async function getWebSocketConstructor(): Promise { _opts = { ...options }; } - const proxy = getProxyForUrl(url.toString().replace(/^wss:/, "https:").replace(/^ws:/, "http:")); - super(url, { ..._opts, ...(proxy ? { proxy } : {}) } as any); + const proxyUrl = resolveHttpProxyUrlForTarget( + url.toString().replace(/^wss:/, "https:").replace(/^ws:/, "http:"), + env, + ); + super(url, { ..._opts, ...(proxyUrl ? { proxy: proxyUrl.toString() } : {}) } as any); } }; - return _cachedWebsocket; + if (!env) { + _cachedWebsocket = WebSocketWithProxy; + } + return WebSocketWithProxy; } const ctor = (globalThis as { WebSocket?: unknown }).WebSocket; @@ -894,8 +896,9 @@ async function connectWebSocket( headers: Headers, signal?: AbortSignal, connectTimeoutMs = DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS, + env?: ProviderEnv, ): Promise { - const WebSocketCtor = await getWebSocketConstructor(); + const WebSocketCtor = await getWebSocketConstructor(env); if (!WebSocketCtor) { throw new Error("WebSocket transport is not available in this runtime"); } @@ -972,6 +975,7 @@ async function acquireWebSocket( sessionId: string | undefined, signal?: AbortSignal, connectTimeoutMs?: number, + env?: ProviderEnv, ): Promise<{ socket: WebSocketLike; entry?: CachedWebSocketConnection; @@ -979,7 +983,7 @@ async function acquireWebSocket( release: (options?: { keep?: boolean }) => void; }> { if (!sessionId) { - const socket = await connectWebSocket(url, headers, signal, connectTimeoutMs); + const socket = await connectWebSocket(url, headers, signal, connectTimeoutMs, env); return { socket, reused: false, @@ -1011,7 +1015,7 @@ async function acquireWebSocket( }; } if (cached.busy) { - const socket = await connectWebSocket(url, headers, signal, connectTimeoutMs); + const socket = await connectWebSocket(url, headers, signal, connectTimeoutMs, env); return { socket, reused: false, @@ -1026,7 +1030,7 @@ async function acquireWebSocket( } } - const socket = await connectWebSocket(url, headers, signal, connectTimeoutMs); + const socket = await connectWebSocket(url, headers, signal, connectTimeoutMs, env); const entry: CachedWebSocketConnection = { socket, busy: true }; websocketSessionCache.set(sessionId, entry); return { @@ -1312,6 +1316,7 @@ async function processWebSocketStream( options?.sessionId, options?.signal, websocketConnectTimeoutMs, + options?.env, ); let keepConnection = true; const useCachedContext = options?.transport === "websocket-cached" || options?.transport === "auto"; diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index c2d5cff9..61b03631 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -19,6 +19,7 @@ import type { Message, Model, OpenAICompletionsCompat, + ProviderEnv, SimpleStreamOptions, StopReason, StreamFunction, @@ -32,6 +33,7 @@ import type { import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; import { parseStreamingJson } from "../utils/json-parse.ts"; +import { getProviderEnvValue } from "../utils/provider-env.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.ts"; import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts"; @@ -98,11 +100,11 @@ type ChatCompletionToolWithCacheControl = OpenAI.Chat.Completions.ChatCompletion cache_control?: OpenAICompatCacheControl; }; -function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention { +function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention { if (cacheRetention) { return cacheRetention; } - if (typeof process !== "undefined" && process.env.PI_CACHE_RETENTION === "long") { + if (getProviderEnvValue("PI_CACHE_RETENTION", env) === "long") { return "long"; } return "short"; @@ -140,9 +142,9 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA throw new Error(`No API key for provider: ${model.provider}`); } const compat = getCompat(model); - const cacheRetention = resolveCacheRetention(options?.cacheRetention); + const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env); const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId; - const client = createClient(model, context, apiKey, options?.headers, cacheSessionId, compat); + const client = createClient(model, context, apiKey, options?.headers, cacheSessionId, compat, options?.env); let params = buildParams(model, context, options, compat, cacheRetention); const nextParams = await options?.onPayload?.(params, model); if (nextParams !== undefined) { @@ -454,6 +456,7 @@ function createClient( optionsHeaders?: Record, sessionId?: string, compat: ResolvedOpenAICompletionsCompat = getCompat(model), + env?: ProviderEnv, ) { const headers = { ...model.headers }; if (model.provider === "github-copilot") { @@ -487,7 +490,7 @@ function createClient( return new OpenAI({ apiKey, - baseURL: isCloudflareProvider(model.provider) ? resolveCloudflareBaseUrl(model) : model.baseUrl, + baseURL: isCloudflareProvider(model.provider) ? resolveCloudflareBaseUrl(model, env) : model.baseUrl, dangerouslyAllowBrowser: true, defaultHeaders, }); @@ -498,7 +501,7 @@ function buildParams( context: Context, options?: OpenAICompletionsOptions, compat: ResolvedOpenAICompletionsCompat = getCompat(model), - cacheRetention: CacheRetention = resolveCacheRetention(options?.cacheRetention), + cacheRetention: CacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env), ) { const messages = convertMessages(model, context, compat); const cacheControl = getCompatCacheControl(compat, cacheRetention); diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 9afeff29..014233d5 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -8,6 +8,7 @@ import type { Context, Model, OpenAIResponsesCompat, + ProviderEnv, SimpleStreamOptions, StreamFunction, StreamOptions, @@ -15,6 +16,7 @@ import type { } from "../types.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; +import { getProviderEnvValue } from "../utils/provider-env.ts"; import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.ts"; import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts"; import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts"; @@ -27,11 +29,11 @@ const OPENAI_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode" * Resolve cache retention preference. * Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility. */ -function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention { +function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention { if (cacheRetention) { return cacheRetention; } - if (typeof process !== "undefined" && process.env.PI_CACHE_RETENTION === "long") { + if (getProviderEnvValue("PI_CACHE_RETENTION", env) === "long") { return "long"; } return "short"; @@ -111,9 +113,9 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses", OpenAIRes if (!apiKey) { throw new Error(`No API key for provider: ${model.provider}`); } - const cacheRetention = resolveCacheRetention(options?.cacheRetention); + const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env); const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId; - const client = createClient(model, context, apiKey, options?.headers, cacheSessionId); + const client = createClient(model, context, apiKey, options?.headers, cacheSessionId, options?.env); let params = buildParams(model, context, options); const nextParams = await options?.onPayload?.(params, model); if (nextParams !== undefined) { @@ -185,6 +187,7 @@ function createClient( apiKey: string, optionsHeaders?: Record, sessionId?: string, + env?: ProviderEnv, ) { const compat = getCompat(model); const headers = { ...model.headers }; @@ -220,7 +223,7 @@ function createClient( return new OpenAI({ apiKey, - baseURL: isCloudflareProvider(model.provider) ? resolveCloudflareBaseUrl(model) : model.baseUrl, + baseURL: isCloudflareProvider(model.provider) ? resolveCloudflareBaseUrl(model, env) : model.baseUrl, dangerouslyAllowBrowser: true, defaultHeaders, }); @@ -229,7 +232,7 @@ function createClient( function buildParams(model: Model<"openai-responses">, context: Context, options?: OpenAIResponsesOptions) { const messages = convertResponsesMessages(model, context, OPENAI_TOOL_CALL_PROVIDERS); - const cacheRetention = resolveCacheRetention(options?.cacheRetention); + const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env); const compat = getCompat(model); const params: ResponseCreateParamsStreaming = { model: model.id, diff --git a/packages/ai/src/providers/simple-options.ts b/packages/ai/src/providers/simple-options.ts index f1709062..773e8d95 100644 --- a/packages/ai/src/providers/simple-options.ts +++ b/packages/ai/src/providers/simple-options.ts @@ -17,6 +17,7 @@ export function buildBaseOptions(_model: Model, options?: SimpleStreamOptio maxRetries: options?.maxRetries, maxRetryDelayMs: options?.maxRetryDelayMs, metadata: options?.metadata, + env: options?.env, }; } diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 3ae631a1..3f333d9e 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -24,7 +24,7 @@ function withEnvApiKey( options: TOptions | undefined, ): TOptions | undefined { if (hasExplicitApiKey(options?.apiKey)) return options; - const apiKey = getEnvApiKey(model.provider); + const apiKey = getEnvApiKey(model.provider, options?.env); if (!apiKey) return options; return { ...options, apiKey } as TOptions; } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index b7d0fda0..cedac6e8 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -79,6 +79,9 @@ export type CacheRetention = "none" | "short" | "long"; export type Transport = "sse" | "websocket" | "websocket-cached" | "auto"; +/** Provider-scoped environment overrides. Values take precedence over process.env. */ +export type ProviderEnv = Record; + export interface ProviderResponse { status: number; headers: Record; @@ -153,6 +156,12 @@ export interface StreamOptions { * For example, Anthropic uses `user_id` for abuse tracking and rate limiting. */ metadata?: Record; + /** + * Provider-scoped environment values. These take precedence over process.env for + * provider configuration such as regional settings, endpoint placeholders, and + * proxy variables. + */ + env?: ProviderEnv; } export type ProviderStreamOptions = StreamOptions & Record; diff --git a/packages/ai/src/utils/node-http-proxy.ts b/packages/ai/src/utils/node-http-proxy.ts index 7b5f4750..81ff0089 100644 --- a/packages/ai/src/utils/node-http-proxy.ts +++ b/packages/ai/src/utils/node-http-proxy.ts @@ -1,7 +1,5 @@ -import type { Agent as HttpAgent } from "node:http"; -import type { Agent as HttpsAgent } from "node:https"; -import { HttpProxyAgent } from "http-proxy-agent"; -import { HttpsProxyAgent } from "https-proxy-agent"; +import type { ProviderEnv } from "../types.ts"; +import { getProviderEnvValue } from "./provider-env.ts"; const DEFAULT_PROXY_PORTS: Record = { ftp: 21, @@ -12,16 +10,16 @@ const DEFAULT_PROXY_PORTS: Record = { wss: 443, }; -export interface NodeHttpProxyAgents { - httpAgent: HttpAgent; - httpsAgent: HttpsAgent; -} - -export const UNSUPPORTED_PROXY_PROTOCOL_MESSAGE = - "Unsupported proxy protocol. SOCKS and PAC proxy URLs are not supported; use an HTTP or HTTPS proxy URL."; - -function getProxyEnv(key: string): string { - return process.env[key.toLowerCase()] || process.env[key.toUpperCase()] || ""; +function getProxyEnv(key: string, env?: ProviderEnv): string { + const lowercaseKey = key.toLowerCase(); + const uppercaseKey = key.toUpperCase(); + return ( + env?.[lowercaseKey] || + env?.[uppercaseKey] || + getProviderEnvValue(lowercaseKey) || + getProviderEnvValue(uppercaseKey) || + "" + ); } function parseProxyTargetUrl(targetUrl: string | URL): URL | undefined { @@ -36,8 +34,8 @@ function parseProxyTargetUrl(targetUrl: string | URL): URL | undefined { } } -function shouldProxyHostname(hostname: string, port: number): boolean { - const noProxy = getProxyEnv("no_proxy").toLowerCase(); +function shouldProxyHostname(hostname: string, port: number, env?: ProviderEnv): boolean { + const noProxy = getProxyEnv("no_proxy", env).toLowerCase(); if (!noProxy) { return true; } @@ -68,7 +66,7 @@ function shouldProxyHostname(hostname: string, port: number): boolean { }); } -function getProxyForUrl(targetUrl: string | URL): string { +function getProxyForUrl(targetUrl: string | URL, env?: ProviderEnv): string { const parsedUrl = parseProxyTargetUrl(targetUrl); if (!parsedUrl?.protocol || !parsedUrl.host) { return ""; @@ -77,19 +75,22 @@ function getProxyForUrl(targetUrl: string | URL): string { const protocol = parsedUrl.protocol.split(":", 1)[0]!; const hostname = parsedUrl.host.replace(/:\d*$/, ""); const port = Number.parseInt(parsedUrl.port, 10) || DEFAULT_PROXY_PORTS[protocol] || 0; - if (!shouldProxyHostname(hostname, port)) { + if (!shouldProxyHostname(hostname, port, env)) { return ""; } - let proxy = getProxyEnv(`${protocol}_proxy`) || getProxyEnv("all_proxy"); + let proxy = getProxyEnv(`${protocol}_proxy`, env) || getProxyEnv("all_proxy", env); if (proxy && !proxy.includes("://")) { proxy = `${protocol}://${proxy}`; } return proxy; } -export function resolveHttpProxyUrlForTarget(targetUrl: string | URL): URL | undefined { - const proxy = getProxyForUrl(targetUrl); +export const UNSUPPORTED_PROXY_PROTOCOL_MESSAGE = + "Unsupported proxy protocol. SOCKS and PAC proxy URLs are not supported; use an HTTP or HTTPS proxy URL."; + +export function resolveHttpProxyUrlForTarget(targetUrl: string | URL, env?: ProviderEnv): URL | undefined { + const proxy = getProxyForUrl(targetUrl, env); if (!proxy) { return undefined; } @@ -109,15 +110,3 @@ export function resolveHttpProxyUrlForTarget(targetUrl: string | URL): URL | und return proxyUrl; } - -export function createHttpProxyAgentsForTarget(targetUrl: string | URL): NodeHttpProxyAgents | undefined { - const proxyUrl = resolveHttpProxyUrlForTarget(targetUrl); - if (!proxyUrl) { - return undefined; - } - - return { - httpAgent: new HttpProxyAgent(proxyUrl), - httpsAgent: new HttpsProxyAgent(proxyUrl) as unknown as HttpsAgent, - }; -} diff --git a/packages/ai/src/utils/oauth/anthropic.ts b/packages/ai/src/utils/oauth/anthropic.ts index feeb34fa..1b3e4244 100644 --- a/packages/ai/src/utils/oauth/anthropic.ts +++ b/packages/ai/src/utils/oauth/anthropic.ts @@ -6,6 +6,7 @@ */ import type { Server } from "node:http"; +import { getProviderEnvValue } from "../provider-env.ts"; import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts"; import { generatePKCE } from "./pkce.ts"; import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types.ts"; @@ -28,7 +29,7 @@ const decode = (s: string) => atob(s); const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl"); const AUTHORIZE_URL = "https://claude.ai/oauth/authorize"; const TOKEN_URL = "https://platform.claude.com/v1/oauth/token"; -const CALLBACK_HOST = process.env.PI_OAUTH_CALLBACK_HOST || "127.0.0.1"; +const CALLBACK_HOST = getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.1"; const CALLBACK_PORT = 53692; const CALLBACK_PATH = "/callback"; const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`; diff --git a/packages/ai/src/utils/oauth/openai-codex.ts b/packages/ai/src/utils/oauth/openai-codex.ts index 2f769a84..a5103c39 100644 --- a/packages/ai/src/utils/oauth/openai-codex.ts +++ b/packages/ai/src/utils/oauth/openai-codex.ts @@ -17,6 +17,7 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version }); } +import { getProviderEnvValue } from "../provider-env.ts"; import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts"; import { generatePKCE } from "./pkce.ts"; @@ -47,7 +48,7 @@ type OAuthToken = { access: string; refresh: string; expires: number }; type TokenOperation = "exchange" | "refresh"; function getCallbackHost(): string { - return typeof process !== "undefined" ? process.env.PI_OAUTH_CALLBACK_HOST || "127.0.0.1" : "127.0.0.1"; + return getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.1"; } type DeviceAuthInfo = { diff --git a/packages/ai/src/utils/provider-env.ts b/packages/ai/src/utils/provider-env.ts new file mode 100644 index 00000000..db496067 --- /dev/null +++ b/packages/ai/src/utils/provider-env.ts @@ -0,0 +1,52 @@ +import type { ProviderEnv } from "../types.ts"; + +let procEnvCache: Map | null = null; + +/** + * Fallback for https://github.com/oven-sh/bun/issues/27802. + * Bun compiled binaries can expose an empty process.env inside Linux sandboxes + * even though /proc/self/environ contains the environment. + * + * This intentionally duplicates restoreSandboxEnv() in + * packages/coding-agent/src/bun/restore-sandbox-env.ts. The ai package can be + * used directly, without going through that entrypoint, so provider env lookup + * must not depend on process.env having been patched. + */ +function getBunSandboxEnvValue(name: string): string | undefined { + if (typeof process === "undefined" || !process.versions?.bun || Object.keys(process.env).length > 0) { + return undefined; + } + + if (procEnvCache === null) { + procEnvCache = new Map(); + try { + const { readFileSync } = require("node:fs") as { + readFileSync(path: string, encoding: BufferEncoding): string; + }; + const data = readFileSync("/proc/self/environ", "utf-8"); + for (const entry of data.split("\0")) { + const idx = entry.indexOf("="); + if (idx > 0) { + procEnvCache.set(entry.slice(0, idx), entry.slice(idx + 1)); + } + } + } catch { + // /proc/self/environ may not exist or may not be readable. + } + } + + return procEnvCache.get(name); +} + +/** + * Resolve a provider env value from scoped overrides, normal process.env, then + * the duplicated Bun sandbox fallback for direct pi-ai consumers. + */ +export function getProviderEnvValue(name: string, env?: ProviderEnv): string | undefined { + return ( + env?.[name] || + (typeof process !== "undefined" ? process.env[name] : undefined) || + getBunSandboxEnvValue(name) || + undefined + ); +} diff --git a/packages/ai/test/bedrock-endpoint-resolution.test.ts b/packages/ai/test/bedrock-endpoint-resolution.test.ts index 412c62e0..18be2476 100644 --- a/packages/ai/test/bedrock-endpoint-resolution.test.ts +++ b/packages/ai/test/bedrock-endpoint-resolution.test.ts @@ -45,7 +45,7 @@ vi.mock("@aws-sdk/client-bedrock-runtime", () => { }); import { getModel } from "../src/models.ts"; -import { streamBedrock } from "../src/providers/amazon-bedrock.ts"; +import { type BedrockOptions, streamBedrock } from "../src/providers/amazon-bedrock.ts"; import type { Context, Model } from "../src/types.ts"; const context: Context = { @@ -83,8 +83,12 @@ afterEach(() => { } }); -async function captureClientConfig(model: Model<"bedrock-converse-stream">): Promise> { - await streamBedrock(model, context, { cacheRetention: "none" }).result(); +async function captureClientConfig( + model: Model<"bedrock-converse-stream">, + options: BedrockOptions = {}, +): Promise> { + bedrockMock.constructorCalls.length = 0; + await streamBedrock(model, context, { cacheRetention: "none", ...options }).result(); expect(bedrockMock.constructorCalls).toHaveLength(1); return bedrockMock.constructorCalls[0]; } @@ -115,6 +119,29 @@ describe("bedrock endpoint resolution", () => { expect(config.region).toBe("eu-central-1"); }); + it("handles missing regions for explicit, scoped, and ambient profiles", async () => { + const model = getModel("amazon-bedrock", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"); + + let config = await captureClientConfig(model, { profile: "bedrock-profile" }); + + expect(config.profile).toBe("bedrock-profile"); + expect(config.endpoint).toBe("https://bedrock-runtime.eu-central-1.amazonaws.com"); + expect(config.region).toBe("eu-central-1"); + + config = await captureClientConfig(model, { env: { AWS_PROFILE: "scoped-bedrock-profile" } }); + + expect(config.profile).toBe("scoped-bedrock-profile"); + expect(config.endpoint).toBe("https://bedrock-runtime.eu-central-1.amazonaws.com"); + expect(config.region).toBe("eu-central-1"); + + process.env.AWS_PROFILE = "ambient-bedrock-profile"; + config = await captureClientConfig(model); + + expect(config.profile).toBe("ambient-bedrock-profile"); + expect(config.endpoint).toBeUndefined(); + expect(config.region).toBeUndefined(); + }); + it("still passes custom Bedrock endpoints through to the SDK client", async () => { process.env.AWS_REGION = "us-west-2"; const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8"); diff --git a/packages/ai/test/node-http-proxy.test.ts b/packages/ai/test/node-http-proxy.test.ts index f4c9b735..a077a928 100644 --- a/packages/ai/test/node-http-proxy.test.ts +++ b/packages/ai/test/node-http-proxy.test.ts @@ -54,6 +54,17 @@ describe("node HTTP proxy resolution", () => { ); }); + it("prefers scoped proxy env aliases before process env aliases", () => { + resetProxyEnv(); + process.env.https_proxy = "http://process-proxy.example:8080"; + + expect( + resolveHttpProxyUrlForTarget("https://bedrock-runtime.us-east-1.amazonaws.com", { + HTTPS_PROXY: "http://scoped-proxy.example:8080", + })?.toString(), + ).toBe("http://scoped-proxy.example:8080/"); + }); + it("rejects SOCKS and PAC proxy URLs explicitly", () => { resetProxyEnv(); process.env.HTTPS_PROXY = "socks5://proxy.example:1080"; diff --git a/packages/ai/test/openai-completions-empty-tools.test.ts b/packages/ai/test/openai-completions-empty-tools.test.ts index a743351c..be233670 100644 --- a/packages/ai/test/openai-completions-empty-tools.test.ts +++ b/packages/ai/test/openai-completions-empty-tools.test.ts @@ -163,6 +163,31 @@ describe("openai-completions empty tools handling", () => { expect(clientOptions.defaultHeaders?.["cf-aig-authorization"]).toBe("Bearer test"); }); + it("uses provider env before process.env for Cloudflare AI Gateway base URL", async () => { + process.env.CLOUDFLARE_ACCOUNT_ID = "process-account"; + process.env.CLOUDFLARE_GATEWAY_ID = "process-gateway"; + const model = getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6")!; + + await streamSimple( + model, + { + messages: [{ role: "user", content: "hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + env: { + CLOUDFLARE_ACCOUNT_ID: "provider-account", + CLOUDFLARE_GATEWAY_ID: "provider-gateway", + }, + }, + ).result(); + + const clientOptions = mockState.lastClientOptions as { baseURL?: string }; + expect(clientOptions.baseURL).toBe( + "https://gateway.ai.cloudflare.com/v1/provider-account/provider-gateway/compat", + ); + }); + it("preserves inline upstream Authorization for Cloudflare AI Gateway BYOK requests", async () => { process.env.CLOUDFLARE_ACCOUNT_ID = "account-id"; process.env.CLOUDFLARE_GATEWAY_ID = "gateway-id"; diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 11cb9900..9cee0caf 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `auth.json` API key `env` values so provider-specific environment overrides can be scoped to Pi and propagated to inherited provider configuration ([#5728](https://github.com/earendil-works/pi/issues/5728)). + ### Fixed - Fixed successful `pi update` on Windows to exit naturally instead of calling `process.exit(0)`, avoiding a Node.js/libuv assertion after version-check network requests ([#5805](https://github.com/earendil-works/pi/issues/5805)). diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index f139aa30..86f1de06 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -104,6 +104,24 @@ Store credentials in `~/.pi/agent/auth.json`: The file is created with `0600` permissions (user read/write only). Auth file credentials take priority over environment variables. +API key credentials can also include provider-scoped environment values. These values are used before process environment variables when resolving the credential key, provider/model headers, and provider configuration such as Cloudflare account IDs, Azure OpenAI settings, Vertex project/location, Bedrock settings, `PI_CACHE_RETENTION`, and `HTTP_PROXY`/`HTTPS_PROXY`. + +```json +{ + "cloudflare-ai-gateway": { + "type": "api_key", + "key": "$CLOUDFLARE_API_KEY", + "env": { + "CLOUDFLARE_API_KEY": "...", + "CLOUDFLARE_ACCOUNT_ID": "account-id", + "CLOUDFLARE_GATEWAY_ID": "gateway-id" + } + } +} +``` + +Use this when pi should use different provider settings than the project shell environment. + ### Key Resolution The `key` field supports command execution, environment interpolation, and literals: @@ -194,7 +212,7 @@ export AWS_BEDROCK_FORCE_HTTP1=1 ### Cloudflare AI Gateway -`CLOUDFLARE_API_KEY` can be set via `/login`. The account ID and gateway slug must be set as environment variables. +`CLOUDFLARE_API_KEY` can be set via `/login`. The account ID and gateway slug can be set as environment variables or in the API key credential's `env` object in `auth.json`. ```bash export CLOUDFLARE_API_KEY=... # or use /login @@ -218,7 +236,7 @@ For normal pi usage, prefer unified billing or stored BYOK. Inline BYOK requires ### Cloudflare Workers AI -`CLOUDFLARE_API_KEY` can be set via `/login`. `CLOUDFLARE_ACCOUNT_ID` must be set as an environment variable. +`CLOUDFLARE_API_KEY` can be set via `/login`. `CLOUDFLARE_ACCOUNT_ID` can be set as an environment variable or in the API key credential's `env` object in `auth.json`. ```bash export CLOUDFLARE_API_KEY=... # or use /login diff --git a/packages/coding-agent/src/bun/restore-sandbox-env.ts b/packages/coding-agent/src/bun/restore-sandbox-env.ts index b175a494..445ddfd6 100644 --- a/packages/coding-agent/src/bun/restore-sandbox-env.ts +++ b/packages/coding-agent/src/bun/restore-sandbox-env.ts @@ -4,6 +4,10 @@ * Bun compiled binaries have an empty `process.env` when running inside * sandbox environments (e.g. nono on Linux/macOS). On Linux we can recover * the environment from `/proc/self/environ`. + * + * Keep this in sync with getBunSandboxEnvValue() in + * packages/ai/src/utils/provider-env.ts. The ai package duplicates the lookup + * for direct consumers that do not go through this coding-agent entrypoint. */ import { readFileSync } from "node:fs"; diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index af9d32d7..54de3103 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -357,6 +357,7 @@ export class AgentSession { private async _getRequiredRequestAuth(model: Model): Promise<{ apiKey: string; headers?: Record; + env?: Record; }> { const result = await this._modelRegistry.getApiKeyAndHeaders(model); if (!result.ok) { @@ -366,7 +367,7 @@ export class AgentSession { throw new Error(result.error); } if (result.apiKey) { - return { apiKey: result.apiKey, headers: result.headers }; + return { apiKey: result.apiKey, headers: result.headers, env: result.env }; } const isOAuth = this._modelRegistry.isUsingOAuth(model); @@ -383,13 +384,14 @@ export class AgentSession { private async _getCompactionRequestAuth(model: Model): Promise<{ apiKey?: string; headers?: Record; + env?: Record; }> { if (this.agent.streamFn === streamSimple) { return this._getRequiredRequestAuth(model); } const result = await this._modelRegistry.getApiKeyAndHeaders(model); - return result.ok ? { apiKey: result.apiKey, headers: result.headers } : {}; + return result.ok ? { apiKey: result.apiKey, headers: result.headers, env: result.env } : {}; } /** @@ -1649,7 +1651,7 @@ export class AgentSession { throw new Error(formatNoModelSelectedMessage()); } - const { apiKey, headers } = await this._getCompactionRequestAuth(this.model); + const { apiKey, headers, env } = await this._getCompactionRequestAuth(this.model); const pathEntries = this.sessionManager.getBranch(); const settings = this.settingsManager.getCompactionSettings(); @@ -1708,6 +1710,7 @@ export class AgentSession { this._compactionAbortController.signal, this.thinkingLevel, this.agent.streamFn, + env, ); summary = result.summary; firstKeptEntryId = result.firstKeptEntryId; @@ -1898,6 +1901,7 @@ export class AgentSession { let apiKey: string | undefined; let headers: Record | undefined; + let env: Record | undefined; if (this.agent.streamFn === streamSimple) { const authResult = await this._modelRegistry.getApiKeyAndHeaders(this.model); if (!authResult.ok || !authResult.apiKey) { @@ -1912,8 +1916,9 @@ export class AgentSession { } apiKey = authResult.apiKey; headers = authResult.headers; + env = authResult.env; } else { - ({ apiKey, headers } = await this._getCompactionRequestAuth(this.model)); + ({ apiKey, headers, env } = await this._getCompactionRequestAuth(this.model)); } const pathEntries = this.sessionManager.getBranch(); @@ -1981,6 +1986,7 @@ export class AgentSession { this._autoCompactionAbortController.signal, this.thinkingLevel, this.agent.streamFn, + env, ); summary = compactResult.summary; firstKeptEntryId = compactResult.firstKeptEntryId; @@ -2784,12 +2790,13 @@ export class AgentSession { let summaryDetails: unknown; if (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) { const model = this.model!; - const { apiKey, headers } = await this._getRequiredRequestAuth(model); + const { apiKey, headers, env } = await this._getRequiredRequestAuth(model); const branchSummarySettings = this.settingsManager.getBranchSummarySettings(); const result = await generateBranchSummary(entriesToSummarize, { model, apiKey, headers, + env, signal: this._branchSummaryAbortController.signal, customInstructions, replaceInstructions, diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index 9a0564ec..3394a063 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -24,6 +24,7 @@ import { resolveConfigValue } from "./resolve-config-value.ts"; export type ApiKeyCredential = { type: "api_key"; key: string; + env?: Record; }; export type OAuthCredential = { @@ -303,6 +304,14 @@ export class AuthStorage { return this.data[provider] ?? undefined; } + /** + * Get provider-scoped environment values for an API key credential. + */ + getProviderEnv(provider: string): Record | undefined { + const cred = this.data[provider]; + return cred?.type === "api_key" && cred.env ? { ...cred.env } : undefined; + } + /** * Set credential for a provider. */ @@ -471,7 +480,7 @@ export class AuthStorage { const cred = this.data[providerId]; if (cred?.type === "api_key") { - return resolveConfigValue(cred.key); + return resolveConfigValue(cred.key, cred.env); } if (cred?.type === "oauth") { diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index f6ce3d67..3378eb19 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -69,6 +69,8 @@ export interface GenerateBranchSummaryOptions { apiKey: string; /** Request headers for the model */ headers?: Record; + /** Provider-scoped environment values for the model */ + env?: Record; /** Abort signal for cancellation */ signal: AbortSignal; /** Optional custom instructions for summarization */ @@ -290,6 +292,7 @@ export async function generateBranchSummary( model, apiKey, headers, + env, signal, customInstructions, replaceInstructions, @@ -335,7 +338,7 @@ export async function generateBranchSummary( // request behavior (timeouts, retries, attribution headers) stays consistent // without running through agent state/events. const context = { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }; - const requestOptions: SimpleStreamOptions = { apiKey, headers, signal, maxTokens: 2048 }; + const requestOptions: SimpleStreamOptions = { apiKey, headers, env, signal, maxTokens: 2048 }; const response = streamFn ? await (await streamFn(model, context, requestOptions)).result() : await completeSimple(model, context, requestOptions); diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index eae19794..07f2f9fe 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -528,10 +528,11 @@ function createSummarizationOptions( maxTokens: number, apiKey: string | undefined, headers: Record | undefined, + env: Record | undefined, signal: AbortSignal | undefined, thinkingLevel: ThinkingLevel | undefined, ): SimpleStreamOptions { - const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers }; + const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers, env }; if (model.reasoning && thinkingLevel && thinkingLevel !== "off") { options.reasoning = thinkingLevel; } @@ -566,6 +567,7 @@ export async function generateSummary( previousSummary?: string, thinkingLevel?: ThinkingLevel, streamFn?: StreamFn, + env?: Record, ): Promise { const maxTokens = Math.min( Math.floor(0.8 * reserveTokens), @@ -598,7 +600,7 @@ export async function generateSummary( }, ]; - const completionOptions = createSummarizationOptions(model, maxTokens, apiKey, headers, signal, thinkingLevel); + const completionOptions = createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel); const response = await completeSummarization( model, @@ -753,6 +755,7 @@ export async function compact( signal?: AbortSignal, thinkingLevel?: ThinkingLevel, streamFn?: StreamFn, + env?: Record, ): Promise { const { firstKeptEntryId, @@ -783,6 +786,7 @@ export async function compact( previousSummary, thinkingLevel, streamFn, + env, ) : Promise.resolve("No prior history."), generateTurnPrefixSummary( @@ -791,6 +795,7 @@ export async function compact( settings.reserveTokens, apiKey, headers, + env, signal, thinkingLevel, streamFn, @@ -811,6 +816,7 @@ export async function compact( previousSummary, thinkingLevel, streamFn, + env, ); } @@ -839,6 +845,7 @@ async function generateTurnPrefixSummary( reserveTokens: number, apiKey: string | undefined, headers?: Record, + env?: Record, signal?: AbortSignal, thinkingLevel?: ThinkingLevel, streamFn?: StreamFn, @@ -861,7 +868,7 @@ async function generateTurnPrefixSummary( const response = await completeSummarization( model, { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, - createSummarizationOptions(model, maxTokens, apiKey, headers, signal, thinkingLevel), + createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel), streamFn, ); diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index ff172966..60f9001f 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -240,6 +240,7 @@ export type ResolvedRequestAuth = ok: true; apiKey?: string; headers?: Record; + env?: Record; } | { ok: false; @@ -684,17 +685,27 @@ export class ModelRegistry { async getApiKeyAndHeaders(model: Model): Promise { try { const providerConfig = this.providerRequestConfigs.get(model.provider); + const providerEnv = this.authStorage.getProviderEnv(model.provider); const apiKeyFromAuthStorage = await this.authStorage.getApiKey(model.provider, { includeFallback: false }); const apiKey = apiKeyFromAuthStorage ?? (providerConfig?.apiKey - ? resolveConfigValueOrThrow(providerConfig.apiKey, `API key for provider "${model.provider}"`) + ? resolveConfigValueOrThrow( + providerConfig.apiKey, + `API key for provider "${model.provider}"`, + providerEnv, + ) : undefined); - const providerHeaders = resolveHeadersOrThrow(providerConfig?.headers, `provider "${model.provider}"`); + const providerHeaders = resolveHeadersOrThrow( + providerConfig?.headers, + `provider "${model.provider}"`, + providerEnv, + ); const modelHeaders = resolveHeadersOrThrow( this.modelRequestHeaders.get(this.getModelRequestKey(model.provider, model.id)), `model "${model.provider}/${model.id}"`, + providerEnv, ); let headers = @@ -713,6 +724,7 @@ export class ModelRegistry { ok: true, apiKey, headers: headers && Object.keys(headers).length > 0 ? headers : undefined, + env: providerEnv && Object.keys(providerEnv).length > 0 ? providerEnv : undefined, }; } catch (error) { return { @@ -777,7 +789,9 @@ export class ModelRegistry { } const providerApiKey = this.providerRequestConfigs.get(provider)?.apiKey; - return providerApiKey ? resolveConfigValueUncached(providerApiKey) : undefined; + return providerApiKey + ? resolveConfigValueUncached(providerApiKey, this.authStorage.getProviderEnv(provider)) + : undefined; } /** diff --git a/packages/coding-agent/src/core/resolve-config-value.ts b/packages/coding-agent/src/core/resolve-config-value.ts index 6c2263f6..9d47cff3 100644 --- a/packages/coding-agent/src/core/resolve-config-value.ts +++ b/packages/coding-agent/src/core/resolve-config-value.ts @@ -85,8 +85,8 @@ function parseConfigValueReference(config: string): ConfigValueReference { return { type: "template", parts: parseConfigValueTemplate(config) }; } -function resolveEnvConfigValue(name: string): string | undefined { - return process.env[name] || undefined; +function resolveEnvConfigValue(name: string, env?: Record): string | undefined { + return env?.[name] || process.env[name] || undefined; } function getTemplateEnvVarNames(parts: TemplatePart[]): string[] { @@ -98,14 +98,14 @@ function getTemplateEnvVarNames(parts: TemplatePart[]): string[] { return names; } -function resolveTemplate(parts: TemplatePart[]): string | undefined { +function resolveTemplate(parts: TemplatePart[], env?: Record): string | undefined { let resolved = ""; for (const part of parts) { if (part.type === "literal") { resolved += part.value; continue; } - const envValue = resolveEnvConfigValue(part.name); + const envValue = resolveEnvConfigValue(part.name, env); if (envValue === undefined) return undefined; resolved += envValue; } @@ -123,16 +123,16 @@ export function getConfigValueEnvVarNames(config: string): string[] { return reference.type === "template" ? getTemplateEnvVarNames(reference.parts) : []; } -export function getMissingConfigValueEnvVarNames(config: string): string[] { - return getConfigValueEnvVarNames(config).filter((name) => resolveEnvConfigValue(name) === undefined); +export function getMissingConfigValueEnvVarNames(config: string, env?: Record): string[] { + return getConfigValueEnvVarNames(config).filter((name) => resolveEnvConfigValue(name, env) === undefined); } export function isCommandConfigValue(config: string): boolean { return parseConfigValueReference(config).type === "command"; } -export function isConfigValueConfigured(config: string): boolean { - return getMissingConfigValueEnvVarNames(config).length === 0; +export function isConfigValueConfigured(config: string, env?: Record): boolean { + return getMissingConfigValueEnvVarNames(config, env).length === 0; } /** @@ -142,12 +142,12 @@ export function isConfigValueConfigured(config: string): boolean { * - In non-command values, "$$" escapes a literal "$" and "$!" escapes a literal "!" * - Otherwise treats the value as a literal */ -export function resolveConfigValue(config: string): string | undefined { +export function resolveConfigValue(config: string, env?: Record): string | undefined { const reference = parseConfigValueReference(config); if (reference.type === "command") { return executeCommand(reference.config); } - return resolveTemplate(reference.parts); + return resolveTemplate(reference.parts, env); } function executeWithConfiguredShell(command: string): { executed: boolean; value: string | undefined } { @@ -216,16 +216,16 @@ function executeCommand(commandConfig: string): string | undefined { /** * Resolve all header values using the same resolution logic as API keys. */ -export function resolveConfigValueUncached(config: string): string | undefined { +export function resolveConfigValueUncached(config: string, env?: Record): string | undefined { const reference = parseConfigValueReference(config); if (reference.type === "command") { return executeCommandUncached(reference.config); } - return resolveTemplate(reference.parts); + return resolveTemplate(reference.parts, env); } -export function resolveConfigValueOrThrow(config: string, description: string): string { - const resolvedValue = resolveConfigValueUncached(config); +export function resolveConfigValueOrThrow(config: string, description: string, env?: Record): string { + const resolvedValue = resolveConfigValueUncached(config, env); if (resolvedValue !== undefined) { return resolvedValue; } @@ -236,7 +236,7 @@ export function resolveConfigValueOrThrow(config: string, description: string): } if (reference.type === "template") { - const missingEnvVars = getMissingConfigValueEnvVarNames(config); + const missingEnvVars = getMissingConfigValueEnvVarNames(config, env); if (missingEnvVars.length === 1) { throw new Error(`Failed to resolve ${description} from environment variable: ${missingEnvVars[0]}`); } @@ -251,11 +251,14 @@ export function resolveConfigValueOrThrow(config: string, description: string): /** * Resolve all header values using the same resolution logic as API keys. */ -export function resolveHeaders(headers: Record | undefined): Record | undefined { +export function resolveHeaders( + headers: Record | undefined, + env?: Record, +): Record | undefined { if (!headers) return undefined; const resolved: Record = {}; for (const [key, value] of Object.entries(headers)) { - const resolvedValue = resolveConfigValue(value); + const resolvedValue = resolveConfigValue(value, env); if (resolvedValue) { resolved[key] = resolvedValue; } @@ -266,11 +269,12 @@ export function resolveHeaders(headers: Record | undefined): Rec export function resolveHeadersOrThrow( headers: Record | undefined, description: string, + env?: Record, ): Record | undefined { if (!headers) return undefined; const resolved: Record = {}; for (const [key, value] of Object.entries(headers)) { - resolved[key] = resolveConfigValueOrThrow(value, `${description} header "${key}"`); + resolved[key] = resolveConfigValueOrThrow(value, `${description} header "${key}"`, env); } return Object.keys(resolved).length > 0 ? resolved : undefined; } diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index e6db747b..180846cb 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -303,6 +303,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} if (!auth.ok) { throw new Error(auth.error); } + const env = auth.env || options?.env ? { ...(auth.env ?? {}), ...(options?.env ?? {}) } : undefined; const providerRetrySettings = settingsManager.getProviderRetrySettings(); const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs(); // SDKs treat timeout=0 as 0ms (immediate timeout), not "no timeout". @@ -314,6 +315,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} return streamSimple(model, context, { ...options, apiKey: auth.apiKey, + env, timeoutMs, websocketConnectTimeoutMs, maxRetries: options?.maxRetries ?? providerRetrySettings.maxRetries, diff --git a/packages/coding-agent/test/auth-storage.test.ts b/packages/coding-agent/test/auth-storage.test.ts index 24242b6b..bcc353c1 100644 --- a/packages/coding-agent/test/auth-storage.test.ts +++ b/packages/coding-agent/test/auth-storage.test.ts @@ -134,6 +134,34 @@ describe("AuthStorage", () => { } }); + test("apiKey env bag takes precedence over process.env", async () => { + const originalEnv = process.env.TEST_AUTH_SCOPED_API_KEY_12345; + process.env.TEST_AUTH_SCOPED_API_KEY_12345 = "process-env-value"; + + try { + writeAuthJson({ + anthropic: { + type: "api_key", + key: "$TEST_AUTH_SCOPED_API_KEY_12345", + env: { TEST_AUTH_SCOPED_API_KEY_12345: "credential-env-value" }, + }, + }); + + authStorage = AuthStorage.create(authJsonPath); + + expect(await authStorage.getApiKey("anthropic")).toBe("credential-env-value"); + expect(authStorage.getProviderEnv("anthropic")).toEqual({ + TEST_AUTH_SCOPED_API_KEY_12345: "credential-env-value", + }); + } finally { + if (originalEnv === undefined) { + delete process.env.TEST_AUTH_SCOPED_API_KEY_12345; + } else { + process.env.TEST_AUTH_SCOPED_API_KEY_12345 = originalEnv; + } + } + }); + test("apiKey with braced env syntax resolves to env value", async () => { const originalEnv = process.env.TEST_AUTH_BRACED_API_KEY_12345; process.env.TEST_AUTH_BRACED_API_KEY_12345 = "braced-env-api-key-value"; diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 3cba5264..fe8609f2 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -893,6 +893,38 @@ describe("ModelRegistry", () => { expect(registry.getProviderDisplayName("oauth-provider")).toBe("OAuth Provider"); }); + test("stored API key env propagates to request auth and resolves headers", async () => { + authStorage.set("cloudflare-ai-gateway", { + type: "api_key", + key: "$CLOUDFLARE_API_KEY", + env: { + CLOUDFLARE_API_KEY: "stored-cf-token", + CLOUDFLARE_ACCOUNT_ID: "stored-account", + }, + }); + writeRawModelsJson({ + "cloudflare-ai-gateway": { + headers: { "x-account": "$CLOUDFLARE_ACCOUNT_ID" }, + }, + }); + + const registry = ModelRegistry.create(authStorage, modelsJsonPath); + const model = registry.getAll().find((m) => m.provider === "cloudflare-ai-gateway"); + expect(model).toBeDefined(); + + const auth = await registry.getApiKeyAndHeaders(model!); + + expect(auth).toEqual({ + ok: true, + apiKey: "stored-cf-token", + headers: { "x-account": "stored-account" }, + env: { + CLOUDFLARE_API_KEY: "stored-cf-token", + CLOUDFLARE_ACCOUNT_ID: "stored-account", + }, + }); + }); + test("registerProvider treats uppercase apiKey and headers as literals", async () => { const envKeys = ["CUSTOM_NAME", "BEARER", "MODEL_TOKEN"]; const savedEnv: Record = {}; From 8f0e9251b9265e73cb85e97b8a190db40c96e57c Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 16 Jun 2026 17:21:30 +0200 Subject: [PATCH 286/352] fix(coding-agent): do not open browser for device code login --- .../src/modes/interactive/components/login-dialog.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index 958db6d6..3fc4b516 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -128,7 +128,6 @@ export class LoginDialogComponent extends Container implements Focusable { this.contentContainer.addChild(new Spacer(1)); this.contentContainer.addChild(new Text(theme.fg("warning", `Enter code: ${info.userCode}`), 1, 0)); - openBrowser(info.verificationUri); this.tui.requestRender(); } From 0680726ac446770f5cb7b629bf1d29f8a8b4aecc Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 16 Jun 2026 17:41:59 +0200 Subject: [PATCH 287/352] fix: upgrade marked to 18.0.5 --- package-lock.json | 10 +-- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/npm-shrinkwrap.json | 10 +-- .../src/core/export-html/vendor/marked.min.js | 78 ++++++++++++++++++- packages/tui/CHANGELOG.md | 4 + packages/tui/package.json | 2 +- 6 files changed, 94 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4063babb..a522cacc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3981,15 +3981,15 @@ } }, "node_modules/marked": { - "version": "15.0.12", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", - "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", "license": "MIT", "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/merge2": { @@ -6369,7 +6369,7 @@ "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", - "marked": "15.0.12" + "marked": "18.0.5" }, "devDependencies": { "@xterm/headless": "5.5.0", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9cee0caf..672122da 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,10 @@ - Added `auth.json` API key `env` values so provider-specific environment overrides can be scoped to Pi and propagated to inherited provider configuration ([#5728](https://github.com/earendil-works/pi/issues/5728)). +### Changed + +- Updated the vendored Markdown parser used by HTML session exports to `marked` 18.0.5. + ### Fixed - Fixed successful `pi update` on Windows to exit naturally instead of calling `process.exit(0)`, avoiding a Node.js/libuv assertion after version-check network requests ([#5805](https://github.com/earendil-works/pi/issues/5805)). diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 438d1319..2a3434d6 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -516,7 +516,7 @@ "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", - "marked": "15.0.12" + "marked": "18.0.5" }, "engines": { "node": ">=22.19.0" @@ -1399,15 +1399,15 @@ } }, "node_modules/marked": { - "version": "15.0.12", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", - "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", "license": "MIT", "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/minimatch": { diff --git a/packages/coding-agent/src/core/export-html/vendor/marked.min.js b/packages/coding-agent/src/core/export-html/vendor/marked.min.js index 79394fd8..9d79575e 100644 --- a/packages/coding-agent/src/core/export-html/vendor/marked.min.js +++ b/packages/coding-agent/src/core/export-html/vendor/marked.min.js @@ -1,6 +1,78 @@ /** - * marked v15.0.4 - a markdown parser - * Copyright (c) 2011-2024, Christopher Jeffrey. (MIT Licensed) + * marked v18.0.5 - a markdown parser + * Copyright (c) 2018-2026, MarkedJS. (MIT License) + * Copyright (c) 2011-2018, Christopher Jeffrey. (MIT License) * https://github.com/markedjs/marked */ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,(function(e){"use strict";function t(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function n(t){e.defaults=t}e.defaults={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};const s={exec:()=>null};function r(e,t=""){let n="string"==typeof e?e:e.source;const s={replace:(e,t)=>{let r="string"==typeof t?t:t.source;return r=r.replace(i.caret,"$1"),n=n.replace(e,r),s},getRegex:()=>new RegExp(n,t)};return s}const i={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:e=>new RegExp(`^( {0,3}${e})((?:[\t ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),hrRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}#`),htmlBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}<(?:[a-z].*>|!--)`,"i")},l=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,o=/(?:[*+-]|\d{1,9}[.)])/,a=r(/^(?!bull |blockCode|fences|blockquote|heading|html)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html))+?)\n {0,3}(=+|-+) *(?:\n+|$)/).replace(/bull/g,o).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).getRegex(),c=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,h=/(?!\s*\])(?:\\.|[^\[\]\\])+/,p=r(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",h).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),u=r(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,o).getRegex(),g="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",k=/|$))/,f=r("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$))","i").replace("comment",k).replace("tag",g).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),d=r(c).replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",g).getRegex(),x={blockquote:r(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",d).getRegex(),code:/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,def:p,fences:/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,heading:/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,hr:l,html:f,lheading:a,list:u,newline:/^(?:[ \t]*(?:\n|$))+/,paragraph:d,table:s,text:/^[^\n]+/},b=r("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3}\t)[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",g).getRegex(),w={...x,table:b,paragraph:r(c).replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",b).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",g).getRegex()},m={...x,html:r("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",k).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:s,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:r(c).replace("hr",l).replace("heading"," *#{1,6} *[^\n]").replace("lheading",a).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},y=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,$=/^( {2,}|\\)\n(?!\s*$)/,R=/[\p{P}\p{S}]/u,S=/[\s\p{P}\p{S}]/u,T=/[^\s\p{P}\p{S}]/u,z=r(/^((?![*_])punctSpace)/,"u").replace(/punctSpace/g,S).getRegex(),A=r(/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,"u").replace(/punct/g,R).getRegex(),_=r("^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)","gu").replace(/notPunctSpace/g,T).replace(/punctSpace/g,S).replace(/punct/g,R).getRegex(),P=r("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,T).replace(/punctSpace/g,S).replace(/punct/g,R).getRegex(),I=r(/\\(punct)/,"gu").replace(/punct/g,R).getRegex(),L=r(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),B=r(k).replace("(?:--\x3e|$)","--\x3e").getRegex(),C=r("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",B).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),E=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,q=r(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/).replace("label",E).replace("href",/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),Z=r(/^!?\[(label)\]\[(ref)\]/).replace("label",E).replace("ref",h).getRegex(),v=r(/^!?\[(ref)\](?:\[\])?/).replace("ref",h).getRegex(),D={_backpedal:s,anyPunctuation:I,autolink:L,blockSkip:/\[[^[\]]*?\]\((?:\\.|[^\\\(\)]|\((?:\\.|[^\\\(\)])*\))*\)|`[^`]*?`|<[^<>]*?>/g,br:$,code:/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,del:s,emStrongLDelim:A,emStrongRDelimAst:_,emStrongRDelimUnd:P,escape:y,link:q,nolink:v,punctuation:z,reflink:Z,reflinkSearch:r("reflink|nolink(?!\\()","g").replace("reflink",Z).replace("nolink",v).getRegex(),tag:C,text:/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},H=e=>G[e];function X(e,t){if(t){if(i.escapeTest.test(e))return e.replace(i.escapeReplace,H)}else if(i.escapeTestNoEncode.test(e))return e.replace(i.escapeReplaceNoEncode,H);return e}function F(e){try{e=encodeURI(e).replace(i.percentDecode,"%")}catch{return null}return e}function U(e,t){const n=e.replace(i.findPipe,((e,t,n)=>{let s=!1,r=t;for(;--r>=0&&"\\"===n[r];)s=!s;return s?"|":" |"})).split(i.splitPipe);let s=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),t)if(n.length>t)n.splice(t);else for(;n.length0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const e=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?e:J(e,"\n")}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const e=t[0],n=function(e,t,n){const s=e.match(n.other.indentCodeCompensation);if(null===s)return t;const r=s[1];return t.split("\n").map((e=>{const t=e.match(n.other.beginningSpace);if(null===t)return e;const[s]=t;return s.length>=r.length?e.slice(r.length):e})).join("\n")}(e,t[3]||"",this.rules);return{type:"code",raw:e,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:n}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let e=t[2].trim();if(this.rules.other.endingHash.test(e)){const t=J(e,"#");this.options.pedantic?e=t.trim():t&&!this.rules.other.endingSpaceChar.test(t)||(e=t.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:e,tokens:this.lexer.inline(e)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:J(t[0],"\n")}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){let e=J(t[0],"\n").split("\n"),n="",s="";const r=[];for(;e.length>0;){let t=!1;const i=[];let l;for(l=0;l1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");const i=this.rules.other.listItemRegex(n);let l=!1;for(;e;){let n=!1,s="",o="";if(!(t=i.exec(e)))break;if(this.rules.block.hr.test(e))break;s=t[0],e=e.substring(s.length);let a=t[2].split("\n",1)[0].replace(this.rules.other.listReplaceTabs,(e=>" ".repeat(3*e.length))),c=e.split("\n",1)[0],h=!a.trim(),p=0;if(this.options.pedantic?(p=2,o=a.trimStart()):h?p=t[1].length+1:(p=t[2].search(this.rules.other.nonSpaceChar),p=p>4?1:p,o=a.slice(p),p+=t[1].length),h&&this.rules.other.blankLine.test(c)&&(s+=c+"\n",e=e.substring(c.length+1),n=!0),!n){const t=this.rules.other.nextBulletRegex(p),n=this.rules.other.hrRegex(p),r=this.rules.other.fencesBeginRegex(p),i=this.rules.other.headingBeginRegex(p),l=this.rules.other.htmlBeginRegex(p);for(;e;){const u=e.split("\n",1)[0];let g;if(c=u,this.options.pedantic?(c=c.replace(this.rules.other.listReplaceNesting," "),g=c):g=c.replace(this.rules.other.tabCharGlobal," "),r.test(c))break;if(i.test(c))break;if(l.test(c))break;if(t.test(c))break;if(n.test(c))break;if(g.search(this.rules.other.nonSpaceChar)>=p||!c.trim())o+="\n"+g.slice(p);else{if(h)break;if(a.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4)break;if(r.test(a))break;if(i.test(a))break;if(n.test(a))break;o+="\n"+c}h||c.trim()||(h=!0),s+=u+"\n",e=e.substring(u.length+1),a=g.slice(p)}}r.loose||(l?r.loose=!0:this.rules.other.doubleBlankLine.test(s)&&(l=!0));let u,g=null;this.options.gfm&&(g=this.rules.other.listIsTask.exec(o),g&&(u="[ ] "!==g[0],o=o.replace(this.rules.other.listReplaceTask,""))),r.items.push({type:"list_item",raw:s,task:!!g,checked:u,loose:!1,text:o,tokens:[]}),r.raw+=s}const o=r.items.at(-1);if(!o)return;o.raw=o.raw.trimEnd(),o.text=o.text.trimEnd(),r.raw=r.raw.trimEnd();for(let e=0;e"space"===e.type)),n=t.length>0&&t.some((e=>this.rules.other.anyLine.test(e.raw)));r.loose=n}if(r.loose)for(let e=0;e({text:e,tokens:this.lexer.inline(e),header:!1,align:i.align[t]}))));return i}}lheading(e){const t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:"="===t[2].charAt(0)?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){const t=this.rules.block.paragraph.exec(e);if(t){const e="\n"===t[1].charAt(t[1].length-1)?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:e,tokens:this.lexer.inline(e)}}}text(e){const t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){const t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){const t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){const t=this.rules.inline.link.exec(e);if(t){const e=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(e)){if(!this.rules.other.endAngleBracket.test(e))return;const t=J(e.slice(0,-1),"\\");if((e.length-t.length)%2==0)return}else{const e=function(e,t){if(-1===e.indexOf(t[1]))return-1;let n=0;for(let s=0;s-1){const n=(0===t[0].indexOf("!")?5:4)+t[1].length+e;t[2]=t[2].substring(0,e),t[0]=t[0].substring(0,n).trim(),t[3]=""}}let n=t[2],s="";if(this.options.pedantic){const e=this.rules.other.pedanticHrefTitle.exec(n);e&&(n=e[1],s=e[3])}else s=t[3]?t[3].slice(1,-1):"";return n=n.trim(),this.rules.other.startAngleBracket.test(n)&&(n=this.options.pedantic&&!this.rules.other.endAngleBracket.test(e)?n.slice(1):n.slice(1,-1)),K(t,{href:n?n.replace(this.rules.inline.anyPunctuation,"$1"):n,title:s?s.replace(this.rules.inline.anyPunctuation,"$1"):s},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){const e=t[(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," ").toLowerCase()];if(!e){const e=n[0].charAt(0);return{type:"text",raw:e,text:e}}return K(n,e,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s)return;if(s[3]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){const n=[...s[0]].length-1;let r,i,l=n,o=0;const a="*"===s[0][0]?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(a.lastIndex=0,t=t.slice(-1*e.length+n);null!=(s=a.exec(t));){if(r=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!r)continue;if(i=[...r].length,s[3]||s[4]){l+=i;continue}if((s[5]||s[6])&&n%3&&!((n+i)%3)){o+=i;continue}if(l-=i,l>0)continue;i=Math.min(i,i+l+o);const t=[...s[0]][0].length,a=e.slice(0,n+s.index+t+i);if(Math.min(n,i)%2){const e=a.slice(1,-1);return{type:"em",raw:a,text:e,tokens:this.lexer.inlineTokens(e)}}const c=a.slice(2,-2);return{type:"strong",raw:a,text:c,tokens:this.lexer.inlineTokens(c)}}}}codespan(e){const t=this.rules.inline.code.exec(e);if(t){let e=t[2].replace(this.rules.other.newLineCharGlobal," ");const n=this.rules.other.nonSpaceChar.test(e),s=this.rules.other.startingSpaceChar.test(e)&&this.rules.other.endingSpaceChar.test(e);return n&&s&&(e=e.substring(1,e.length-1)),{type:"codespan",raw:t[0],text:e}}}br(e){const t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){const t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){const t=this.rules.inline.autolink.exec(e);if(t){let e,n;return"@"===t[2]?(e=t[1],n="mailto:"+e):(e=t[1],n=e),{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let e,n;if("@"===t[2])e=t[0],n="mailto:"+e;else{let s;do{s=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??""}while(s!==t[0]);e=t[0],n="www."===t[1]?"http://"+t[0]:t[0]}return{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}inlineText(e){const t=this.rules.inline.text.exec(e);if(t){const e=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:e}}}}class W{tokens;options;state;tokenizer;inlineQueue;constructor(t){this.tokens=[],this.tokens.links=Object.create(null),this.options=t||e.defaults,this.options.tokenizer=this.options.tokenizer||new V,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};const n={other:i,block:j.normal,inline:N.normal};this.options.pedantic?(n.block=j.pedantic,n.inline=N.pedantic):this.options.gfm&&(n.block=j.gfm,this.options.breaks?n.inline=N.breaks:n.inline=N.gfm),this.tokenizer.rules=n}static get rules(){return{block:j,inline:N}}static lex(e,t){return new W(t).lex(e)}static lexInline(e,t){return new W(t).inlineTokens(e)}lex(e){e=e.replace(i.carriageReturn,"\n"),this.blockTokens(e,this.tokens);for(let e=0;e!!(s=n.call({lexer:this},e,t))&&(e=e.substring(s.raw.length),t.push(s),!0))))continue;if(s=this.tokenizer.space(e)){e=e.substring(s.raw.length);const n=t.at(-1);1===s.raw.length&&void 0!==n?n.raw+="\n":t.push(s);continue}if(s=this.tokenizer.code(e)){e=e.substring(s.raw.length);const n=t.at(-1);"paragraph"===n?.type||"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.text,this.inlineQueue.at(-1).src=n.text):t.push(s);continue}if(s=this.tokenizer.fences(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.heading(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.hr(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.blockquote(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.list(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.html(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.def(e)){e=e.substring(s.raw.length);const n=t.at(-1);"paragraph"===n?.type||"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.raw,this.inlineQueue.at(-1).src=n.text):this.tokens.links[s.tag]||(this.tokens.links[s.tag]={href:s.href,title:s.title});continue}if(s=this.tokenizer.table(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.lheading(e)){e=e.substring(s.raw.length),t.push(s);continue}let r=e;if(this.options.extensions?.startBlock){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startBlock.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(this.state.top&&(s=this.tokenizer.paragraph(r))){const i=t.at(-1);n&&"paragraph"===i?.type?(i.raw+="\n"+s.raw,i.text+="\n"+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=i.text):t.push(s),n=r.length!==e.length,e=e.substring(s.raw.length)}else if(s=this.tokenizer.text(e)){e=e.substring(s.raw.length);const n=t.at(-1);"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=n.text):t.push(s)}else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n=e,s=null;if(this.tokens.links){const e=Object.keys(this.tokens.links);if(e.length>0)for(;null!=(s=this.tokenizer.rules.inline.reflinkSearch.exec(n));)e.includes(s[0].slice(s[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;null!=(s=this.tokenizer.rules.inline.blockSkip.exec(n));)n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;null!=(s=this.tokenizer.rules.inline.anyPunctuation.exec(n));)n=n.slice(0,s.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);let r=!1,i="";for(;e;){let s;if(r||(i=""),r=!1,this.options.extensions?.inline?.some((n=>!!(s=n.call({lexer:this},e,t))&&(e=e.substring(s.raw.length),t.push(s),!0))))continue;if(s=this.tokenizer.escape(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.tag(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.link(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(s.raw.length);const n=t.at(-1);"text"===s.type&&"text"===n?.type?(n.raw+=s.raw,n.text+=s.text):t.push(s);continue}if(s=this.tokenizer.emStrong(e,n,i)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.codespan(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.br(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.del(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.autolink(e)){e=e.substring(s.raw.length),t.push(s);continue}if(!this.state.inLink&&(s=this.tokenizer.url(e))){e=e.substring(s.raw.length),t.push(s);continue}let l=e;if(this.options.extensions?.startInline){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startInline.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(l=e.substring(0,t+1))}if(s=this.tokenizer.inlineText(l)){e=e.substring(s.raw.length),"_"!==s.raw.slice(-1)&&(i=s.raw.slice(-1)),r=!0;const n=t.at(-1);"text"===n?.type?(n.raw+=s.raw,n.text+=s.text):t.push(s)}else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return t}}class Y{options;parser;constructor(t){this.options=t||e.defaults}space(e){return""}code({text:e,lang:t,escaped:n}){const s=(t||"").match(i.notSpaceStart)?.[0],r=e.replace(i.endingNewline,"")+"\n";return s?'
'+(n?r:X(r,!0))+"
\n":"
"+(n?r:X(r,!0))+"
\n"}blockquote({tokens:e}){return`
\n${this.parser.parse(e)}
\n`}html({text:e}){return e}heading({tokens:e,depth:t}){return`${this.parser.parseInline(e)}\n`}hr(e){return"
\n"}list(e){const t=e.ordered,n=e.start;let s="";for(let t=0;t\n"+s+"\n"}listitem(e){let t="";if(e.task){const n=this.checkbox({checked:!!e.checked});e.loose?"paragraph"===e.tokens[0]?.type?(e.tokens[0].text=n+" "+e.tokens[0].text,e.tokens[0].tokens&&e.tokens[0].tokens.length>0&&"text"===e.tokens[0].tokens[0].type&&(e.tokens[0].tokens[0].text=n+" "+X(e.tokens[0].tokens[0].text),e.tokens[0].tokens[0].escaped=!0)):e.tokens.unshift({type:"text",raw:n+" ",text:n+" ",escaped:!0}):t+=n+" "}return t+=this.parser.parse(e.tokens,!!e.loose),`
  • ${t}
  • \n`}checkbox({checked:e}){return"'}paragraph({tokens:e}){return`

    ${this.parser.parseInline(e)}

    \n`}table(e){let t="",n="";for(let t=0;t${s}`),"\n\n"+t+"\n"+s+"
    \n"}tablerow({text:e}){return`\n${e}\n`}tablecell(e){const t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+`\n`}strong({tokens:e}){return`${this.parser.parseInline(e)}`}em({tokens:e}){return`${this.parser.parseInline(e)}`}codespan({text:e}){return`${X(e,!0)}`}br(e){return"
    "}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:n}){const s=this.parser.parseInline(n),r=F(e);if(null===r)return s;let i='
    ",i}image({href:e,title:t,text:n}){const s=F(e);if(null===s)return X(n);let r=`${n}{const r=e[s].flat(1/0);n=n.concat(this.walkTokens(r,t))})):e.tokens&&(n=n.concat(this.walkTokens(e.tokens,t)))}}return n}use(...e){const t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach((e=>{const n={...e};if(n.async=this.defaults.async||n.async||!1,e.extensions&&(e.extensions.forEach((e=>{if(!e.name)throw new Error("extension name required");if("renderer"in e){const n=t.renderers[e.name];t.renderers[e.name]=n?function(...t){let s=e.renderer.apply(this,t);return!1===s&&(s=n.apply(this,t)),s}:e.renderer}if("tokenizer"in e){if(!e.level||"block"!==e.level&&"inline"!==e.level)throw new Error("extension level must be 'block' or 'inline'");const n=t[e.level];n?n.unshift(e.tokenizer):t[e.level]=[e.tokenizer],e.start&&("block"===e.level?t.startBlock?t.startBlock.push(e.start):t.startBlock=[e.start]:"inline"===e.level&&(t.startInline?t.startInline.push(e.start):t.startInline=[e.start]))}"childTokens"in e&&e.childTokens&&(t.childTokens[e.name]=e.childTokens)})),n.extensions=t),e.renderer){const t=this.defaults.renderer||new Y(this.defaults);for(const n in e.renderer){if(!(n in t))throw new Error(`renderer '${n}' does not exist`);if(["options","parser"].includes(n))continue;const s=n,r=e.renderer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n||""}}n.renderer=t}if(e.tokenizer){const t=this.defaults.tokenizer||new V(this.defaults);for(const n in e.tokenizer){if(!(n in t))throw new Error(`tokenizer '${n}' does not exist`);if(["options","rules","lexer"].includes(n))continue;const s=n,r=e.tokenizer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.tokenizer=t}if(e.hooks){const t=this.defaults.hooks||new ne;for(const n in e.hooks){if(!(n in t))throw new Error(`hook '${n}' does not exist`);if(["options","block"].includes(n))continue;const s=n,r=e.hooks[s],i=t[s];ne.passThroughHooks.has(n)?t[s]=e=>{if(this.defaults.async)return Promise.resolve(r.call(t,e)).then((e=>i.call(t,e)));const n=r.call(t,e);return i.call(t,n)}:t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.hooks=t}if(e.walkTokens){const t=this.defaults.walkTokens,s=e.walkTokens;n.walkTokens=function(e){let n=[];return n.push(s.call(this,e)),t&&(n=n.concat(t.call(this,e))),n}}this.defaults={...this.defaults,...n}})),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return W.lex(e,t??this.defaults)}parser(e,t){return te.parse(e,t??this.defaults)}parseMarkdown(e){return(t,n)=>{const s={...n},r={...this.defaults,...s},i=this.onError(!!r.silent,!!r.async);if(!0===this.defaults.async&&!1===s.async)return i(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(null==t)return i(new Error("marked(): input parameter is undefined or null"));if("string"!=typeof t)return i(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(t)+", string expected"));r.hooks&&(r.hooks.options=r,r.hooks.block=e);const l=r.hooks?r.hooks.provideLexer():e?W.lex:W.lexInline,o=r.hooks?r.hooks.provideParser():e?te.parse:te.parseInline;if(r.async)return Promise.resolve(r.hooks?r.hooks.preprocess(t):t).then((e=>l(e,r))).then((e=>r.hooks?r.hooks.processAllTokens(e):e)).then((e=>r.walkTokens?Promise.all(this.walkTokens(e,r.walkTokens)).then((()=>e)):e)).then((e=>o(e,r))).then((e=>r.hooks?r.hooks.postprocess(e):e)).catch(i);try{r.hooks&&(t=r.hooks.preprocess(t));let e=l(t,r);r.hooks&&(e=r.hooks.processAllTokens(e)),r.walkTokens&&this.walkTokens(e,r.walkTokens);let n=o(e,r);return r.hooks&&(n=r.hooks.postprocess(n)),n}catch(e){return i(e)}}}onError(e,t){return n=>{if(n.message+="\nPlease report this to https://github.com/markedjs/marked.",e){const e="

    An error occurred:

    "+X(n.message+"",!0)+"
    ";return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}}const re=new se;function ie(e,t){return re.parse(e,t)}ie.options=ie.setOptions=function(e){return re.setOptions(e),ie.defaults=re.defaults,n(ie.defaults),ie},ie.getDefaults=t,ie.defaults=e.defaults,ie.use=function(...e){return re.use(...e),ie.defaults=re.defaults,n(ie.defaults),ie},ie.walkTokens=function(e,t){return re.walkTokens(e,t)},ie.parseInline=re.parseInline,ie.Parser=te,ie.parser=te.parse,ie.Renderer=Y,ie.TextRenderer=ee,ie.Lexer=W,ie.lexer=W.lex,ie.Tokenizer=V,ie.Hooks=ne,ie.parse=ie;const le=ie.options,oe=ie.setOptions,ae=ie.use,ce=ie.walkTokens,he=ie.parseInline,pe=ie,ue=te.parse,ge=W.lex;e.Hooks=ne,e.Lexer=W,e.Marked=se,e.Parser=te,e.Renderer=Y,e.TextRenderer=ee,e.Tokenizer=V,e.getDefaults=t,e.lexer=ge,e.marked=ie,e.options=le,e.parse=pe,e.parseInline=he,e.parser=ue,e.setOptions=oe,e.use=ae,e.walkTokens=ce})); + +/** + * DO NOT EDIT THIS FILE + * The code in this file is generated from files in ./src/ + */ +(function(g,f){if(typeof exports=="object"&&typeof module<"u"){module.exports=f()}else if("function"==typeof define && define.amd){define("marked",f)}else {g["marked"]=f()}}(typeof globalThis < "u" ? globalThis : typeof self < "u" ? self : this,function(){var exports={};var __exports=exports;var module={exports}; +"use strict";var N=Object.defineProperty;var Oe=Object.getOwnPropertyDescriptor;var we=Object.getOwnPropertyNames;var ye=Object.prototype.hasOwnProperty;var Pe=(l,e)=>{for(var t in e)N(l,t,{get:e[t],enumerable:!0})},Se=(l,e,t,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of we(e))!ye.call(l,s)&&s!==t&&N(l,s,{get:()=>e[s],enumerable:!(n=Oe(e,s))||n.enumerable});return l};var $e=l=>Se(N({},"__esModule",{value:!0}),l);var Rt={};Pe(Rt,{Hooks:()=>P,Lexer:()=>x,Marked:()=>C,Parser:()=>b,Renderer:()=>y,TextRenderer:()=>S,Tokenizer:()=>w,defaults:()=>T,getDefaults:()=>_,lexer:()=>bt,marked:()=>g,options:()=>ht,parse:()=>mt,parseInline:()=>ft,parser:()=>xt,setOptions:()=>kt,use:()=>dt,walkTokens:()=>gt});module.exports=$e(Rt);function _(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var T=_();function Q(l){T=l}var z={exec:()=>null};function E(l){let e=[];return t=>{let n=Math.max(0,Math.min(3,t-1)),s=e[n];return s||(s=l(n),e[n]=s),s}}function d(l,e=""){let t=typeof l=="string"?l:l.source,n={replace:(s,r)=>{let i=typeof r=="string"?r:r.source;return i=i.replace(m.caret,"$1"),t=t.replace(s,i),n},getRegex:()=>new RegExp(t,e)};return n}var Le=((l="")=>{try{return!!new RegExp("(?<=1)(?/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] +\S/,listReplaceTask:/^\[[ xX]\] +/,listTaskCheckbox:/\[[ xX]\]/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^
    /i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:l=>new RegExp(`^( {0,3}${l})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:E(l=>new RegExp(`^ {0,${l}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`)),hrRegex:E(l=>new RegExp(`^ {0,${l}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`)),fencesBeginRegex:E(l=>new RegExp(`^ {0,${l}}(?:\`\`\`|~~~)`)),headingBeginRegex:E(l=>new RegExp(`^ {0,${l}}#`)),htmlBeginRegex:E(l=>new RegExp(`^ {0,${l}}<(?:[a-z].*>|!--)`,"i")),blockquoteBeginRegex:E(l=>new RegExp(`^ {0,${l}}>`))},_e=/^(?:[ \t]*(?:\n|$))+/,ze=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,Me=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,D=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,Ee=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,F=/ {0,3}(?:[*+-]|\d{1,9}[.)])/,ae=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,le=d(ae).replace(/bull/g,F).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),Ie=d(ae).replace(/bull/g,F).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),U=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,Ae=/^[^\n]+/,K=/(?!\s*\])(?:\\[\s\S]|[^\[\]\\])+/,Ce=d(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",K).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),Be=d(/^(bull)([ \t][^\n]*?)?(?:\n|$)/).replace(/bull/g,F).getRegex(),H="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",W=/|$))/,De=d("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",W).replace("tag",H).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),ue=d(U).replace("hr",D).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]+[^ \\t\\n]").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",H).getRegex(),qe=d(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",ue).getRegex(),X={blockquote:qe,code:ze,def:Ce,fences:Me,heading:Ee,hr:D,html:De,lheading:le,list:Be,newline:_e,paragraph:ue,table:z,text:Ae},ie=d("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",D).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",H).getRegex(),ve={...X,lheading:Ie,table:ie,paragraph:d(U).replace("hr",D).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",ie).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]+[^ \\t\\n]").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",H).getRegex()},He={...X,html:d(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",W).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:z,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:d(U).replace("hr",D).replace("heading",` *#{1,6} *[^ +]`).replace("lheading",le).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},Ze=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,Ge=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,pe=/^( {2,}|\\)\n(?!\s*$)/,Ne=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\`+)[^`]+\k(?!`))*?\]\((?:\\[\s\S]|[^\\\(\)]|\((?:\\[\s\S]|[^\\\(\)])*\))*\)/).replace("precode-",Le?"(?`+)[^`]+\k(?!`)/).replace("html",/<(?! )[^<>]*?>/).getRegex(),he=/^(?:\*+(?:((?!\*)punct)|([^\s*]))?)|^_+(?:((?!_)punct)|([^\s_]))?/,Ke=d(he,"u").replace(/punct/g,I).getRegex(),We=d(he,"u").replace(/punct/g,ce).getRegex(),ke="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",Xe=d(ke,"gu").replace(/notPunctSpace/g,J).replace(/punctSpace/g,Z).replace(/punct/g,I).getRegex(),Je=d(ke,"gu").replace(/notPunctSpace/g,Fe).replace(/punctSpace/g,je).replace(/punct/g,ce).getRegex(),Ve=d("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,J).replace(/punctSpace/g,Z).replace(/punct/g,I).getRegex(),Ye=d(/^~~?(?:((?!~)punct)|[^\s~])/,"u").replace(/punct/g,I).getRegex(),et="^[^~]+(?=[^~])|(?!~)punct(~~?)(?=[\\s]|$)|notPunctSpace(~~?)(?!~)(?=punctSpace|$)|(?!~)punctSpace(~~?)(?=notPunctSpace)|[\\s](~~?)(?!~)(?=punct)|(?!~)punct(~~?)(?!~)(?=punct)|notPunctSpace(~~?)(?=notPunctSpace)",tt=d(et,"gu").replace(/notPunctSpace/g,J).replace(/punctSpace/g,Z).replace(/punct/g,I).getRegex(),nt=d(/\\(punct)/,"gu").replace(/punct/g,I).getRegex(),rt=d(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),st=d(W).replace("(?:-->|$)","-->").getRegex(),it=d("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",st).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),v=/(?:\[(?:\\[\s\S]|[^\[\]\\])*\]|\\[\s\S]|`+(?!`)[^`]*?`+(?!`)|``+(?=\])|[^\[\]\\`])*?/,ot=d(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]+(?:\n[ \t]*)?|\n[ \t]*)(title))?\s*\)/).replace("label",v).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),de=d(/^!?\[(label)\]\[(ref)\]/).replace("label",v).replace("ref",K).getRegex(),ge=d(/^!?\[(ref)\](?:\[\])?/).replace("ref",K).getRegex(),at=d("reflink|nolink(?!\\()","g").replace("reflink",de).replace("nolink",ge).getRegex(),oe=/[hH][tT][tT][pP][sS]?|[fF][tT][pP]/,V={_backpedal:z,anyPunctuation:nt,autolink:rt,blockSkip:Ue,br:pe,code:Ge,del:z,delLDelim:z,delRDelim:z,emStrongLDelim:Ke,emStrongRDelimAst:Xe,emStrongRDelimUnd:Ve,escape:Ze,link:ot,nolink:ge,punctuation:Qe,reflink:de,reflinkSearch:at,tag:it,text:Ne,url:z},lt={...V,link:d(/^!?\[(label)\]\((.*?)\)/).replace("label",v).getRegex(),reflink:d(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",v).getRegex()},j={...V,emStrongRDelimAst:Je,emStrongLDelim:We,delLDelim:Ye,delRDelim:tt,url:d(/^((?:protocol):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/).replace("protocol",oe).replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\[\s\S]|[^\\])*?(?:\\[\s\S]|[^\s~\\]))\1(?=[^~]|$)/,text:d(/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},fe=l=>pt[l];function O(l,e){if(e){if(m.escapeTest.test(l))return l.replace(m.escapeReplace,fe)}else if(m.escapeTestNoEncode.test(l))return l.replace(m.escapeReplaceNoEncode,fe);return l}function Y(l){try{l=encodeURI(l).replace(m.percentDecode,"%")}catch{return null}return l}function ee(l,e){let t=l.replace(m.findPipe,(r,i,o)=>{let u=!1,a=i;for(;--a>=0&&o[a]==="\\";)u=!u;return u?"|":" |"}),n=t.split(m.splitPipe),s=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),e)if(n.length>e)n.splice(e);else for(;n.length=0&&m.blankLine.test(e[t]);)t--;return e.length-t<=2?l:e.slice(0,t+1).join(` +`)}function me(l,e){if(l.indexOf(e[1])===-1)return-1;let t=0;for(let n=0;n0?-2:-1}function xe(l,e=0){let t=e,n="";for(let s of l)if(s===" "){let r=4-t%4;n+=" ".repeat(r),t+=r}else n+=s,t++;return n}function be(l,e,t,n,s){let r=e.href,i=e.title||null,o=l[1].replace(s.other.outputLinkReplace,"$1");n.state.inLink=!0;let u={type:l[0].charAt(0)==="!"?"image":"link",raw:t,href:r,title:i,text:o,tokens:n.inlineTokens(o)};return n.state.inLink=!1,u}function ct(l,e,t){let n=l.match(t.other.indentCodeCompensation);if(n===null)return e;let s=n[1];return e.split(` +`).map(r=>{let i=r.match(t.other.beginningSpace);if(i===null)return r;let[o]=i;return o.length>=s.length?r.slice(s.length):r}).join(` +`)}var w=class{options;rules;lexer;constructor(e){this.options=e||T}space(e){let t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(e){let t=this.rules.block.code.exec(e);if(t){let n=this.options.pedantic?t[0]:te(t[0]),s=n.replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:n,codeBlockStyle:"indented",text:s}}}fences(e){let t=this.rules.block.fences.exec(e);if(t){let n=t[0],s=ct(n,t[3]||"",this.rules);return{type:"code",raw:n,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:s}}}heading(e){let t=this.rules.block.heading.exec(e);if(t){let n=t[2].trim();if(this.rules.other.endingHash.test(n)){let s=L(n,"#");(this.options.pedantic||!s||this.rules.other.endingSpaceChar.test(s))&&(n=s.trim())}return{type:"heading",raw:L(t[0],` +`),depth:t[1].length,text:n,tokens:this.lexer.inline(n)}}}hr(e){let t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:L(t[0],` +`)}}blockquote(e){let t=this.rules.block.blockquote.exec(e);if(t){let n=L(t[0],` +`).split(` +`),s="",r="",i=[];for(;n.length>0;){let o=!1,u=[],a;for(a=0;a1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");let i=this.rules.other.listItemRegex(n),o=!1;for(;e;){let a=!1,c="",p="";if(!(t=i.exec(e))||this.rules.block.hr.test(e))break;c=t[0],e=e.substring(c.length);let k=xe(t[2].split(` +`,1)[0],t[1].length),h=e.split(` +`,1)[0],R=!k.trim(),f=0;if(this.options.pedantic?(f=2,p=k.trimStart()):R?f=t[1].length+1:(f=k.search(this.rules.other.nonSpaceChar),f=f>4?1:f,p=k.slice(f),f+=t[1].length),R&&this.rules.other.blankLine.test(h)&&(c+=h+` +`,e=e.substring(h.length+1),a=!0),!a){let $=this.rules.other.nextBulletRegex(f),ne=this.rules.other.hrRegex(f),re=this.rules.other.fencesBeginRegex(f),se=this.rules.other.headingBeginRegex(f),Re=this.rules.other.htmlBeginRegex(f),Te=this.rules.other.blockquoteBeginRegex(f);for(;e;){let G=e.split(` +`,1)[0],B;if(h=G,this.options.pedantic?(h=h.replace(this.rules.other.listReplaceNesting," "),B=h):B=h.replace(this.rules.other.tabCharGlobal," "),re.test(h)||se.test(h)||Re.test(h)||Te.test(h)||$.test(h)||ne.test(h))break;if(B.search(this.rules.other.nonSpaceChar)>=f||!h.trim())p+=` +`+B.slice(f);else{if(R||k.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||re.test(k)||se.test(k)||ne.test(k))break;p+=` +`+h}R=!h.trim(),c+=G+` +`,e=e.substring(G.length+1),k=B.slice(f)}}r.loose||(o?r.loose=!0:this.rules.other.doubleBlankLine.test(c)&&(o=!0)),r.items.push({type:"list_item",raw:c,task:!!this.options.gfm&&this.rules.other.listIsTask.test(p),loose:!1,text:p,tokens:[]}),r.raw+=c}let u=r.items.at(-1);if(u)u.raw=u.raw.trimEnd(),u.text=u.text.trimEnd();else return;r.raw=r.raw.trimEnd();for(let a of r.items){this.lexer.state.top=!1,a.tokens=this.lexer.blockTokens(a.text,[]);let c=a.tokens[0];if(a.task&&(c?.type==="text"||c?.type==="paragraph")){a.text=a.text.replace(this.rules.other.listReplaceTask,""),c.raw=c.raw.replace(this.rules.other.listReplaceTask,""),c.text=c.text.replace(this.rules.other.listReplaceTask,"");for(let k=this.lexer.inlineQueue.length-1;k>=0;k--)if(this.rules.other.listIsTask.test(this.lexer.inlineQueue[k].src)){this.lexer.inlineQueue[k].src=this.lexer.inlineQueue[k].src.replace(this.rules.other.listReplaceTask,"");break}let p=this.rules.other.listTaskCheckbox.exec(a.raw);if(p){let k={type:"checkbox",raw:p[0]+" ",checked:p[0]!=="[ ]"};a.checked=k.checked,r.loose?a.tokens[0]&&["paragraph","text"].includes(a.tokens[0].type)&&"tokens"in a.tokens[0]&&a.tokens[0].tokens?(a.tokens[0].raw=k.raw+a.tokens[0].raw,a.tokens[0].text=k.raw+a.tokens[0].text,a.tokens[0].tokens.unshift(k)):a.tokens.unshift({type:"paragraph",raw:k.raw,text:k.raw,tokens:[k]}):a.tokens.unshift(k)}}else a.task&&(a.task=!1);if(!r.loose){let p=a.tokens.filter(h=>h.type==="space"),k=p.length>0&&p.some(h=>this.rules.other.anyLine.test(h.raw));r.loose=k}}if(r.loose)for(let a of r.items){a.loose=!0;for(let c of a.tokens)c.type==="text"&&(c.type="paragraph")}return r}}html(e){let t=this.rules.block.html.exec(e);if(t){let n=te(t[0]);return{type:"html",block:!0,raw:n,pre:t[1]==="pre"||t[1]==="script"||t[1]==="style",text:n}}}def(e){let t=this.rules.block.def.exec(e);if(t){let n=t[1].toLowerCase().replace(this.rules.other.multipleSpaceGlobal," "),s=t[2]?t[2].replace(this.rules.other.hrefBrackets,"$1").replace(this.rules.inline.anyPunctuation,"$1"):"",r=t[3]?t[3].substring(1,t[3].length-1).replace(this.rules.inline.anyPunctuation,"$1"):t[3];return{type:"def",tag:n,raw:L(t[0],` +`),href:s,title:r}}}table(e){let t=this.rules.block.table.exec(e);if(!t||!this.rules.other.tableDelimiter.test(t[2]))return;let n=ee(t[1]),s=t[2].replace(this.rules.other.tableAlignChars,"").split("|"),r=t[3]?.trim()?t[3].replace(this.rules.other.tableRowBlankLine,"").split(` +`):[],i={type:"table",raw:L(t[0],` +`),header:[],align:[],rows:[]};if(n.length===s.length){for(let o of s)this.rules.other.tableAlignRight.test(o)?i.align.push("right"):this.rules.other.tableAlignCenter.test(o)?i.align.push("center"):this.rules.other.tableAlignLeft.test(o)?i.align.push("left"):i.align.push(null);for(let o=0;o({text:u,tokens:this.lexer.inline(u),header:!1,align:i.align[a]})));return i}}lheading(e){let t=this.rules.block.lheading.exec(e);if(t){let n=t[1].trim();return{type:"heading",raw:L(t[0],` +`),depth:t[2].charAt(0)==="="?1:2,text:n,tokens:this.lexer.inline(n)}}}paragraph(e){let t=this.rules.block.paragraph.exec(e);if(t){let n=t[1].charAt(t[1].length-1)===` +`?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:n,tokens:this.lexer.inline(n)}}}text(e){let t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){let t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){let t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){let t=this.rules.inline.link.exec(e);if(t){let n=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(n)){if(!this.rules.other.endAngleBracket.test(n))return;let i=L(n.slice(0,-1),"\\");if((n.length-i.length)%2===0)return}else{let i=me(t[2],"()");if(i===-2)return;if(i>-1){let u=(t[0].indexOf("!")===0?5:4)+t[1].length+i;t[2]=t[2].substring(0,i),t[0]=t[0].substring(0,u).trim(),t[3]=""}}let s=t[2],r="";if(this.options.pedantic){let i=this.rules.other.pedanticHrefTitle.exec(s);i&&(s=i[1],r=i[3])}else r=t[3]?t[3].slice(1,-1):"";return s=s.trim(),this.rules.other.startAngleBracket.test(s)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(n)?s=s.slice(1):s=s.slice(1,-1)),be(t,{href:s&&s.replace(this.rules.inline.anyPunctuation,"$1"),title:r&&r.replace(this.rules.inline.anyPunctuation,"$1")},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){let s=(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," "),r=t[s.toLowerCase()];if(!r){let i=n[0].charAt(0);return{type:"text",raw:i,text:i}}return be(n,r,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s||!s[1]&&!s[2]&&!s[3]&&!s[4]||s[4]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(s[1]||s[3]||"")||!n||this.rules.inline.punctuation.exec(n)){let i=[...s[0]].length-1,o,u,a=i,c=0,p=s[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(p.lastIndex=0,t=t.slice(-1*e.length+i);(s=p.exec(t))!==null;){if(o=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!o)continue;if(u=[...o].length,s[3]||s[4]){a+=u;continue}else if((s[5]||s[6])&&i%3&&!((i+u)%3)){c+=u;continue}if(a-=u,a>0)continue;u=Math.min(u,u+a+c);let k=[...s[0]][0].length,h=e.slice(0,i+s.index+k+u);if(Math.min(i,u)%2){let f=h.slice(1,-1);return{type:"em",raw:h,text:f,tokens:this.lexer.inlineTokens(f)}}let R=h.slice(2,-2);return{type:"strong",raw:h,text:R,tokens:this.lexer.inlineTokens(R)}}}}codespan(e){let t=this.rules.inline.code.exec(e);if(t){let n=t[2].replace(this.rules.other.newLineCharGlobal," "),s=this.rules.other.nonSpaceChar.test(n),r=this.rules.other.startingSpaceChar.test(n)&&this.rules.other.endingSpaceChar.test(n);return s&&r&&(n=n.substring(1,n.length-1)),{type:"codespan",raw:t[0],text:n}}}br(e){let t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e,t,n=""){let s=this.rules.inline.delLDelim.exec(e);if(!s)return;if(!(s[1]||"")||!n||this.rules.inline.punctuation.exec(n)){let i=[...s[0]].length-1,o,u,a=i,c=this.rules.inline.delRDelim;for(c.lastIndex=0,t=t.slice(-1*e.length+i);(s=c.exec(t))!==null;){if(o=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!o||(u=[...o].length,u!==i))continue;if(s[3]||s[4]){a+=u;continue}if(a-=u,a>0)continue;u=Math.min(u,u+a);let p=[...s[0]][0].length,k=e.slice(0,i+s.index+p+u),h=k.slice(i,-i);return{type:"del",raw:k,text:h,tokens:this.lexer.inlineTokens(h)}}}}autolink(e){let t=this.rules.inline.autolink.exec(e);if(t){let n,s;return t[2]==="@"?(n=t[1],s="mailto:"+n):(n=t[1],s=n),{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let n,s;if(t[2]==="@")n=t[0],s="mailto:"+n;else{let r;do r=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??"";while(r!==t[0]);n=t[0],t[1]==="www."?s="http://"+t[0]:s=t[0]}return{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}inlineText(e){let t=this.rules.inline.text.exec(e);if(t){let n=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:n}}}};var x=class l{tokens;options;state;inlineQueue;tokenizer;constructor(e){this.tokens=[],this.tokens.links=Object.create(null),this.options=e||T,this.options.tokenizer=this.options.tokenizer||new w,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let t={other:m,block:q.normal,inline:A.normal};this.options.pedantic?(t.block=q.pedantic,t.inline=A.pedantic):this.options.gfm&&(t.block=q.gfm,this.options.breaks?t.inline=A.breaks:t.inline=A.gfm),this.tokenizer.rules=t}static get rules(){return{block:q,inline:A}}static lex(e,t){return new l(t).lex(e)}static lexInline(e,t){return new l(t).inlineTokens(e)}lex(e){e=e.replace(m.carriageReturn,` +`),this.blockTokens(e,this.tokens);for(let t=0;t(r=o.call({lexer:this},e,t))?(e=e.substring(r.raw.length),t.push(r),!0):!1))continue;if(r=this.tokenizer.space(e)){e=e.substring(r.raw.length);let o=t.at(-1);r.raw.length===1&&o!==void 0?o.raw+=` +`:t.push(r);continue}if(r=this.tokenizer.code(e)){e=e.substring(r.raw.length);let o=t.at(-1);o?.type==="paragraph"||o?.type==="text"?(o.raw+=(o.raw.endsWith(` +`)?"":` +`)+r.raw,o.text+=` +`+r.text,this.inlineQueue.at(-1).src=o.text):t.push(r);continue}if(r=this.tokenizer.fences(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.heading(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.hr(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.blockquote(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.list(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.html(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.def(e)){e=e.substring(r.raw.length);let o=t.at(-1);o?.type==="paragraph"||o?.type==="text"?(o.raw+=(o.raw.endsWith(` +`)?"":` +`)+r.raw,o.text+=` +`+r.raw,this.inlineQueue.at(-1).src=o.text):this.tokens.links[r.tag]||(this.tokens.links[r.tag]={href:r.href,title:r.title},t.push(r));continue}if(r=this.tokenizer.table(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.lheading(e)){e=e.substring(r.raw.length),t.push(r);continue}let i=e;if(this.options.extensions?.startBlock){let o=1/0,u=e.slice(1),a;this.options.extensions.startBlock.forEach(c=>{a=c.call({lexer:this},u),typeof a=="number"&&a>=0&&(o=Math.min(o,a))}),o<1/0&&o>=0&&(i=e.substring(0,o+1))}if(this.state.top&&(r=this.tokenizer.paragraph(i))){let o=t.at(-1);n&&o?.type==="paragraph"?(o.raw+=(o.raw.endsWith(` +`)?"":` +`)+r.raw,o.text+=` +`+r.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=o.text):t.push(r),n=i.length!==e.length,e=e.substring(r.raw.length);continue}if(r=this.tokenizer.text(e)){e=e.substring(r.raw.length);let o=t.at(-1);o?.type==="text"?(o.raw+=(o.raw.endsWith(` +`)?"":` +`)+r.raw,o.text+=` +`+r.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=o.text):t.push(r);continue}if(e){this.infiniteLoopError(e.charCodeAt(0));break}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){this.tokenizer.lexer=this;let n=e,s=null;if(this.tokens.links){let a=Object.keys(this.tokens.links);if(a.length>0)for(;(s=this.tokenizer.rules.inline.reflinkSearch.exec(n))!==null;)a.includes(s[0].slice(s[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(s=this.tokenizer.rules.inline.anyPunctuation.exec(n))!==null;)n=n.slice(0,s.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);let r;for(;(s=this.tokenizer.rules.inline.blockSkip.exec(n))!==null;)r=s[2]?s[2].length:0,n=n.slice(0,s.index+r)+"["+"a".repeat(s[0].length-r-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);n=this.options.hooks?.emStrongMask?.call({lexer:this},n)??n;let i=!1,o="",u=1/0;for(;e;){if(e.length(a=p.call({lexer:this},e,t))?(e=e.substring(a.raw.length),t.push(a),!0):!1))continue;if(a=this.tokenizer.escape(e)){e=e.substring(a.raw.length),t.push(a);continue}if(a=this.tokenizer.tag(e)){e=e.substring(a.raw.length),t.push(a);continue}if(a=this.tokenizer.link(e)){e=e.substring(a.raw.length),t.push(a);continue}if(a=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(a.raw.length);let p=t.at(-1);a.type==="text"&&p?.type==="text"?(p.raw+=a.raw,p.text+=a.text):t.push(a);continue}if(a=this.tokenizer.emStrong(e,n,o)){e=e.substring(a.raw.length),t.push(a);continue}if(a=this.tokenizer.codespan(e)){e=e.substring(a.raw.length),t.push(a);continue}if(a=this.tokenizer.br(e)){e=e.substring(a.raw.length),t.push(a);continue}if(a=this.tokenizer.del(e,n,o)){e=e.substring(a.raw.length),t.push(a);continue}if(a=this.tokenizer.autolink(e)){e=e.substring(a.raw.length),t.push(a);continue}if(!this.state.inLink&&(a=this.tokenizer.url(e))){e=e.substring(a.raw.length),t.push(a);continue}let c=e;if(this.options.extensions?.startInline){let p=1/0,k=e.slice(1),h;this.options.extensions.startInline.forEach(R=>{h=R.call({lexer:this},k),typeof h=="number"&&h>=0&&(p=Math.min(p,h))}),p<1/0&&p>=0&&(c=e.substring(0,p+1))}if(a=this.tokenizer.inlineText(c)){e=e.substring(a.raw.length),a.raw.slice(-1)!=="_"&&(o=a.raw.slice(-1)),i=!0;let p=t.at(-1);p?.type==="text"?(p.raw+=a.raw,p.text+=a.text):t.push(a);continue}if(e){this.infiniteLoopError(e.charCodeAt(0));break}}return t}infiniteLoopError(e){let t="Infinite loop on byte: "+e;if(this.options.silent)console.error(t);else throw new Error(t)}};var y=class{options;parser;constructor(e){this.options=e||T}space(e){return""}code({text:e,lang:t,escaped:n}){let s=(t||"").match(m.notSpaceStart)?.[0],r=e.replace(m.endingNewline,"")+` +`;return s?'
    '+(n?r:O(r,!0))+`
    +`:"
    "+(n?r:O(r,!0))+`
    +`}blockquote({tokens:e}){return`
    +${this.parser.parse(e)}
    +`}html({text:e}){return e}def(e){return""}heading({tokens:e,depth:t}){return`${this.parser.parseInline(e)} +`}hr(e){return`
    +`}list(e){let t=e.ordered,n=e.start,s="";for(let o=0;o +`+s+" +`}listitem(e){return`
  • ${this.parser.parse(e.tokens)}
  • +`}checkbox({checked:e}){return" '}paragraph({tokens:e}){return`

    ${this.parser.parseInline(e)}

    +`}table(e){let t="",n="";for(let r=0;r${s}`),` + +`+t+` +`+s+`
    +`}tablerow({text:e}){return` +${e} +`}tablecell(e){let t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+` +`}strong({tokens:e}){return`${this.parser.parseInline(e)}`}em({tokens:e}){return`${this.parser.parseInline(e)}`}codespan({text:e}){return`${O(e,!0)}`}br(e){return"
    "}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:n}){let s=this.parser.parseInline(n),r=Y(e);if(r===null)return s;e=r;let i='
    ",i}image({href:e,title:t,text:n,tokens:s}){s&&(n=this.parser.parseInline(s,this.parser.textRenderer));let r=Y(e);if(r===null)return O(n);e=r;let i=`${O(n)}{let o=r[i].flat(1/0);n=n.concat(this.walkTokens(o,t))}):r.tokens&&(n=n.concat(this.walkTokens(r.tokens,t)))}}return n}use(...e){let t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(n=>{let s={...n};if(s.async=this.defaults.async||s.async||!1,n.extensions&&(n.extensions.forEach(r=>{if(!r.name)throw new Error("extension name required");if("renderer"in r){let i=t.renderers[r.name];i?t.renderers[r.name]=function(...o){let u=r.renderer.apply(this,o);return u===!1&&(u=i.apply(this,o)),u}:t.renderers[r.name]=r.renderer}if("tokenizer"in r){if(!r.level||r.level!=="block"&&r.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let i=t[r.level];i?i.unshift(r.tokenizer):t[r.level]=[r.tokenizer],r.start&&(r.level==="block"?t.startBlock?t.startBlock.push(r.start):t.startBlock=[r.start]:r.level==="inline"&&(t.startInline?t.startInline.push(r.start):t.startInline=[r.start]))}"childTokens"in r&&r.childTokens&&(t.childTokens[r.name]=r.childTokens)}),s.extensions=t),n.renderer){let r=this.defaults.renderer||new y(this.defaults);for(let i in n.renderer){if(!(i in r))throw new Error(`renderer '${i}' does not exist`);if(["options","parser"].includes(i))continue;let o=i,u=n.renderer[o],a=r[o];r[o]=(...c)=>{let p=u.apply(r,c);return p===!1&&(p=a.apply(r,c)),p||""}}s.renderer=r}if(n.tokenizer){let r=this.defaults.tokenizer||new w(this.defaults);for(let i in n.tokenizer){if(!(i in r))throw new Error(`tokenizer '${i}' does not exist`);if(["options","rules","lexer"].includes(i))continue;let o=i,u=n.tokenizer[o],a=r[o];r[o]=(...c)=>{let p=u.apply(r,c);return p===!1&&(p=a.apply(r,c)),p}}s.tokenizer=r}if(n.hooks){let r=this.defaults.hooks||new P;for(let i in n.hooks){if(!(i in r))throw new Error(`hook '${i}' does not exist`);if(["options","block"].includes(i))continue;let o=i,u=n.hooks[o],a=r[o];P.passThroughHooks.has(i)?r[o]=c=>{if(this.defaults.async&&P.passThroughHooksRespectAsync.has(i))return(async()=>{let k=await u.call(r,c);return a.call(r,k)})();let p=u.call(r,c);return a.call(r,p)}:r[o]=(...c)=>{if(this.defaults.async)return(async()=>{let k=await u.apply(r,c);return k===!1&&(k=await a.apply(r,c)),k})();let p=u.apply(r,c);return p===!1&&(p=a.apply(r,c)),p}}s.hooks=r}if(n.walkTokens){let r=this.defaults.walkTokens,i=n.walkTokens;s.walkTokens=function(o){let u=[];return u.push(i.call(this,o)),r&&(u=u.concat(r.call(this,o))),u}}this.defaults={...this.defaults,...s}}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return x.lex(e,t??this.defaults)}parser(e,t){return b.parse(e,t??this.defaults)}parseMarkdown(e){return(n,s)=>{let r={...s},i={...this.defaults,...r},o=this.onError(!!i.silent,!!i.async);if(this.defaults.async===!0&&r.async===!1)return o(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof n>"u"||n===null)return o(new Error("marked(): input parameter is undefined or null"));if(typeof n!="string")return o(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));if(i.hooks&&(i.hooks.options=i,i.hooks.block=e),i.async)return(async()=>{let u=i.hooks?await i.hooks.preprocess(n):n,c=await(i.hooks?await i.hooks.provideLexer(e):e?x.lex:x.lexInline)(u,i),p=i.hooks?await i.hooks.processAllTokens(c):c;i.walkTokens&&await Promise.all(this.walkTokens(p,i.walkTokens));let h=await(i.hooks?await i.hooks.provideParser(e):e?b.parse:b.parseInline)(p,i);return i.hooks?await i.hooks.postprocess(h):h})().catch(o);try{i.hooks&&(n=i.hooks.preprocess(n));let a=(i.hooks?i.hooks.provideLexer(e):e?x.lex:x.lexInline)(n,i);i.hooks&&(a=i.hooks.processAllTokens(a)),i.walkTokens&&this.walkTokens(a,i.walkTokens);let p=(i.hooks?i.hooks.provideParser(e):e?b.parse:b.parseInline)(a,i);return i.hooks&&(p=i.hooks.postprocess(p)),p}catch(u){return o(u)}}}onError(e,t){return n=>{if(n.message+=` +Please report this to https://github.com/markedjs/marked.`,e){let s="

    An error occurred:

    "+O(n.message+"",!0)+"
    ";return t?Promise.resolve(s):s}if(t)return Promise.reject(n);throw n}}};var M=new C;function g(l,e){return M.parse(l,e)}g.options=g.setOptions=function(l){return M.setOptions(l),g.defaults=M.defaults,Q(g.defaults),g};g.getDefaults=_;g.defaults=T;g.use=function(...l){return M.use(...l),g.defaults=M.defaults,Q(g.defaults),g};g.walkTokens=function(l,e){return M.walkTokens(l,e)};g.parseInline=M.parseInline;g.Parser=b;g.parser=b.parse;g.Renderer=y;g.TextRenderer=S;g.Lexer=x;g.lexer=x.lex;g.Tokenizer=w;g.Hooks=P;g.parse=g;var ht=g.options,kt=g.setOptions,dt=g.use,gt=g.walkTokens,ft=g.parseInline,mt=g,xt=b.parse,bt=x.lex; + +if(__exports != exports)module.exports = exports;return module.exports})); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 5998b2b1..8896ee78 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Updated Markdown parsing to `marked` 18.0.5. + ## [0.79.4] - 2026-06-15 ### Added diff --git a/packages/tui/package.json b/packages/tui/package.json index 749a3933..b634c8d3 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -38,7 +38,7 @@ "types": "./dist/index.d.ts", "dependencies": { "get-east-asian-width": "1.6.0", - "marked": "15.0.12" + "marked": "18.0.5" }, "devDependencies": { "@xterm/headless": "5.5.0", From 910508595a4a24a010a8ca18ec868d9307b13080 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 16 Jun 2026 18:53:19 +0200 Subject: [PATCH 288/352] feat(coding-agent): add settings http proxy closes #5790 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/settings.md | 12 +++++ .../coding-agent/src/core/http-dispatcher.ts | 7 +++ .../coding-agent/src/core/settings-manager.ts | 1 + packages/coding-agent/src/main.ts | 13 +++-- .../coding-agent/test/http-dispatcher.test.ts | 53 +++++++++++++++++++ 6 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 packages/coding-agent/test/http-dispatcher.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 672122da..042da954 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added a global `httpProxy` setting that applies as `HTTP_PROXY` and `HTTPS_PROXY` for Pi-managed HTTP clients ([#5790](https://github.com/earendil-works/pi/issues/5790)). - Added `auth.json` API key `env` values so provider-specific environment overrides can be scoped to Pi and propagated to inherited provider configuration ([#5728](https://github.com/earendil-works/pi/issues/5728)). ### Changed diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index fccfab06..18c9ad97 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -69,6 +69,18 @@ Use `/trust` in interactive mode to save a project trust decision for future ses Set `PI_SKIP_VERSION_CHECK=1` to disable the Pi version update check. Use `--offline` or `PI_OFFLINE=1` to disable all startup network operations described here, including update checks, package update checks, and install/update telemetry. +### Network + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `httpProxy` | string | - | HTTP proxy URL applied as `HTTP_PROXY` and `HTTPS_PROXY`. Global setting only. | + +```json +{ + "httpProxy": "http://127.0.0.1:7890" +} +``` + ### Warnings | Setting | Type | Default | Description | diff --git a/packages/coding-agent/src/core/http-dispatcher.ts b/packages/coding-agent/src/core/http-dispatcher.ts index 12ce9fb0..2ec8144e 100644 --- a/packages/coding-agent/src/core/http-dispatcher.ts +++ b/packages/coding-agent/src/core/http-dispatcher.ts @@ -36,6 +36,13 @@ export function formatHttpIdleTimeoutMs(timeoutMs: number): string { return `${timeoutMs / 1000} sec`; } +export function applyHttpProxySettings(httpProxy: string | undefined): void { + const proxy = httpProxy?.trim(); + if (!proxy) return; + process.env.HTTP_PROXY ??= proxy; + process.env.HTTPS_PROXY ??= proxy; +} + export function configureHttpDispatcher(timeoutMs: number = DEFAULT_HTTP_IDLE_TIMEOUT_MS): void { const normalizedTimeoutMs = parseHttpIdleTimeoutMs(timeoutMs); if (normalizedTimeoutMs === undefined) { diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 8acd5998..6b54a187 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -117,6 +117,7 @@ export interface Settings { markdown?: MarkdownSettings; warnings?: WarningSettings; sessionDir?: string; // Custom session storage directory (same format as --session-dir CLI flag) + httpProxy?: string; // Proxy URL applied as HTTP_PROXY and HTTPS_PROXY for Pi-managed HTTP clients httpIdleTimeoutMs?: number; // HTTP header/body idle timeout in milliseconds; 0 disables it websocketConnectTimeoutMs?: number; // WebSocket connect/open handshake timeout in milliseconds; 0 disables it } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 42ce1ce5..f66040bb 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -26,7 +26,7 @@ import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts"; import { AuthStorage } from "./core/auth-storage.ts"; import { exportFromFile } from "./core/export-html/index.ts"; import type { ExtensionFactory } from "./core/extensions/types.ts"; -import { configureHttpDispatcher } from "./core/http-dispatcher.ts"; +import { applyHttpProxySettings, configureHttpDispatcher } from "./core/http-dispatcher.ts"; import type { ModelRegistry } from "./core/model-registry.ts"; import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.ts"; import { restoreStdout, takeOverStdout } from "./core/output-guard.ts"; @@ -466,6 +466,12 @@ export async function main(args: string[], options?: MainOptions) { cleanupWindowsSelfUpdateQuarantine(getPackageDir()); } + const cwd = process.cwd(); + const agentDir = getAgentDir(); + const bootstrapSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false }); + applyHttpProxySettings(bootstrapSettingsManager.getGlobalSettings().httpProxy); + configureHttpDispatcher(); + if (await handlePackageCommand(args, { extensionFactories: options?.extensionFactories })) { const exitCode = process.exitCode ?? 0; if (process.platform === "win32" && exitCode === 0 && args[0] === "update") { @@ -529,11 +535,9 @@ export async function main(args: string[], options?: MainOptions) { validateSessionIdFlags(parsed); // Run migrations (pass cwd for project-local migrations) - const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(process.cwd()); + const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(cwd); time("runMigrations"); - const cwd = process.cwd(); - const agentDir = getAgentDir(); const startupSettingsManager = SettingsManager.create(cwd, agentDir); reportDiagnostics(collectSettingsDiagnostics(startupSettingsManager, "startup session lookup")); @@ -726,6 +730,7 @@ export async function main(args: string[], options?: MainOptions) { time("createAgentSessionRuntime"); const { services, session, modelFallbackMessage } = runtime; const { settingsManager, modelRegistry, resourceLoader } = services; + applyHttpProxySettings(settingsManager.getGlobalSettings().httpProxy); configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs()); if (parsed.help) { diff --git a/packages/coding-agent/test/http-dispatcher.test.ts b/packages/coding-agent/test/http-dispatcher.test.ts new file mode 100644 index 00000000..f70022f1 --- /dev/null +++ b/packages/coding-agent/test/http-dispatcher.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { applyHttpProxySettings } from "../src/core/http-dispatcher.ts"; + +const PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY"] as const; + +describe("http proxy settings", () => { + let savedEnv: Record<(typeof PROXY_ENV_KEYS)[number], string | undefined>; + + beforeEach(() => { + savedEnv = Object.fromEntries(PROXY_ENV_KEYS.map((key) => [key, process.env[key]])) as Record< + (typeof PROXY_ENV_KEYS)[number], + string | undefined + >; + for (const key of PROXY_ENV_KEYS) { + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of PROXY_ENV_KEYS) { + const value = savedEnv[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it("applies httpProxy to HTTP_PROXY and HTTPS_PROXY", () => { + applyHttpProxySettings("http://127.0.0.1:7890"); + + expect(process.env.HTTP_PROXY).toBe("http://127.0.0.1:7890"); + expect(process.env.HTTPS_PROXY).toBe("http://127.0.0.1:7890"); + }); + + it("does not override existing proxy env vars", () => { + process.env.HTTP_PROXY = "http://env-http:8080"; + process.env.HTTPS_PROXY = "http://env-https:8080"; + + applyHttpProxySettings("http://settings:7890"); + + expect(process.env.HTTP_PROXY).toBe("http://env-http:8080"); + expect(process.env.HTTPS_PROXY).toBe("http://env-https:8080"); + }); + + it("ignores empty values", () => { + applyHttpProxySettings(" "); + + expect(process.env.HTTP_PROXY).toBeUndefined(); + expect(process.env.HTTPS_PROXY).toBeUndefined(); + }); +}); From 2d597f0212145c9e4b8d6f66d85349e5e2cb86a4 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 16 Jun 2026 19:00:12 +0200 Subject: [PATCH 289/352] fix(ai): tolerate null Responses message content closes #5819 --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/providers/openai-responses-shared.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index e4d9ced0..bb108725 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed OpenAI Responses streaming to tolerate null message content from OpenAI-compatible servers before tool calls ([#5819](https://github.com/earendil-works/pi/issues/5819)). - Fixed Z.AI GLM-5.2 thinking requests to send `reasoning_effort` with the provider's `high`/`max` effort mapping ([#5770](https://github.com/earendil-works/pi/issues/5770)). - Fixed Google and `google-vertex` Gemini model metadata to map `latest` aliases to the current models, add Gemini 3.5 Flash for Vertex, correct Gemini 2.5 Flash Vertex cache pricing, and remove shut-down Vertex preview models ([#5761](https://github.com/earendil-works/pi/issues/5761)). - Fixed Moonshot AI China model metadata to include Kimi K2.7 Code, and omitted unsupported thinking-off payloads for Kimi K2.7 Code models ([#5760](https://github.com/earendil-works/pi/issues/5760)). diff --git a/packages/ai/src/providers/openai-responses-shared.ts b/packages/ai/src/providers/openai-responses-shared.ts index c006c284..6fd59a44 100644 --- a/packages/ai/src/providers/openai-responses-shared.ts +++ b/packages/ai/src/providers/openai-responses-shared.ts @@ -456,7 +456,8 @@ export async function processResponsesStream( }); currentBlock = null; } else if (item.type === "message" && currentBlock?.type === "text") { - currentBlock.text = item.content.map((c) => (c.type === "output_text" ? c.text : c.refusal)).join(""); + currentBlock.text = + item.content?.map((c) => (c.type === "output_text" ? c.text : c.refusal)).join("") || ""; currentBlock.textSignature = encodeTextSignatureV1(item.id, item.phase ?? undefined); stream.push({ type: "text_end", From 2431491c9278bd839c24f87e8baafb6a254414f9 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 16 Jun 2026 18:59:28 +0200 Subject: [PATCH 290/352] fix(ai): avoid duplicate OpenCode DeepSeek reasoning controls closes #5818 --- packages/ai/CHANGELOG.md | 1 + packages/ai/scripts/generate-models.ts | 6 +++++- packages/ai/src/models.generated.ts | 27 +++++++++++++++++++++----- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index bb108725..6ee6ca4e 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - Fixed OpenAI Responses streaming to tolerate null message content from OpenAI-compatible servers before tool calls ([#5819](https://github.com/earendil-works/pi/issues/5819)). +- Fixed OpenCode DeepSeek V4 thinking requests to avoid sending both `thinking` and `reasoning_effort` ([#5818](https://github.com/earendil-works/pi/issues/5818)). - Fixed Z.AI GLM-5.2 thinking requests to send `reasoning_effort` with the provider's `high`/`max` effort mapping ([#5770](https://github.com/earendil-works/pi/issues/5770)). - Fixed Google and `google-vertex` Gemini model metadata to map `latest` aliases to the current models, add Gemini 3.5 Flash for Vertex, correct Gemini 2.5 Flash Vertex cache pricing, and remove shut-down Vertex preview models ([#5761](https://github.com/earendil-works/pi/issues/5761)). - Fixed Moonshot AI China model metadata to include Kimi K2.7 Code, and omitted unsupported thinking-off payloads for Kimi K2.7 Code models ([#5760](https://github.com/earendil-works/pi/issues/5760)). diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 8cc890ee..7f4f9cf0 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -1852,9 +1852,13 @@ async function generateModels() { for (const candidate of allModels) { if (candidate.api === "openai-completions" && candidate.id.includes("deepseek-v4")) { + const preservesNativeReasoningEffort = + candidate.provider === "openrouter" || + candidate.provider === "opencode" || + candidate.provider === "opencode-go"; candidate.compat = { ...candidate.compat, - ...(candidate.provider === "openrouter" + ...(preservesNativeReasoningEffort ? { requiresReasoningContentOnAssistantMessages: deepseekCompat.requiresReasoningContentOnAssistantMessages, diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index d7e2f9f3..6f1f21de 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -7938,7 +7938,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", - compat: {"maxTokensField":"max_tokens","supportsLongCacheRetention":false,"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + compat: {"maxTokensField":"max_tokens","supportsLongCacheRetention":false,"requiresReasoningContentOnAssistantMessages":true}, reasoning: true, thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, input: ["text"], @@ -7957,7 +7957,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", - compat: {"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + compat: {"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true}, reasoning: true, thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, input: ["text"], @@ -7976,7 +7976,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", - compat: {"maxTokensField":"max_tokens","supportsLongCacheRetention":false,"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + compat: {"maxTokensField":"max_tokens","supportsLongCacheRetention":false,"requiresReasoningContentOnAssistantMessages":true}, reasoning: true, thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, input: ["text"], @@ -8554,7 +8554,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", - compat: {"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + compat: {"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true}, reasoning: true, thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, input: ["text"], @@ -8573,7 +8573,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", - compat: {"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + compat: {"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true}, reasoning: true, thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, input: ["text"], @@ -16310,6 +16310,23 @@ export const MODELS = { contextWindow: 202800, maxTokens: 64000, } satisfies Model<"anthropic-messages">, + "zai/glm-5.2": { + id: "zai/glm-5.2", + name: "GLM 5.2", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 1.4, + output: 4.4, + cacheRead: 0.26, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, "zai/glm-5v-turbo": { id: "zai/glm-5v-turbo", name: "GLM 5V Turbo", From b6b5bed9aebb4842f0d8daba3298b20b1e624b74 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 16 Jun 2026 19:46:15 +0200 Subject: [PATCH 291/352] docs: update unreleased changelogs --- packages/coding-agent/CHANGELOG.md | 13 +++++++++++++ packages/tui/CHANGELOG.md | 4 ++++ 2 files changed, 17 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 042da954..714b0140 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,8 +2,16 @@ ## [Unreleased] +### New Features + +- **Provider-scoped API key environments** - `auth.json` API key entries can now include `env` overrides for provider-specific Cloudflare, Azure OpenAI, Google Vertex, Amazon Bedrock, cache retention, and proxy settings without changing the project shell. See [Auth File](docs/providers.md#auth-file). +- **Global HTTP proxy setting** - Configure `httpProxy` once in global settings to apply `HTTP_PROXY` and `HTTPS_PROXY` to Pi-managed HTTP clients. See [Network](docs/settings.md#network). +- **Vercel AI Gateway attribution** - Vercel AI Gateway requests now include Pi attribution headers by default. See [API Keys](docs/providers.md#api-keys). + ### Added +- Added Vercel AI Gateway request attribution headers (`http-referer` and `x-title`) for Vercel AI Gateway models ([#5798](https://github.com/earendil-works/pi/pull/5798) by [@rwachtler](https://github.com/rwachtler)). +- Added an `xp` footer marker when experimental features are enabled. - Added a global `httpProxy` setting that applies as `HTTP_PROXY` and `HTTPS_PROXY` for Pi-managed HTTP clients ([#5790](https://github.com/earendil-works/pi/issues/5790)). - Added `auth.json` API key `env` values so provider-specific environment overrides can be scoped to Pi and propagated to inherited provider configuration ([#5728](https://github.com/earendil-works/pi/issues/5728)). @@ -13,6 +21,11 @@ ### Fixed +- Fixed inherited OpenAI Responses streaming to tolerate null message content from OpenAI-compatible servers before tool calls ([#5819](https://github.com/earendil-works/pi/issues/5819)). +- Fixed inherited OpenCode DeepSeek V4 thinking requests to avoid sending both `thinking` and `reasoning_effort` ([#5818](https://github.com/earendil-works/pi/issues/5818)). +- Fixed device-code login to stop opening the browser automatically. +- Fixed inherited editor Cursor Up handling so non-empty drafts jump to the start of the line before browsing input history ([#5789](https://github.com/earendil-works/pi/pull/5789) by [@4h9fbZ](https://github.com/4h9fbZ)). +- Fixed inherited Z.AI GLM-5.2 thinking requests to send `reasoning_effort` with the provider's `high`/`max` effort mapping ([#5770](https://github.com/earendil-works/pi/issues/5770)). - Fixed successful `pi update` on Windows to exit naturally instead of calling `process.exit(0)`, avoiding a Node.js/libuv assertion after version-check network requests ([#5805](https://github.com/earendil-works/pi/issues/5805)). - Fixed inherited Google and `google-vertex` Gemini model metadata to map `latest` aliases to the current models, add Gemini 3.5 Flash for Vertex, correct Gemini 2.5 Flash Vertex cache pricing, and remove shut-down Vertex preview models ([#5761](https://github.com/earendil-works/pi/issues/5761)). - Fixed the session selector to stay open and show the all-sessions empty state when both current-folder and all-scope session lists are empty ([#5747](https://github.com/earendil-works/pi/issues/5747)). diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 8896ee78..8ecc6e87 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -6,6 +6,10 @@ - Updated Markdown parsing to `marked` 18.0.5. +### Fixed + +- Fixed editor Cursor Up handling so non-empty drafts jump to the start of the line before browsing input history ([#5789](https://github.com/earendil-works/pi/pull/5789) by [@4h9fbZ](https://github.com/4h9fbZ)). + ## [0.79.4] - 2026-06-15 ### Added From 6561cb293b1dfdbe1f3f98cd73592860ded4c30b Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 16 Jun 2026 19:54:23 +0200 Subject: [PATCH 292/352] Release v0.79.5 --- package-lock.json | 26 +++++++------- packages/agent/CHANGELOG.md | 2 +- packages/agent/package.json | 4 +-- packages/ai/CHANGELOG.md | 2 +- packages/ai/package.json | 2 +- packages/ai/src/models.generated.ts | 35 +++++++++++++++++++ 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 +- packages/coding-agent/npm-shrinkwrap.json | 24 ++++++------- packages/coding-agent/package.json | 8 ++--- packages/tui/CHANGELOG.md | 2 +- packages/tui/package.json | 2 +- 20 files changed, 85 insertions(+), 50 deletions(-) diff --git a/package-lock.json b/package-lock.json index a522cacc..3c0aa234 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6176,10 +6176,10 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.79.4", + "version": "0.79.5", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.79.4", + "@earendil-works/pi-ai": "^0.79.5", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -6213,7 +6213,7 @@ }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.79.4", + "version": "0.79.5", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -6258,12 +6258,12 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "0.79.4", + "version": "0.79.5", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.4", - "@earendil-works/pi-ai": "^0.79.4", - "@earendil-works/pi-tui": "^0.79.4", + "@earendil-works/pi-agent-core": "^0.79.5", + "@earendil-works/pi-ai": "^0.79.5", + "@earendil-works/pi-tui": "^0.79.5", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -6304,32 +6304,32 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.79.4", + "version": "0.79.5", "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.79.4" + "version": "0.79.5" }, "packages/coding-agent/examples/extensions/gondolin": { "name": "pi-extension-gondolin", - "version": "0.79.4", + "version": "0.79.5", "dependencies": { "@earendil-works/gondolin": "0.12.0" } }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.9.4", + "version": "1.9.5", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.79.4", + "version": "0.79.5", "dependencies": { "ms": "2.1.3" }, @@ -6365,7 +6365,7 @@ }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.79.4", + "version": "0.79.5", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 64f79b1b..f99f0831 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.5] - 2026-06-16 ## [0.79.4] - 2026-06-15 diff --git a/packages/agent/package.json b/packages/agent/package.json index 36812b0f..4102aea3 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "0.79.4", + "version": "0.79.5", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -29,7 +29,7 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "^0.79.4", + "@earendil-works/pi-ai": "^0.79.5", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 6ee6ca4e..b835ce48 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.5] - 2026-06-16 ### Added diff --git a/packages/ai/package.json b/packages/ai/package.json index 8278c9b9..f99e4052 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.79.4", + "version": "0.79.5", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 6f1f21de..564d92e3 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -3813,6 +3813,24 @@ export const MODELS = { contextWindow: 131072, maxTokens: 131072, } satisfies Model<"openai-completions">, + "@cf/zai-org/glm-5.2": { + id: "@cf/zai-org/glm-5.2", + name: "Glm 5.2", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + compat: {"sendSessionAffinityHeaders":true}, + reasoning: true, + input: ["text"], + cost: { + input: 1.4, + output: 4.4, + cacheRead: 0.26, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, }, "deepseek": { "deepseek-v4-flash": { @@ -12956,6 +12974,23 @@ export const MODELS = { contextWindow: 202752, maxTokens: 4096, } satisfies Model<"openai-completions">, + "z-ai/glm-5.2": { + id: "z-ai/glm-5.2", + name: "Z.ai: GLM 5.2", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 1.4, + output: 4.4, + cacheRead: 0.26, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-completions">, "~anthropic/claude-fable-latest": { id: "~anthropic/claude-fable-latest", name: "Anthropic: Claude Fable Latest", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 714b0140..ea861202 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.5] - 2026-06-16 ### 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 5d512449..fd9cacc8 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.79.4", + "version": "0.79.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "0.79.4", + "version": "0.79.5", "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 404ac822..ccbe6692 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.79.4", + "version": "0.79.5", "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 f5e5cdeb..02d15c6c 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.79.4", + "version": "0.79.5", "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 c7cae39d..7f471fb6 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.79.4", + "version": "0.79.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-gondolin", - "version": "0.79.4", + "version": "0.79.5", "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 ba925197..a14897e8 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.79.4", + "version": "0.79.5", "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 84bc5c66..a27c9dc2 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.9.4", + "version": "1.9.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.9.4", + "version": "1.9.5", "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 e2962554..1a0bf2e2 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.9.4", + "version": "1.9.5", "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 555b18ba..91f7d746 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.79.4", + "version": "0.79.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "0.79.4", + "version": "0.79.5", "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 db2ac17e..d1b9caf3 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.79.4", + "version": "0.79.5", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 2a3434d6..8171f15a 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -1,17 +1,17 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.79.4", + "version": "0.79.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent", - "version": "0.79.4", + "version": "0.79.5", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.4", - "@earendil-works/pi-ai": "^0.79.4", - "@earendil-works/pi-tui": "^0.79.4", + "@earendil-works/pi-agent-core": "^0.79.5", + "@earendil-works/pi-ai": "^0.79.5", + "@earendil-works/pi-tui": "^0.79.5", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -474,11 +474,11 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.79.4", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.4.tgz", + "version": "0.79.5", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.5.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.79.4", + "@earendil-works/pi-ai": "^0.79.5", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -488,8 +488,8 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.79.4", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.4.tgz", + "version": "0.79.5", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.5.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -511,8 +511,8 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.79.4", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.4.tgz", + "version": "0.79.5", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.5.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 e67d13dd..96261f0d 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.79.4", + "version": "0.79.5", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -36,9 +36,9 @@ "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" }, "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.4", - "@earendil-works/pi-ai": "^0.79.4", - "@earendil-works/pi-tui": "^0.79.4", + "@earendil-works/pi-agent-core": "^0.79.5", + "@earendil-works/pi-ai": "^0.79.5", + "@earendil-works/pi-tui": "^0.79.5", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 8ecc6e87..36aebf33 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.5] - 2026-06-16 ### Changed diff --git a/packages/tui/package.json b/packages/tui/package.json index b634c8d3..eddeb896 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-tui", - "version": "0.79.4", + "version": "0.79.5", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", From 0b0b9eae62157cdc48ac38a267bc7a2df705a069 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 16 Jun 2026 19:54:26 +0200 Subject: [PATCH 293/352] Add [Unreleased] section for next cycle --- packages/agent/CHANGELOG.md | 2 ++ packages/ai/CHANGELOG.md | 2 ++ packages/coding-agent/CHANGELOG.md | 2 ++ packages/tui/CHANGELOG.md | 2 ++ 4 files changed, 8 insertions(+) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index f99f0831..ec27e984 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.5] - 2026-06-16 ## [0.79.4] - 2026-06-15 diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b835ce48..6813c6ad 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.5] - 2026-06-16 ### Added diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ea861202..d032a706 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.5] - 2026-06-16 ### New Features diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 36aebf33..e1ce9705 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.5] - 2026-06-16 ### Changed From a78cd7ccb18952c3139c086c54b488a87c6e32e4 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 16 Jun 2026 20:02:20 +0200 Subject: [PATCH 294/352] fix(coding-agent): stabilize self-update tests --- .../coding-agent/test/package-command-paths.test.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index 4cc1ce5e..df87cf34 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ENV_AGENT_DIR, PACKAGE_NAME, VERSION } from "../src/config.ts"; import { ProjectTrustStore } from "../src/core/trust-manager.ts"; import { main } from "../src/main.ts"; +import { handlePackageCommand } from "../src/package-manager-cli.ts"; describe("package commands", () => { let tempDir: string; @@ -22,6 +23,10 @@ describe("package commands", () => { return `${major}.${minor}.${Number.parseInt(patch, 10) + 1}`; } + async function runPackageCommandDirectly(args: string[]): Promise { + expect(await handlePackageCommand(args)).toBe(true); + } + beforeEach(() => { tempDir = join(tmpdir(), `pi-package-commands-${Date.now()}-${Math.random().toString(36).slice(2)}`); agentDir = join(tempDir, "agent"); @@ -404,7 +409,7 @@ else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args)); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { - await expect(main(["update", "--self", "--force"])).resolves.toBeUndefined(); + await expect(runPackageCommandDirectly(["update", "--self", "--force"])).resolves.toBeUndefined(); expect(process.exitCode).toBeUndefined(); expect(errorSpy).not.toHaveBeenCalled(); @@ -448,7 +453,7 @@ else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args)); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { - await expect(main(["update", "--self"])).resolves.toBeUndefined(); + await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); expect(process.exitCode).toBeUndefined(); expect(errorSpy).not.toHaveBeenCalled(); @@ -497,7 +502,7 @@ else { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { - await expect(main(["update", "--self"])).resolves.toBeUndefined(); + await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); expect(process.exitCode).toBeUndefined(); expect(errorSpy).not.toHaveBeenCalled(); @@ -550,7 +555,7 @@ if(args.includes("install")) process.exit(23); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { - await expect(main(["update", "--self"])).resolves.toBeUndefined(); + await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); expect(process.exitCode).toBe(1); const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); From a93f0666076c6ea514a21995f35a633183a2215e Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 16 Jun 2026 20:03:36 +0200 Subject: [PATCH 295/352] fix(coding-agent): preserve fetch overrides --- packages/coding-agent/src/core/http-dispatcher.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/core/http-dispatcher.ts b/packages/coding-agent/src/core/http-dispatcher.ts index 2ec8144e..0910f4d6 100644 --- a/packages/coding-agent/src/core/http-dispatcher.ts +++ b/packages/coding-agent/src/core/http-dispatcher.ts @@ -10,6 +10,9 @@ export const HTTP_IDLE_TIMEOUT_CHOICES = [ { label: "disabled", timeoutMs: 0 }, ] as const; +const originalGlobalFetch = globalThis.fetch; +let installedGlobalFetch: typeof globalThis.fetch | undefined; + export function parseHttpIdleTimeoutMs(value: unknown): number | undefined { if (typeof value === "string") { const trimmed = value.trim(); @@ -58,5 +61,13 @@ export function configureHttpDispatcher(timeoutMs: number = DEFAULT_HTTP_IDLE_TI // Keep fetch and the dispatcher on the same undici implementation. Node 26.0's // bundled fetch can otherwise consume compressed responses through npm undici's // dispatcher without decompressing them, causing response.json() failures. - undici.install?.(); + // If a caller replaced fetch after module load, preserve that deliberate override. + const shouldInstallGlobals = + installedGlobalFetch === undefined + ? globalThis.fetch === originalGlobalFetch + : globalThis.fetch === installedGlobalFetch; + if (shouldInstallGlobals) { + undici.install?.(); + installedGlobalFetch = globalThis.fetch; + } } From bd9f8773ad59aefc202ff470bd5240634ab9e81f Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 16 Jun 2026 23:14:35 +0200 Subject: [PATCH 296/352] fix(ai): restore OpenCode Go DeepSeek thinking controls --- packages/ai/CHANGELOG.md | 4 ++++ packages/ai/scripts/generate-models.ts | 5 +---- packages/ai/src/models.generated.ts | 20 ++++++++++---------- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 6813c6ad..21e4b074 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed OpenCode Go DeepSeek V4 thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter. + ## [0.79.5] - 2026-06-16 ### Added diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 7f4f9cf0..3d75a82e 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -1852,10 +1852,7 @@ async function generateModels() { for (const candidate of allModels) { if (candidate.api === "openai-completions" && candidate.id.includes("deepseek-v4")) { - const preservesNativeReasoningEffort = - candidate.provider === "openrouter" || - candidate.provider === "opencode" || - candidate.provider === "opencode-go"; + const preservesNativeReasoningEffort = candidate.provider === "openrouter" || candidate.provider === "opencode"; candidate.compat = { ...candidate.compat, ...(preservesNativeReasoningEffort diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 564d92e3..11851721 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -8572,7 +8572,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", - compat: {"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true}, + compat: {"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, input: ["text"], @@ -8591,7 +8591,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", - compat: {"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true}, + compat: {"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, input: ["text"], @@ -9499,13 +9499,13 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh"}, input: ["text"], cost: { - input: 0.098, - output: 0.196, + input: 0.09, + output: 0.18, cacheRead: 0.02, cacheWrite: 0, }, contextWindow: 1048576, - maxTokens: 4096, + maxTokens: 65536, } satisfies Model<"openai-completions">, "deepseek/deepseek-v4-pro": { id: "deepseek/deepseek-v4-pro", @@ -10540,13 +10540,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.75, + input: 0.74, output: 3.5, - cacheRead: 0.16, + cacheRead: 0.15, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 16384, } satisfies Model<"openai-completions">, "nex-agi/nex-n2-pro:free": { id: "nex-agi/nex-n2-pro:free", @@ -12985,11 +12985,11 @@ export const MODELS = { cost: { input: 1.4, output: 4.4, - cacheRead: 0.26, + cacheRead: 0.25, cacheWrite: 0, }, contextWindow: 1048576, - maxTokens: 131072, + maxTokens: 16384, } satisfies Model<"openai-completions">, "~anthropic/claude-fable-latest": { id: "~anthropic/claude-fable-latest", From 7da475db665a76228c2dd7bf86175c7ad0c3601e Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 16 Jun 2026 23:58:43 +0200 Subject: [PATCH 297/352] fix(ai): regenerate model catalog --- packages/ai/src/models.generated.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 11851721..52eac523 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -12985,11 +12985,11 @@ export const MODELS = { cost: { input: 1.4, output: 4.4, - cacheRead: 0.25, + cacheRead: 0.26, cacheWrite: 0, }, contextWindow: 1048576, - maxTokens: 16384, + maxTokens: 262144, } satisfies Model<"openai-completions">, "~anthropic/claude-fable-latest": { id: "~anthropic/claude-fable-latest", From 34b6aea1e6624320458169a945776963d160b3f4 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 16 Jun 2026 23:59:00 +0200 Subject: [PATCH 298/352] docs(coding-agent): add changelog entries for fetch override and DeepSeek V4 thinking-off fixes --- packages/coding-agent/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d032a706..95c585cc 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Fixed + +- Fixed HTTP dispatcher configuration to preserve a caller's deliberate `fetch` override instead of reinstalling the undici global fetch over it. +- Fixed inherited OpenCode Go DeepSeek V4 thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter. + ## [0.79.5] - 2026-06-16 ### New Features From 31bfb2f16f7a1dd707876e970f0f80caa61f8435 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 16 Jun 2026 23:59:14 +0200 Subject: [PATCH 299/352] Release v0.79.6 --- package-lock.json | 26 +++++++++---------- packages/agent/CHANGELOG.md | 2 +- packages/agent/package.json | 4 +-- packages/ai/CHANGELOG.md | 2 +- packages/ai/package.json | 2 +- packages/coding-agent/CHANGELOG.md | 2 +- .../package-lock.json | 4 +-- .../custom-provider-anthropic/package.json | 2 +- .../custom-provider-gitlab-duo/package.json | 2 +- .../extensions/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 +- packages/coding-agent/npm-shrinkwrap.json | 24 ++++++++--------- packages/coding-agent/package.json | 8 +++--- packages/tui/CHANGELOG.md | 2 +- packages/tui/package.json | 2 +- 19 files changed, 50 insertions(+), 50 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3c0aa234..d4811db6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6176,10 +6176,10 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.79.5", + "version": "0.79.6", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.79.5", + "@earendil-works/pi-ai": "^0.79.6", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -6213,7 +6213,7 @@ }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.79.5", + "version": "0.79.6", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -6258,12 +6258,12 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "0.79.5", + "version": "0.79.6", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.5", - "@earendil-works/pi-ai": "^0.79.5", - "@earendil-works/pi-tui": "^0.79.5", + "@earendil-works/pi-agent-core": "^0.79.6", + "@earendil-works/pi-ai": "^0.79.6", + "@earendil-works/pi-tui": "^0.79.6", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -6304,32 +6304,32 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.79.5", + "version": "0.79.6", "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.79.5" + "version": "0.79.6" }, "packages/coding-agent/examples/extensions/gondolin": { "name": "pi-extension-gondolin", - "version": "0.79.5", + "version": "0.79.6", "dependencies": { "@earendil-works/gondolin": "0.12.0" } }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.9.5", + "version": "1.9.6", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.79.5", + "version": "0.79.6", "dependencies": { "ms": "2.1.3" }, @@ -6365,7 +6365,7 @@ }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.79.5", + "version": "0.79.6", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index ec27e984..7f9cf03e 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.6] - 2026-06-16 ## [0.79.5] - 2026-06-16 diff --git a/packages/agent/package.json b/packages/agent/package.json index 4102aea3..66ee4847 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "0.79.5", + "version": "0.79.6", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -29,7 +29,7 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "^0.79.5", + "@earendil-works/pi-ai": "^0.79.6", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 21e4b074..776f5cef 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.6] - 2026-06-16 ### Fixed diff --git a/packages/ai/package.json b/packages/ai/package.json index f99e4052..11e4261b 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.79.5", + "version": "0.79.6", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 95c585cc..87e933c5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.6] - 2026-06-16 ### Fixed 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 fd9cacc8..bd9ae9f3 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.79.5", + "version": "0.79.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "0.79.5", + "version": "0.79.6", "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 ccbe6692..7f2d2bb4 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.79.5", + "version": "0.79.6", "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 02d15c6c..0478f9c8 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.79.5", + "version": "0.79.6", "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 7f471fb6..c573e8e0 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.79.5", + "version": "0.79.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-gondolin", - "version": "0.79.5", + "version": "0.79.6", "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 a14897e8..71e89d6c 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.79.5", + "version": "0.79.6", "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 a27c9dc2..24e20622 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.9.5", + "version": "1.9.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.9.5", + "version": "1.9.6", "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 1a0bf2e2..7f1fa016 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.9.5", + "version": "1.9.6", "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 91f7d746..fb5d53cd 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.79.5", + "version": "0.79.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "0.79.5", + "version": "0.79.6", "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 d1b9caf3..b8d759de 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.79.5", + "version": "0.79.6", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 8171f15a..39313f9d 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -1,17 +1,17 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.79.5", + "version": "0.79.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent", - "version": "0.79.5", + "version": "0.79.6", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.5", - "@earendil-works/pi-ai": "^0.79.5", - "@earendil-works/pi-tui": "^0.79.5", + "@earendil-works/pi-agent-core": "^0.79.6", + "@earendil-works/pi-ai": "^0.79.6", + "@earendil-works/pi-tui": "^0.79.6", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -474,11 +474,11 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.79.5", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.5.tgz", + "version": "0.79.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.6.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.79.5", + "@earendil-works/pi-ai": "^0.79.6", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -488,8 +488,8 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.79.5", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.5.tgz", + "version": "0.79.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.6.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -511,8 +511,8 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.79.5", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.5.tgz", + "version": "0.79.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.6.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 96261f0d..22b408ce 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.79.5", + "version": "0.79.6", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -36,9 +36,9 @@ "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" }, "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.5", - "@earendil-works/pi-ai": "^0.79.5", - "@earendil-works/pi-tui": "^0.79.5", + "@earendil-works/pi-agent-core": "^0.79.6", + "@earendil-works/pi-ai": "^0.79.6", + "@earendil-works/pi-tui": "^0.79.6", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index e1ce9705..eb44f2e3 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.6] - 2026-06-16 ## [0.79.5] - 2026-06-16 diff --git a/packages/tui/package.json b/packages/tui/package.json index eddeb896..9c73829c 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-tui", - "version": "0.79.5", + "version": "0.79.6", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", From 12bb8dd2c965d2088f08d27a19a049c54de53ead Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 16 Jun 2026 23:59:17 +0200 Subject: [PATCH 300/352] Add [Unreleased] section for next cycle --- packages/agent/CHANGELOG.md | 2 ++ packages/ai/CHANGELOG.md | 2 ++ packages/coding-agent/CHANGELOG.md | 2 ++ packages/tui/CHANGELOG.md | 2 ++ 4 files changed, 8 insertions(+) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 7f9cf03e..41203247 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.6] - 2026-06-16 ## [0.79.5] - 2026-06-16 diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 776f5cef..b69233a1 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.6] - 2026-06-16 ### Fixed diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 87e933c5..e4bf022c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.6] - 2026-06-16 ### Fixed diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index eb44f2e3..8fa900ce 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.6] - 2026-06-16 ## [0.79.5] - 2026-06-16 From 29c1504cc1e14822a9d058a51f59c19fb1ce076f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 17 Jun 2026 07:24:29 +0000 Subject: [PATCH 301/352] chore: approve contributor dodiego --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index f38c892d..a8db269c 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -239,3 +239,5 @@ vdxz pr dangooddd pr Mearman pr + +dodiego pr From 068ab5d19b9c7a17e14bde90bcbc4fc4c78da1b1 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Wed, 17 Jun 2026 11:04:01 +0200 Subject: [PATCH 302/352] fix(coding-agent): horizontally pan tree selector Fixes #5830 --- .../interactive/components/tree-selector.ts | 67 +++++++++++++++++-- packages/tui/src/index.ts | 2 +- 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 7c2cbf3e..8bcdc2fb 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -6,6 +6,7 @@ import { Input, type Keybinding, Spacer, + sliceByColumn, Text, truncateToWidth, visibleWidth, @@ -37,6 +38,59 @@ interface FlatNode { isVirtualRootChild: boolean; } +interface HorizontalViewportRow { + gutter: string; + body: string; + anchorCol: number; + bodyWidth: number; + isSelected: boolean; +} + +const TREE_GUTTER_WIDTH = 2; +const MIN_VISIBLE_ANCHOR_CONTENT_WIDTH = 4; +const MAX_VISIBLE_ANCHOR_CONTENT_WIDTH = 20; +const MIN_ANCHOR_CONTEXT_WIDTH = 2; +const MAX_ANCHOR_CONTEXT_WIDTH = 12; + +/** + * Render tree rows into a horizontally clipped viewport. + * + * The tree gutter is always kept visible. The row bodies are shifted left only + * when the selected row's anchor (the start of its entry text after tree + * indentation/markers) would otherwise be too far right to see useful content. + */ +function renderHorizontalViewport(rows: HorizontalViewportRow[], width: number): string[] { + const viewportWidth = Math.max(0, width - TREE_GUTTER_WIDTH); + const maxBodyWidth = rows.reduce((max, row) => Math.max(max, row.bodyWidth), 0); + const maxHorizontalScroll = Math.max(0, maxBodyWidth - viewportWidth); + const selectedRow = rows.find((row) => row.isSelected); + + // Only pan horizontally when needed to keep enough selected-row content visible after its anchor. + let horizontalScroll = 0; + if (selectedRow && maxHorizontalScroll > 0) { + const minVisibleAnchorContentWidth = Math.min( + MAX_VISIBLE_ANCHOR_CONTENT_WIDTH, + Math.max(MIN_VISIBLE_ANCHOR_CONTENT_WIDTH, Math.floor(viewportWidth / 3)), + ); + if (selectedRow.anchorCol > viewportWidth - minVisibleAnchorContentWidth) { + const anchorContextWidth = Math.min( + MAX_ANCHOR_CONTEXT_WIDTH, + Math.max(MIN_ANCHOR_CONTEXT_WIDTH, Math.floor(viewportWidth / 4)), + ); + horizontalScroll = Math.min(maxHorizontalScroll, selectedRow.anchorCol - anchorContextWidth); + } + } + + // Clip only the body; the fixed-width gutter remains visible as navigation context. + return rows.map((row) => { + const line = + horizontalScroll > 0 + ? `${row.gutter}${sliceByColumn(row.body, horizontalScroll, viewportWidth, true)}\x1b[0m` + : row.gutter + row.body; + return truncateToWidth(line, width, ""); + }); +} + /** Filter mode for tree display */ export type FilterMode = "default" | "no-tools" | "user-only" | "labeled-only" | "all"; @@ -619,6 +673,7 @@ class TreeList implements Component { ); const endIndex = Math.min(startIndex + this.maxVisibleLines, this.filteredNodes.length); + const renderedRows: HorizontalViewportRow[] = []; for (let i = startIndex; i < endIndex; i++) { const flatNode = this.filteredNodes[i]; const entry = flatNode.node.entry; @@ -682,14 +737,18 @@ class TreeList implements Component { ? theme.fg("muted", `${this.formatLabelTimestamp(flatNode.node.labelTimestamp)} `) : ""; const content = this.getEntryDisplayText(flatNode.node, isSelected); - - let line = cursor + theme.fg("dim", prefix) + foldMarker + pathMarker + label + labelTimestamp + content; + const prefixPart = theme.fg("dim", prefix) + foldMarker + pathMarker; + const anchorCol = visibleWidth(prefixPart); + let gutter = cursor; + let body = prefixPart + label + labelTimestamp + content; if (isSelected) { - line = theme.bg("selectedBg", line); + gutter = theme.bg("selectedBg", gutter); + body = theme.bg("selectedBg", body); } - lines.push(truncateToWidth(line, width)); + renderedRows.push({ gutter, body, anchorCol, bodyWidth: visibleWidth(body), isSelected }); } + lines.push(...renderHorizontalViewport(renderedRows, width)); lines.push( truncateToWidth( theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredNodes.length})${this.getStatusLabels()}`), diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 47bb5240..e22bf08a 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -106,4 +106,4 @@ export { TUI, } from "./tui.ts"; // Utilities -export { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils.ts"; +export { sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils.ts"; From ae89286d076748a02034131af50d165879fac3a7 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Wed, 17 Jun 2026 14:48:35 +0200 Subject: [PATCH 303/352] docs: update changelogs for tree panning closes #5830 --- packages/coding-agent/CHANGELOG.md | 4 ++++ packages/tui/CHANGELOG.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e4bf022c..73e538fc 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed the tree navigator to horizontally pan deep entries so the selected item remains readable ([#5830](https://github.com/earendil-works/pi/issues/5830)). + ## [0.79.6] - 2026-06-16 ### Fixed diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 8fa900ce..9b90a5b1 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Exported `sliceByColumn` for ANSI-aware horizontal column slicing. + ## [0.79.6] - 2026-06-16 ## [0.79.5] - 2026-06-16 From 6d5ede31c8b8584b422bd0fa2ce10a39b2a0cdce Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 17 Jun 2026 17:16:39 +0200 Subject: [PATCH 304/352] fix(coding-agent): match provider-first model searches --- packages/coding-agent/CHANGELOG.md | 1 + .../interactive/components/model-selector.ts | 7 ++- .../components/scoped-models-selector.ts | 7 ++- .../src/modes/interactive/interactive-mode.ts | 6 ++- .../src/modes/interactive/model-search.ts | 11 ++++ .../test/interactive-mode-status.test.ts | 53 +++++++++++++++++++ 6 files changed, 78 insertions(+), 7 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/model-search.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 73e538fc..3bbc9cbd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed `/model` autocomplete and model selection searches to match provider/model queries regardless of whether the provider or model token is typed first. - Fixed the tree navigator to horizontally pan deep entries so the selected item remains readable ([#5830](https://github.com/earendil-works/pi/issues/5830)). ## [0.79.6] - 2026-06-16 diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts index b9f5ec77..a3cdf7d5 100644 --- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts @@ -11,6 +11,7 @@ import { } from "@earendil-works/pi-tui"; import type { ModelRegistry } from "../../../core/model-registry.ts"; import type { SettingsManager } from "../../../core/settings-manager.ts"; +import { getModelSearchText } from "../model-search.ts"; import { theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { keyHint } from "./keybinding-hints.ts"; @@ -217,10 +218,8 @@ export class ModelSelectorComponent extends Container implements Focusable { private filterModels(query: string): void { this.filteredModels = query - ? fuzzyFilter( - this.activeModels, - query, - ({ id, provider }) => `${id} ${provider} ${provider}/${id} ${provider} ${id}`, + ? fuzzyFilter(this.activeModels, query, ({ id, provider, model }) => + getModelSearchText({ id, provider, name: model.name }), ) : this.activeModels; this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1)); diff --git a/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts b/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts index 06ce9169..772e3af0 100644 --- a/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts @@ -10,6 +10,7 @@ import { Spacer, Text, } from "@earendil-works/pi-tui"; +import { getModelSearchText } from "../model-search.ts"; import { theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { keyText } from "./keybinding-hints.ts"; @@ -182,7 +183,11 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl private refresh(): void { const query = this.searchInput.getValue(); const items = this.buildItems(); - this.filteredItems = query ? fuzzyFilter(items, query, (i) => `${i.model.id} ${i.model.provider}`) : items; + this.filteredItems = query + ? fuzzyFilter(items, query, (i) => + getModelSearchText({ id: i.model.id, provider: i.model.provider, name: i.model.name }), + ) + : items; this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1)); this.updateList(); this.footerText.setText(this.getFooterText()); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 56ef9098..c2dc7875 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -125,6 +125,7 @@ import { TreeSelectorComponent } from "./components/tree-selector.ts"; import { TrustSelectorComponent } from "./components/trust-selector.ts"; import { UserMessageComponent } from "./components/user-message.ts"; import { UserMessageSelectorComponent } from "./components/user-message-selector.ts"; +import { getModelSearchText } from "./model-search.ts"; import { detectTerminalBackgroundTheme, getAvailableThemes, @@ -518,11 +519,12 @@ export class InteractiveMode { const items = models.map((m) => ({ id: m.id, provider: m.provider, + name: m.name, label: `${m.provider}/${m.id}`, })); - // Fuzzy filter by model ID + provider (allows "opus anthropic" to match) - const filtered = fuzzyFilter(items, prefix, (item) => `${item.id} ${item.provider}`); + // Fuzzy filter by model ID + provider in either order. + const filtered = fuzzyFilter(items, prefix, getModelSearchText); if (filtered.length === 0) return null; diff --git a/packages/coding-agent/src/modes/interactive/model-search.ts b/packages/coding-agent/src/modes/interactive/model-search.ts new file mode 100644 index 00000000..f1dbc73c --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/model-search.ts @@ -0,0 +1,11 @@ +export interface ModelSearchItem { + id: string; + provider: string; + name?: string; +} + +export function getModelSearchText(item: ModelSearchItem): string { + const { id, provider } = item; + const name = item.name ? ` ${item.name}` : ""; + return `${id} ${provider} ${provider}/${id} ${provider} ${id}${name}`; +} diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 7ec54c7b..8f1018b2 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -356,6 +356,59 @@ describe("InteractiveMode.setupAutocompleteProvider", () => { }); }); +describe("InteractiveMode.createBaseAutocompleteProvider", () => { + test("matches model command arguments across provider/model order", async () => { + type TestModel = { id: string; provider: string; name: string }; + type FakeInteractiveMode = { + session: { + scopedModels: Array<{ model: TestModel }>; + modelRegistry: { getAvailable: () => TestModel[] }; + promptTemplates: []; + extensionRunner: { getRegisteredCommands: () => [] }; + resourceLoader: { getSkills: () => { skills: [] } }; + }; + settingsManager: { getEnableSkillCommands: () => boolean }; + skillCommands: Map; + sessionManager: { getCwd: () => string }; + fdPath: null; + }; + + const createBaseAutocompleteProvider = ( + InteractiveMode as unknown as { + prototype: { createBaseAutocompleteProvider(this: FakeInteractiveMode): AutocompleteProvider }; + } + ).prototype.createBaseAutocompleteProvider; + const models = [ + { id: "gpt-5.2-codex", provider: "github-copilot", name: "GPT-5.2 Codex" }, + { id: "gpt-5.5", provider: "openai-codex", name: "GPT-5.5" }, + ]; + const fakeThis: FakeInteractiveMode = { + session: { + scopedModels: [], + modelRegistry: { getAvailable: () => models }, + promptTemplates: [], + extensionRunner: { getRegisteredCommands: () => [] }, + resourceLoader: { getSkills: () => ({ skills: [] }) }, + }, + settingsManager: { getEnableSkillCommands: () => false }, + skillCommands: new Map(), + sessionManager: { getCwd: () => "/tmp" }, + fdPath: null, + }; + + const provider = createBaseAutocompleteProvider.call(fakeThis); + const line = "/model codexgpt"; + const suggestions = await provider.getSuggestions([line], 0, line.length, { + signal: new AbortController().signal, + }); + + expect(suggestions?.items.map((item) => item.value)).toEqual([ + "openai-codex/gpt-5.5", + "github-copilot/gpt-5.2-codex", + ]); + }); +}); + describe("InteractiveMode.showLoadedResources", () => { beforeAll(() => { initTheme("dark"); From 58dd2f5996b23eda709582fb7883d63842a5ce98 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Thu, 18 Jun 2026 10:06:23 +0200 Subject: [PATCH 305/352] feat(ai): add GLM-5.2 to OpenCode Go model catalog closes #5860 --- packages/ai/CHANGELOG.md | 4 + packages/ai/src/models.generated.ts | 171 +++++++++++++++++++--------- 2 files changed, 124 insertions(+), 51 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b69233a1..a49ddf52 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added GLM-5.2 model to the OpenCode Go subscription model catalog ([#5860](https://github.com/earendil-works/pi/issues/5860)). + ## [0.79.6] - 2026-06-16 ### Fixed diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 52eac523..424f8bcd 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -3885,7 +3885,7 @@ export const MODELS = { cost: { input: 0.14, output: 0.28, - cacheRead: 0.03, + cacheRead: 0.028, cacheWrite: 0, }, contextWindow: 1000000, @@ -3927,6 +3927,24 @@ export const MODELS = { contextWindow: 202800, maxTokens: 131072, } satisfies Model<"anthropic-messages">, + "accounts/fireworks/models/glm-5p2": { + id: "accounts/fireworks/models/glm-5p2", + name: "GLM 5.2", + api: "anthropic-messages", + provider: "fireworks", + baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 1.4, + output: 4.4, + cacheRead: 0.26, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, "accounts/fireworks/models/gpt-oss-120b": { id: "accounts/fireworks/models/gpt-oss-120b", name: "GPT OSS 120B", @@ -3939,7 +3957,7 @@ export const MODELS = { cost: { input: 0.15, output: 0.6, - cacheRead: 0.01, + cacheRead: 0.015, cacheWrite: 0, }, contextWindow: 131072, @@ -4117,7 +4135,7 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 2, + input: 1.9, output: 8, cacheRead: 0.38, cacheWrite: 0, @@ -8604,24 +8622,6 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 384000, } satisfies Model<"openai-completions">, - "glm-5": { - id: "glm-5", - name: "GLM-5", - api: "openai-completions", - provider: "opencode-go", - baseUrl: "https://opencode.ai/zen/go/v1", - compat: {"maxTokensField":"max_tokens"}, - reasoning: true, - input: ["text"], - cost: { - input: 1, - output: 3.2, - cacheRead: 0.2, - cacheWrite: 0, - }, - contextWindow: 202752, - maxTokens: 32768, - } satisfies Model<"openai-completions">, "glm-5.1": { id: "glm-5.1", name: "GLM-5.1", @@ -8640,6 +8640,24 @@ export const MODELS = { contextWindow: 202752, maxTokens: 32768, } satisfies Model<"openai-completions">, + "glm-5.2": { + id: "glm-5.2", + name: "GLM-5.2", + api: "openai-completions", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"maxTokensField":"max_tokens"}, + reasoning: true, + input: ["text"], + cost: { + input: 1.4, + output: 4.4, + cacheRead: 0.26, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 131072, + } satisfies Model<"openai-completions">, "kimi-k2.6": { id: "kimi-k2.6", name: "Kimi K2.6", @@ -9352,6 +9370,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4000, } satisfies Model<"openai-completions">, + "cohere/north-mini-code:free": { + id: "cohere/north-mini-code:free", + name: "Cohere: North Mini Code (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, "deepseek/deepseek-chat": { id: "deepseek/deepseek-chat", name: "DeepSeek: DeepSeek V3", @@ -9660,7 +9695,24 @@ export const MODELS = { cacheWrite: 0.083333, }, contextWindow: 1048576, - maxTokens: 65536, + maxTokens: 65535, + } satisfies Model<"openai-completions">, + "google/gemini-3-pro-image": { + id: "google/gemini-3-pro-image", + name: "Google: Nano Banana Pro (Gemini 3 Pro Image)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 12, + cacheRead: 0.2, + cacheWrite: 0.375, + }, + contextWindow: 65536, + maxTokens: 32768, } satisfies Model<"openai-completions">, "google/gemini-3.1-flash-lite": { id: "google/gemini-3.1-flash-lite", @@ -9847,7 +9899,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 32768, + maxTokens: 8192, } satisfies Model<"openai-completions">, "ibm-granite/granite-4.1-8b": { id: "ibm-granite/granite-4.1-8b", @@ -9952,6 +10004,23 @@ export const MODELS = { contextWindow: 256000, maxTokens: 80000, } satisfies Model<"openai-completions">, + "liquid/lfm-2.5-1.2b-thinking:free": { + id: "liquid/lfm-2.5-1.2b-thinking:free", + name: "LiquidAI: LFM2.5-1.2B-Thinking (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-70b-instruct": { id: "meta-llama/llama-3.1-70b-instruct", name: "Meta: Llama 3.1 70B Instruct", @@ -10677,8 +10746,8 @@ export const MODELS = { input: ["text"], cost: { input: 0.5, - output: 2.5, - cacheRead: 0.15, + output: 2.2, + cacheRead: 0.1, cacheWrite: 0, }, contextWindow: 1000000, @@ -11493,7 +11562,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 8192, + maxTokens: 32768, } satisfies Model<"openai-completions">, "openai/gpt-oss-safeguard-20b": { id: "openai/gpt-oss-safeguard-20b", @@ -11784,6 +11853,23 @@ export const MODELS = { contextWindow: 131072, maxTokens: 16384, } satisfies Model<"openai-completions">, + "qwen/qwen-2.5-7b-instruct": { + id: "qwen/qwen-2.5-7b-instruct", + name: "Qwen: Qwen2.5 7B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.04, + output: 0.1, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "qwen/qwen-plus": { id: "qwen/qwen-plus", name: "Qwen: Qwen-Plus", @@ -12753,23 +12839,6 @@ export const MODELS = { contextWindow: 256000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "xiaomi/mimo-v2-flash": { - id: "xiaomi/mimo-v2-flash", - name: "Xiaomi: MiMo-V2-Flash", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.1, - output: 0.3, - cacheRead: 0.01, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 65536, - } satisfies Model<"openai-completions">, "xiaomi/mimo-v2.5": { id: "xiaomi/mimo-v2.5", name: "Xiaomi: MiMo-V2.5", @@ -14948,13 +15017,13 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.02, - output: 0.04, + input: 0.15, + output: 0.15, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 131072, + contextWindow: 128000, + maxTokens: 128000, } satisfies Model<"anthropic-messages">, "mistral/mistral-small": { id: "mistral/mistral-small", @@ -15107,7 +15176,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 4096, + maxTokens: 32768, } satisfies Model<"anthropic-messages">, "nvidia/nemotron-3-super-120b-a12b": { id: "nvidia/nemotron-3-super-120b-a12b", @@ -16354,9 +16423,9 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 1.4, - output: 4.4, - cacheRead: 0.26, + input: 1.5, + output: 4.5, + cacheRead: 0.3, cacheWrite: 0, }, contextWindow: 1000000, From 008c76f955ae95b6a15703064cc313fdd7b0fde0 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Thu, 18 Jun 2026 10:27:11 +0200 Subject: [PATCH 306/352] feat(coding-agent): export project config dir name --- packages/coding-agent/CHANGELOG.md | 8 ++++++++ packages/coding-agent/docs/extensions.md | 14 ++++++++++++++ packages/coding-agent/docs/sdk.md | 3 ++- .../coding-agent/examples/extensions/preset.ts | 14 ++++++++++---- .../examples/extensions/provider-payload.ts | 10 +++++----- .../examples/extensions/sandbox/index.ts | 4 ++-- .../examples/extensions/subagent/agents.ts | 4 ++-- .../examples/extensions/subagent/index.ts | 12 +++++++++--- packages/coding-agent/src/core/project-trust.ts | 3 ++- packages/coding-agent/src/index.ts | 10 +++++++++- .../interactive/components/config-selector.ts | 10 +++++----- .../src/modes/interactive/interactive-mode.ts | 3 ++- packages/coding-agent/src/package-manager-cli.ts | 5 +++-- 13 files changed, 73 insertions(+), 27 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3bbc9cbd..716b210f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Added + +- Exported `CONFIG_DIR_NAME` from the coding-agent public API so extensions can resolve project config paths without hardcoding `.pi`. + +### Changed + +- Updated extension docs, examples, runtime help, trust prompts, and config labels to use the configured project config directory instead of hardcoded `.pi` paths. + ### Fixed - Fixed `/model` autocomplete and model selection searches to match provider/model queries regardless of whether the provider or model token is typed first. diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index ab3188ce..5a559813 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -898,6 +898,20 @@ Current run mode: `"tui"`, `"rpc"`, `"json"`, or `"print"`. Use `ctx.mode === "t Current working directory. +Use `CONFIG_DIR_NAME` instead of hardcoding `.pi` when constructing project-local config paths. Rebranded distributions can use a different config directory name. + +```typescript +import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { join } from "node:path"; + +export default function (pi: ExtensionAPI) { + pi.on("session_start", (_event, ctx) => { + const projectConfigPath = join(ctx.cwd, CONFIG_DIR_NAME, "my-extension.json"); + // ... + }); +} +``` + ### ctx.isProjectTrusted() Returns whether project-local trust is active for the current session context. This includes temporary trust decisions and CLI trust overrides, not just saved decisions in the global trust store. diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index c6b2a75d..0c521e74 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -1110,7 +1110,8 @@ DefaultResourceLoader type ResourceLoader createEventBus -// Helpers +// Constants and helpers +CONFIG_DIR_NAME defineTool getAgentDir getPackageDir diff --git a/packages/coding-agent/examples/extensions/preset.ts b/packages/coding-agent/examples/extensions/preset.ts index 92224ec2..b78237ca 100644 --- a/packages/coding-agent/examples/extensions/preset.ts +++ b/packages/coding-agent/examples/extensions/preset.ts @@ -42,7 +42,7 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import type { Api, Model } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; -import { DynamicBorder, getAgentDir } from "@earendil-works/pi-coding-agent"; +import { CONFIG_DIR_NAME, DynamicBorder, getAgentDir } from "@earendil-works/pi-coding-agent"; import { Container, Key, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui"; // Preset configuration @@ -69,7 +69,7 @@ interface PresetsConfig { */ function loadPresets(cwd: string): PresetsConfig { const globalPath = join(getAgentDir(), "presets.json"); - const projectPath = join(cwd, ".pi", "presets.json"); + const projectPath = join(cwd, CONFIG_DIR_NAME, "presets.json"); let globalPresets: PresetsConfig = {}; let projectPresets: PresetsConfig = {}; @@ -200,7 +200,10 @@ export default function presetExtension(pi: ExtensionAPI) { const presetNames = Object.keys(presets); if (presetNames.length === 0) { - ctx.ui.notify("No presets defined. Add presets to ~/.pi/agent/presets.json or .pi/presets.json", "warning"); + ctx.ui.notify( + `No presets defined. Add presets to ${join(getAgentDir(), "presets.json")} or ${join(ctx.cwd, CONFIG_DIR_NAME, "presets.json")}`, + "warning", + ); return; } @@ -308,7 +311,10 @@ export default function presetExtension(pi: ExtensionAPI) { async function cyclePreset(ctx: ExtensionContext): Promise { const presetNames = getPresetOrder(); if (presetNames.length === 0) { - ctx.ui.notify("No presets defined. Add presets to ~/.pi/agent/presets.json or .pi/presets.json", "warning"); + ctx.ui.notify( + `No presets defined. Add presets to ${join(getAgentDir(), "presets.json")} or ${join(ctx.cwd, CONFIG_DIR_NAME, "presets.json")}`, + "warning", + ); return; } diff --git a/packages/coding-agent/examples/extensions/provider-payload.ts b/packages/coding-agent/examples/extensions/provider-payload.ts index 860ddc00..7f02a077 100644 --- a/packages/coding-agent/examples/extensions/provider-payload.ts +++ b/packages/coding-agent/examples/extensions/provider-payload.ts @@ -1,18 +1,18 @@ import { appendFileSync } from "node:fs"; import { join } from "node:path"; -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-agent"; export default function (pi: ExtensionAPI) { - const logFile = join(process.cwd(), ".pi", "provider-payload.log"); - - pi.on("before_provider_request", (event) => { + pi.on("before_provider_request", (event, ctx) => { + const logFile = join(ctx.cwd, CONFIG_DIR_NAME, "provider-payload.log"); appendFileSync(logFile, `${JSON.stringify(event.payload, null, 2)}\n\n`, "utf8"); // Optional: replace the payload instead of only logging it. // return { ...event.payload, temperature: 0 }; }); - pi.on("after_provider_response", (event) => { + pi.on("after_provider_response", (event, ctx) => { + const logFile = join(ctx.cwd, CONFIG_DIR_NAME, "provider-payload.log"); appendFileSync(logFile, `[${event.status}] ${JSON.stringify(event.headers)}\n\n`, "utf8"); }); } diff --git a/packages/coding-agent/examples/extensions/sandbox/index.ts b/packages/coding-agent/examples/extensions/sandbox/index.ts index 94f8f1cf..b54d75d1 100644 --- a/packages/coding-agent/examples/extensions/sandbox/index.ts +++ b/packages/coding-agent/examples/extensions/sandbox/index.ts @@ -46,7 +46,7 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { SandboxManager, type SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { type BashOperations, createBashTool, getAgentDir } from "@earendil-works/pi-coding-agent"; +import { type BashOperations, CONFIG_DIR_NAME, createBashTool, getAgentDir } from "@earendil-works/pi-coding-agent"; interface SandboxConfig extends SandboxRuntimeConfig { enabled?: boolean; @@ -77,7 +77,7 @@ const DEFAULT_CONFIG: SandboxConfig = { }; function loadConfig(cwd: string): SandboxConfig { - const projectConfigPath = join(cwd, ".pi", "sandbox.json"); + const projectConfigPath = join(cwd, CONFIG_DIR_NAME, "sandbox.json"); const globalConfigPath = join(getAgentDir(), "extensions", "sandbox.json"); let globalConfig: Partial = {}; diff --git a/packages/coding-agent/examples/extensions/subagent/agents.ts b/packages/coding-agent/examples/extensions/subagent/agents.ts index eab6c630..c41ef579 100644 --- a/packages/coding-agent/examples/extensions/subagent/agents.ts +++ b/packages/coding-agent/examples/extensions/subagent/agents.ts @@ -4,7 +4,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent"; +import { CONFIG_DIR_NAME, getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent"; export type AgentScope = "user" | "project" | "both"; @@ -85,7 +85,7 @@ function isDirectory(p: string): boolean { function findNearestProjectAgentsDir(cwd: string): string | null { let currentDir = cwd; while (true) { - const candidate = path.join(currentDir, ".pi", "agents"); + const candidate = path.join(currentDir, CONFIG_DIR_NAME, "agents"); if (isDirectory(candidate)) return candidate; const parentDir = path.dirname(currentDir); diff --git a/packages/coding-agent/examples/extensions/subagent/index.ts b/packages/coding-agent/examples/extensions/subagent/index.ts index 92b471a3..832dcc74 100644 --- a/packages/coding-agent/examples/extensions/subagent/index.ts +++ b/packages/coding-agent/examples/extensions/subagent/index.ts @@ -19,7 +19,13 @@ import * as path from "node:path"; import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import type { Message } from "@earendil-works/pi-ai"; import { StringEnum } from "@earendil-works/pi-ai"; -import { type ExtensionAPI, getMarkdownTheme, withFileMutationQueue } from "@earendil-works/pi-coding-agent"; +import { + CONFIG_DIR_NAME, + type ExtensionAPI, + getAgentDir, + getMarkdownTheme, + withFileMutationQueue, +} from "@earendil-works/pi-coding-agent"; import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.ts"; @@ -458,8 +464,8 @@ export default function (pi: ExtensionAPI) { description: [ "Delegate tasks to specialized subagents with isolated context.", "Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).", - 'Default agent scope is "user" (from ~/.pi/agent/agents).', - 'To enable project-local agents in .pi/agents, set agentScope: "both" (or "project").', + `Default agent scope is "user" (from ${path.join(getAgentDir(), "agents")}).`, + `To enable project-local agents in ${CONFIG_DIR_NAME}/agents, set agentScope: "both" (or "project").`, ].join(" "), parameters: SubagentParams, diff --git a/packages/coding-agent/src/core/project-trust.ts b/packages/coding-agent/src/core/project-trust.ts index c0892439..2521ad5d 100644 --- a/packages/coding-agent/src/core/project-trust.ts +++ b/packages/coding-agent/src/core/project-trust.ts @@ -1,3 +1,4 @@ +import { CONFIG_DIR_NAME } from "../config.ts"; import { emitProjectTrustEvent } from "./extensions/runner.ts"; import type { LoadExtensionsResult, ProjectTrustContext } from "./extensions/types.ts"; import type { DefaultProjectTrust } from "./settings-manager.ts"; @@ -21,7 +22,7 @@ export interface ResolveProjectTrustedOptions { } function formatProjectTrustPrompt(cwd: string): string { - return `Trust project folder?\n${cwd}\n\nThis allows pi to load .pi settings and resources, install missing project packages, and execute project extensions.`; + return `Trust project folder?\n${cwd}\n\nThis allows pi to load ${CONFIG_DIR_NAME} settings and resources, install missing project packages, and execute project extensions.`; } async function selectProjectTrustOption( diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 3176a167..70416af1 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -3,7 +3,15 @@ export { type Args, parseArgs } from "./cli/args.ts"; // Config paths -export { getAgentDir, getDocsPath, getExamplesPath, getPackageDir, getReadmePath, VERSION } from "./config.ts"; +export { + CONFIG_DIR_NAME, + getAgentDir, + getDocsPath, + getExamplesPath, + getPackageDir, + getReadmePath, + VERSION, +} from "./config.ts"; export { AgentSession, type AgentSessionConfig, diff --git a/packages/coding-agent/src/modes/interactive/components/config-selector.ts b/packages/coding-agent/src/modes/interactive/components/config-selector.ts index 93ef4bea..7c46841d 100644 --- a/packages/coding-agent/src/modes/interactive/components/config-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/config-selector.ts @@ -73,7 +73,7 @@ function formatBaseDir(baseDir: string): string { return displayPath.endsWith("/") ? displayPath : `${displayPath}/`; } -function getGroupLabel(metadata: PathMetadata): string { +function getGroupLabel(metadata: PathMetadata, agentDir: string): string { if (metadata.origin === "package") { return `${metadata.source} (${metadata.scope})`; } @@ -84,12 +84,12 @@ function getGroupLabel(metadata: PathMetadata): string { ? `User (${formatBaseDir(metadata.baseDir)})` : `Project (${formatBaseDir(metadata.baseDir)})`; } - return metadata.scope === "user" ? "User (~/.pi/agent/)" : "Project (.pi/)"; + return metadata.scope === "user" ? `User (${formatBaseDir(agentDir)})` : `Project (${CONFIG_DIR_NAME}/)`; } return metadata.scope === "user" ? "User settings" : "Project settings"; } -function buildGroups(resolved: ResolvedPaths): ResourceGroup[] { +function buildGroups(resolved: ResolvedPaths, agentDir: string): ResourceGroup[] { const groupMap = new Map(); const addToGroup = (resources: ResolvedResource[], resourceType: ResourceType) => { @@ -100,7 +100,7 @@ function buildGroups(resolved: ResolvedPaths): ResourceGroup[] { if (!groupMap.has(groupKey)) { groupMap.set(groupKey, { key: groupKey, - label: getGroupLabel(metadata), + label: getGroupLabel(metadata, agentDir), scope: metadata.scope, origin: metadata.origin, source: metadata.source, @@ -601,7 +601,7 @@ export class ConfigSelectorComponent extends Container implements Focusable { ) { super(); - const groups = buildGroups(resolvedPaths); + const groups = buildGroups(resolvedPaths, agentDir); // Add header this.addChild(new Spacer(1)); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index c2dc7875..ad9d3d3a 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -52,6 +52,7 @@ import { spawn, spawnSync } from "child_process"; import { APP_NAME, APP_TITLE, + CONFIG_DIR_NAME, getAgentDir, getAuthPath, getDebugLogPath, @@ -3307,7 +3308,7 @@ export class InteractiveMode { new Text( theme.fg( "warning", - "This project is not trusted. Project .pi resources and packages are ignored. Use /trust to save a trust decision, then restart pi.", + `This project is not trusted. Project ${CONFIG_DIR_NAME} resources and packages are ignored. Use /trust to save a trust decision, then restart pi.`, ), 1, 0, diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index 0dbe750c..e00de1b4 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -4,6 +4,7 @@ import { selectConfig } from "./cli/config-selector.ts"; import { createProjectTrustContext } from "./cli/project-trust.ts"; import { APP_NAME, + CONFIG_DIR_NAME, detectInstallMethod, getAgentDir, getPackageDir, @@ -93,7 +94,7 @@ function printPackageCommandHelp(command: PackageCommand): void { Install a package and add it to settings. Options: - -l, --local Install project-locally (.pi/settings.json) + -l, --local Install project-locally (${CONFIG_DIR_NAME}/settings.json) -a, --approve Trust project-local files for this command -na, --no-approve Ignore project-local files for this command @@ -115,7 +116,7 @@ Remove a package and its source from settings. Alias: ${APP_NAME} uninstall [-l] Options: - -l, --local Remove from project settings (.pi/settings.json) + -l, --local Remove from project settings (${CONFIG_DIR_NAME}/settings.json) -a, --approve Trust project-local files for this command -na, --no-approve Ignore project-local files for this command From 51f752358712bc69f313d75ec505db244d8c7ff5 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Thu, 18 Jun 2026 10:44:59 +0200 Subject: [PATCH 307/352] fix(coding-agent): include RPC request id on unknown commands closes #5868 --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/modes/rpc/rpc-mode.ts | 2 +- .../5868-rpc-unknown-command-id.test.ts | 113 ++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/test/suite/regressions/5868-rpc-unknown-command-id.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 716b210f..deb0a5e5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,7 @@ ### Fixed +- Fixed RPC unknown-command errors to include the request id so clients do not hang waiting for a response ([#5868](https://github.com/earendil-works/pi/issues/5868)). - Fixed `/model` autocomplete and model selection searches to match provider/model queries regardless of whether the provider or model token is typed first. - Fixed the tree navigator to horizontally pan deep entries so the selected item remains readable ([#5830](https://github.com/earendil-works/pi/issues/5830)). diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 9bb1f089..1150b826 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -667,7 +667,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise ({ + outputLines: [] as string[], + lineHandler: undefined as ((line: string) => void) | undefined, +})); + +vi.mock("../../../src/core/output-guard.js", () => ({ + flushRawStdout: vi.fn(async () => {}), + takeOverStdout: vi.fn(), + waitForRawStdoutBackpressure: vi.fn(async () => {}), + writeRawStdout: (line: string) => { + rpcIo.outputLines.push(line); + }, +})); + +vi.mock("../../../src/modes/interactive/theme/theme.js", () => ({ theme: {} })); + +vi.mock("../../../src/modes/rpc/jsonl.js", () => ({ + attachJsonlLineReader: vi.fn((_stream: NodeJS.ReadableStream, onLine: (line: string) => void) => { + rpcIo.lineHandler = onLine; + return () => { + rpcIo.lineHandler = undefined; + }; + }), + serializeJsonLine: (value: unknown) => `${JSON.stringify(value)}\n`, +})); + +type NodeListener = Parameters[1]; + +type ListenerSnapshot = { + stdinEnd: NodeListener[]; + signals: Map; +}; + +function takeListenerSnapshot(): ListenerSnapshot { + const signals: NodeJS.Signals[] = process.platform === "win32" ? ["SIGTERM"] : ["SIGTERM", "SIGHUP"]; + return { + stdinEnd: process.stdin.listeners("end") as NodeListener[], + signals: new Map(signals.map((signal) => [signal, process.listeners(signal) as NodeListener[]])), + }; +} + +function restoreListeners(snapshot: ListenerSnapshot): void { + for (const listener of process.stdin.listeners("end") as NodeListener[]) { + if (!snapshot.stdinEnd.includes(listener)) { + process.stdin.off("end", listener); + } + } + + for (const [signal, previousListeners] of snapshot.signals) { + for (const listener of process.listeners(signal) as NodeListener[]) { + if (!previousListeners.includes(listener)) { + process.off(signal, listener); + } + } + } +} + +function parseOutputLines(): Array> { + return rpcIo.outputLines + .flatMap((line) => line.split("\n")) + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line) as Record); +} + +function createRuntimeHost(harness: Harness): AgentSessionRuntime { + return { + session: harness.session, + newSession: vi.fn(async () => ({ cancelled: true })), + switchSession: vi.fn(async () => ({ cancelled: true })), + fork: vi.fn(async () => ({ cancelled: true, selectedText: "" })), + dispose: vi.fn(async () => {}), + setRebindSession: vi.fn(), + } as unknown as AgentSessionRuntime; +} + +describe("RPC unknown command responses (#5868)", () => { + afterEach(() => { + rpcIo.outputLines = []; + rpcIo.lineHandler = undefined; + }); + + test("preserves the request id on unknown command errors", async () => { + const listenerSnapshot = takeListenerSnapshot(); + const harness = await createHarness(); + + try { + void runRpcMode(createRuntimeHost(harness)); + await vi.waitFor(() => expect(rpcIo.lineHandler).toBeDefined()); + + rpcIo.lineHandler?.(JSON.stringify({ id: "test", type: "foobar" })); + + await vi.waitFor(() => { + expect(parseOutputLines()).toContainEqual({ + id: "test", + type: "response", + command: "foobar", + success: false, + error: "Unknown command: foobar", + }); + }); + } finally { + harness.cleanup(); + restoreListeners(listenerSnapshot); + } + }); +}); From 7a14325b24e91824a57916d5d2d44a8b45cdefd4 Mon Sep 17 00:00:00 2001 From: Diego de Oliveira Date: Thu, 18 Jun 2026 05:45:51 -0300 Subject: [PATCH 308/352] feat(tui): detect Warp terminal and enable Kitty image protocol (#5841) Warp supports the Kitty graphics protocol and OSC 8 hyperlinks, but pi's terminal-image capability detection did not recognize it. Add detection via TERM_PROGRAM=WarpTerminal, WARP_SESSION_ID, and WARP_TERMINAL_SESSION_UUID so images render inline without requiring the TERM_PROGRAM=kitty workaround. Fixes #5827 --- packages/coding-agent/docs/tui.md | 2 +- packages/tui/src/terminal-image.ts | 5 +++ packages/tui/test/terminal-image.test.ts | 44 ++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index c3b31204..c8ff3554 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -257,7 +257,7 @@ md.setText("Updated markdown"); ### Image -Renders images in supported terminals (Kitty, iTerm2, Ghostty, WezTerm). +Renders images in supported terminals (Kitty, iTerm2, Ghostty, WezTerm, Warp). ```typescript const image = new Image( diff --git a/packages/tui/src/terminal-image.ts b/packages/tui/src/terminal-image.ts index d7012c1d..8854a86a 100644 --- a/packages/tui/src/terminal-image.ts +++ b/packages/tui/src/terminal-image.ts @@ -92,6 +92,11 @@ export function detectCapabilities(tmuxForwardsHyperlink: () => boolean = probeT return { images: "kitty", trueColor: true, hyperlinks: true }; } + // Warp supports the Kitty graphics protocol and OSC 8 hyperlinks. + if (termProgram === "warpterminal" || process.env.WARP_SESSION_ID || process.env.WARP_TERMINAL_SESSION_UUID) { + return { images: "kitty", trueColor: true, hyperlinks: true }; + } + if (process.env.ITERM_SESSION_ID || termProgram === "iterm.app") { return { images: "iterm2", trueColor: true, hyperlinks: true }; } diff --git a/packages/tui/test/terminal-image.test.ts b/packages/tui/test/terminal-image.test.ts index 4e9a3fc9..cc7e01e5 100644 --- a/packages/tui/test/terminal-image.test.ts +++ b/packages/tui/test/terminal-image.test.ts @@ -30,6 +30,8 @@ const ENV_KEYS = [ "ITERM_SESSION_ID", "WT_SESSION", "CMUX_WORKSPACE_ID", + "WARP_SESSION_ID", + "WARP_TERMINAL_SESSION_UUID", ] as const; function withEnv(overrides: Record, fn: () => void): void { @@ -271,6 +273,48 @@ describe("detectCapabilities", () => { }); }); + it("enables images and hyperlinks for Warp via TERM_PROGRAM", () => { + withEnv({ TERM_PROGRAM: "WarpTerminal" }, () => { + const caps = detectCapabilities(); + assert.strictEqual(caps.images, "kitty"); + assert.strictEqual(caps.trueColor, true); + assert.strictEqual(caps.hyperlinks, true); + }); + }); + + it("enables images and hyperlinks for Warp via WARP_SESSION_ID", () => { + withEnv({ WARP_SESSION_ID: "some-session-id" }, () => { + const caps = detectCapabilities(); + assert.strictEqual(caps.images, "kitty"); + assert.strictEqual(caps.trueColor, true); + assert.strictEqual(caps.hyperlinks, true); + }); + }); + + it("enables images and hyperlinks for Warp via WARP_TERMINAL_SESSION_UUID", () => { + withEnv({ WARP_TERMINAL_SESSION_UUID: "d0e1a2e5-7ca7-44cd-9037-ac7222011161" }, () => { + const caps = detectCapabilities(); + assert.strictEqual(caps.images, "kitty"); + assert.strictEqual(caps.trueColor, true); + assert.strictEqual(caps.hyperlinks, true); + }); + }); + + it("disables images for Warp inside tmux", () => { + withEnv( + { + TERM_PROGRAM: "WarpTerminal", + TMUX: "/tmp/tmux-1000/default,1234,0", + TERM: "tmux-256color", + }, + () => { + const caps = detectCapabilities(() => true); + assert.strictEqual(caps.images, null); + assert.strictEqual(caps.hyperlinks, true); + }, + ); + }); + it("enables hyperlinks for iTerm2", () => { withEnv({ TERM_PROGRAM: "iterm.app" }, () => { const caps = detectCapabilities(); From 20da9bc1407c027406e770caf23f09c97966936d Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Thu, 18 Jun 2026 10:59:25 +0200 Subject: [PATCH 309/352] fix attribution for 008c76f9 --- packages/coding-agent/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index deb0a5e5..a2fd43e8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Exported `CONFIG_DIR_NAME` from the coding-agent public API so extensions can resolve project config paths without hardcoding `.pi`. +- Exported `CONFIG_DIR_NAME` from the coding-agent public API so extensions can resolve project config paths without hardcoding `.pi` ([#5869](https://github.com/earendil-works/pi/pulls/5869) by [@xl0](https://github.com/xl0)) ### Changed From bc93655e1c07627d40b32c978d3208eff688dafb Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 18 Jun 2026 16:26:01 +0200 Subject: [PATCH 310/352] meta: Added report template --- .github/ISSUE_TEMPLATE/package-report.yml | 51 +++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/package-report.yml diff --git a/.github/ISSUE_TEMPLATE/package-report.yml b/.github/ISSUE_TEMPLATE/package-report.yml new file mode 100644 index 00000000..c2b4e76a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/package-report.yml @@ -0,0 +1,51 @@ +name: Package Report +description: Report a problematic Pi package listed on pi.dev +labels: ["package-report"] +body: + - type: markdown + attributes: + value: | + Use this form to report a package listed on pi.dev. For Pi core bugs, use the bug report template instead. + + New issues from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/earendil-works/pi-mono/blob/main/CONTRIBUTING.md) will not be reopened or receive a reply. + + Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice. + + - type: input + id: package-name + attributes: + label: Package name + description: The npm package name from pi.dev. + placeholder: "@scope/package" + validations: + required: true + + - type: input + id: package-version + attributes: + label: Version + description: The package version shown on pi.dev. + placeholder: "0.1.0" + validations: + required: false + + - type: dropdown + id: report-type + attributes: + label: What are you reporting? + options: + - Malicious or unsafe behavior + - Broken package or install instructions + - Misleading metadata or impersonation + - Spam or low-quality package + - Other + validations: + required: true + + - type: textarea + id: details + attributes: + label: Details + description: Describe the concern and include links, logs, or screenshots if helpful. + validations: + required: true From 908be616f27ac2f97f8049feb94f3d21de6cb7b6 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 18 Jun 2026 17:12:03 +0200 Subject: [PATCH 311/352] ref: Remove some options from package reporting --- .github/ISSUE_TEMPLATE/package-report.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/package-report.yml b/.github/ISSUE_TEMPLATE/package-report.yml index c2b4e76a..846e25ee 100644 --- a/.github/ISSUE_TEMPLATE/package-report.yml +++ b/.github/ISSUE_TEMPLATE/package-report.yml @@ -35,10 +35,8 @@ body: label: What are you reporting? options: - Malicious or unsafe behavior - - Broken package or install instructions - - Misleading metadata or impersonation - - Spam or low-quality package - - Other + - Impersonation + - Trademark / TOS Violations validations: required: true From d0b46764b1dde621ce89932f92f6e59a4c286133 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 18 Jun 2026 17:13:47 +0200 Subject: [PATCH 312/352] feat(coding-agent): add automatic theme mode (#5874) --- packages/coding-agent/docs/themes.md | 2 +- .../coding-agent/src/core/settings-manager.ts | 9 +- .../components/settings-selector.ts | 279 ++++++++++++++++-- .../src/modes/interactive/interactive-mode.ts | 71 ++--- .../interactive/theme/theme-controller.ts | 135 +++++++++ .../modes/interactive/theme/theme-schema.json | 3 +- .../src/modes/interactive/theme/theme.ts | 43 ++- .../test/interactive-mode-status.test.ts | 13 + .../test/settings-manager.test.ts | 18 ++ ...-signal-shutdown-extension-cleanup.test.ts | 2 + .../5724-sigterm-signal-exit.test.ts | 2 + .../coding-agent/test/theme-detection.test.ts | 12 + packages/tui/src/index.ts | 7 +- packages/tui/src/terminal-colors.ts | 11 + packages/tui/src/tui.ts | 75 ++++- packages/tui/test/terminal-colors.test.ts | 18 +- 16 files changed, 620 insertions(+), 80 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/theme/theme-controller.ts diff --git a/packages/coding-agent/docs/themes.md b/packages/coding-agent/docs/themes.md index c18e954b..11655128 100644 --- a/packages/coding-agent/docs/themes.md +++ b/packages/coding-agent/docs/themes.md @@ -137,7 +137,7 @@ vim ~/.pi/agent/themes/my-theme.json } ``` -- `name` is required and must be unique. +- `name` is required, must be unique, and must not contain `/`. - `vars` is optional. Define reusable colors here, then reference them in `colors`. - `colors` must define all 51 required tokens. diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 6b54a187..a90916a4 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -714,8 +714,15 @@ export class SettingsManager { this.save(); } + getThemeSetting(): string | undefined { + const value = this.settings.theme; + if (typeof value === "string") return value; + return undefined; + } + getTheme(): string | undefined { - return this.settings.theme; + const theme = this.getThemeSetting(); + return theme?.includes("/") ? undefined : theme; } setTheme(theme: string): void { diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 39d25f80..7cc92614 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -1,6 +1,7 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { Transport } from "@earendil-works/pi-ai"; import { + type Component, Container, getCapabilities, type SelectItem, @@ -13,7 +14,13 @@ import { } from "@earendil-works/pi-tui"; import { formatHttpIdleTimeoutMs, HTTP_IDLE_TIMEOUT_CHOICES } from "../../../core/http-dispatcher.ts"; import type { DefaultProjectTrust, WarningSettings } from "../../../core/settings-manager.ts"; -import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.ts"; +import { + getSelectListTheme, + getSettingsListTheme, + parseAutoThemeSetting, + type TerminalTheme, + theme, +} from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { keyDisplayText } from "./keybinding-hints.ts"; @@ -55,6 +62,7 @@ export interface SettingsConfig { thinkingLevel: ThinkingLevel; availableThinkingLevels: ThinkingLevel[]; currentTheme: string; + terminalTheme: TerminalTheme; availableThemes: string[]; hideThinkingBlock: boolean; collapseChangelog: boolean; @@ -210,6 +218,249 @@ class SelectSubmenu extends Container { } } +function themeItems(availableThemes: string[]): SelectItem[] { + return availableThemes.map((name) => ({ value: name, label: name })); +} + +const AUTOMATIC_THEME_VALUE = "/"; + +function singleModeThemeItems(availableThemes: string[]): SelectItem[] { + return [ + { + value: AUTOMATIC_THEME_VALUE, + label: "Automatic", + description: "Use separate themes for light and dark terminal appearance", + }, + ...themeItems(availableThemes), + ]; +} + +function preferredTheme(availableThemes: string[], preferred: string | undefined, fallback: string): string { + if (preferred && availableThemes.includes(preferred)) return preferred; + if (availableThemes.includes(fallback)) return fallback; + return availableThemes[0] ?? fallback; +} + +function defaultAutomaticThemes( + currentThemeSetting: string, + availableThemes: string[], +): { lightTheme: string; darkTheme: string } { + const autoTheme = parseAutoThemeSetting(currentThemeSetting); + if (autoTheme) return autoTheme; + + const currentFixedTheme = currentThemeSetting.includes("/") ? undefined : currentThemeSetting; + const themeName = preferredTheme(availableThemes, currentFixedTheme, "dark"); + return { lightTheme: themeName, darkTheme: themeName }; +} + +class ThemeSubmenu extends Container { + private inputComponent: Component | undefined; + private readonly callbacks: SettingsCallbacks; + private readonly availableThemes: string[]; + private readonly terminalTheme: TerminalTheme; + private readonly onDone: (selectedValue?: string) => void; + private readonly originalThemeSetting: string; + private mode: "single" | "automatic"; + private singleTheme: string; + private lightTheme: string; + private darkTheme: string; + + constructor( + currentThemeSetting: string, + terminalTheme: TerminalTheme, + availableThemes: string[], + callbacks: SettingsCallbacks, + onDone: (selectedValue?: string) => void, + ) { + super(); + this.callbacks = callbacks; + this.availableThemes = availableThemes; + this.terminalTheme = terminalTheme; + this.onDone = onDone; + this.originalThemeSetting = currentThemeSetting; + const autoTheme = parseAutoThemeSetting(currentThemeSetting); + const automaticThemes = defaultAutomaticThemes(currentThemeSetting, availableThemes); + const fixedTheme = autoTheme || currentThemeSetting.includes("/") ? undefined : currentThemeSetting; + this.mode = autoTheme ? "automatic" : "single"; + this.lightTheme = automaticThemes.lightTheme; + this.darkTheme = automaticThemes.darkTheme; + this.singleTheme = preferredTheme( + availableThemes, + fixedTheme ?? (autoTheme ? this.getActiveAutomaticTheme() : undefined), + "dark", + ); + + if (this.mode === "automatic") { + this.showAutomaticMenu(); + } else { + this.showSingleMenu(); + } + } + + handleInput(data: string): void { + this.inputComponent?.handleInput?.(data); + } + + private setContent(renderComponent: Component, inputComponent: Component = renderComponent): void { + this.clear(); + this.addChild(renderComponent); + this.inputComponent = inputComponent; + } + + private showSingleMenu(): void { + this.mode = "single"; + const menu = new SelectSubmenu( + "Theme", + "Select a theme, or choose Automatic to follow terminal appearance.", + singleModeThemeItems(this.availableThemes), + this.singleTheme, + (value) => { + if (value === AUTOMATIC_THEME_VALUE) { + this.mode = "automatic"; + this.callbacks.onThemePreview?.(this.getThemeSetting()); + this.showAutomaticMenu(); + return; + } + + this.singleTheme = value; + this.apply(value); + }, + () => this.cancel(), + (value) => { + this.callbacks.onThemePreview?.(value === AUTOMATIC_THEME_VALUE ? this.getAutomaticThemeSetting() : value); + }, + ); + this.setContent(menu); + } + + private showAutomaticMenu(): void { + this.mode = "automatic"; + const content = new Container(); + content.addChild(new Text(theme.bold(theme.fg("accent", "Automatic Theme")), 0, 0)); + content.addChild(new Spacer(1)); + content.addChild(new Text(theme.fg("muted", "Choose themes for terminal light and dark appearance."), 0, 0)); + content.addChild(new Text(theme.fg("muted", "Light/dark detection requires terminal support."), 0, 0)); + content.addChild(new Spacer(1)); + + const items: SettingItem[] = [ + { + id: "light-theme", + label: "Light theme", + description: "Theme to use in automatic mode when the terminal is light", + currentValue: this.lightTheme, + submenu: (currentValue, done) => + this.createThemeSelect( + "Light Theme", + "Select the theme to use for light terminal appearance", + currentValue, + done, + (value) => { + this.lightTheme = value; + this.callbacks.onThemePreview?.(this.getThemeSetting()); + done(value); + }, + ), + }, + { + id: "dark-theme", + label: "Dark theme", + description: "Theme to use in automatic mode when the terminal is dark", + currentValue: this.darkTheme, + submenu: (currentValue, done) => + this.createThemeSelect( + "Dark Theme", + "Select the theme to use for dark terminal appearance", + currentValue, + done, + (value) => { + this.darkTheme = value; + this.callbacks.onThemePreview?.(this.getThemeSetting()); + done(value); + }, + ), + }, + { + id: "apply", + label: "Apply", + description: "Save and go back", + currentValue: "save and go back", + values: ["save and go back"], + }, + { + id: "single-mode", + label: "Change mode", + description: "Switch to one theme for light and dark", + currentValue: "switch to single theme", + values: ["switch to single theme"], + }, + ]; + + const settingsList = new SettingsList( + items, + Math.min(items.length, 10), + getSettingsListTheme(), + (id) => { + switch (id) { + case "single-mode": + this.mode = "single"; + this.singleTheme = this.getActiveAutomaticTheme(); + this.callbacks.onThemePreview?.(this.singleTheme); + this.showSingleMenu(); + break; + case "apply": + this.apply(this.getAutomaticThemeSetting()); + break; + } + }, + () => this.cancel(), + ); + content.addChild(settingsList); + this.setContent(content, settingsList); + } + + private createThemeSelect( + title: string, + description: string, + currentValue: string, + done: (selectedValue?: string) => void, + onSelect: (value: string) => void, + ): SelectSubmenu { + return new SelectSubmenu( + title, + description, + themeItems(this.availableThemes), + currentValue, + onSelect, + () => { + this.callbacks.onThemePreview?.(this.getThemeSetting()); + done(); + }, + (value) => this.callbacks.onThemePreview?.(value), + ); + } + + private getThemeSetting(): string { + return this.mode === "automatic" ? this.getAutomaticThemeSetting() : this.singleTheme; + } + + private getActiveAutomaticTheme(): string { + return this.terminalTheme === "light" ? this.lightTheme : this.darkTheme; + } + + private getAutomaticThemeSetting(): string { + return `${this.lightTheme}/${this.darkTheme}`; + } + + private apply(themeSetting: string): void { + this.onDone(themeSetting); + } + + private cancel(): void { + this.callbacks.onThemePreview?.(this.originalThemeSetting); + this.onDone(); + } +} + /** * Main settings selector component. */ @@ -353,28 +604,7 @@ export class SettingsSelectorComponent extends Container { description: "Color theme for the interface", currentValue: config.currentTheme, submenu: (currentValue, done) => - new SelectSubmenu( - "Theme", - "Select color theme", - config.availableThemes.map((t) => ({ - value: t, - label: t, - })), - currentValue, - (value) => { - callbacks.onThemeChange(value); - done(value); - }, - () => { - // Restore original theme on cancel - callbacks.onThemePreview?.(currentValue); - done(); - }, - (value) => { - // Preview theme on selection change - callbacks.onThemePreview?.(value); - }, - ), + new ThemeSubmenu(currentValue, config.terminalTheme, config.availableThemes, callbacks, done), }, ]; @@ -561,6 +791,9 @@ export class SettingsSelectorComponent extends Container { case "terminal-progress": callbacks.onShowTerminalProgressChange(newValue === "true"); break; + case "theme": + callbacks.onThemeChange(newValue); + break; } }, callbacks.onCancel, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index ad9d3d3a..0cde5ee4 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -128,22 +128,19 @@ import { UserMessageComponent } from "./components/user-message.ts"; import { UserMessageSelectorComponent } from "./components/user-message-selector.ts"; import { getModelSearchText } from "./model-search.ts"; import { - detectTerminalBackgroundTheme, getAvailableThemes, getAvailableThemesWithPaths, getEditorTheme, getMarkdownTheme, getThemeByName, - initTheme, onThemeChange, setRegisteredThemes, - setTheme, - setThemeInstance, stopThemeWatcher, Theme, type ThemeColor, theme, } from "./theme/theme.ts"; +import { InteractiveThemeController } from "./theme/theme-controller.ts"; /** Interface for components that can be expanded/collapsed */ interface Expandable { @@ -374,6 +371,7 @@ export class InteractiveMode { private options: InteractiveModeOptions; private autoTrustOnReloadCwd: string | undefined; + private themeController: InteractiveThemeController; // Convenience accessors private get session(): AgentSession { @@ -428,26 +426,12 @@ export class InteractiveMode { // Register themes from resource loader and initialize setRegisteredThemes(this.session.resourceLoader.getThemes().themes); - initTheme(this.settingsManager.getTheme(), true); - } - - private async detectThemeIfUnset(): Promise { - if (this.settingsManager.getTheme()) { - return; - } - - const detection = await detectTerminalBackgroundTheme({ ui: this.ui, timeoutMs: 100 }); - const result = setTheme(detection.theme, true); - if (!result.success) { - return; - } - - if (detection.confidence === "high") { - this.settingsManager.setTheme(detection.theme); - await this.settingsManager.flush(); - } - this.updateEditorBorderColor(); - this.ui.requestRender(); + this.themeController = new InteractiveThemeController( + this.ui, + this.settingsManager, + (message) => this.showError(message), + () => this.updateEditorBorderColor(), + ); } private getAutocompleteSourceTag(sourceInfo?: SourceInfo): string | undefined { @@ -672,7 +656,7 @@ export class InteractiveMode { this.ui.start(); this.isInitialized = true; - await this.detectThemeIfUnset(); + await this.themeController.applyFromSettings(); // Add header with keybindings from config (unless silenced) if (this.options.verbose || !this.settingsManager.getQuietStartup()) { @@ -2080,16 +2064,13 @@ export class InteractiveMode { getTheme: (name) => getThemeByName(name), setTheme: (themeOrName) => { if (themeOrName instanceof Theme) { - setThemeInstance(themeOrName); - this.ui.requestRender(); - return { success: true }; + return this.themeController.setThemeInstance(themeOrName); } - const result = setTheme(themeOrName, true); + const result = this.themeController.setThemeName(themeOrName); if (result.success) { if (this.settingsManager.getTheme() !== themeOrName) { this.settingsManager.setTheme(themeOrName); } - this.ui.requestRender(); } return result; }, @@ -3378,6 +3359,7 @@ export class InteractiveMode { // which the stdout/stderr error handler turns into emergencyTerminalExit; // the render loop is already idle, so this cannot hot-spin (see #4144). await this.runtimeHost.dispose(); + this.themeController.disableAutoSync(); await this.ui.terminal.drainInput(1000); this.stop(); process.exit(0); @@ -3388,6 +3370,7 @@ export class InteractiveMode { // the final frame while the process is exiting. // Drain any in-flight Kitty key release events before stopping. // This prevents escape sequences from leaking to the parent shell over slow SSH. + this.themeController.disableAutoSync(); await this.ui.terminal.drainInput(1000); this.stop(); @@ -3991,7 +3974,8 @@ export class InteractiveMode { httpIdleTimeoutMs: this.settingsManager.getHttpIdleTimeoutMs(), thinkingLevel: this.session.thinkingLevel, availableThinkingLevels: this.session.getAvailableThinkingLevels(), - currentTheme: this.settingsManager.getTheme() || "dark", + currentTheme: this.settingsManager.getThemeSetting() || "dark", + terminalTheme: this.themeController.getTerminalTheme(), availableThemes: getAvailableThemes(), hideThinkingBlock: this.hideThinkingBlock, collapseChangelog: this.settingsManager.getCollapseChangelog(), @@ -4058,21 +4042,11 @@ export class InteractiveMode { this.footer.invalidate(); this.updateEditorBorderColor(); }, - onThemeChange: (themeName) => { - const result = setTheme(themeName, true); - this.settingsManager.setTheme(themeName); - this.ui.invalidate(); - if (!result.success) { - this.showError(`Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`); - } - }, - onThemePreview: (themeName) => { - const result = setTheme(themeName, true); - if (result.success) { - this.ui.invalidate(); - this.ui.requestRender(); - } + onThemeChange: (themeSetting) => { + this.settingsManager.setTheme(themeSetting); + void this.themeController.applyFromSettings(); }, + onThemePreview: (themeName) => this.themeController.preview(themeName), onHideThinkingBlockChange: (hidden) => { this.hideThinkingBlock = hidden; this.settingsManager.setHideThinkingBlock(hidden); @@ -5109,11 +5083,7 @@ export class InteractiveMode { } setRegisteredThemes(this.session.resourceLoader.getThemes().themes); this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); - const themeName = this.settingsManager.getTheme(); - const themeResult = themeName ? setTheme(themeName, true) : { success: true }; - if (!themeResult.success) { - this.showError(`Failed to load theme "${themeName}": ${themeResult.error}\nFell back to dark theme.`); - } + await this.themeController.applyFromSettings(); const editorPaddingX = this.settingsManager.getEditorPaddingX(); const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible(); this.defaultEditor.setPaddingX(editorPaddingX); @@ -5754,6 +5724,7 @@ export class InteractiveMode { this.loadingAnimation.stop(); this.loadingAnimation = undefined; } + this.themeController.disableAutoSync(); this.clearExtensionTerminalInputListeners(); this.footer.dispose(); this.footerDataProvider.dispose(); diff --git a/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts b/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts new file mode 100644 index 00000000..9fe9e8cc --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts @@ -0,0 +1,135 @@ +import type { TUI } from "@earendil-works/pi-tui"; +import type { SettingsManager } from "../../../core/settings-manager.ts"; +import { + detectTerminalBackgroundFromEnv, + detectTerminalBackgroundTheme, + initTheme, + parseAutoThemeSetting, + resolveThemeSetting, + setTheme, + setThemeInstance, + type TerminalTheme, + type Theme, +} from "./theme.ts"; + +type ThemeResult = { success: boolean; error?: string }; + +export class InteractiveThemeController { + private readonly ui: TUI; + private readonly settingsManager: SettingsManager; + private readonly showError: (message: string) => void; + private readonly onChanged: () => void; + private terminalTheme: TerminalTheme = detectTerminalBackgroundFromEnv().theme; + private activeThemeName: string | undefined; + private autoSyncEnabled = false; + + constructor(ui: TUI, settingsManager: SettingsManager, showError: (message: string) => void, onChanged: () => void) { + this.ui = ui; + this.settingsManager = settingsManager; + this.showError = showError; + this.onChanged = onChanged; + this.activeThemeName = resolveThemeSetting(this.settingsManager.getThemeSetting(), this.terminalTheme); + initTheme(this.activeThemeName, true); + this.ui.onTerminalColorSchemeChange((terminalTheme) => this.applyTerminalTheme(terminalTheme)); + } + + async applyFromSettings(): Promise { + const themeSetting = this.settingsManager.getThemeSetting(); + const autoTheme = parseAutoThemeSetting(themeSetting); + if (autoTheme) { + this.terminalTheme = await this.detectTerminalThemeForAuto(); + this.setAutoSync(true); + this.applyThemeName(this.terminalTheme === "light" ? autoTheme.lightTheme : autoTheme.darkTheme, true); + return; + } + + this.setAutoSync(false); + if (themeSetting !== undefined) { + this.applyThemeName(themeSetting, true); + return; + } + + const detection = await detectTerminalBackgroundTheme({ ui: this.ui, timeoutMs: 100 }); + this.terminalTheme = detection.theme; + if (!this.applyThemeName(detection.theme).success) return; + if (detection.confidence === "high") { + this.settingsManager.setTheme(detection.theme); + await this.settingsManager.flush(); + } + } + + setThemeName(themeName: string, showError = false): ThemeResult { + this.setAutoSync(false); + return this.applyThemeName(themeName, showError); + } + + setThemeInstance(themeInstance: Theme): ThemeResult { + this.setAutoSync(false); + setThemeInstance(themeInstance); + this.activeThemeName = ""; + this.notifyChanged(); + return { success: true }; + } + + preview(themeSettingOrName: string): void { + const themeName = resolveThemeSetting(themeSettingOrName, this.terminalTheme) ?? this.activeThemeName; + if (!themeName) return; + if (setTheme(themeName, true).success) { + this.ui.invalidate(); + this.ui.requestRender(); + } + } + + disableAutoSync(): void { + this.setAutoSync(false); + } + + getTerminalTheme(): TerminalTheme { + return this.terminalTheme; + } + + private applyThemeName(themeName: string, showError = false): ThemeResult { + const result = setTheme(themeName, true); + this.activeThemeName = result.success ? themeName : "dark"; + this.notifyChanged(); + if (!result.success && showError) { + this.showError(`Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`); + } + return result; + } + + private notifyChanged(): void { + this.ui.invalidate(); + this.onChanged(); + } + + private setAutoSync(enabled: boolean): void { + if (this.autoSyncEnabled === enabled) return; + this.autoSyncEnabled = enabled; + this.ui.setTerminalColorSchemeNotifications(enabled); + } + + private async detectTerminalThemeForAuto(): Promise { + try { + const colorScheme = await this.ui.queryTerminalColorScheme({ timeoutMs: 100 }); + if (colorScheme) return colorScheme; + } catch { + // Fall back to OSC 11 / COLORFGBG detection when color-scheme DSR is unsupported. + } + return (await detectTerminalBackgroundTheme({ ui: this.ui, timeoutMs: 100 })).theme; + } + + private applyTerminalTheme(terminalTheme: TerminalTheme): void { + if (!this.autoSyncEnabled) return; + this.terminalTheme = terminalTheme; + const autoTheme = parseAutoThemeSetting(this.settingsManager.getThemeSetting()); + if (!autoTheme) { + this.setAutoSync(false); + return; + } + const themeName = terminalTheme === "light" ? autoTheme.lightTheme : autoTheme.darkTheme; + if (themeName !== this.activeThemeName) { + this.applyThemeName(themeName); + } + } +} diff --git a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json b/packages/coding-agent/src/modes/interactive/theme/theme-schema.json index 7bc495da..9d94a12a 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json +++ b/packages/coding-agent/src/modes/interactive/theme/theme-schema.json @@ -11,7 +11,8 @@ }, "name": { "type": "string", - "description": "Theme name" + "pattern": "^[^/]+$", + "description": "Theme name. Must not contain '/' because it is reserved for automatic light/dark theme settings." }, "vars": { "type": "object", diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index 778f8028..58e5aac3 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -503,6 +503,14 @@ function getCustomThemeInfos(): ThemeInfo[] { return result; } +function assertThemeNameIsValid(name: string): void { + if (name.includes("/")) { + throw new Error( + `Invalid theme name "${name}": theme names cannot contain "/" because it is reserved for automatic light/dark theme settings.`, + ); + } +} + function parseThemeJson(label: string, json: unknown): ThemeJson { if (!validateThemeJson.Check(json)) { const errors = Array.from(validateThemeJson.Errors(json)); @@ -539,7 +547,9 @@ function parseThemeJson(label: string, json: unknown): ThemeJson { throw new Error(errorMessage); } - return json as ThemeJson; + const themeJson = json as ThemeJson; + assertThemeNameIsValid(themeJson.name); + return themeJson; } function parseThemeJsonContent(label: string, content: string): ThemeJson { @@ -625,6 +635,36 @@ export function getThemeByName(name: string): Theme | undefined { export type TerminalTheme = "dark" | "light"; +export function parseAutoThemeSetting( + themeSetting: string | undefined, +): { lightTheme: string; darkTheme: string } | undefined { + if (!themeSetting) return undefined; + const slashIndex = themeSetting.indexOf("/"); + if (slashIndex === -1 || themeSetting.indexOf("/", slashIndex + 1) !== -1) { + return undefined; + } + + const lightTheme = themeSetting.slice(0, slashIndex).trim(); + const darkTheme = themeSetting.slice(slashIndex + 1).trim(); + if (!lightTheme || !darkTheme) { + return undefined; + } + return { lightTheme, darkTheme }; +} + +export function resolveThemeSetting( + themeSetting: string | undefined, + terminalTheme: TerminalTheme, +): string | undefined { + const autoTheme = parseAutoThemeSetting(themeSetting); + if (autoTheme) { + return terminalTheme === "light" ? autoTheme.lightTheme : autoTheme.darkTheme; + } + if (themeSetting?.includes("/")) return undefined; + if (typeof themeSetting === "string") return themeSetting; + return undefined; +} + export interface TerminalThemeDetection { theme: TerminalTheme; source: "terminal background" | "COLORFGBG" | "fallback"; @@ -752,6 +792,7 @@ export function setRegisteredThemes(themes: Theme[]): void { registeredThemes.clear(); for (const theme of themes) { if (theme.name) { + assertThemeNameIsValid(theme.name); registeredThemes.set(theme.name, theme); } } diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 8f1018b2..6ba7db91 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -151,6 +151,13 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => { const fakeThis: any = { session: { settingsManager }, settingsManager, + themeController: { + setThemeInstance: vi.fn(() => ({ success: true })), + setThemeName: vi.fn(() => { + fakeThis.ui.requestRender(); + return { success: true }; + }), + }, ui: { requestRender: vi.fn() }, }; @@ -158,6 +165,7 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => { const result = uiContext.setTheme("light"); expect(result.success).toBe(true); + expect(fakeThis.themeController.setThemeName).toHaveBeenCalledWith("light"); expect(settingsManager.setTheme).toHaveBeenCalledWith("light"); expect(currentTheme).toBe("light"); expect(fakeThis.ui.requestRender).toHaveBeenCalledTimes(1); @@ -173,6 +181,10 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => { const fakeThis: any = { session: { settingsManager }, settingsManager, + themeController: { + setThemeInstance: vi.fn(() => ({ success: true })), + setThemeName: vi.fn(() => ({ success: false, error: "Theme not found" })), + }, ui: { requestRender: vi.fn() }, }; @@ -180,6 +192,7 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => { const result = uiContext.setTheme("__missing_theme__"); expect(result.success).toBe(false); + expect(fakeThis.themeController.setThemeName).toHaveBeenCalledWith("__missing_theme__"); expect(settingsManager.setTheme).not.toHaveBeenCalled(); expect(fakeThis.ui.requestRender).not.toHaveBeenCalled(); }); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 279bece1..d86c3f91 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -198,6 +198,24 @@ describe("SettingsManager", () => { }); }); + describe("theme setting", () => { + it("stores slash-separated automatic theme settings separately from fixed theme names", async () => { + const settingsPath = join(agentDir, "settings.json"); + writeFileSync(settingsPath, JSON.stringify({ theme: "light/dark" })); + + const manager = SettingsManager.create(projectDir, agentDir); + + expect(manager.getTheme()).toBeUndefined(); + expect(manager.getThemeSetting()).toBe("light/dark"); + + manager.setTheme("solarized-light/tokyo-night"); + await manager.flush(); + + const savedSettings = JSON.parse(readFileSync(settingsPath, "utf-8")); + expect(savedSettings.theme).toBe("solarized-light/tokyo-night"); + }); + }); + describe("error tracking", () => { it("should collect and clear load errors via drainErrors", () => { const globalSettingsPath = join(agentDir, "settings.json"); diff --git a/packages/coding-agent/test/suite/regressions/5080-signal-shutdown-extension-cleanup.test.ts b/packages/coding-agent/test/suite/regressions/5080-signal-shutdown-extension-cleanup.test.ts index 33a960e2..9ccf77cd 100644 --- a/packages/coding-agent/test/suite/regressions/5080-signal-shutdown-extension-cleanup.test.ts +++ b/packages/coding-agent/test/suite/regressions/5080-signal-shutdown-extension-cleanup.test.ts @@ -21,6 +21,7 @@ type ShutdownThis = { unregisterSignalHandlers: () => void; runtimeHost: { dispose: () => Promise }; ui: { terminal: { drainInput: (ms: number) => Promise } }; + themeController: { disableAutoSync: () => void }; stop: () => void; sessionManager: SessionManager; }; @@ -81,6 +82,7 @@ function createContext(order: string[], sessionManager = createSessionManager()) }), }, }, + themeController: { disableAutoSync: vi.fn() }, stop: vi.fn(() => { order.push("stop"); }), diff --git a/packages/coding-agent/test/suite/regressions/5724-sigterm-signal-exit.test.ts b/packages/coding-agent/test/suite/regressions/5724-sigterm-signal-exit.test.ts index d170f1cf..75e82fb7 100644 --- a/packages/coding-agent/test/suite/regressions/5724-sigterm-signal-exit.test.ts +++ b/packages/coding-agent/test/suite/regressions/5724-sigterm-signal-exit.test.ts @@ -13,6 +13,7 @@ type ShutdownThis = { unregisterSignalHandlers: () => void; runtimeHost: { dispose: () => Promise }; ui: { terminal: { drainInput: (ms: number) => Promise } }; + themeController: { disableAutoSync: () => void }; stop: () => void; }; @@ -73,6 +74,7 @@ describe("InteractiveMode SIGTERM shutdown with signal-exit (#5724)", () => { }), }, }, + themeController: { disableAutoSync: vi.fn() }, stop: vi.fn(() => { order.push("stop"); }), diff --git a/packages/coding-agent/test/theme-detection.test.ts b/packages/coding-agent/test/theme-detection.test.ts index 4ad0cf56..6bdc597b 100644 --- a/packages/coding-agent/test/theme-detection.test.ts +++ b/packages/coding-agent/test/theme-detection.test.ts @@ -5,6 +5,8 @@ import { detectTerminalBackgroundTheme, getThemeByName, getThemeForRgbColor, + parseAutoThemeSetting, + resolveThemeSetting, } from "../src/modes/interactive/theme/theme.ts"; afterEach(() => { @@ -119,3 +121,13 @@ describe("theme detection from RGB", () => { expect(getThemeForRgbColor({ r: 250, g: 250, b: 250 })).toBe("light"); }); }); + +describe("theme setting helpers", () => { + it("parses and resolves automatic theme settings", () => { + expect(parseAutoThemeSetting("light/dark")).toEqual({ lightTheme: "light", darkTheme: "dark" }); + expect(resolveThemeSetting("dark", "light")).toBe("dark"); + expect(resolveThemeSetting("light/dark", "light")).toBe("light"); + expect(resolveThemeSetting("light/dark", "dark")).toBe("dark"); + expect(resolveThemeSetting("light/dark/extra", "dark")).toBeUndefined(); + }); +}); diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index e22bf08a..4e76b107 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -62,7 +62,12 @@ export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from " // Terminal interface and implementations export { ProcessTerminal, type Terminal } from "./terminal.ts"; // Terminal colors -export { parseOsc11BackgroundColor, type RgbColor } from "./terminal-colors.ts"; +export { + parseOsc11BackgroundColor, + parseTerminalColorSchemeReport, + type RgbColor, + type TerminalColorScheme, +} from "./terminal-colors.ts"; // Terminal image support export { allocateImageId, diff --git a/packages/tui/src/terminal-colors.ts b/packages/tui/src/terminal-colors.ts index 1c383a51..cff6dc8e 100644 --- a/packages/tui/src/terminal-colors.ts +++ b/packages/tui/src/terminal-colors.ts @@ -4,6 +4,8 @@ export interface RgbColor { b: number; } +export type TerminalColorScheme = "dark" | "light"; + function hexToRgb(hex: string): RgbColor { const normalized = hex.startsWith("#") ? hex.slice(1) : hex; const r = parseInt(normalized.slice(0, 2), 16); @@ -24,6 +26,7 @@ function parseOscHexChannel(channel: string): number | undefined { } const OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN = /^\x1b\]11;([^\x07\x1b]*)(?:\x07|\x1b\\)$/i; +const COLOR_SCHEME_REPORT_PATTERN = /^\x1b\[\?997;(1|2)n$/; export function isOsc11BackgroundColorResponse(data: string): boolean { return OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN.test(data); @@ -60,3 +63,11 @@ export function parseOsc11BackgroundColor(data: string): RgbColor | undefined { const b = parseOscHexChannel(blue); return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined; } + +export function parseTerminalColorSchemeReport(data: string): TerminalColorScheme | undefined { + const match = data.match(COLOR_SCHEME_REPORT_PATTERN); + if (!match) { + return undefined; + } + return match[1] === "2" ? "light" : "dark"; +} diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 1fff6545..a7054888 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -8,7 +8,13 @@ import * as path from "node:path"; import { performance } from "node:perf_hooks"; import { isKeyRelease, matchesKey } from "./keys.ts"; import type { Terminal } from "./terminal.ts"; -import { isOsc11BackgroundColorResponse, parseOsc11BackgroundColor, type RgbColor } from "./terminal-colors.ts"; +import { + isOsc11BackgroundColorResponse, + parseOsc11BackgroundColor, + parseTerminalColorSchemeReport, + type RgbColor, + type TerminalColorScheme, +} from "./terminal-colors.ts"; import { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.ts"; import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.ts"; @@ -311,6 +317,8 @@ export class TUI extends Container { private stopped = false; private pendingOsc11BackgroundReplies = 0; private pendingOsc11BackgroundQueries: PendingOsc11BackgroundQuery[] = []; + private terminalColorSchemeListeners = new Set<(scheme: TerminalColorScheme) => void>(); + private terminalColorSchemeNotificationsEnabled = false; // Overlay stack for modal components rendered on top of base content private focusOrderCounter = 0; @@ -631,6 +639,9 @@ export class TUI extends Container { () => this.requestRender(), ); this.terminal.hideCursor(); + if (this.terminalColorSchemeNotificationsEnabled) { + this.terminal.write("\x1b[?2031h"); + } this.queryCellSize(); this.requestRender(); } @@ -646,6 +657,23 @@ export class TUI extends Container { this.inputListeners.delete(listener); } + onTerminalColorSchemeChange(listener: (scheme: TerminalColorScheme) => void): () => void { + this.terminalColorSchemeListeners.add(listener); + return () => { + this.terminalColorSchemeListeners.delete(listener); + }; + } + + setTerminalColorSchemeNotifications(enabled: boolean): void { + if (this.terminalColorSchemeNotificationsEnabled === enabled) { + return; + } + this.terminalColorSchemeNotificationsEnabled = enabled; + if (!this.stopped) { + this.terminal.write(enabled ? "\x1b[?2031h" : "\x1b[?2031l"); + } + } + private queryCellSize(): void { // Only query if terminal supports images (cell size is only used for image rendering) if (!getCapabilities().images) { @@ -662,6 +690,9 @@ export class TUI extends Container { clearTimeout(this.renderTimer); this.renderTimer = undefined; } + if (this.terminalColorSchemeNotificationsEnabled) { + this.terminal.write("\x1b[?2031l"); + } // Move cursor to the end of the content to prevent overwriting/artifacts on exit if (this.previousLines.length > 0) { const targetRow = this.previousLines.length; // Line after the last content @@ -731,6 +762,9 @@ export class TUI extends Container { if (this.consumeOsc11BackgroundResponse(data)) { return; } + if (this.consumeTerminalColorSchemeReport(data)) { + return; + } if (this.inputListeners.size > 0) { let current = data; @@ -824,6 +858,18 @@ export class TUI extends Container { return true; } + private consumeTerminalColorSchemeReport(data: string): boolean { + const scheme = parseTerminalColorSchemeReport(data); + if (!scheme) { + return false; + } + + for (const listener of this.terminalColorSchemeListeners) { + listener(scheme); + } + return true; + } + private consumeCellSizeResponse(data: string): boolean { // Response format: ESC [ 6 ; height ; width t const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/); @@ -1638,4 +1684,31 @@ export class TUI extends Container { this.terminal.write("\x1b]11;?\x07"); }); } + + /** + * Query the terminal's color-scheme preference with DSR (`CSI ? 996 n`). + * Terminals that support the color palette notification protocol reply with + * `CSI ? 997 ; 1 n` for dark or `CSI ? 997 ; 2 n` for light. + */ + queryTerminalColorScheme({ timeoutMs }: { timeoutMs: number }): Promise { + return new Promise((resolve) => { + let settled = false; + let timer: NodeJS.Timeout | undefined; + let unsubscribe: () => void = () => {}; + const settle = (scheme: TerminalColorScheme | undefined) => { + if (settled) return; + settled = true; + if (timer) { + clearTimeout(timer); + timer = undefined; + } + unsubscribe(); + resolve(scheme); + }; + + unsubscribe = this.onTerminalColorSchemeChange(settle); + timer = setTimeout(() => settle(undefined), timeoutMs); + this.terminal.write("\x1b[?996n"); + }); + } } diff --git a/packages/tui/test/terminal-colors.test.ts b/packages/tui/test/terminal-colors.test.ts index 9ac2a4ee..d777e061 100644 --- a/packages/tui/test/terminal-colors.test.ts +++ b/packages/tui/test/terminal-colors.test.ts @@ -1,6 +1,12 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { type Component, parseOsc11BackgroundColor, type Terminal, TUI } from "../src/index.ts"; +import { + type Component, + parseOsc11BackgroundColor, + parseTerminalColorSchemeReport, + type Terminal, + TUI, +} from "../src/index.ts"; class TestTerminal implements Terminal { private inputHandler?: (data: string) => void; @@ -104,6 +110,16 @@ describe("parseOsc11BackgroundColor", () => { }); }); +describe("parseTerminalColorSchemeReport", () => { + it("parses color scheme reports", () => { + assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;1n"), "dark"); + assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;2n"), "light"); + assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;3n"), undefined); + assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?996n"), undefined); + assert.strictEqual(parseTerminalColorSchemeReport("x\x1b[?997;1n"), undefined); + }); +}); + describe("TUI.queryTerminalBackgroundColor", () => { it("writes OSC 11 query and resolves with the parsed RGB reply", async () => { const terminal = new TestTerminal(); From 2b46f388635f61a3fab2eed241998a61d67e7753 Mon Sep 17 00:00:00 2001 From: Alexey Zaytsev Date: Thu, 18 Jun 2026 10:14:39 -0500 Subject: [PATCH 313/352] feat(coding-agent): Expose edit-diff for extensions (#5756) --- packages/coding-agent/src/core/tools/edit-diff.ts | 3 +-- packages/coding-agent/src/index.ts | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/core/tools/edit-diff.ts b/packages/coding-agent/src/core/tools/edit-diff.ts index f280bf19..cc94c202 100644 --- a/packages/coding-agent/src/core/tools/edit-diff.ts +++ b/packages/coding-agent/src/core/tools/edit-diff.ts @@ -1,6 +1,5 @@ /** - * Shared diff computation utilities for the edit tool. - * Used by both edit.ts (for execution) and tool-execution.ts (for preview rendering). + * Shared diff computation utilities for the edit and similar tools. */ import * as Diff from "diff"; diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 70416af1..5830ecda 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -246,6 +246,7 @@ export { type SkillFrontmatter, } from "./core/skills.ts"; export { createSyntheticSourceInfo } from "./core/source-info.ts"; +export { type EditDiffResult, generateDiffString, generateUnifiedPatch } from "./core/tools/edit-diff.ts"; // Tools export { type BashOperations, From aae62dfa87c7935f124c4349fb34216eee84502a Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 18 Jun 2026 18:09:03 +0200 Subject: [PATCH 314/352] feat(coding-agent): make bare update self-only --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/README.md | 8 +-- packages/coding-agent/docs/packages.md | 9 ++-- packages/coding-agent/docs/usage.md | 5 +- packages/coding-agent/src/cli/args.ts | 2 +- .../src/modes/interactive/interactive-mode.ts | 2 +- .../coding-agent/src/package-manager-cli.ts | 51 +++++++++++++++---- 7 files changed, 58 insertions(+), 20 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a2fd43e8..e29e19d2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Changed +- Changed bare `pi update` to update only pi, added `pi update --all` for updating pi and extensions together, and clarified extension update prompts. - Updated extension docs, examples, runtime help, trust prompts, and config labels to use the configured project config directory instead of hardcoded `.pi` paths. ### Fixed diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 41a7bbc2..ce7dba2a 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -419,7 +419,8 @@ pi install ssh://git@github.com/user/repo@v1 # tag or commit pi remove npm:@foo/pi-tools pi uninstall npm:@foo/pi-tools # alias for remove pi list -pi update # update pi and packages (skips pinned packages) +pi update # update pi only +pi update --all # update pi and packages pi update --extensions # update packages only pi update --self # update pi only pi update --self --force # reinstall pi even if current @@ -427,7 +428,7 @@ pi update npm:@foo/pi-tools # update one package pi config # enable/disable extensions, skills, prompts, themes ``` -Packages install to `~/.pi/agent/git/` (git) or `~/.pi/agent/npm/` (npm). Use `-l` for project-local installs (`.pi/git/`, `.pi/npm/`). Git `@ref` values are pinned tags or commits; pinned packages are skipped by `pi update`, so use `pi install git:host/user/repo@new-ref` to move an existing package to a new ref. Git packages install dependencies with `npm install --omit=dev` by default, so runtime deps must be listed under `dependencies`; when `npmCommand` is configured, git packages use plain `install` for compatibility with wrappers. If you use a Node version manager and want package installs to reuse a stable npm context, set `npmCommand` in `settings.json`, for example `["mise", "exec", "node@20", "--", "npm"]`. +Packages install to `~/.pi/agent/git/` (git) or `~/.pi/agent/npm/` (npm). Use `-l` for project-local installs (`.pi/git/`, `.pi/npm/`). Git `@ref` values are pinned tags or commits; pinned packages are skipped by `pi update --extensions` and `pi update --all`, so use `pi install git:host/user/repo@new-ref` to move an existing package to a new ref. Git packages install dependencies with `npm install --omit=dev` by default, so runtime deps must be listed under `dependencies`; when `npmCommand` is configured, git packages use plain `install` for compatibility with wrappers. If you use a Node version manager and want package installs to reuse a stable npm context, set `npmCommand` in `settings.json`, for example `["mise", "exec", "node@20", "--", "npm"]`. Create a package by adding a `pi` key to `package.json`: @@ -518,7 +519,8 @@ pi [options] [@files...] [messages...] pi install [-l] # Install package, -l for project-local pi remove [-l] # Remove package pi uninstall [-l] # Alias for remove -pi update [source|self|pi] # Update pi and packages (skips pinned packages) +pi update [source|self|pi] # Update pi only, or one package source +pi update --all # Update pi and packages pi update --extensions # Update packages only pi update --self # Update pi only pi update --self --force # Reinstall pi even if current diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index 7009b773..73f2dccb 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -28,7 +28,8 @@ pi install ./relative/path/to/package pi remove npm:@foo/bar pi list # show installed packages from settings -pi update # update pi, update packages, and reconcile pinned git refs +pi update # update pi only +pi update --all # update pi, update packages, and reconcile pinned git refs pi update --extensions # update packages and reconcile pinned git refs only pi update --self # update pi only pi update --self --force # reinstall pi even if current @@ -36,7 +37,7 @@ pi update npm:@foo/bar # update one package pi update --extension npm:@foo/bar ``` -These commands manage pi packages, not 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. 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. @@ -58,7 +59,7 @@ npm:@scope/pkg@1.2.3 npm:pkg ``` -- Versioned specs are pinned and skipped by package updates (`pi update`, `pi update --extensions`). +- Versioned specs are pinned and skipped by package updates (`pi update --extensions`, `pi update --all`). - User installs go under `~/.pi/agent/npm/`. - Project installs go under `.pi/npm/`. - Set `npmCommand` in `settings.json` to pin npm package lookup and install operations to a specific wrapper command such as `mise` or `asdf`. @@ -85,7 +86,7 @@ ssh://git@github.com/user/repo@v1 - HTTPS and SSH URLs are both supported. - SSH URLs use your configured SSH keys automatically (respects `~/.ssh/config`). - For non-interactive runs (for example CI), you can set `GIT_TERMINAL_PROMPT=0` to disable credential prompts and set `GIT_SSH_COMMAND` (for example `ssh -o BatchMode=yes -o ConnectTimeout=5`) to fail fast. -- Refs are pinned tags or commits. `pi update` and `pi update --extensions` do not move them to newer refs, but they do reconcile an existing clone to the configured ref. +- Refs are pinned tags or commits. `pi update --extensions` and `pi update --all` do not move them to newer refs, but they do reconcile an existing clone to the configured ref. - Use `pi install git:host/user/repo@new-ref` to update settings and move an existing package to a new pinned ref. - Cloned to `~/.pi/agent/git//` (global) or `.pi/git//` (project). - When reconciliation changes the checkout, pi resets and cleans the clone, then runs `npm install` if `package.json` exists. diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 4f8f5954..5ff0cf73 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -145,7 +145,8 @@ pi [options] [@files...] [messages...] pi install [-l] # Install package, -l for project-local pi remove [-l] # Remove package pi uninstall [-l] # Alias for remove -pi update [source|self|pi] # Update pi and packages; reconcile pinned git refs +pi update [source|self|pi] # Update pi only, or one package source +pi update --all # Update pi and packages; reconcile pinned git refs pi update --extensions # Update packages only; reconcile pinned git refs pi update --self # Update pi only pi update --extension # Update one package @@ -153,7 +154,7 @@ pi list # List installed packages pi config # Enable/disable package resources ``` -These commands manage pi packages, not the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). `pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command. `pi update` never prompts for project trust. +These commands manage pi packages and `pi update` can update the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). `pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command. `pi update` never prompts for project trust. See [Pi Packages](packages.md) for package sources and security notes. diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 839c60e8..5d3c4af8 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -229,7 +229,7 @@ ${chalk.bold("Commands:")} ${APP_NAME} install [-l] Install extension source and add to settings ${APP_NAME} remove [-l] Remove extension source from settings ${APP_NAME} uninstall [-l] Alias for remove - ${APP_NAME} update [source|self|pi] Update pi and installed extensions + ${APP_NAME} update [source|self|pi] Update pi (use --all for pi and extensions) ${APP_NAME} list List installed extensions from settings ${APP_NAME} config Open TUI to enable/disable package resources ${APP_NAME} --help Show help for install/remove/uninstall/update/list diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 0cde5ee4..00054eb2 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -3740,7 +3740,7 @@ export class InteractiveMode { } showPackageUpdateNotification(packages: string[]): void { - const action = theme.fg("accent", `${APP_NAME} update`); + const action = theme.fg("accent", `${APP_NAME} update --extensions`); const updateInstruction = theme.fg("muted", "Package updates are available. Run ") + action; const packageLines = packages.map((pkg) => `- ${pkg}`).join("\n"); diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index e00de1b4..d47df930 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -52,6 +52,7 @@ interface PackageCommandOptions { command: PackageCommand; source?: string; updateTarget?: UpdateTarget; + showExtensionsSkippedNote: boolean; local: boolean; force: boolean; projectTrustOverride?: boolean; @@ -79,7 +80,7 @@ function getPackageCommandUsage(command: PackageCommand): string { case "remove": return `${APP_NAME} remove [-l] [--approve|--no-approve]`; case "update": - return `${APP_NAME} update [source|self|pi] [--self] [--extensions] [--extension ] [--approve|--no-approve] [--force]`; + return `${APP_NAME} update [source|self|pi] [--self|--extensions|--all] [--extension ] [--approve|--no-approve] [--force]`; case "list": return `${APP_NAME} list [--approve|--no-approve]`; } @@ -133,15 +134,17 @@ Examples: Update pi and installed packages. Options: - --self Update pi only + --self Update pi only (default when no target is given) --extensions Update installed packages only + --all Update pi and installed packages --extension Update one package only -a, --approve Trust project-local files for this command -na, --no-approve Ignore project-local files for this command --force Reinstall pi even if the current version is latest Short forms: - ${APP_NAME} update Update pi and all extensions + ${APP_NAME} update Update pi only + ${APP_NAME} update --all Update pi and all extensions ${APP_NAME} update Update one package ${APP_NAME} update pi Update pi only (self works as alias to pi) `); @@ -184,6 +187,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined let source: string | undefined; let selfFlag = false; let extensionsFlag = false; + let allFlag = false; let extensionFlagSource: string | undefined; for (let index = 0; index < rest.length; index++) { @@ -220,6 +224,15 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined continue; } + if (arg === "--all") { + if (command === "update") { + allFlag = true; + } else { + invalidOption = invalidOption ?? arg; + } + continue; + } + if (arg === "--approve" || arg === "-a") { projectTrustOverride = true; continue; @@ -271,10 +284,20 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined } let updateTarget: UpdateTarget | undefined; + let showExtensionsSkippedNote = false; if (command === "update") { + if (allFlag && (selfFlag || extensionsFlag || extensionFlagSource)) { + conflictingOptions = + conflictingOptions ?? "--all cannot be combined with --self, --extensions, or --extension"; + } + if (allFlag && source) { + conflictingOptions = conflictingOptions ?? "--all cannot be combined with a positional source"; + } + if (extensionFlagSource) { - if (selfFlag || extensionsFlag) { - conflictingOptions = conflictingOptions ?? "--extension cannot be combined with --self or --extensions"; + if (selfFlag || extensionsFlag || allFlag) { + conflictingOptions = + conflictingOptions ?? "--extension cannot be combined with --self, --extensions, or --all"; } if (source) { conflictingOptions = conflictingOptions ?? "--extension cannot be combined with a positional source"; @@ -285,12 +308,15 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined if (sourceIsSelf) { updateTarget = extensionsFlag ? { type: "all" } : { type: "self" }; } else { - if (extensionsFlag || selfFlag) { + if (extensionsFlag || selfFlag || allFlag) { conflictingOptions = - conflictingOptions ?? "positional update targets cannot be combined with --self or --extensions"; + conflictingOptions ?? + "positional update targets cannot be combined with --self, --extensions, or --all"; } updateTarget = { type: "extensions", source }; } + } else if (allFlag) { + updateTarget = { type: "all" }; } else if (selfFlag && extensionsFlag) { updateTarget = { type: "all" }; } else if (selfFlag) { @@ -298,7 +324,8 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined } else if (extensionsFlag) { updateTarget = { type: "extensions" }; } else { - updateTarget = { type: "all" }; + updateTarget = { type: "self" }; + showExtensionsSkippedNote = true; } } @@ -306,6 +333,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined command, source, updateTarget, + showExtensionsSkippedNote, local, force, projectTrustOverride, @@ -660,7 +688,12 @@ export async function handlePackageCommand( } case "update": { - const target = options.updateTarget ?? { type: "all" }; + const target = options.updateTarget ?? { type: "self" }; + if (options.showExtensionsSkippedNote) { + console.log( + chalk.dim(`Extensions are skipped. Run ${APP_NAME} update --extensions to update extensions.`), + ); + } if (updateTargetIncludesExtensions(target)) { const updateSource = target.type === "extensions" ? target.source : undefined; await packageManager.update(updateSource); From 717494223d41d663224f1b177c8427c05ad62448 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 18 Jun 2026 18:16:48 +0200 Subject: [PATCH 315/352] docs: audit unreleased changelogs --- packages/coding-agent/CHANGELOG.md | 13 ++++++++++++- packages/tui/CHANGELOG.md | 2 ++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e29e19d2..3f953510 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,13 +2,24 @@ ## [Unreleased] +### New Features + +- **Automatic theme mode** - `/settings` can choose separate light and dark themes and follow terminal color-scheme changes. See [Selecting a Theme](docs/themes.md#selecting-a-theme). +- **Self-only updates by default** - `pi update` now updates pi only, with `pi update --all` for updating pi and packages together. See [Install and Manage](docs/packages.md#install-and-manage). +- **Extension API helpers** - extensions can use `CONFIG_DIR_NAME` for project config paths and import edit diff helpers for edit-style diffs. See [`ctx.cwd`](docs/extensions.md#ctxcwd) and [SDK Exports](docs/sdk.md#exports). +- **Warp inline images** - Warp terminals now get inline image rendering through Kitty graphics detection. See [Image](docs/tui.md#image). + ### Added -- Exported `CONFIG_DIR_NAME` from the coding-agent public API so extensions can resolve project config paths without hardcoding `.pi` ([#5869](https://github.com/earendil-works/pi/pulls/5869) by [@xl0](https://github.com/xl0)) +- Added automatic theme mode so `/settings` can use separate light and dark themes and follow terminal color-scheme changes ([#5874](https://github.com/earendil-works/pi/pull/5874)). +- Added inherited Warp terminal image capability detection so inline images render through Warp's Kitty graphics support ([#5841](https://github.com/earendil-works/pi/pull/5841) by [@dodiego](https://github.com/dodiego)). +- Exported `CONFIG_DIR_NAME` from the coding-agent public API so extensions can resolve project config paths without hardcoding `.pi` ([#5869](https://github.com/earendil-works/pi/pull/5869) by [@xl0](https://github.com/xl0)). +- Exported edit diff helpers (`generateDiffString`, `generateUnifiedPatch`, and `EditDiffResult`) from the public API for extensions that need edit-style diffs ([#5756](https://github.com/earendil-works/pi/pull/5756) by [@xl0](https://github.com/xl0)). ### Changed - Changed bare `pi update` to update only pi, added `pi update --all` for updating pi and extensions together, and clarified extension update prompts. +- Reserved `/` in theme names for automatic light/dark theme settings. - Updated extension docs, examples, runtime help, trust prompts, and config labels to use the configured project config directory instead of hardcoded `.pi` paths. ### Fixed diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 9b90a5b1..ca28b0a3 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- Added terminal color-scheme query and notification support for light/dark appearance detection (`TUI.queryTerminalColorScheme()`, `TUI.onTerminalColorSchemeChange()`, and `TUI.setTerminalColorSchemeNotifications()`) ([#5874](https://github.com/earendil-works/pi/pull/5874)). +- Added Warp terminal detection for Kitty graphics inline image support ([#5841](https://github.com/earendil-works/pi/pull/5841) by [@dodiego](https://github.com/dodiego)). - Exported `sliceByColumn` for ANSI-aware horizontal column slicing. ## [0.79.6] - 2026-06-16 From c4ab61dcbf719e75018f0ae90eb900b78a881603 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 18 Jun 2026 18:22:07 +0200 Subject: [PATCH 316/352] Release v0.79.7 --- package-lock.json | 26 ++++---- packages/agent/CHANGELOG.md | 2 +- packages/agent/package.json | 4 +- packages/ai/CHANGELOG.md | 2 +- packages/ai/package.json | 2 +- packages/ai/src/image-models.generated.ts | 30 +++++++++ packages/ai/src/models.generated.ts | 65 ++++++++++++------- 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 +- packages/coding-agent/npm-shrinkwrap.json | 24 +++---- packages/coding-agent/package.json | 8 +-- packages/tui/CHANGELOG.md | 2 +- packages/tui/package.json | 2 +- 21 files changed, 120 insertions(+), 75 deletions(-) diff --git a/package-lock.json b/package-lock.json index d4811db6..821c9d30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6176,10 +6176,10 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.79.6", + "version": "0.79.7", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.79.6", + "@earendil-works/pi-ai": "^0.79.7", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -6213,7 +6213,7 @@ }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.79.6", + "version": "0.79.7", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -6258,12 +6258,12 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "0.79.6", + "version": "0.79.7", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.6", - "@earendil-works/pi-ai": "^0.79.6", - "@earendil-works/pi-tui": "^0.79.6", + "@earendil-works/pi-agent-core": "^0.79.7", + "@earendil-works/pi-ai": "^0.79.7", + "@earendil-works/pi-tui": "^0.79.7", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -6304,32 +6304,32 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.79.6", + "version": "0.79.7", "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.79.6" + "version": "0.79.7" }, "packages/coding-agent/examples/extensions/gondolin": { "name": "pi-extension-gondolin", - "version": "0.79.6", + "version": "0.79.7", "dependencies": { "@earendil-works/gondolin": "0.12.0" } }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.9.6", + "version": "1.9.7", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.79.6", + "version": "0.79.7", "dependencies": { "ms": "2.1.3" }, @@ -6365,7 +6365,7 @@ }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.79.6", + "version": "0.79.7", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 41203247..76368ff6 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.7] - 2026-06-18 ## [0.79.6] - 2026-06-16 diff --git a/packages/agent/package.json b/packages/agent/package.json index 66ee4847..81d3e356 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "0.79.6", + "version": "0.79.7", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -29,7 +29,7 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "^0.79.6", + "@earendil-works/pi-ai": "^0.79.7", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index a49ddf52..f6070588 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.7] - 2026-06-18 ### Added diff --git a/packages/ai/package.json b/packages/ai/package.json index 11e4261b..9c71d156 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.79.6", + "version": "0.79.7", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/ai/src/image-models.generated.ts b/packages/ai/src/image-models.generated.ts index 09c74180..7545a9f1 100644 --- a/packages/ai/src/image-models.generated.ts +++ b/packages/ai/src/image-models.generated.ts @@ -95,6 +95,21 @@ export const IMAGE_MODELS = { cacheWrite: 0.08333333333333334, }, } satisfies ImagesModel<"openrouter-images">, + "google/gemini-3-pro-image": { + id: "google/gemini-3-pro-image", + name: "Google: Nano Banana Pro (Gemini 3 Pro Image)", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["image", "text"], + output: ["image", "text"], + cost: { + input: 2, + output: 12, + cacheRead: 0.19999999999999998, + cacheWrite: 0.375, + }, + } satisfies ImagesModel<"openrouter-images">, "google/gemini-3-pro-image-preview": { id: "google/gemini-3-pro-image-preview", name: "Google: Nano Banana Pro (Gemini 3 Pro Image Preview)", @@ -110,6 +125,21 @@ export const IMAGE_MODELS = { cacheWrite: 0.375, }, } satisfies ImagesModel<"openrouter-images">, + "google/gemini-3.1-flash-image": { + id: "google/gemini-3.1-flash-image", + name: "Google: Nano Banana 2 (Gemini 3.1 Flash Image)", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["image", "text"], + output: ["image", "text"], + cost: { + input: 0.5, + output: 3, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, "google/gemini-3.1-flash-image-preview": { id: "google/gemini-3.1-flash-image-preview", name: "Google: Nano Banana 2 (Gemini 3.1 Flash Image Preview)", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 424f8bcd..8e1b776f 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -4566,25 +4566,6 @@ export const MODELS = { contextWindow: 400000, maxTokens: 128000, } satisfies Model<"openai-responses">, - "raptor-mini": { - id: "raptor-mini", - name: "Raptor mini", - api: "openai-completions", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.25, - output: 2, - cacheRead: 0.025, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, }, "google": { "gemini-2.0-flash": { @@ -11785,6 +11766,23 @@ export const MODELS = { contextWindow: 1048756, maxTokens: 262144, } satisfies Model<"openai-completions">, + "poolside/laguna-m.1": { + id: "poolside/laguna-m.1", + name: "Poolside: Laguna M.1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.2, + output: 0.4, + cacheRead: 0.1, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "poolside/laguna-m.1:free": { id: "poolside/laguna-m.1:free", name: "Poolside: Laguna M.1 (free)", @@ -11802,6 +11800,23 @@ export const MODELS = { contextWindow: 262144, maxTokens: 32768, } satisfies Model<"openai-completions">, + "poolside/laguna-xs.2": { + id: "poolside/laguna-xs.2", + name: "Poolside: Laguna XS.2", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.1, + output: 0.2, + cacheRead: 0.05, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "poolside/laguna-xs.2:free": { id: "poolside/laguna-xs.2:free", name: "Poolside: Laguna XS.2 (free)", @@ -12425,11 +12440,11 @@ export const MODELS = { cost: { input: 0.14, output: 1, - cacheRead: 0.05, + cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 81920, + maxTokens: 262144, } satisfies Model<"openai-completions">, "qwen/qwen3.5-397b-a17b": { id: "qwen/qwen3.5-397b-a17b", @@ -12542,9 +12557,9 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.15, + input: 0.14, output: 1, - cacheRead: 0.05, + cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, @@ -13054,11 +13069,11 @@ export const MODELS = { cost: { input: 1.4, output: 4.4, - cacheRead: 0.26, + cacheRead: 0.7, cacheWrite: 0, }, contextWindow: 1048576, - maxTokens: 262144, + maxTokens: 524288, } satisfies Model<"openai-completions">, "~anthropic/claude-fable-latest": { id: "~anthropic/claude-fable-latest", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3f953510..89e7f892 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.7] - 2026-06-18 ### 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 bd9ae9f3..c3f35b25 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.79.6", + "version": "0.79.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "0.79.6", + "version": "0.79.7", "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 7f2d2bb4..c817a94e 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.79.6", + "version": "0.79.7", "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 0478f9c8..12363537 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.79.6", + "version": "0.79.7", "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 c573e8e0..ea80b459 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.79.6", + "version": "0.79.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-gondolin", - "version": "0.79.6", + "version": "0.79.7", "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 71e89d6c..89705d97 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.79.6", + "version": "0.79.7", "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 24e20622..41b01437 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.9.6", + "version": "1.9.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.9.6", + "version": "1.9.7", "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 7f1fa016..a80abe4a 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.9.6", + "version": "1.9.7", "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 fb5d53cd..b45b2214 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.79.6", + "version": "0.79.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "0.79.6", + "version": "0.79.7", "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 b8d759de..02abd397 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.79.6", + "version": "0.79.7", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 39313f9d..c366f1a5 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -1,17 +1,17 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.79.6", + "version": "0.79.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent", - "version": "0.79.6", + "version": "0.79.7", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.6", - "@earendil-works/pi-ai": "^0.79.6", - "@earendil-works/pi-tui": "^0.79.6", + "@earendil-works/pi-agent-core": "^0.79.7", + "@earendil-works/pi-ai": "^0.79.7", + "@earendil-works/pi-tui": "^0.79.7", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -474,11 +474,11 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.79.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.6.tgz", + "version": "0.79.7", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.7.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.79.6", + "@earendil-works/pi-ai": "^0.79.7", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -488,8 +488,8 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.79.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.6.tgz", + "version": "0.79.7", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.7.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -511,8 +511,8 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.79.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.6.tgz", + "version": "0.79.7", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.7.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 22b408ce..c873bb8d 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.79.6", + "version": "0.79.7", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -36,9 +36,9 @@ "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" }, "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.6", - "@earendil-works/pi-ai": "^0.79.6", - "@earendil-works/pi-tui": "^0.79.6", + "@earendil-works/pi-agent-core": "^0.79.7", + "@earendil-works/pi-ai": "^0.79.7", + "@earendil-works/pi-tui": "^0.79.7", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index ca28b0a3..28a38ba8 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.7] - 2026-06-18 ### Added diff --git a/packages/tui/package.json b/packages/tui/package.json index 9c73829c..24590a18 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-tui", - "version": "0.79.6", + "version": "0.79.7", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", From 788a044483da3d3c1fe1b4603fde7ffac0fbae3f Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 18 Jun 2026 18:22:10 +0200 Subject: [PATCH 317/352] Add [Unreleased] section for next cycle --- packages/agent/CHANGELOG.md | 2 ++ packages/ai/CHANGELOG.md | 2 ++ packages/coding-agent/CHANGELOG.md | 2 ++ packages/tui/CHANGELOG.md | 2 ++ 4 files changed, 8 insertions(+) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 76368ff6..3050117f 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.7] - 2026-06-18 ## [0.79.6] - 2026-06-16 diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index f6070588..72851f5f 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.7] - 2026-06-18 ### Added diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 89e7f892..ed277120 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.7] - 2026-06-18 ### New Features diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 28a38ba8..4db80b91 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.7] - 2026-06-18 ### Added From 6b9f3f492f1de0683246e6fb2f86d35f70335f56 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 18 Jun 2026 19:30:14 +0200 Subject: [PATCH 318/352] fix(coding-agent): avoid retrying successful overflow compaction closes #5720 --- packages/coding-agent/CHANGELOG.md | 4 +++ .../coding-agent/src/core/agent-session.ts | 13 ++++++-- .../suite/agent-session-compaction.test.ts | 31 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ed277120..ab9812c5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed successful overflow-triggered auto-compaction to avoid retrying completed assistant responses ([#5720](https://github.com/earendil-works/pi/issues/5720)). + ## [0.79.7] - 2026-06-18 ### New Features diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 54de3103..23fb5983 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1824,8 +1824,17 @@ export class AgentSession { return false; } - // Case 1: Overflow - LLM returned context overflow error + // Case 1: Overflow - LLM returned context overflow error, or reported usage exceeded + // the configured window. A successful response over the configured window should compact + // but must not retry: the assistant answer already completed and agent.continue() cannot + // continue from an assistant message. if (sameModel && isContextOverflow(assistantMessage, contextWindow)) { + const willRetry = assistantMessage.stopReason !== "stop"; + + if (!willRetry) { + return await this._runAutoCompaction("overflow", false); + } + if (this._overflowRecoveryAttempted) { this._emit({ type: "compaction_end", @@ -1846,7 +1855,7 @@ export class AgentSession { if (messages.length > 0 && messages[messages.length - 1].role === "assistant") { this.agent.state.messages = messages.slice(0, -1); } - return await this._runAutoCompaction("overflow", true); + return await this._runAutoCompaction("overflow", willRetry); } // Case 2: Threshold - context is getting large diff --git a/packages/coding-agent/test/suite/agent-session-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-compaction.test.ts index e04732d0..e790d896 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -248,6 +248,37 @@ describe("AgentSession compaction characterization", () => { ); }); + it("compacts successful overflow responses without retrying", async () => { + const harness = await createHarness({ + settings: { compaction: { enabled: true, keepRecentTokens: 1, reserveTokens: 0 } }, + models: [{ id: "faux-1", contextWindow: 1, maxTokens: 100 }], + extensionFactories: [ + (pi) => { + pi.on("session_before_compact", async (event) => ({ + compaction: { + summary: "successful overflow compacted", + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + details: {}, + }, + })); + }, + ], + }); + harnesses.push(harness); + harness.setResponses([fauxAssistantMessage("completed answer")]); + + await expect(harness.session.prompt("hello")).resolves.toBeUndefined(); + + const compactionEnd = harness.eventsOfType("compaction_end").at(-1); + expect(compactionEnd).toMatchObject({ + reason: "overflow", + aborted: false, + willRetry: false, + }); + expect(harness.faux.state.callCount).toBe(1); + }); + it("ignores stale pre-compaction assistant usage on pre-prompt checks", async () => { const harness = await createHarness(); harnesses.push(harness); From 7d08c81a0962b4731ca41e89cddc9ed9eb103455 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 18 Jun 2026 22:17:57 +0200 Subject: [PATCH 319/352] fix(coding-agent): avoid empty compaction summaries closes #4811 --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/agent-session.ts | 53 +++++++------------ .../src/core/compaction/compaction.ts | 4 ++ .../src/modes/interactive/interactive-mode.ts | 9 ---- .../test/compaction-extensions.test.ts | 1 + packages/coding-agent/test/compaction.test.ts | 25 +-------- .../suite/agent-session-compaction.test.ts | 19 ++++--- 7 files changed, 37 insertions(+), 75 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ab9812c5..0fcc303a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed compaction to refuse sessions with no eligible messages instead of producing empty summaries ([#4811](https://github.com/earendil-works/pi/issues/4811)). - Fixed successful overflow-triggered auto-compaction to avoid retrying completed assistant responses ([#5720](https://github.com/earendil-works/pi/issues/5720)). ## [0.79.7] - 2026-06-18 diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 23fb5983..cae330f3 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1892,19 +1892,10 @@ export class AgentSession { */ private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise { const settings = this.settingsManager.getCompactionSettings(); - - this._emit({ type: "compaction_start", reason }); - this._autoCompactionAbortController = new AbortController(); + let started = false; try { if (!this.model) { - this._emit({ - type: "compaction_end", - reason, - result: undefined, - aborted: false, - willRetry: false, - }); return false; } @@ -1914,13 +1905,6 @@ export class AgentSession { if (this.agent.streamFn === streamSimple) { const authResult = await this._modelRegistry.getApiKeyAndHeaders(this.model); if (!authResult.ok || !authResult.apiKey) { - this._emit({ - type: "compaction_end", - reason, - result: undefined, - aborted: false, - willRetry: false, - }); return false; } apiKey = authResult.apiKey; @@ -1934,16 +1918,13 @@ export class AgentSession { const preparation = prepareCompaction(pathEntries, settings); if (!preparation) { - this._emit({ - type: "compaction_end", - reason, - result: undefined, - aborted: false, - willRetry: false, - }); return false; } + this._emit({ type: "compaction_start", reason }); + this._autoCompactionAbortController = new AbortController(); + started = true; + let extensionCompaction: CompactionResult | undefined; let fromExtension = false; @@ -2054,17 +2035,19 @@ export class AgentSession { return this.agent.hasQueuedMessages(); } catch (error) { const errorMessage = error instanceof Error ? error.message : "compaction failed"; - this._emit({ - type: "compaction_end", - reason, - result: undefined, - aborted: false, - willRetry: false, - errorMessage: - reason === "overflow" - ? `Context overflow recovery failed: ${errorMessage}` - : `Auto-compaction failed: ${errorMessage}`, - }); + if (started) { + this._emit({ + type: "compaction_end", + reason, + result: undefined, + aborted: false, + willRetry: false, + errorMessage: + reason === "overflow" + ? `Context overflow recovery failed: ${errorMessage}` + : `Auto-compaction failed: ${errorMessage}`, + }); + } return false; } finally { this._autoCompactionAbortController = undefined; diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index 07f2f9fe..f3f3d59f 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -698,6 +698,10 @@ export function prepareCompaction( } } + if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0) { + return undefined; + } + // Extract file operations from messages and previous compaction const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 00054eb2..962c414f 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -3700,7 +3700,6 @@ export class InteractiveMode { showError(errorMessage: string): void { this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0)); - this.chatContainer.addChild(new Spacer(1)); this.ui.requestRender(); } @@ -5695,14 +5694,6 @@ export class InteractiveMode { } private async handleCompactCommand(customInstructions?: string): Promise { - const entries = this.sessionManager.getEntries(); - const messageCount = entries.filter((e) => e.type === "message").length; - - if (messageCount < 2) { - this.showWarning("Nothing to compact (no messages yet)"); - return; - } - if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = undefined; diff --git a/packages/coding-agent/test/compaction-extensions.test.ts b/packages/coding-agent/test/compaction-extensions.test.ts index 2c41210e..c95125f2 100644 --- a/packages/coding-agent/test/compaction-extensions.test.ts +++ b/packages/coding-agent/test/compaction-extensions.test.ts @@ -98,6 +98,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => { const sessionManager = SessionManager.create(tempDir); const settingsManager = SettingsManager.create(tempDir, tempDir); + settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } }); const authStorage = AuthStorage.create(join(tempDir, "auth.json")); const modelRegistry = ModelRegistry.create(authStorage); diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index 929d06ec..be3d5c63 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -9,7 +9,6 @@ import { calculateContextTokens, compact, DEFAULT_COMPACTION_SETTINGS, - estimateContextTokens, findCutPoint, getLastAssistantUsage, prepareCompaction, @@ -396,7 +395,7 @@ describe("buildSessionContext", () => { }); describe("prepareCompaction with previous compaction", () => { - it("should preserve kept messages across repeated compactions when they still fit", () => { + it("should skip repeated compactions when kept messages still fit", () => { const u1 = createMessageEntry(createUserMessage("user msg 1 (summarized by compaction1)")); const a1 = createMessageEntry(createAssistantMessage("assistant msg 1")); const u2 = createMessageEntry(createUserMessage("user msg 2 - kept by compaction1")); @@ -408,29 +407,9 @@ describe("prepareCompaction with previous compaction", () => { const a4 = createMessageEntry(createAssistantMessage("assistant msg 4", createMockUsage(8000, 2000))); const pathEntries = [u1, a1, u2, a2, u3, a3, compaction1, u4, a4]; - const contextBefore = buildSessionContext(pathEntries); const preparation = prepareCompaction(pathEntries, DEFAULT_COMPACTION_SETTINGS); - expect(preparation).toBeDefined(); - expect(preparation!.firstKeptEntryId).toBe(u2.id); - expect(preparation!.previousSummary).toBe("First summary"); - expect(extractText(preparation!.messagesToSummarize)).not.toContain("First summary"); - expect(preparation!.tokensBefore).toBe(estimateContextTokens(contextBefore.messages).tokens); - - const compaction2: CompactionEntry = { - type: "compaction", - id: "compaction2-id", - parentId: a4.id, - timestamp: new Date().toISOString(), - summary: "Second summary", - firstKeptEntryId: preparation!.firstKeptEntryId, - tokensBefore: preparation!.tokensBefore, - }; - const contextAfter = buildSessionContext([...pathEntries, compaction2]); - const contextAfterText = extractText(contextAfter.messages); - - expect(contextAfterText).toContain("user msg 2 - kept by compaction1"); - expect(contextAfterText).toContain("user msg 3 - kept by compaction1"); + expect(preparation).toBeUndefined(); }); it("should re-summarize previously kept messages when the recent window moves past them", () => { diff --git a/packages/coding-agent/test/suite/agent-session-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-compaction.test.ts index e790d896..a3302adf 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -67,19 +67,20 @@ function useSummaryStreamFn(harness: Harness, summary: string): () => number { } 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, }); - harness.sessionManager.appendMessage( - createAssistant(harness, { - stopReason: "stop", - totalTokens: 100, - timestamp: now - 500, - }), - ); + const assistant = createAssistant(harness, { + stopReason: "stop", + totalTokens: 100, + timestamp: now - 500, + }); + assistant.content = [{ type: "text", text: "assistant response to compact" }]; + harness.sessionManager.appendMessage(assistant); harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages; } @@ -96,6 +97,7 @@ describe("AgentSession compaction characterization", () => { it("manually compacts using an extension-provided summary", async () => { const harness = await createHarness({ + settings: { compaction: { keepRecentTokens: 1 } }, extensionFactories: [ (pi) => { pi.on("session_before_compact", async (event) => ({ @@ -145,7 +147,7 @@ describe("AgentSession compaction characterization", () => { const result = await harness.session.compact(); - expect(result.summary).toBe("summary from custom stream"); + expect(result.summary).toContain("summary from custom stream"); expect(getStreamCallCount()).toBe(1); }); @@ -165,6 +167,7 @@ describe("AgentSession compaction characterization", () => { it("cancels in-progress manual compaction when abortCompaction is called", async () => { const harness = await createHarness({ + settings: { compaction: { keepRecentTokens: 1 } }, extensionFactories: [ (pi) => { pi.on("session_before_compact", async (event) => { From b09fbde00eaeb33413b9879137612e002bbb4086 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 18 Jun 2026 23:39:02 +0300 Subject: [PATCH 320/352] feat(ai): add OpenRouter Fusion alias (#5866) --- packages/ai/scripts/generate-models.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 3d75a82e..0c1bcc42 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -2030,6 +2030,32 @@ async function generateModels() { }); } + // Add "fusion" alias for openrouter/fusion. OpenRouter exposes Fusion as a + // router alias/plugin entry point; its model metadata does not advertise + // tools, but the alias resolves to a concrete model that can invoke caller + // tools and has the openrouter:fusion server tool auto-injected. + if (!allModels.some(m => m.provider === "openrouter" && m.id === "openrouter/fusion")) { + allModels.push({ + id: "openrouter/fusion", + name: "OpenRouter: Fusion", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + // we dont know about the costs because Fusion routes to multiple models + // and then charges you for the underlying used models + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 30000, + }); + } + // Azure Foundry deploys these with larger context windows than OpenAI's own API, // which caps gpt-5.4/gpt-5.5 at 272k. See models-sold-directly-by-azure docs. const AZURE_CONTEXT_WINDOW_OVERRIDES: Record = { From c60f6a8ab3b36155d572fe9c10df61f7613281ee Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 18 Jun 2026 23:01:10 +0200 Subject: [PATCH 321/352] feat(coding-agent): expose post-compaction token estimates closes #5877 --- packages/coding-agent/CHANGELOG.md | 4 ++++ packages/coding-agent/docs/rpc.md | 4 ++++ packages/coding-agent/src/core/agent-session.ts | 15 ++++++++++++++- .../src/core/compaction/compaction.ts | 1 + .../test/suite/agent-session-compaction.test.ts | 5 +++++ 5 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0fcc303a..e58cd877 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added estimated post-compaction token counts to compact results and compaction events ([#5877](https://github.com/earendil-works/pi/issues/5877)). + ### Fixed - Fixed compaction to refuse sessions with no eligible messages instead of producing empty summaries ([#4811](https://github.com/earendil-works/pi/issues/4811)). diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index 9aa16ffc..a9942409 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -374,11 +374,14 @@ Response: "summary": "Summary of conversation...", "firstKeptEntryId": "abc123", "tokensBefore": 150000, + "estimatedTokensAfter": 32000, "details": {} } } ``` +`estimatedTokensAfter` is a heuristic estimate over the rebuilt message context immediately after compaction, not a provider-exact token count. + #### set_auto_compaction Enable or disable automatic compaction when context is nearly full. @@ -924,6 +927,7 @@ The `reason` field is `"manual"`, `"threshold"`, or `"overflow"`. "summary": "Summary of conversation...", "firstKeptEntryId": "abc123", "tokensBefore": 150000, + "estimatedTokensAfter": 32000, "details": {} }, "aborted": false, diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index cae330f3..f8af4320 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -45,6 +45,7 @@ import { collectEntriesForBranchSummary, compact, estimateContextTokens, + estimateTokens, generateBranchSummary, prepareCompaction, shouldCompact, @@ -242,6 +243,14 @@ interface ToolDefinitionEntry { sourceInfo: SourceInfo; } +function estimateMessagesTokens(messages: AgentMessage[]): number { + let tokens = 0; + for (const message of messages) { + tokens += estimateTokens(message); + } + return tokens; +} + // ============================================================================ // Constants // ============================================================================ @@ -1726,6 +1735,7 @@ export class AgentSession { const newEntries = this.sessionManager.getEntries(); const sessionContext = this.sessionManager.buildSessionContext(); this.agent.state.messages = sessionContext.messages; + const estimatedTokensAfter = estimateMessagesTokens(sessionContext.messages); // Get the saved compaction entry for the extension event const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as @@ -1740,10 +1750,11 @@ export class AgentSession { }); } - const compactionResult = { + const compactionResult: CompactionResult = { summary, firstKeptEntryId, tokensBefore, + estimatedTokensAfter, details, }; this._emit({ @@ -1999,6 +2010,7 @@ export class AgentSession { const newEntries = this.sessionManager.getEntries(); const sessionContext = this.sessionManager.buildSessionContext(); this.agent.state.messages = sessionContext.messages; + const estimatedTokensAfter = estimateMessagesTokens(sessionContext.messages); // Get the saved compaction entry for the extension event const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as @@ -2017,6 +2029,7 @@ export class AgentSession { summary, firstKeptEntryId, tokensBefore, + estimatedTokensAfter, details, }; this._emit({ type: "compaction_end", reason, result, aborted: false, willRetry }); diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index f3f3d59f..83315a84 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -104,6 +104,7 @@ export interface CompactionResult { summary: string; firstKeptEntryId: string; tokensBefore: number; + estimatedTokensAfter?: number; /** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */ details?: T; } diff --git a/packages/coding-agent/test/suite/agent-session-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-compaction.test.ts index a3302adf..ba5ae5c7 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -5,6 +5,7 @@ import { type Model, } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { estimateTokens } from "../../src/core/compaction/index.ts"; import { createHarness, type Harness } from "./harness.ts"; type SessionWithCompactionInternals = { @@ -118,8 +119,10 @@ describe("AgentSession compaction characterization", () => { const result = await harness.session.compact(); const compactionEntries = harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction"); + const estimatedTokensAfter = harness.session.messages.reduce((sum, message) => sum + estimateTokens(message), 0); expect(result.summary).toBe("summary from extension"); + expect(result.estimatedTokensAfter).toBe(estimatedTokensAfter); expect(compactionEntries).toHaveLength(1); expect(harness.session.messages[0]?.role).toBe("compactionSummary"); }); @@ -161,7 +164,9 @@ describe("AgentSession compaction characterization", () => { await sessionInternals._runAutoCompaction("threshold", false); const compactionEntries = harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction"); + const compactionEnd = harness.eventsOfType("compaction_end").at(-1); expect(compactionEntries).toHaveLength(1); + expect(compactionEnd?.result?.estimatedTokensAfter).toBeGreaterThan(0); expect(getStreamCallCount()).toBe(1); }); From cab89d143587e19b4f77b0d390028bd90a279b19 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 18 Jun 2026 23:09:41 +0200 Subject: [PATCH 322/352] docs: audit unreleased changelogs --- packages/ai/CHANGELOG.md | 4 ++++ packages/coding-agent/CHANGELOG.md | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 72851f5f..2b5aeca1 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added the OpenRouter Fusion alias as `openrouter/fusion` ([#5866](https://github.com/earendil-works/pi/pull/5866) by [@dannote](https://github.com/dannote)). + ## [0.79.7] - 2026-06-18 ### Added diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e58cd877..5f0c6186 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,9 +2,15 @@ ## [Unreleased] +### New Features + +- **Post-compaction token estimates** - Compact results and compaction events now include estimated post-compaction token counts so clients can show the approximate context reduction. See [RPC compact](docs/rpc.md#compact) and [compaction events](docs/rpc.md#compaction_start--compaction_end). +- **OpenRouter Fusion alias** - `openrouter/fusion` is available as a built-in OpenRouter model alias. See [API Keys](docs/providers.md#api-keys). + ### Added - Added estimated post-compaction token counts to compact results and compaction events ([#5877](https://github.com/earendil-works/pi/issues/5877)). +- Added the inherited OpenRouter Fusion alias as `openrouter/fusion` ([#5866](https://github.com/earendil-works/pi/pull/5866) by [@dannote](https://github.com/dannote)). ### Fixed From fd1ba2c7feaedb78e5d87107f6e6a274044121b8 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 18 Jun 2026 23:15:18 +0200 Subject: [PATCH 323/352] test(coding-agent): seed auto-compaction queue fixture --- ...gent-session-auto-compaction-queue.test.ts | 103 ++++++++++-------- 1 file changed, 59 insertions(+), 44 deletions(-) diff --git a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts index 1abe3e28..7fd56b46 100644 --- a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts +++ b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts @@ -2,7 +2,12 @@ import { existsSync, mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Agent } from "@earendil-works/pi-agent-core"; -import { type AssistantMessage, getModel } from "@earendil-works/pi-ai"; +import { + type AssistantMessage, + createAssistantMessageEventStream, + fauxAssistantMessage, + getModel, +} from "@earendil-works/pi-ai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AgentSession } from "../src/core/agent-session.ts"; import { AuthStorage } from "../src/core/auth-storage.ts"; @@ -11,51 +16,10 @@ import { SessionManager } from "../src/core/session-manager.ts"; import { SettingsManager } from "../src/core/settings-manager.ts"; import { createTestResourceLoader } from "./utilities.ts"; -vi.mock("../src/core/compaction/index.js", () => ({ - calculateContextTokens: (usage: { - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - totalTokens?: number; - }) => usage.totalTokens ?? usage.input + usage.output + usage.cacheRead + usage.cacheWrite, - collectEntriesForBranchSummary: () => ({ entries: [], commonAncestorId: null }), - compact: async () => ({ - summary: "compacted", - firstKeptEntryId: "entry-1", - tokensBefore: 100, - details: {}, - }), - estimateContextTokens: ( - messages: Array<{ - role: string; - usage?: { input: number; output: number; cacheRead: number; cacheWrite: number; totalTokens?: number }; - stopReason?: string; - }>, - ) => { - // Walk backwards to find last non-error, non-aborted assistant with usage - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]; - if (msg.role === "assistant" && msg.stopReason !== "error" && msg.stopReason !== "aborted" && msg.usage) { - const tokens = - msg.usage.totalTokens ?? msg.usage.input + msg.usage.output + msg.usage.cacheRead + msg.usage.cacheWrite; - return { tokens, usageTokens: tokens, trailingTokens: 0, lastUsageIndex: i }; - } - } - return { tokens: 0, usageTokens: 0, trailingTokens: 0, lastUsageIndex: null }; - }, - generateBranchSummary: async () => ({ summary: "", aborted: false, readFiles: [], modifiedFiles: [] }), - prepareCompaction: () => ({ dummy: true }), - shouldCompact: ( - contextTokens: number, - contextWindow: number, - settings: { enabled: boolean; reserveTokens: number }, - ) => settings.enabled && contextTokens > contextWindow - settings.reserveTokens, -})); - describe("AgentSession auto-compaction queue resume", () => { let session: AgentSession; let sessionManager: SessionManager; + let settingsManager: SettingsManager; let tempDir: string; beforeEach(() => { @@ -73,7 +37,7 @@ describe("AgentSession auto-compaction queue resume", () => { }); sessionManager = SessionManager.inMemory(); - const settingsManager = SettingsManager.create(tempDir, tempDir); + settingsManager = SettingsManager.create(tempDir, tempDir); const authStorage = AuthStorage.create(join(tempDir, "auth.json")); authStorage.setRuntimeApiKey("anthropic", "test-key"); const modelRegistry = ModelRegistry.create(authStorage, tempDir); @@ -98,6 +62,57 @@ describe("AgentSession auto-compaction queue resume", () => { }); it("should resume after threshold compaction when only agent-level queued messages exist", async () => { + settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } }); + const model = session.model!; + const now = Date.now(); + sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "message to compact" }], + timestamp: now - 1000, + }); + sessionManager.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "assistant response to compact" }], + 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 }, + }, + stopReason: "stop", + timestamp: now - 500, + }); + session.agent.state.messages = sessionManager.buildSessionContext().messages; + session.agent.streamFn = (summaryModel) => { + const stream = createAssistantMessageEventStream(); + queueMicrotask(() => { + stream.push({ + type: "done", + reason: "stop", + message: { + ...fauxAssistantMessage("compacted"), + api: summaryModel.api, + provider: summaryModel.provider, + model: summaryModel.id, + usage: { + input: 10, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 10, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }, + }); + }); + return stream; + }; + session.agent.followUp({ role: "custom", customType: "test", From 8025fdd01f718f3dd2624b18089ac3623ee3c628 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 18 Jun 2026 23:36:08 +0200 Subject: [PATCH 324/352] meta: Update readmes slightly --- README.md | 52 ++++++++++++++++----------------- packages/coding-agent/README.md | 13 ++++----- 2 files changed, 32 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index dc31b930..130a412f 100644 --- a/README.md +++ b/README.md @@ -5,46 +5,24 @@

    Discord -

    -

    - pi.dev domain graciously donated by -

    - Exy mascot
    exe.dev
    + npm

    > New issues and PRs from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. See [CONTRIBUTING.md](CONTRIBUTING.md). ---- +# Pi Agent Harness -# Pi Agent Harness Mono Repo - -This is the home of the pi agent harness project including our self extensible coding agent. +This is the home of the Pi agent harness project including our self extensible coding agent. * **[@earendil-works/pi-coding-agent](packages/coding-agent)**: Interactive coding agent CLI * **[@earendil-works/pi-agent-core](packages/agent)**: Agent runtime with tool calling and state management * **[@earendil-works/pi-ai](packages/ai)**: Unified multi-provider LLM API (OpenAI, Anthropic, Google, …) -To learn more about pi: +To learn more about Pi: * [Visit pi.dev](https://pi.dev), the project website with demos * [Read the documentation](https://pi.dev/docs/latest), but you can also ask the agent to explain itself -## Share your OSS coding agent sessions - -If you use pi or other coding agents for open source work, please share your sessions. - -Public OSS session data helps improve coding agents with real-world tasks, tool use, failures, and fixes instead of toy benchmarks. - -For the full explanation, see [this post on X](https://x.com/badlogicgames/status/2037811643774652911). - -To publish sessions, use [`badlogic/pi-share-hf`](https://github.com/badlogic/pi-share-hf). Read its README.md for setup instructions. All you need is a Hugging Face account, the Hugging Face CLI, and `pi-share-hf`. - -You can also watch [this video](https://x.com/badlogicgames/status/2041151967695634619), where I show how I publish my `pi-mono` sessions. - -I regularly publish my own `pi-mono` work sessions here: - -- [badlogicgames/pi-mono on Hugging Face](https://huggingface.co/datasets/badlogicgames/pi-mono) - ## All Packages | Package | Description | @@ -94,6 +72,28 @@ We treat npm dependency changes as reviewed code changes. - CI installs with `npm ci --ignore-scripts`, and a scheduled GitHub workflow runs `npm audit --omit=dev` plus `npm audit signatures --omit=dev`. - Shrinkwrap generation has an explicit allowlist for dependency lifecycle scripts; new lifecycle-script deps fail checks until reviewed. +## Share your OSS coding agent sessions + +If you use Pi or other coding agents for open source work, please share your sessions. + +Public OSS session data helps improve coding agents with real-world tasks, tool use, failures, and fixes instead of toy benchmarks. + +For the full explanation, see [this post on X](https://x.com/badlogicgames/status/2037811643774652911). + +To publish sessions, use [`badlogic/pi-share-hf`](https://github.com/badlogic/pi-share-hf). Read its README.md for setup instructions. All you need is a Hugging Face account, the Hugging Face CLI, and `pi-share-hf`. + +You can also watch [this video](https://x.com/badlogicgames/status/2041151967695634619), where I show how I publish my `pi-mono` sessions. + +I regularly publish my own `pi-mono` work sessions here: + +- [badlogicgames/pi-mono on Hugging Face](https://huggingface.co/datasets/badlogicgames/pi-mono) + ## License MIT + +

    + pi.dev domain graciously donated by +

    + Exy mascot
    exe.dev
    +

    diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index ce7dba2a..68e17a62 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -7,11 +7,6 @@ Discord npm

    -

    - pi.dev domain graciously donated by -

    - Exy mascot
    exe.dev
    -

    > New issues and PRs from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. See [CONTRIBUTING.md](../../CONTRIBUTING.md). @@ -675,8 +670,6 @@ pi --thinking high "Solve this complex problem" See [CONTRIBUTING.md](../../CONTRIBUTING.md) for guidelines and [docs/development.md](docs/development.md) for setup, forking, and debugging. ---- - ## License MIT @@ -686,3 +679,9 @@ MIT - [@earendil-works/pi-ai](https://www.npmjs.com/package/@earendil-works/pi-ai): Core LLM toolkit - [@earendil-works/pi-agent-core](https://www.npmjs.com/package/@earendil-works/pi-agent-core): Agent framework - [@earendil-works/pi-tui](https://www.npmjs.com/package/@earendil-works/pi-tui): Terminal UI components + +

    + pi.dev domain graciously donated by +

    + Exy mascot
    exe.dev
    +

    From 651d10d90b4bd80c85c3a09e18a719e4d5dc4c13 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 18 Jun 2026 23:47:25 +0200 Subject: [PATCH 325/352] feat(ai): enable Mistral prompt caching --- package-lock.json | 36 ++++++++++-- packages/ai/CHANGELOG.md | 1 + packages/ai/package.json | 5 +- packages/ai/scripts/generate-models.ts | 2 +- packages/ai/src/models.generated.ts | 58 +++++++++---------- packages/ai/src/providers/mistral.ts | 39 +++++++++++-- .../ai/test/mistral-reasoning-mode.test.ts | 18 ++++++ packages/coding-agent/npm-shrinkwrap.json | 38 ++++++++++-- 8 files changed, 152 insertions(+), 45 deletions(-) diff --git a/package-lock.json b/package-lock.json index 821c9d30..d1abbf97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1624,14 +1624,23 @@ } }, "node_modules/@mistralai/mistralai": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", - "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", + "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", "license": "Apache-2.0", "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } } }, "node_modules/@nodable/entities": { @@ -1684,6 +1693,24 @@ "node": ">= 8" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -6219,7 +6246,8 @@ "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.1", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 2b5aeca1..c4c63d8b 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added prompt caching for Mistral requests using the pi session ID as `prompt_cache_key`, including cached-token usage and cost accounting ([#5854](https://github.com/earendil-works/pi/issues/5854)). - Added the OpenRouter Fusion alias as `openrouter/fusion` ([#5866](https://github.com/earendil-works/pi/pull/5866) by [@dannote](https://github.com/dannote)). ## [0.79.7] - 2026-06-18 diff --git a/packages/ai/package.json b/packages/ai/package.json index 9c71d156..2c0bd6cf 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -69,9 +69,10 @@ "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@smithy/node-http-handler": "4.7.3", "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.1", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", + "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 0c1bcc42..c1fddc92 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -948,7 +948,7 @@ async function loadModelsDevData(): Promise[]> { cost: { input: m.cost?.input || 0, output: m.cost?.output || 0, - cacheRead: m.cost?.cache_read || 0, + cacheRead: m.cost?.cache_read ?? (m.cost?.input ? roundCost(m.cost.input * 0.1) : 0), cacheWrite: m.cost?.cache_write || 0, }, contextWindow: m.limit?.context || 4096, diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 8e1b776f..82d0e137 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -5761,7 +5761,7 @@ export const MODELS = { cost: { input: 0.3, output: 0.9, - cacheRead: 0, + cacheRead: 0.03, cacheWrite: 0, }, contextWindow: 256000, @@ -5778,7 +5778,7 @@ export const MODELS = { cost: { input: 0.4, output: 2, - cacheRead: 0, + cacheRead: 0.04, cacheWrite: 0, }, contextWindow: 262144, @@ -5795,7 +5795,7 @@ export const MODELS = { cost: { input: 0.4, output: 2, - cacheRead: 0, + cacheRead: 0.04, cacheWrite: 0, }, contextWindow: 262144, @@ -5812,7 +5812,7 @@ export const MODELS = { cost: { input: 0.4, output: 2, - cacheRead: 0, + cacheRead: 0.04, cacheWrite: 0, }, contextWindow: 128000, @@ -5829,7 +5829,7 @@ export const MODELS = { cost: { input: 0.4, output: 2, - cacheRead: 0, + cacheRead: 0.04, cacheWrite: 0, }, contextWindow: 262144, @@ -5846,7 +5846,7 @@ export const MODELS = { cost: { input: 0.1, output: 0.3, - cacheRead: 0, + cacheRead: 0.01, cacheWrite: 0, }, contextWindow: 128000, @@ -5863,7 +5863,7 @@ export const MODELS = { cost: { input: 0.1, output: 0.3, - cacheRead: 0, + cacheRead: 0.01, cacheWrite: 0, }, contextWindow: 128000, @@ -5897,7 +5897,7 @@ export const MODELS = { cost: { input: 2, output: 5, - cacheRead: 0, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 128000, @@ -5914,7 +5914,7 @@ export const MODELS = { cost: { input: 0.5, output: 1.5, - cacheRead: 0, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 128000, @@ -5931,7 +5931,7 @@ export const MODELS = { cost: { input: 0.04, output: 0.04, - cacheRead: 0, + cacheRead: 0.004, cacheWrite: 0, }, contextWindow: 128000, @@ -5948,7 +5948,7 @@ export const MODELS = { cost: { input: 0.1, output: 0.1, - cacheRead: 0, + cacheRead: 0.01, cacheWrite: 0, }, contextWindow: 128000, @@ -5965,7 +5965,7 @@ export const MODELS = { cost: { input: 2, output: 6, - cacheRead: 0, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 131072, @@ -5982,7 +5982,7 @@ export const MODELS = { cost: { input: 0.5, output: 1.5, - cacheRead: 0, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 262144, @@ -5999,7 +5999,7 @@ export const MODELS = { cost: { input: 0.5, output: 1.5, - cacheRead: 0, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 262144, @@ -6016,7 +6016,7 @@ export const MODELS = { cost: { input: 0.4, output: 2, - cacheRead: 0, + cacheRead: 0.04, cacheWrite: 0, }, contextWindow: 131072, @@ -6033,7 +6033,7 @@ export const MODELS = { cost: { input: 0.4, output: 2, - cacheRead: 0, + cacheRead: 0.04, cacheWrite: 0, }, contextWindow: 262144, @@ -6050,7 +6050,7 @@ export const MODELS = { cost: { input: 1.5, output: 7.5, - cacheRead: 0, + cacheRead: 0.15, cacheWrite: 0, }, contextWindow: 262144, @@ -6067,7 +6067,7 @@ export const MODELS = { cost: { input: 1.5, output: 7.5, - cacheRead: 0, + cacheRead: 0.15, cacheWrite: 0, }, contextWindow: 262144, @@ -6084,7 +6084,7 @@ export const MODELS = { cost: { input: 0.4, output: 2, - cacheRead: 0, + cacheRead: 0.04, cacheWrite: 0, }, contextWindow: 262144, @@ -6101,7 +6101,7 @@ export const MODELS = { cost: { input: 0.15, output: 0.15, - cacheRead: 0, + cacheRead: 0.015, cacheWrite: 0, }, contextWindow: 128000, @@ -6118,7 +6118,7 @@ export const MODELS = { cost: { input: 0.1, output: 0.3, - cacheRead: 0, + cacheRead: 0.01, cacheWrite: 0, }, contextWindow: 128000, @@ -6135,7 +6135,7 @@ export const MODELS = { cost: { input: 0.15, output: 0.6, - cacheRead: 0, + cacheRead: 0.015, cacheWrite: 0, }, contextWindow: 256000, @@ -6152,7 +6152,7 @@ export const MODELS = { cost: { input: 0.15, output: 0.6, - cacheRead: 0, + cacheRead: 0.015, cacheWrite: 0, }, contextWindow: 256000, @@ -6169,7 +6169,7 @@ export const MODELS = { cost: { input: 0.25, output: 0.25, - cacheRead: 0, + cacheRead: 0.025, cacheWrite: 0, }, contextWindow: 8000, @@ -6186,7 +6186,7 @@ export const MODELS = { cost: { input: 0.15, output: 0.15, - cacheRead: 0, + cacheRead: 0.015, cacheWrite: 0, }, contextWindow: 128000, @@ -6203,7 +6203,7 @@ export const MODELS = { cost: { input: 2, output: 6, - cacheRead: 0, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 64000, @@ -6220,7 +6220,7 @@ export const MODELS = { cost: { input: 0.7, output: 0.7, - cacheRead: 0, + cacheRead: 0.07, cacheWrite: 0, }, contextWindow: 32000, @@ -6237,7 +6237,7 @@ export const MODELS = { cost: { input: 0.15, output: 0.15, - cacheRead: 0, + cacheRead: 0.015, cacheWrite: 0, }, contextWindow: 128000, @@ -6254,7 +6254,7 @@ export const MODELS = { cost: { input: 2, output: 6, - cacheRead: 0, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 128000, diff --git a/packages/ai/src/providers/mistral.ts b/packages/ai/src/providers/mistral.ts index 1bc7d4ce..6a132236 100644 --- a/packages/ai/src/providers/mistral.ts +++ b/packages/ai/src/providers/mistral.ts @@ -226,7 +226,7 @@ function buildRequestOptions(model: Model<"mistral-conversations">, options?: Mi // Mistral infrastructure uses `x-affinity` for KV-cache reuse (prefix caching). // Respect explicit caller-provided header values. - if (options?.sessionId && !headers["x-affinity"]) { + if (shouldUsePromptCaching(options) && !headers["x-affinity"]) { headers["x-affinity"] = options.sessionId; } @@ -255,6 +255,7 @@ function buildChatPayload( if (options?.toolChoice) payload.toolChoice = mapToolChoice(options.toolChoice); if (options?.promptMode) payload.promptMode = options.promptMode; if (options?.reasoningEffort) payload.reasoningEffort = options.reasoningEffort; + if (shouldUsePromptCaching(options)) payload.promptCacheKey = options.sessionId; if (context.systemPrompt) { payload.messages.unshift({ @@ -266,6 +267,31 @@ function buildChatPayload( return payload; } +function shouldUsePromptCaching(options?: MistralOptions): options is MistralOptions & { sessionId: string } { + return options?.cacheRetention !== "none" && !!options?.sessionId; +} + +function getMistralCachedPromptTokens(usage: unknown, promptTokens: number): number { + const rawUsage = usage as { + promptTokensDetails?: { cachedTokens?: unknown } | null; + prompt_tokens_details?: { cached_tokens?: unknown } | null; + promptTokenDetails?: { cachedTokens?: unknown } | null; + prompt_token_details?: { cached_tokens?: unknown } | null; + numCachedTokens?: unknown; + num_cached_tokens?: unknown; + }; + const rawCachedTokens = + rawUsage.promptTokensDetails?.cachedTokens ?? + rawUsage.prompt_tokens_details?.cached_tokens ?? + rawUsage.promptTokenDetails?.cachedTokens ?? + rawUsage.prompt_token_details?.cached_tokens ?? + rawUsage.numCachedTokens ?? + rawUsage.num_cached_tokens ?? + 0; + const cachedTokens = typeof rawCachedTokens === "number" && Number.isFinite(rawCachedTokens) ? rawCachedTokens : 0; + return Math.min(promptTokens, Math.max(0, cachedTokens)); +} + async function consumeChatStream( model: Model<"mistral-conversations">, output: AssistantMessage, @@ -305,11 +331,16 @@ async function consumeChatStream( output.responseId ||= chunk.id; if (chunk.usage) { - output.usage.input = chunk.usage.promptTokens || 0; + const promptTokens = chunk.usage.promptTokens || 0; + const cachedPromptTokens = getMistralCachedPromptTokens(chunk.usage, promptTokens); + + output.usage.input = Math.max(0, promptTokens - cachedPromptTokens); output.usage.output = chunk.usage.completionTokens || 0; - output.usage.cacheRead = 0; + output.usage.cacheRead = cachedPromptTokens; output.usage.cacheWrite = 0; - output.usage.totalTokens = chunk.usage.totalTokens || output.usage.input + output.usage.output; + output.usage.totalTokens = + chunk.usage.totalTokens || + output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; calculateCost(model, output.usage); } diff --git a/packages/ai/test/mistral-reasoning-mode.test.ts b/packages/ai/test/mistral-reasoning-mode.test.ts index 35a5b96b..d197a56a 100644 --- a/packages/ai/test/mistral-reasoning-mode.test.ts +++ b/packages/ai/test/mistral-reasoning-mode.test.ts @@ -6,6 +6,7 @@ import type { Context, Model, SimpleStreamOptions } from "../src/types.ts"; interface MistralPayload { promptMode?: "reasoning"; reasoningEffort?: "none" | "high"; + promptCacheKey?: string; } function makeContext(): Context { @@ -77,4 +78,21 @@ describe("Mistral reasoning mode selection", () => { expect(payload.reasoningEffort).toBeUndefined(); expect(payload.promptMode).toBeUndefined(); }); + + it("uses the session id as prompt cache key", async () => { + const payload = await capturePayload(getModel("mistral", "mistral-large-latest"), { + sessionId: "session-123", + }); + + expect(payload.promptCacheKey).toBe("session-123"); + }); + + it("omits prompt cache key when cache retention is disabled", async () => { + const payload = await capturePayload(getModel("mistral", "mistral-large-latest"), { + sessionId: "session-123", + cacheRetention: "none", + }); + + expect(payload.promptCacheKey).toBeUndefined(); + }); }); diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index c366f1a5..ad672bf2 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -494,9 +494,10 @@ "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@smithy/node-http-handler": "4.7.3", "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.1", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", + "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", @@ -741,14 +742,23 @@ "optional": true }, "node_modules/@mistralai/mistralai": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", - "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", + "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", "license": "Apache-2.0", "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } } }, "node_modules/@nodable/entities": { @@ -763,6 +773,24 @@ } ] }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", From 9179734cc205fb40a4deaa70e4cd586529cdf927 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 18 Jun 2026 23:58:34 +0200 Subject: [PATCH 326/352] docs(coding-agent): audit unreleased changelog --- packages/coding-agent/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5f0c6186..06ab4f9b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,11 +4,13 @@ ### New Features +- **Mistral prompt caching** - Mistral sessions now use provider-side prompt caching with session affinity and cached-token usage/cost accounting. See [API Keys](docs/providers.md#api-keys) and [Environment Variables](docs/usage.md#environment-variables). - **Post-compaction token estimates** - Compact results and compaction events now include estimated post-compaction token counts so clients can show the approximate context reduction. See [RPC compact](docs/rpc.md#compact) and [compaction events](docs/rpc.md#compaction_start--compaction_end). - **OpenRouter Fusion alias** - `openrouter/fusion` is available as a built-in OpenRouter model alias. See [API Keys](docs/providers.md#api-keys). ### Added +- Added inherited Mistral prompt caching using the pi session ID as `prompt_cache_key`, including cached-token usage and cost accounting ([#5854](https://github.com/earendil-works/pi/issues/5854)). - Added estimated post-compaction token counts to compact results and compaction events ([#5877](https://github.com/earendil-works/pi/issues/5877)). - Added the inherited OpenRouter Fusion alias as `openrouter/fusion` ([#5866](https://github.com/earendil-works/pi/pull/5866) by [@dannote](https://github.com/dannote)). From 1a418ad223a13a58aab10549f0dcf15a84670c8f Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 19 Jun 2026 00:26:30 +0200 Subject: [PATCH 327/352] chore: remove inprogress label on close --- .../workflows/remove-inprogress-on-close.yml | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/remove-inprogress-on-close.yml diff --git a/.github/workflows/remove-inprogress-on-close.yml b/.github/workflows/remove-inprogress-on-close.yml new file mode 100644 index 00000000..e94b10e2 --- /dev/null +++ b/.github/workflows/remove-inprogress-on-close.yml @@ -0,0 +1,31 @@ +name: Remove In Progress Label On Close + +on: + issues: + types: [closed] + +jobs: + remove-label: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Remove inprogress label + uses: actions/github-script@v7 + with: + script: | + const labelName = 'inprogress'; + const labels = context.payload.issue.labels ?? []; + const hasLabel = labels.some((label) => label.name === labelName); + + if (!hasLabel) { + console.log(`Issue does not have ${labelName} label`); + return; + } + + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name: labelName, + }); From 0d89a333739d35254cc62e5579a8cf526c096550 Mon Sep 17 00:00:00 2001 From: "Fred K. Schott" <622227+FredKSchott@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:41:49 -0700 Subject: [PATCH 328/352] feat(packages): Add selective pi-ai base entrypoints (#5348) --- packages/agent/README.md | 18 + packages/agent/package.json | 4 + packages/agent/src/agent-loop.ts | 2 +- packages/agent/src/agent.ts | 2 +- packages/agent/src/base.ts | 39 ++ packages/agent/src/harness/agent-harness.ts | 2 +- .../compaction/branch-summarization.ts | 3 +- .../src/harness/compaction/compaction.ts | 10 +- .../agent/src/harness/compaction/utils.ts | 2 +- packages/agent/src/harness/messages.ts | 2 +- packages/agent/src/harness/session/session.ts | 2 +- packages/agent/src/harness/types.ts | 4 +- packages/agent/src/index.ts | 49 +- packages/agent/src/proxy.ts | 2 +- packages/agent/src/types.ts | 2 +- packages/agent/vitest.config.ts | 6 + packages/agent/vitest.harness.config.ts | 6 + packages/ai/README.md | 32 +- packages/ai/package.json | 12 + packages/ai/src/base.ts | 45 ++ packages/ai/src/bedrock-provider.ts | 4 +- packages/ai/src/images-api-registry.ts | 4 + packages/ai/src/images.ts | 2 - packages/ai/src/index.ts | 49 +- packages/ai/src/providers/amazon-bedrock.ts | 9 + packages/ai/src/providers/anthropic.ts | 9 + .../src/providers/azure-openai-responses.ts | 9 + packages/ai/src/providers/google-vertex.ts | 9 + packages/ai/src/providers/google.ts | 9 + .../ai/src/providers/images/openrouter.ts | 8 + .../src/providers/images/register-builtins.ts | 50 -- packages/ai/src/providers/mistral.ts | 9 + .../src/providers/openai-codex-responses.ts | 9 + .../ai/src/providers/openai-completions.ts | 9 + packages/ai/src/providers/openai-responses.ts | 9 + .../ai/src/providers/register-builtins.ts | 464 +++++++----------- packages/ai/src/stream.ts | 4 - packages/ai/test/abort.test.ts | 2 +- .../anthropic-eager-tool-input-e2e.test.ts | 2 +- ...ic-empty-thinking-signature-compat.test.ts | 2 +- .../anthropic-force-adaptive-thinking.test.ts | 2 +- ...anthropic-long-cache-retention-e2e.test.ts | 2 +- .../ai/test/anthropic-opus-4-8-smoke.test.ts | 2 +- .../test/anthropic-temperature-compat.test.ts | 2 +- .../test/anthropic-thinking-disable.test.ts | 2 +- .../anthropic-tool-name-normalization.test.ts | 2 +- packages/ai/test/base-entrypoint.test.ts | 68 +++ packages/ai/test/bedrock-models.test.ts | 2 +- packages/ai/test/cache-retention.test.ts | 2 +- packages/ai/test/context-overflow.test.ts | 2 +- .../ai/test/cross-provider-handoff.test.ts | 2 +- packages/ai/test/empty.test.ts | 2 +- .../ai/test/google-thinking-disable.test.ts | 2 +- packages/ai/test/interleaved-thinking.test.ts | 2 +- packages/ai/test/lazy-module-load.test.ts | 52 +- .../ai/test/mistral-reasoning-mode.test.ts | 2 +- packages/ai/test/mistral-tool-schema.test.ts | 2 +- .../openai-codex-cache-affinity-e2e.test.ts | 2 +- .../openai-completions-empty-tools.test.ts | 2 +- .../openai-completions-response-model.test.ts | 2 +- .../openai-completions-tool-choice.test.ts | 2 +- ...penai-responses-cache-affinity-e2e.test.ts | 2 +- ...nai-responses-reasoning-replay-e2e.test.ts | 2 +- .../test/openrouter-cache-write-repro.test.ts | 2 +- packages/ai/test/openrouter-images.test.ts | 12 + packages/ai/test/responseid.test.ts | 2 +- packages/ai/test/stream.test.ts | 2 +- packages/ai/test/tokens.test.ts | 2 +- .../test/tool-call-id-normalization.test.ts | 2 +- .../ai/test/tool-call-without-result.test.ts | 2 +- packages/ai/test/total-tokens.test.ts | 2 +- packages/ai/test/unicode-surrogate.test.ts | 2 +- packages/ai/test/xhigh.test.ts | 2 +- ...ms-anthropic-empty-signature-smoke.test.ts | 2 +- packages/ai/test/zen.test.ts | 2 +- packages/coding-agent/vitest.config.ts | 9 + scripts/check-browser-smoke.mjs | 44 ++ tsconfig.json | 1 + 78 files changed, 671 insertions(+), 495 deletions(-) create mode 100644 packages/agent/src/base.ts create mode 100644 packages/ai/src/base.ts delete mode 100644 packages/ai/src/providers/images/register-builtins.ts create mode 100644 packages/ai/test/base-entrypoint.test.ts diff --git a/packages/agent/README.md b/packages/agent/README.md index f0fedb81..ca4c1080 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -31,6 +31,24 @@ agent.subscribe((event) => { await agent.prompt("Hello!"); ``` +## Base Entry Point + +Use `@earendil-works/pi-agent-core/base` with `@earendil-works/pi-ai/base` when bundling applications that should include only selected provider transports: + +```typescript +import { Agent } from "@earendil-works/pi-agent-core/base"; +import { getModel } from "@earendil-works/pi-ai/base"; +import { register } from "@earendil-works/pi-ai/anthropic"; + +register(); + +const agent = new Agent({ + initialState: { model: getModel("anthropic", "claude-sonnet-4-6") }, +}); +``` + +The default `@earendil-works/pi-agent-core` entry point remains batteries-included and registers pi-ai's lazy built-in transports for backward compatibility. + ## Core Concepts ### AgentMessage vs LLM Message diff --git a/packages/agent/package.json b/packages/agent/package.json index 81d3e356..48934f8f 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -10,6 +10,10 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./base": { + "types": "./dist/base.d.ts", + "import": "./dist/base.js" + }, "./node": { "types": "./dist/node.d.ts", "import": "./dist/node.js" diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 77bd9568..8a8f20f1 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -10,7 +10,7 @@ import { streamSimple, type ToolResultMessage, validateToolArguments, -} from "@earendil-works/pi-ai"; +} from "@earendil-works/pi-ai/base"; import type { AgentContext, AgentEvent, diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index db6684a8..ef26e213 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -7,7 +7,7 @@ import { type TextContent, type ThinkingBudgets, type Transport, -} from "@earendil-works/pi-ai"; +} from "@earendil-works/pi-ai/base"; import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts"; import type { AfterToolCallContext, diff --git a/packages/agent/src/base.ts b/packages/agent/src/base.ts new file mode 100644 index 00000000..ad1abafa --- /dev/null +++ b/packages/agent/src/base.ts @@ -0,0 +1,39 @@ +export * from "./agent.ts"; +export * from "./agent-loop.ts"; +export * from "./harness/agent-harness.ts"; +export { + type BranchPreparation, + type BranchSummaryDetails, + type CollectEntriesResult, + collectEntriesForBranchSummary, + generateBranchSummary, + prepareBranchEntries, +} from "./harness/compaction/branch-summarization.ts"; +export { + calculateContextTokens, + compact, + DEFAULT_COMPACTION_SETTINGS, + estimateContextTokens, + estimateTokens, + findCutPoint, + findTurnStartIndex, + generateSummary, + getLastAssistantUsage, + prepareCompaction, + serializeConversation, + shouldCompact, +} from "./harness/compaction/compaction.ts"; +export * from "./harness/messages.ts"; +export * from "./harness/prompt-templates.ts"; +export * from "./harness/session/jsonl-repo.ts"; +export * from "./harness/session/memory-repo.ts"; +export * from "./harness/session/repo-utils.ts"; +export * from "./harness/session/session.ts"; +export { uuidv7 } from "./harness/session/uuid.ts"; +export * from "./harness/skills.ts"; +export * from "./harness/system-prompt.ts"; +export * from "./harness/types.ts"; +export * from "./harness/utils/shell-output.ts"; +export * from "./harness/utils/truncate.ts"; +export * from "./proxy.ts"; +export * from "./types.ts"; diff --git a/packages/agent/src/harness/agent-harness.ts b/packages/agent/src/harness/agent-harness.ts index 96563465..f097ee75 100644 --- a/packages/agent/src/harness/agent-harness.ts +++ b/packages/agent/src/harness/agent-harness.ts @@ -4,7 +4,7 @@ import { type Model, streamSimple, type UserMessage, -} from "@earendil-works/pi-ai"; +} from "@earendil-works/pi-ai/base"; import { runAgentLoop } from "../agent-loop.ts"; import type { AgentContext, diff --git a/packages/agent/src/harness/compaction/branch-summarization.ts b/packages/agent/src/harness/compaction/branch-summarization.ts index c1824ebf..60ffe771 100644 --- a/packages/agent/src/harness/compaction/branch-summarization.ts +++ b/packages/agent/src/harness/compaction/branch-summarization.ts @@ -1,5 +1,4 @@ -import type { Model } from "@earendil-works/pi-ai"; -import { completeSimple } from "@earendil-works/pi-ai"; +import { completeSimple, type Model } from "@earendil-works/pi-ai/base"; import type { AgentMessage } from "../../types.ts"; import { convertToLlm, diff --git a/packages/agent/src/harness/compaction/compaction.ts b/packages/agent/src/harness/compaction/compaction.ts index dba753d7..d619b34a 100644 --- a/packages/agent/src/harness/compaction/compaction.ts +++ b/packages/agent/src/harness/compaction/compaction.ts @@ -1,5 +1,11 @@ -import type { AssistantMessage, ImageContent, Model, TextContent, Usage } from "@earendil-works/pi-ai"; -import { completeSimple } from "@earendil-works/pi-ai"; +import { + type AssistantMessage, + completeSimple, + type ImageContent, + type Model, + type TextContent, + type Usage, +} from "@earendil-works/pi-ai/base"; import type { AgentMessage, ThinkingLevel } from "../../types.ts"; import { convertToLlm, diff --git a/packages/agent/src/harness/compaction/utils.ts b/packages/agent/src/harness/compaction/utils.ts index 07535b79..388dd84a 100644 --- a/packages/agent/src/harness/compaction/utils.ts +++ b/packages/agent/src/harness/compaction/utils.ts @@ -1,4 +1,4 @@ -import type { Message } from "@earendil-works/pi-ai"; +import type { Message } from "@earendil-works/pi-ai/base"; import type { AgentMessage } from "../../types.ts"; /** File paths touched by a session branch or compaction range. */ diff --git a/packages/agent/src/harness/messages.ts b/packages/agent/src/harness/messages.ts index 36ce96a1..8368dd34 100644 --- a/packages/agent/src/harness/messages.ts +++ b/packages/agent/src/harness/messages.ts @@ -1,4 +1,4 @@ -import type { ImageContent, Message, TextContent } from "@earendil-works/pi-ai"; +import type { ImageContent, Message, TextContent } from "@earendil-works/pi-ai/base"; import type { AgentMessage } from "../types.ts"; export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary: diff --git a/packages/agent/src/harness/session/session.ts b/packages/agent/src/harness/session/session.ts index 6f136208..3fd76c6b 100644 --- a/packages/agent/src/harness/session/session.ts +++ b/packages/agent/src/harness/session/session.ts @@ -1,4 +1,4 @@ -import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; +import type { ImageContent, TextContent } from "@earendil-works/pi-ai/base"; import type { AgentMessage } from "../../types.ts"; import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage } from "../messages.ts"; import type { diff --git a/packages/agent/src/harness/types.ts b/packages/agent/src/harness/types.ts index 4756ca84..8f281570 100644 --- a/packages/agent/src/harness/types.ts +++ b/packages/agent/src/harness/types.ts @@ -1,5 +1,5 @@ -import type { ImageContent, Model, SimpleStreamOptions, TextContent, Transport } from "@earendil-works/pi-ai"; -import type { AgentEvent, AgentMessage, AgentTool, QueueMode, ThinkingLevel } from "../index.ts"; +import type { ImageContent, Model, SimpleStreamOptions, TextContent, Transport } from "@earendil-works/pi-ai/base"; +import type { AgentEvent, AgentMessage, AgentTool, QueueMode, ThinkingLevel } from "../types.ts"; import type { Session } from "./session/session.ts"; /** Result of a fallible operation. Expected failures are returned as `ok: false` instead of thrown. */ diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index fd3c2b9f..08b7b9cb 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -1,44 +1,5 @@ -// Core Agent -export * from "./agent.ts"; -// Loop functions -export * from "./agent-loop.ts"; -export * from "./harness/agent-harness.ts"; -export { - type BranchPreparation, - type BranchSummaryDetails, - type CollectEntriesResult, - collectEntriesForBranchSummary, - generateBranchSummary, - prepareBranchEntries, -} from "./harness/compaction/branch-summarization.ts"; -export { - calculateContextTokens, - compact, - DEFAULT_COMPACTION_SETTINGS, - estimateContextTokens, - estimateTokens, - findCutPoint, - findTurnStartIndex, - generateSummary, - getLastAssistantUsage, - prepareCompaction, - serializeConversation, - shouldCompact, -} from "./harness/compaction/compaction.ts"; -export * from "./harness/messages.ts"; -export * from "./harness/prompt-templates.ts"; -export * from "./harness/session/jsonl-repo.ts"; -export * from "./harness/session/memory-repo.ts"; -export * from "./harness/session/repo-utils.ts"; -export * from "./harness/session/session.ts"; -export { uuidv7 } from "./harness/session/uuid.ts"; -export * from "./harness/skills.ts"; -export * from "./harness/system-prompt.ts"; -// Harness -export * from "./harness/types.ts"; -export * from "./harness/utils/shell-output.ts"; -export * from "./harness/utils/truncate.ts"; -// Proxy utilities -export * from "./proxy.ts"; -// Types -export * from "./types.ts"; +// Import the default pi-ai entrypoint so that all built-in providers register +// automatically. Unlike "@earendil-works/pi-ai/base" which does not. +import "@earendil-works/pi-ai"; + +export * from "./base.ts"; diff --git a/packages/agent/src/proxy.ts b/packages/agent/src/proxy.ts index 5f0925c9..8e5cb2c2 100644 --- a/packages/agent/src/proxy.ts +++ b/packages/agent/src/proxy.ts @@ -14,7 +14,7 @@ import { type SimpleStreamOptions, type StopReason, type ToolCall, -} from "@earendil-works/pi-ai"; +} from "@earendil-works/pi-ai/base"; // Create stream class matching ProxyMessageEventStream class ProxyMessageEventStream extends EventStream { diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index cb99a79a..541d8d63 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -9,7 +9,7 @@ import type { TextContent, Tool, ToolResultMessage, -} from "@earendil-works/pi-ai"; +} from "@earendil-works/pi-ai/base"; import type { Static, TSchema } from "typebox"; /** diff --git a/packages/agent/vitest.config.ts b/packages/agent/vitest.config.ts index bcc497fa..297fbd50 100644 --- a/packages/agent/vitest.config.ts +++ b/packages/agent/vitest.config.ts @@ -1,6 +1,12 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ + resolve: { + alias: { + "@earendil-works/pi-ai/base": new URL("../ai/src/base.ts", import.meta.url).pathname, + "@earendil-works/pi-ai": new URL("../ai/src/index.ts", import.meta.url).pathname, + }, + }, test: { globals: true, environment: "node", diff --git a/packages/agent/vitest.harness.config.ts b/packages/agent/vitest.harness.config.ts index 9421e5a9..37acc34d 100644 --- a/packages/agent/vitest.harness.config.ts +++ b/packages/agent/vitest.harness.config.ts @@ -1,6 +1,12 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ + resolve: { + alias: { + "@earendil-works/pi-ai/base": new URL("../ai/src/base.ts", import.meta.url).pathname, + "@earendil-works/pi-ai": new URL("../ai/src/index.ts", import.meta.url).pathname, + }, + }, test: { globals: true, environment: "node", diff --git a/packages/ai/README.md b/packages/ai/README.md index a7b8028e..8137ccd4 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -8,6 +8,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an - [Supported Providers](#supported-providers) - [Installation](#installation) +- [Base Entry Point](#base-entry-point) - [Quick Start](#quick-start) - [Tools](#tools) - [Defining Tools](#defining-tools) @@ -88,6 +89,30 @@ npm install @earendil-works/pi-ai TypeBox exports are re-exported from `@earendil-works/pi-ai`: `Type`, `Static`, and `TSchema`. +## Base Entry Point + +Use `@earendil-works/pi-ai/base` when a bundler should include only explicitly selected transport implementations. The base entry point exposes model discovery, registries, generic dispatch, environment-key helpers, and provider-neutral utilities without registering built-in transports during module initialization. Generic dispatch still resolves configured environment keys when called. + +Import each selected transport directly and call its `register()` function: + +```typescript +import { getModel, streamSimple } from '@earendil-works/pi-ai/base'; +import { register as registerAnthropic } from '@earendil-works/pi-ai/anthropic'; + +registerAnthropic(); + +const model = getModel('anthropic', 'claude-sonnet-4-6'); +const response = await streamSimple(model, { + messages: [{ role: 'user', content: 'Hello!' }] +}, { + apiKey: process.env.ANTHROPIC_API_KEY +}).result(); +``` + +Direct transport imports bundle their implementation and SDK code. Register only the transports used by the application. For example, OpenRouter, Groq, xAI, and DeepSeek chat models share the `@earendil-works/pi-ai/openai-completions` transport. + +Use `@earendil-works/pi-ai` for the batteries-included behavior. The root entry point remains backward-compatible: it registers lazy wrappers for all built-in transports and resolves environment API keys automatically during dispatch. + ## Quick Start ```typescript @@ -438,7 +463,7 @@ Do not use `stream()` or `complete()` for image generation. Image generation is ### Basic Image Generation ```typescript -import { getImageModel, generateImages } from '@mariozechner/pi-ai'; +import { getImageModel, generateImages } from '@earendil-works/pi-ai'; const model = getImageModel('openrouter', 'google/gemini-2.5-flash-image'); @@ -1097,6 +1122,7 @@ const response = await complete(model, { - OAuth login flows are not supported in browser environments. Use the `@earendil-works/pi-ai/oauth` entry point in Node.js. - In browser builds, Bedrock can still appear in model lists. Calls to Bedrock models fail at runtime. - Use a server-side proxy or backend service if you need Bedrock or OAuth-based auth from a web app. +- Use `@earendil-works/pi-ai/base` plus explicit direct transport registration when a browser bundle should exclude unused provider SDK implementations. ### Environment Variables (Node.js only) @@ -1333,6 +1359,7 @@ Create a new provider file (for example `amazon-bedrock.ts`) that exports: - `stream()` function returning `AssistantMessageEventStream` - `streamSimple()` for `SimpleStreamOptions` mapping +- `register()` for explicit direct transport registration from `@earendil-works/pi-ai/base` - Provider-specific options interface - Message conversion functions to transform `Context` to provider format - Tool conversion if the provider supports tools @@ -1343,7 +1370,8 @@ Create a new provider file (for example `amazon-bedrock.ts`) that exports: - Register the API with `registerApiProvider()` - Add a package subpath export in `package.json` for the provider module (`./dist/providers/.js`) - Add lazy loader wrappers in `src/providers/register-builtins.ts`, do not statically import provider implementation modules there -- Add any root-level `export type` re-exports in `src/index.ts` that should remain available from `@earendil-works/pi-ai` +- Add any root-level `export type` re-exports in `src/index.ts` and `src/base.ts` that should remain available from `@earendil-works/pi-ai` and `@earendil-works/pi-ai/base` +- Keep `src/base.ts` free of built-in registration imports - Add credential detection in `env-api-keys.ts` for the new provider - Ensure `streamSimple` handles auth lookup via `getEnvApiKey()` or provider-specific auth diff --git a/packages/ai/package.json b/packages/ai/package.json index 2c0bd6cf..1b34843a 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -10,6 +10,14 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./base": { + "types": "./dist/base.d.ts", + "import": "./dist/base.js" + }, + "./amazon-bedrock": { + "types": "./dist/providers/amazon-bedrock.d.ts", + "import": "./dist/providers/amazon-bedrock.js" + }, "./anthropic": { "types": "./dist/providers/anthropic.d.ts", "import": "./dist/providers/anthropic.js" @@ -42,6 +50,10 @@ "types": "./dist/providers/openai-responses.d.ts", "import": "./dist/providers/openai-responses.js" }, + "./openrouter-images": { + "types": "./dist/providers/images/openrouter.d.ts", + "import": "./dist/providers/images/openrouter.js" + }, "./oauth": { "types": "./dist/oauth.d.ts", "import": "./dist/oauth.js" diff --git a/packages/ai/src/base.ts b/packages/ai/src/base.ts new file mode 100644 index 00000000..f1a8a110 --- /dev/null +++ b/packages/ai/src/base.ts @@ -0,0 +1,45 @@ +export type { Static, TSchema } from "typebox"; +export { Type } from "typebox"; + +export * from "./api-registry.ts"; +export * from "./env-api-keys.ts"; +export * from "./image-models.ts"; +export * from "./images.ts"; +export * from "./images-api-registry.ts"; +export * from "./models.ts"; +export type { BedrockOptions, BedrockThinkingDisplay } from "./providers/amazon-bedrock.ts"; +export type { AnthropicEffort, AnthropicOptions, AnthropicThinkingDisplay } from "./providers/anthropic.ts"; +export type { AzureOpenAIResponsesOptions } from "./providers/azure-openai-responses.ts"; +export * from "./providers/faux.ts"; +export type { GoogleOptions } from "./providers/google.ts"; +export type { GoogleThinkingLevel } from "./providers/google-shared.ts"; +export type { GoogleVertexOptions } from "./providers/google-vertex.ts"; +export type { MistralOptions } from "./providers/mistral.ts"; +export type { + OpenAICodexResponsesOptions, + OpenAICodexWebSocketDebugStats, +} from "./providers/openai-codex-responses.ts"; +export type { OpenAICompletionsOptions } from "./providers/openai-completions.ts"; +export type { OpenAIResponsesOptions } from "./providers/openai-responses.ts"; +export * from "./session-resources.ts"; +export * from "./stream.ts"; +export * from "./types.ts"; +export * from "./utils/diagnostics.ts"; +export * from "./utils/event-stream.ts"; +export * from "./utils/json-parse.ts"; +export type { + OAuthAuthInfo, + OAuthCredentials, + OAuthDeviceCodeInfo, + OAuthLoginCallbacks, + OAuthPrompt, + OAuthProvider, + OAuthProviderId, + OAuthProviderInfo, + OAuthProviderInterface, + OAuthSelectOption, + OAuthSelectPrompt, +} from "./utils/oauth/types.ts"; +export * from "./utils/overflow.ts"; +export * from "./utils/typebox-helpers.ts"; +export * from "./utils/validation.ts"; diff --git a/packages/ai/src/bedrock-provider.ts b/packages/ai/src/bedrock-provider.ts index cf08b33e..f5aa3998 100644 --- a/packages/ai/src/bedrock-provider.ts +++ b/packages/ai/src/bedrock-provider.ts @@ -1,4 +1,6 @@ -import { streamBedrock, streamSimpleBedrock } from "./providers/amazon-bedrock.ts"; +import { register, streamBedrock, streamSimpleBedrock } from "./providers/amazon-bedrock.ts"; + +export { register }; export const bedrockProviderModule = { streamBedrock, diff --git a/packages/ai/src/images-api-registry.ts b/packages/ai/src/images-api-registry.ts index 4758439b..5335a926 100644 --- a/packages/ai/src/images-api-registry.ts +++ b/packages/ai/src/images-api-registry.ts @@ -51,3 +51,7 @@ export function registerImagesApiProvider, project: string, diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index a270792a..62ad9cfd 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -4,6 +4,7 @@ import { GoogleGenAI, type ThinkingConfig, } from "@google/genai"; +import { registerApiProvider } from "../api-registry.ts"; import { calculateCost, clampThinkingLevel } from "../models.ts"; import type { Api, @@ -315,6 +316,14 @@ export const streamSimpleGoogle: StreamFunction<"google-generative-ai", SimpleSt } satisfies GoogleOptions); }; +export function register(): void { + registerApiProvider({ + api: "google-generative-ai", + stream: streamGoogle, + streamSimple: streamSimpleGoogle, + }); +} + function createClient( model: Model<"google-generative-ai">, apiKey?: string, diff --git a/packages/ai/src/providers/images/openrouter.ts b/packages/ai/src/providers/images/openrouter.ts index 54caeaf0..d1b2c7e8 100644 --- a/packages/ai/src/providers/images/openrouter.ts +++ b/packages/ai/src/providers/images/openrouter.ts @@ -6,6 +6,7 @@ import type { ChatCompletionContentPartText, ChatCompletionCreateParamsNonStreaming, } from "openai/resources/chat/completions.js"; +import { registerImagesApiProvider } from "../../images-api-registry.ts"; import type { AssistantImages, ImageContent, @@ -103,6 +104,13 @@ export const generateImagesOpenRouter: ImagesFunction<"openrouter-images", Image } }; +export function register(): void { + registerImagesApiProvider({ + api: "openrouter-images", + generateImages: generateImagesOpenRouter, + }); +} + function createClient( model: ImagesModel<"openrouter-images">, apiKey: string, diff --git a/packages/ai/src/providers/images/register-builtins.ts b/packages/ai/src/providers/images/register-builtins.ts deleted file mode 100644 index e3decbb9..00000000 --- a/packages/ai/src/providers/images/register-builtins.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { registerImagesApiProvider } from "../../images-api-registry.ts"; -import type { AssistantImages, ImagesContext, ImagesFunction, ImagesModel, ImagesOptions } from "../../types.ts"; -import type { generateImagesOpenRouter as generateImagesOpenRouterFunction } from "./openrouter.ts"; - -interface OpenRouterImagesProviderModule { - generateImagesOpenRouter: typeof generateImagesOpenRouterFunction; -} - -let openRouterImagesProviderModulePromise: Promise | undefined; - -function createLazyLoadErrorImages(model: ImagesModel<"openrouter-images">, error: unknown): AssistantImages { - return { - api: model.api, - provider: model.provider, - model: model.id, - output: [], - stopReason: "error", - errorMessage: error instanceof Error ? error.message : String(error), - timestamp: Date.now(), - }; -} - -function loadOpenRouterImagesProviderModule(): Promise { - openRouterImagesProviderModulePromise ||= import("./openrouter.ts").then( - (module) => module as OpenRouterImagesProviderModule, - ); - return openRouterImagesProviderModulePromise; -} - -export const generateImagesOpenRouter: ImagesFunction<"openrouter-images", ImagesOptions> = async ( - model: ImagesModel<"openrouter-images">, - context: ImagesContext, - options?: ImagesOptions, -) => { - try { - const module = await loadOpenRouterImagesProviderModule(); - return await module.generateImagesOpenRouter(model, context, options); - } catch (error) { - return createLazyLoadErrorImages(model, error); - } -}; - -export function registerBuiltInImagesApiProviders(): void { - registerImagesApiProvider({ - api: "openrouter-images", - generateImages: generateImagesOpenRouter, - }); -} - -registerBuiltInImagesApiProviders(); diff --git a/packages/ai/src/providers/mistral.ts b/packages/ai/src/providers/mistral.ts index 6a132236..ba331af4 100644 --- a/packages/ai/src/providers/mistral.ts +++ b/packages/ai/src/providers/mistral.ts @@ -6,6 +6,7 @@ import type { ContentChunk, FunctionTool, } from "@mistralai/mistralai/models/components"; +import { registerApiProvider } from "../api-registry.ts"; import { calculateCost, clampThinkingLevel } from "../models.ts"; import type { AssistantMessage, @@ -130,6 +131,14 @@ export const streamSimpleMistral: StreamFunction<"mistral-conversations", Simple } satisfies MistralOptions); }; +export function register(): void { + registerApiProvider({ + api: "mistral-conversations", + stream: streamMistral, + streamSimple: streamSimpleMistral, + }); +} + function createOutput(model: Model<"mistral-conversations">): AssistantMessage { return { role: "assistant", diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 19a2f5d7..1bc2879a 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -20,6 +20,7 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version }); } +import { registerApiProvider } from "../api-registry.ts"; import { clampThinkingLevel } from "../models.ts"; import { registerSessionResourceCleanup } from "../session-resources.ts"; import type { @@ -428,6 +429,14 @@ export const streamSimpleOpenAICodexResponses: StreamFunction<"openai-codex-resp } satisfies OpenAICodexResponsesOptions); }; +export function register(): void { + registerApiProvider({ + api: "openai-codex-responses", + stream: streamOpenAICodexResponses, + streamSimple: streamSimpleOpenAICodexResponses, + }); +} + // ============================================================================ // Request Building // ============================================================================ diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 61b03631..2f5edb62 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -10,6 +10,7 @@ import type { ChatCompletionSystemMessageParam, ChatCompletionToolMessageParam, } from "openai/resources/chat/completions.js"; +import { registerApiProvider } from "../api-registry.ts"; import { calculateCost, clampThinkingLevel } from "../models.ts"; import type { AssistantMessage, @@ -449,6 +450,14 @@ export const streamSimpleOpenAICompletions: StreamFunction<"openai-completions", } satisfies OpenAICompletionsOptions); }; +export function register(): void { + registerApiProvider({ + api: "openai-completions", + stream: streamOpenAICompletions, + streamSimple: streamSimpleOpenAICompletions, + }); +} + function createClient( model: Model<"openai-completions">, context: Context, diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 014233d5..b1692978 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -1,5 +1,6 @@ import OpenAI from "openai"; import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js"; +import { registerApiProvider } from "../api-registry.ts"; import { clampThinkingLevel } from "../models.ts"; import type { Api, @@ -181,6 +182,14 @@ export const streamSimpleOpenAIResponses: StreamFunction<"openai-responses", Sim } satisfies OpenAIResponsesOptions); }; +export function register(): void { + registerApiProvider({ + api: "openai-responses", + stream: streamOpenAIResponses, + streamSimple: streamSimpleOpenAIResponses, + }); +} + function createClient( model: Model<"openai-responses">, context: Context, diff --git a/packages/ai/src/providers/register-builtins.ts b/packages/ai/src/providers/register-builtins.ts index 8fdcaaf0..6192f8a2 100644 --- a/packages/ai/src/providers/register-builtins.ts +++ b/packages/ai/src/providers/register-builtins.ts @@ -1,9 +1,14 @@ -import { clearApiProviders, registerApiProvider } from "../api-registry.ts"; +import { type ApiProvider, clearApiProviders, getApiProvider, registerApiProvider } from "../api-registry.ts"; +import { getImagesApiProvider, type ImagesApiProvider, registerImagesApiProvider } from "../images-api-registry.ts"; import type { Api, + AssistantImages, AssistantMessage, AssistantMessageEvent, - Context, + ImagesApi, + ImagesContext, + ImagesModel, + ImagesOptions, Model, SimpleStreamOptions, StreamFunction, @@ -20,70 +25,47 @@ import type { OpenAICodexResponsesOptions } from "./openai-codex-responses.ts"; import type { OpenAICompletionsOptions } from "./openai-completions.ts"; import type { OpenAIResponsesOptions } from "./openai-responses.ts"; -interface LazyProviderModule< - TApi extends Api, - TOptions extends StreamOptions, - TSimpleOptions extends SimpleStreamOptions, -> { - stream: (model: Model, context: Context, options?: TOptions) => AsyncIterable; - streamSimple: ( - model: Model, - context: Context, - options?: TSimpleOptions, - ) => AsyncIterable; +interface RegisteringProviderModule { + register(): void; } -interface AnthropicProviderModule { - streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOptions>; - streamSimpleAnthropic: StreamFunction<"anthropic-messages", SimpleStreamOptions>; +function createLazyLoadErrorImages(model: ImagesModel<"openrouter-images">, error: unknown): AssistantImages { + return { + api: model.api, + provider: model.provider, + model: model.id, + output: [], + stopReason: "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp: Date.now(), + }; } -interface AzureOpenAIResponsesProviderModule { - streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses", AzureOpenAIResponsesOptions>; - streamSimpleAzureOpenAIResponses: StreamFunction<"azure-openai-responses", SimpleStreamOptions>; -} - -interface GoogleProviderModule { - streamGoogle: StreamFunction<"google-generative-ai", GoogleOptions>; - streamSimpleGoogle: StreamFunction<"google-generative-ai", SimpleStreamOptions>; -} - -interface GoogleVertexProviderModule { - streamGoogleVertex: StreamFunction<"google-vertex", GoogleVertexOptions>; - streamSimpleGoogleVertex: StreamFunction<"google-vertex", SimpleStreamOptions>; -} - -interface MistralProviderModule { - streamMistral: StreamFunction<"mistral-conversations", MistralOptions>; - streamSimpleMistral: StreamFunction<"mistral-conversations", SimpleStreamOptions>; -} - -interface OpenAICodexResponsesProviderModule { - streamOpenAICodexResponses: StreamFunction<"openai-codex-responses", OpenAICodexResponsesOptions>; - streamSimpleOpenAICodexResponses: StreamFunction<"openai-codex-responses", SimpleStreamOptions>; -} - -interface OpenAICompletionsProviderModule { - streamOpenAICompletions: StreamFunction<"openai-completions", OpenAICompletionsOptions>; - streamSimpleOpenAICompletions: StreamFunction<"openai-completions", SimpleStreamOptions>; -} - -interface OpenAIResponsesProviderModule { - streamOpenAIResponses: StreamFunction<"openai-responses", OpenAIResponsesOptions>; - streamSimpleOpenAIResponses: StreamFunction<"openai-responses", SimpleStreamOptions>; +function createLazyImagesApiProvider( + api: TApi, + loadModule: () => Promise, +): ImagesApiProvider { + return { + api, + generateImages: async (model: ImagesModel, context: ImagesContext, options?: TOptions) => { + try { + const module = await loadModule(); + module.register(); + const provider = getImagesApiProvider(api); + if (!provider) { + throw new Error(`No API provider registered for api: ${api}`); + } + return await provider.generateImages(model, context, options); + } catch (error) { + return createLazyLoadErrorImages(model as ImagesModel<"openrouter-images">, error); + } + }, + }; } interface BedrockProviderModule { - streamBedrock: ( - model: Model<"bedrock-converse-stream">, - context: Context, - options?: BedrockOptions, - ) => AsyncIterable; - streamSimpleBedrock: ( - model: Model<"bedrock-converse-stream">, - context: Context, - options?: SimpleStreamOptions, - ) => AsyncIterable; + streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOptions>; + streamSimpleBedrock: StreamFunction<"bedrock-converse-stream", SimpleStreamOptions>; } const importNodeOnlyProvider = (specifier: string): Promise => { @@ -91,42 +73,10 @@ const importNodeOnlyProvider = (specifier: string): Promise => { return import(runtimeSpecifier); }; -let anthropicProviderModulePromise: - | Promise> - | undefined; -let azureOpenAIResponsesProviderModulePromise: - | Promise> - | undefined; -let googleProviderModulePromise: - | Promise> - | undefined; -let googleVertexProviderModulePromise: - | Promise> - | undefined; -let mistralProviderModulePromise: - | Promise> - | undefined; -let openAICodexResponsesProviderModulePromise: - | Promise> - | undefined; -let openAICompletionsProviderModulePromise: - | Promise> - | undefined; -let openAIResponsesProviderModulePromise: - | Promise> - | undefined; -let bedrockProviderModuleOverride: - | LazyProviderModule<"bedrock-converse-stream", BedrockOptions, SimpleStreamOptions> - | undefined; -let bedrockProviderModulePromise: - | Promise> - | undefined; +let bedrockProviderModuleOverride: BedrockProviderModule | undefined; export function setBedrockProviderModule(module: BedrockProviderModule): void { - bedrockProviderModuleOverride = { - stream: module.streamBedrock, - streamSimple: module.streamSimpleBedrock, - }; + bedrockProviderModuleOverride = module; } function forwardStream(target: AssistantMessageEventStream, source: AsyncIterable): void { @@ -159,15 +109,29 @@ function createLazyLoadErrorMessage(model: Model, error: }; } -function createLazyStream( - loadModule: () => Promise>, +async function loadAndRegisterProvider( + api: TApi, + loadModule: () => Promise, +) { + const module = await loadModule(); + module.register(); + const provider = getApiProvider(api); + if (!provider) { + throw new Error(`No API provider registered for api: ${api}`); + } + return provider; +} + +function createLazyStream( + api: TApi, + loadModule: () => Promise, ): StreamFunction { return (model, context, options) => { const outer = new AssistantMessageEventStream(); - loadModule() - .then((module) => { - const inner = module.stream(model, context, options); + loadAndRegisterProvider(api, loadModule) + .then((provider) => { + const inner = provider.stream(model, context, options); forwardStream(outer, inner); }) .catch((error) => { @@ -180,17 +144,16 @@ function createLazyStream(loadModule: () => Promise>): StreamFunction { +function createLazySimpleStream( + api: TApi, + loadModule: () => Promise, +): StreamFunction { return (model, context, options) => { const outer = new AssistantMessageEventStream(); - loadModule() - .then((module) => { - const inner = module.streamSimple(model, context, options); + loadAndRegisterProvider(api, loadModule) + .then((provider) => { + const inner = provider.streamSimple(model, context, options); forwardStream(outer, inner); }) .catch((error) => { @@ -203,201 +166,114 @@ function createLazySimpleStream< }; } -function loadAnthropicProviderModule(): Promise< - LazyProviderModule<"anthropic-messages", AnthropicOptions, SimpleStreamOptions> -> { - anthropicProviderModulePromise ||= import("./anthropic.ts").then((module) => { - const provider = module as AnthropicProviderModule; - return { - stream: provider.streamAnthropic, - streamSimple: provider.streamSimpleAnthropic, - }; - }); - return anthropicProviderModulePromise; +function createLazyApiProvider( + api: TApi, + loadModule: () => Promise, +): ApiProvider { + return { + api, + stream: createLazyStream(api, loadModule), + streamSimple: createLazySimpleStream(api, loadModule), + }; } -function loadAzureOpenAIResponsesProviderModule(): Promise< - LazyProviderModule<"azure-openai-responses", AzureOpenAIResponsesOptions, SimpleStreamOptions> -> { - azureOpenAIResponsesProviderModulePromise ||= import("./azure-openai-responses.ts").then((module) => { - const provider = module as AzureOpenAIResponsesProviderModule; - return { - stream: provider.streamAzureOpenAIResponses, - streamSimple: provider.streamSimpleAzureOpenAIResponses, - }; - }); - return azureOpenAIResponsesProviderModulePromise; -} - -function loadGoogleProviderModule(): Promise< - LazyProviderModule<"google-generative-ai", GoogleOptions, SimpleStreamOptions> -> { - googleProviderModulePromise ||= import("./google.ts").then((module) => { - const provider = module as GoogleProviderModule; - return { - stream: provider.streamGoogle, - streamSimple: provider.streamSimpleGoogle, - }; - }); - return googleProviderModulePromise; -} - -function loadGoogleVertexProviderModule(): Promise< - LazyProviderModule<"google-vertex", GoogleVertexOptions, SimpleStreamOptions> -> { - googleVertexProviderModulePromise ||= import("./google-vertex.ts").then((module) => { - const provider = module as GoogleVertexProviderModule; - return { - stream: provider.streamGoogleVertex, - streamSimple: provider.streamSimpleGoogleVertex, - }; - }); - return googleVertexProviderModulePromise; -} - -function loadMistralProviderModule(): Promise< - LazyProviderModule<"mistral-conversations", MistralOptions, SimpleStreamOptions> -> { - mistralProviderModulePromise ||= import("./mistral.ts").then((module) => { - const provider = module as MistralProviderModule; - return { - stream: provider.streamMistral, - streamSimple: provider.streamSimpleMistral, - }; - }); - return mistralProviderModulePromise; -} - -function loadOpenAICodexResponsesProviderModule(): Promise< - LazyProviderModule<"openai-codex-responses", OpenAICodexResponsesOptions, SimpleStreamOptions> -> { - openAICodexResponsesProviderModulePromise ||= import("./openai-codex-responses.ts").then((module) => { - const provider = module as OpenAICodexResponsesProviderModule; - return { - stream: provider.streamOpenAICodexResponses, - streamSimple: provider.streamSimpleOpenAICodexResponses, - }; - }); - return openAICodexResponsesProviderModulePromise; -} - -function loadOpenAICompletionsProviderModule(): Promise< - LazyProviderModule<"openai-completions", OpenAICompletionsOptions, SimpleStreamOptions> -> { - openAICompletionsProviderModulePromise ||= import("./openai-completions.ts").then((module) => { - const provider = module as OpenAICompletionsProviderModule; - return { - stream: provider.streamOpenAICompletions, - streamSimple: provider.streamSimpleOpenAICompletions, - }; - }); - return openAICompletionsProviderModulePromise; -} - -function loadOpenAIResponsesProviderModule(): Promise< - LazyProviderModule<"openai-responses", OpenAIResponsesOptions, SimpleStreamOptions> -> { - openAIResponsesProviderModulePromise ||= import("./openai-responses.ts").then((module) => { - const provider = module as OpenAIResponsesProviderModule; - return { - stream: provider.streamOpenAIResponses, - streamSimple: provider.streamSimpleOpenAIResponses, - }; - }); - return openAIResponsesProviderModulePromise; -} - -function loadBedrockProviderModule(): Promise< - LazyProviderModule<"bedrock-converse-stream", BedrockOptions, SimpleStreamOptions> -> { - if (bedrockProviderModuleOverride) { - return Promise.resolve(bedrockProviderModuleOverride); - } - bedrockProviderModulePromise ||= importNodeOnlyProvider("./amazon-bedrock.ts").then((module) => { - const provider = module as BedrockProviderModule; - return { - stream: provider.streamBedrock, - streamSimple: provider.streamSimpleBedrock, - }; - }); - return bedrockProviderModulePromise; -} - -export const streamAnthropic = createLazyStream(loadAnthropicProviderModule); -export const streamSimpleAnthropic = createLazySimpleStream(loadAnthropicProviderModule); -export const streamAzureOpenAIResponses = createLazyStream(loadAzureOpenAIResponsesProviderModule); -export const streamSimpleAzureOpenAIResponses = createLazySimpleStream(loadAzureOpenAIResponsesProviderModule); -export const streamGoogle = createLazyStream(loadGoogleProviderModule); -export const streamSimpleGoogle = createLazySimpleStream(loadGoogleProviderModule); -export const streamGoogleVertex = createLazyStream(loadGoogleVertexProviderModule); -export const streamSimpleGoogleVertex = createLazySimpleStream(loadGoogleVertexProviderModule); -export const streamMistral = createLazyStream(loadMistralProviderModule); -export const streamSimpleMistral = createLazySimpleStream(loadMistralProviderModule); -export const streamOpenAICodexResponses = createLazyStream(loadOpenAICodexResponsesProviderModule); -export const streamSimpleOpenAICodexResponses = createLazySimpleStream(loadOpenAICodexResponsesProviderModule); -export const streamOpenAICompletions = createLazyStream(loadOpenAICompletionsProviderModule); -export const streamSimpleOpenAICompletions = createLazySimpleStream(loadOpenAICompletionsProviderModule); -export const streamOpenAIResponses = createLazyStream(loadOpenAIResponsesProviderModule); -export const streamSimpleOpenAIResponses = createLazySimpleStream(loadOpenAIResponsesProviderModule); -const streamBedrockLazy = createLazyStream(loadBedrockProviderModule); -const streamSimpleBedrockLazy = createLazySimpleStream(loadBedrockProviderModule); - -export function registerBuiltInApiProviders(): void { - registerApiProvider({ - api: "anthropic-messages", - stream: streamAnthropic, - streamSimple: streamSimpleAnthropic, - }); - - registerApiProvider({ - api: "openai-completions", - stream: streamOpenAICompletions, - streamSimple: streamSimpleOpenAICompletions, - }); - - registerApiProvider({ - api: "mistral-conversations", - stream: streamMistral, - streamSimple: streamSimpleMistral, - }); - - registerApiProvider({ - api: "openai-responses", - stream: streamOpenAIResponses, - streamSimple: streamSimpleOpenAIResponses, - }); - - registerApiProvider({ - api: "azure-openai-responses", - stream: streamAzureOpenAIResponses, - streamSimple: streamSimpleAzureOpenAIResponses, - }); - - registerApiProvider({ - api: "openai-codex-responses", - stream: streamOpenAICodexResponses, - streamSimple: streamSimpleOpenAICodexResponses, - }); - - registerApiProvider({ - api: "google-generative-ai", - stream: streamGoogle, - streamSimple: streamSimpleGoogle, - }); - - registerApiProvider({ - api: "google-vertex", - stream: streamGoogleVertex, - streamSimple: streamSimpleGoogleVertex, - }); - +function registerBedrockProviderModule(module: BedrockProviderModule): void { registerApiProvider({ api: "bedrock-converse-stream", - stream: streamBedrockLazy, - streamSimple: streamSimpleBedrockLazy, + stream: module.streamBedrock, + streamSimple: module.streamSimpleBedrock, }); } +function loadBedrockProviderModule(): Promise { + const module = bedrockProviderModuleOverride; + if (module) { + return Promise.resolve({ register: () => registerBedrockProviderModule(module) }); + } + return importNodeOnlyProvider("./amazon-bedrock.ts").then((provider) => provider as RegisteringProviderModule); +} + +const anthropicProvider = createLazyApiProvider<"anthropic-messages", AnthropicOptions>( + "anthropic-messages", + () => import("./anthropic.ts"), +); +const azureOpenAIResponsesProvider = createLazyApiProvider<"azure-openai-responses", AzureOpenAIResponsesOptions>( + "azure-openai-responses", + () => import("./azure-openai-responses.ts"), +); +const googleProvider = createLazyApiProvider<"google-generative-ai", GoogleOptions>( + "google-generative-ai", + () => import("./google.ts"), +); +const googleVertexProvider = createLazyApiProvider<"google-vertex", GoogleVertexOptions>( + "google-vertex", + () => import("./google-vertex.ts"), +); +const mistralProvider = createLazyApiProvider<"mistral-conversations", MistralOptions>( + "mistral-conversations", + () => import("./mistral.ts"), +); +const openAICodexResponsesProvider = createLazyApiProvider<"openai-codex-responses", OpenAICodexResponsesOptions>( + "openai-codex-responses", + () => import("./openai-codex-responses.ts"), +); +const openAICompletionsProvider = createLazyApiProvider<"openai-completions", OpenAICompletionsOptions>( + "openai-completions", + () => import("./openai-completions.ts"), +); +const openAIResponsesProvider = createLazyApiProvider<"openai-responses", OpenAIResponsesOptions>( + "openai-responses", + () => import("./openai-responses.ts"), +); +const bedrockProvider = createLazyApiProvider<"bedrock-converse-stream", BedrockOptions>( + "bedrock-converse-stream", + loadBedrockProviderModule, +); +const openRouterImagesProvider = createLazyImagesApiProvider( + "openrouter-images", + () => import("./images/openrouter.ts"), +); + +export const generateImagesOpenRouter = openRouterImagesProvider.generateImages; +export const streamAnthropic = anthropicProvider.stream; +export const streamSimpleAnthropic = anthropicProvider.streamSimple; +export const streamAzureOpenAIResponses = azureOpenAIResponsesProvider.stream; +export const streamSimpleAzureOpenAIResponses = azureOpenAIResponsesProvider.streamSimple; +export const streamGoogle = googleProvider.stream; +export const streamSimpleGoogle = googleProvider.streamSimple; +export const streamGoogleVertex = googleVertexProvider.stream; +export const streamSimpleGoogleVertex = googleVertexProvider.streamSimple; +export const streamMistral = mistralProvider.stream; +export const streamSimpleMistral = mistralProvider.streamSimple; +export const streamOpenAICodexResponses = openAICodexResponsesProvider.stream; +export const streamSimpleOpenAICodexResponses = openAICodexResponsesProvider.streamSimple; +export const streamOpenAICompletions = openAICompletionsProvider.stream; +export const streamSimpleOpenAICompletions = openAICompletionsProvider.streamSimple; +export const streamOpenAIResponses = openAIResponsesProvider.stream; +export const streamSimpleOpenAIResponses = openAIResponsesProvider.streamSimple; + +const registerBuiltInApiProviderFunctions = [ + () => registerApiProvider(anthropicProvider), + () => registerApiProvider(openAICompletionsProvider), + () => registerApiProvider(mistralProvider), + () => registerApiProvider(openAIResponsesProvider), + () => registerApiProvider(azureOpenAIResponsesProvider), + () => registerApiProvider(openAICodexResponsesProvider), + () => registerApiProvider(googleProvider), + () => registerApiProvider(googleVertexProvider), + () => registerApiProvider(bedrockProvider), +]; + +export function registerBuiltInImagesApiProviders(): void { + registerImagesApiProvider(openRouterImagesProvider); +} + +export function registerBuiltInApiProviders(): void { + for (const register of registerBuiltInApiProviderFunctions) { + register(); + } +} + export function resetApiProviders(): void { clearApiProviders(); registerBuiltInApiProviders(); diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 3f333d9e..7b6b28c6 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -1,5 +1,3 @@ -import "./providers/register-builtins.ts"; - import { getApiProvider } from "./api-registry.ts"; import { getEnvApiKey } from "./env-api-keys.ts"; import type { @@ -13,8 +11,6 @@ import type { StreamOptions, } from "./types.ts"; -export { getEnvApiKey } from "./env-api-keys.ts"; - function hasExplicitApiKey(apiKey: string | undefined): apiKey is string { return typeof apiKey === "string" && apiKey.trim().length > 0; } diff --git a/packages/ai/test/abort.test.ts b/packages/ai/test/abort.test.ts index 27c274aa..303044ad 100644 --- a/packages/ai/test/abort.test.ts +++ b/packages/ai/test/abort.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { complete, stream } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { complete, stream } from "../src/stream.ts"; import type { Api, Context, Model, StreamOptions } from "../src/types.ts"; type StreamOptionsWithExtras = StreamOptions & Record; diff --git a/packages/ai/test/anthropic-eager-tool-input-e2e.test.ts b/packages/ai/test/anthropic-eager-tool-input-e2e.test.ts index 2483e1a4..a9862485 100644 --- a/packages/ai/test/anthropic-eager-tool-input-e2e.test.ts +++ b/packages/ai/test/anthropic-eager-tool-input-e2e.test.ts @@ -1,8 +1,8 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; import { getEnvApiKey } from "../src/env-api-keys.ts"; +import { complete } from "../src/index.ts"; import { getModels, getProviders } from "../src/models.ts"; -import { complete } from "../src/stream.ts"; import type { Api, KnownProvider, Model, ProviderStreamOptions, Tool } from "../src/types.ts"; import { resolveApiKey } from "./oauth.ts"; diff --git a/packages/ai/test/anthropic-empty-thinking-signature-compat.test.ts b/packages/ai/test/anthropic-empty-thinking-signature-compat.test.ts index 69e58e27..5663e2a8 100644 --- a/packages/ai/test/anthropic-empty-thinking-signature-compat.test.ts +++ b/packages/ai/test/anthropic-empty-thinking-signature-compat.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { streamSimple } from "../src/stream.ts"; +import { streamSimple } from "../src/index.ts"; import type { AssistantMessage, Context, Model } from "../src/types.ts"; interface AnthropicPayload { diff --git a/packages/ai/test/anthropic-force-adaptive-thinking.test.ts b/packages/ai/test/anthropic-force-adaptive-thinking.test.ts index e797c3a9..cef2ca48 100644 --- a/packages/ai/test/anthropic-force-adaptive-thinking.test.ts +++ b/packages/ai/test/anthropic-force-adaptive-thinking.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { streamSimple } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { streamSimple } from "../src/stream.ts"; import type { Context, Model, SimpleStreamOptions } from "../src/types.ts"; interface AnthropicThinkingPayload { diff --git a/packages/ai/test/anthropic-long-cache-retention-e2e.test.ts b/packages/ai/test/anthropic-long-cache-retention-e2e.test.ts index 2b7667d7..0c9d590f 100644 --- a/packages/ai/test/anthropic-long-cache-retention-e2e.test.ts +++ b/packages/ai/test/anthropic-long-cache-retention-e2e.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { getEnvApiKey } from "../src/env-api-keys.ts"; +import { complete } from "../src/index.ts"; import { getModels, getProviders } from "../src/models.ts"; -import { complete } from "../src/stream.ts"; import type { Api, KnownProvider, Model, ProviderStreamOptions } from "../src/types.ts"; import { resolveApiKey } from "./oauth.ts"; diff --git a/packages/ai/test/anthropic-opus-4-8-smoke.test.ts b/packages/ai/test/anthropic-opus-4-8-smoke.test.ts index bb4b739b..0c1288b3 100644 --- a/packages/ai/test/anthropic-opus-4-8-smoke.test.ts +++ b/packages/ai/test/anthropic-opus-4-8-smoke.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { streamSimple } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { streamSimple } from "../src/stream.ts"; import type { Context } from "../src/types.ts"; interface AnthropicThinkingPayload { diff --git a/packages/ai/test/anthropic-temperature-compat.test.ts b/packages/ai/test/anthropic-temperature-compat.test.ts index 00161ab8..06a05698 100644 --- a/packages/ai/test/anthropic-temperature-compat.test.ts +++ b/packages/ai/test/anthropic-temperature-compat.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { streamSimple } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { streamSimple } from "../src/stream.ts"; import type { Context, Model, SimpleStreamOptions } from "../src/types.ts"; interface AnthropicTemperaturePayload { diff --git a/packages/ai/test/anthropic-thinking-disable.test.ts b/packages/ai/test/anthropic-thinking-disable.test.ts index 13d333e5..581d3a34 100644 --- a/packages/ai/test/anthropic-thinking-disable.test.ts +++ b/packages/ai/test/anthropic-thinking-disable.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { streamSimple } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { streamSimple } from "../src/stream.ts"; import type { Context, Model, SimpleStreamOptions } from "../src/types.ts"; interface AnthropicThinkingPayload { diff --git a/packages/ai/test/anthropic-tool-name-normalization.test.ts b/packages/ai/test/anthropic-tool-name-normalization.test.ts index bb454081..d52ff50e 100644 --- a/packages/ai/test/anthropic-tool-name-normalization.test.ts +++ b/packages/ai/test/anthropic-tool-name-normalization.test.ts @@ -1,7 +1,7 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; +import { stream } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { stream } from "../src/stream.ts"; import type { Context, Tool } from "../src/types.ts"; import { resolveApiKey } from "./oauth.ts"; diff --git a/packages/ai/test/base-entrypoint.test.ts b/packages/ai/test/base-entrypoint.test.ts new file mode 100644 index 00000000..d54fa492 --- /dev/null +++ b/packages/ai/test/base-entrypoint.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { clearApiProviders, complete, getApiProvider, getApiProviders } from "../src/base.ts"; +import { register as registerAmazonBedrock } from "../src/providers/amazon-bedrock.ts"; +import { register as registerAnthropic } from "../src/providers/anthropic.ts"; +import { register as registerAzureOpenAIResponses } from "../src/providers/azure-openai-responses.ts"; +import { fauxAssistantMessage, registerFauxProvider } from "../src/providers/faux.ts"; +import { register as registerGoogle } from "../src/providers/google.ts"; +import { register as registerGoogleVertex } from "../src/providers/google-vertex.ts"; +import { register as registerMistral } from "../src/providers/mistral.ts"; +import { register as registerOpenAICodexResponses } from "../src/providers/openai-codex-responses.ts"; +import { register as registerOpenAICompletions } from "../src/providers/openai-completions.ts"; +import { register as registerOpenAIResponses } from "../src/providers/openai-responses.ts"; + +const registrations = [ + ["bedrock-converse-stream", registerAmazonBedrock], + ["anthropic-messages", registerAnthropic], + ["azure-openai-responses", registerAzureOpenAIResponses], + ["google-generative-ai", registerGoogle], + ["google-vertex", registerGoogleVertex], + ["mistral-conversations", registerMistral], + ["openai-codex-responses", registerOpenAICodexResponses], + ["openai-completions", registerOpenAICompletions], + ["openai-responses", registerOpenAIResponses], +] as const; + +afterEach(() => { + clearApiProviders(); +}); + +describe("base entrypoint", () => { + it("starts without built-in provider registrations", () => { + expect(getApiProviders()).toEqual([]); + }); + + it.each(registrations)("registers the %s transport explicitly", (api, register) => { + register(); + expect(getApiProvider(api)?.api).toBe(api); + }); + + it("dispatches custom providers", async () => { + const registration = registerFauxProvider(); + registration.setResponses([fauxAssistantMessage("hello")]); + const response = await complete(registration.getModel(), { + messages: [{ role: "user", content: "hi", timestamp: Date.now() }], + }); + expect(response.content).toEqual([{ type: "text", text: "hello" }]); + }); + + it("fails clearly when no transport is registered", async () => { + await expect( + complete( + { + id: "missing-model", + name: "Missing Model", + api: "missing-api", + provider: "missing-provider", + baseUrl: "https://example.com", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1, + maxTokens: 1, + }, + { messages: [] }, + ), + ).rejects.toThrow("No API provider registered for api: missing-api"); + }); +}); diff --git a/packages/ai/test/bedrock-models.test.ts b/packages/ai/test/bedrock-models.test.ts index 2cfd8fb9..5bd5a1f3 100644 --- a/packages/ai/test/bedrock-models.test.ts +++ b/packages/ai/test/bedrock-models.test.ts @@ -17,8 +17,8 @@ */ import { describe, expect, it } from "vitest"; +import { complete } from "../src/index.ts"; import { getModels } from "../src/models.ts"; -import { complete } from "../src/stream.ts"; import type { Context } from "../src/types.ts"; import { hasBedrockCredentials } from "./bedrock-utils.ts"; diff --git a/packages/ai/test/cache-retention.test.ts b/packages/ai/test/cache-retention.test.ts index 6e2c1a5b..b5c55198 100644 --- a/packages/ai/test/cache-retention.test.ts +++ b/packages/ai/test/cache-retention.test.ts @@ -1,10 +1,10 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { stream } from "../src/index.ts"; import { MODELS } from "../src/models.generated.ts"; import { getModel } from "../src/models.ts"; import { streamAnthropic } from "../src/providers/anthropic.ts"; import { streamOpenAICompletions } from "../src/providers/openai-completions.ts"; import { streamOpenAIResponses } from "../src/providers/openai-responses.ts"; -import { stream } from "../src/stream.ts"; import type { Context, Model } from "../src/types.ts"; class PayloadCaptured extends Error { diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts index 9f021a41..54087a29 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -14,8 +14,8 @@ import type { ChildProcess } from "child_process"; import { execSync, spawn } from "child_process"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { complete } from "../src/index.ts"; import { getModel, getModels } from "../src/models.ts"; -import { complete } from "../src/stream.ts"; import type { AssistantMessage, Context, Model, Usage } from "../src/types.ts"; import { isContextOverflow } from "../src/utils/overflow.ts"; import { hasAzureOpenAICredentials } from "./azure-utils.ts"; diff --git a/packages/ai/test/cross-provider-handoff.test.ts b/packages/ai/test/cross-provider-handoff.test.ts index 23593394..73fac229 100644 --- a/packages/ai/test/cross-provider-handoff.test.ts +++ b/packages/ai/test/cross-provider-handoff.test.ts @@ -25,8 +25,8 @@ import { writeFileSync } from "fs"; import { Type } from "typebox"; import { beforeAll, describe, expect, it } from "vitest"; +import { completeSimple, getEnvApiKey } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { completeSimple, getEnvApiKey } from "../src/stream.ts"; import type { Api, AssistantMessage, Message, Model, Tool, ToolResultMessage } from "../src/types.ts"; import { hasAzureOpenAICredentials } from "./azure-utils.ts"; import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.ts"; diff --git a/packages/ai/test/empty.test.ts b/packages/ai/test/empty.test.ts index a8453dcc..fadd67a9 100644 --- a/packages/ai/test/empty.test.ts +++ b/packages/ai/test/empty.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { complete } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { complete } from "../src/stream.ts"; import type { Api, AssistantMessage, Context, Model, StreamOptions, UserMessage } from "../src/types.ts"; type StreamOptionsWithExtras = StreamOptions & Record; diff --git a/packages/ai/test/google-thinking-disable.test.ts b/packages/ai/test/google-thinking-disable.test.ts index 30df3638..3920f4c8 100644 --- a/packages/ai/test/google-thinking-disable.test.ts +++ b/packages/ai/test/google-thinking-disable.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { streamSimple } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { streamSimple } from "../src/stream.ts"; import type { Api, Context, Model, SimpleStreamOptions } from "../src/types.ts"; type SimpleOptionsWithExtras = SimpleStreamOptions & Record; diff --git a/packages/ai/test/interleaved-thinking.test.ts b/packages/ai/test/interleaved-thinking.test.ts index 4cf387ce..a2d3f5a6 100644 --- a/packages/ai/test/interleaved-thinking.test.ts +++ b/packages/ai/test/interleaved-thinking.test.ts @@ -1,8 +1,8 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; import { getEnvApiKey } from "../src/env-api-keys.ts"; +import { completeSimple } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { completeSimple } from "../src/stream.ts"; import type { Api, Context, Model, StopReason, Tool, ToolCall, ToolResultMessage } from "../src/types.ts"; import { StringEnum } from "../src/utils/typebox-helpers.ts"; import { hasBedrockCredentials } from "./bedrock-utils.ts"; diff --git a/packages/ai/test/lazy-module-load.test.ts b/packages/ai/test/lazy-module-load.test.ts index e21f0d12..14bccff0 100644 --- a/packages/ai/test/lazy-module-load.test.ts +++ b/packages/ai/test/lazy-module-load.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const aiEntryUrl = new URL("../src/index.ts", import.meta.url).href; +const baseEntryUrl = new URL("../src/base.ts", import.meta.url).href; const SDK_SPECIFIERS = [ "@anthropic-ai/sdk", @@ -16,9 +17,10 @@ const SDK_SPECIFIERS = [ type ProbeResult = { loadedSpecifiers: string[]; + value?: unknown; }; -function runProbe(action: string): ProbeResult { +function runProbe(action: string, entryUrl = aiEntryUrl): ProbeResult { const script = ` import { registerHooks } from "node:module"; @@ -34,9 +36,11 @@ function runProbe(action: string): ProbeResult { }, }); - const mod = await import(${JSON.stringify(aiEntryUrl)}); - ${action} - console.log(JSON.stringify({ loadedSpecifiers: [...new Set(loaded)] })); + const mod = await import(${JSON.stringify(entryUrl)}); + const value = await (async () => { + ${action} + })(); + console.log(JSON.stringify({ loadedSpecifiers: [...new Set(loaded)], value })); `; const result = spawnSync(process.execPath, ["--input-type=module", "--eval", script], { @@ -66,6 +70,33 @@ describe("lazy provider module loading", () => { expect(result.loadedSpecifiers).toEqual([]); }); + it("registers built-in transports when importing the root barrel", () => { + const result = runProbe(`return mod.getApiProviders().map((provider) => provider.api).sort();`); + expect(result.value).toEqual([ + "anthropic-messages", + "azure-openai-responses", + "bedrock-converse-stream", + "google-generative-ai", + "google-vertex", + "mistral-conversations", + "openai-codex-responses", + "openai-completions", + "openai-responses", + ]); + }); + + it("registers built-in image transports when importing the root barrel", () => { + const result = runProbe(`return mod.getImagesApiProvider("openrouter-images")?.api;`); + expect(result.loadedSpecifiers).toEqual([]); + expect(result.value).toBe("openrouter-images"); + }); + + it("does not load provider SDKs or register transports when importing the base barrel", () => { + const result = runProbe(`return mod.getApiProviders().map((provider) => provider.api);`, baseEntryUrl); + expect(result.loadedSpecifiers).toEqual([]); + expect(result.value).toEqual([]); + }); + it("loads only the Anthropic SDK when calling the root lazy wrapper", () => { const result = runProbe(` const model = { @@ -96,4 +127,17 @@ describe("lazy provider module loading", () => { expect(result.loadedSpecifiers).toEqual(["@anthropic-ai/sdk"]); }); + + it("dispatches through a lazy wrapper again after resetting providers", () => { + const result = runProbe(` + const model = mod.getModel("anthropic", "claude-sonnet-4-6"); + const context = { messages: [{ role: "user", content: "hi" }] }; + await mod.streamSimple(model, context).result(); + mod.resetApiProviders(); + return (await mod.streamSimple(model, context).result()).stopReason; + `); + + expect(result.loadedSpecifiers).toEqual(["@anthropic-ai/sdk"]); + expect(result.value).toBe("error"); + }); }); diff --git a/packages/ai/test/mistral-reasoning-mode.test.ts b/packages/ai/test/mistral-reasoning-mode.test.ts index d197a56a..0e2f45c5 100644 --- a/packages/ai/test/mistral-reasoning-mode.test.ts +++ b/packages/ai/test/mistral-reasoning-mode.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { streamSimple } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { streamSimple } from "../src/stream.ts"; import type { Context, Model, SimpleStreamOptions } from "../src/types.ts"; interface MistralPayload { diff --git a/packages/ai/test/mistral-tool-schema.test.ts b/packages/ai/test/mistral-tool-schema.test.ts index c6898fc6..3c490c6d 100644 --- a/packages/ai/test/mistral-tool-schema.test.ts +++ b/packages/ai/test/mistral-tool-schema.test.ts @@ -1,7 +1,7 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; +import { complete } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { complete } from "../src/stream.ts"; import type { Context, Model } from "../src/types.ts"; interface MistralToolPayload { diff --git a/packages/ai/test/openai-codex-cache-affinity-e2e.test.ts b/packages/ai/test/openai-codex-cache-affinity-e2e.test.ts index 0abb46f6..62245dcf 100644 --- a/packages/ai/test/openai-codex-cache-affinity-e2e.test.ts +++ b/packages/ai/test/openai-codex-cache-affinity-e2e.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { complete } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { complete } from "../src/stream.ts"; import type { Context } from "../src/types.ts"; import { resolveApiKey } from "./oauth.ts"; diff --git a/packages/ai/test/openai-completions-empty-tools.test.ts b/packages/ai/test/openai-completions-empty-tools.test.ts index be233670..0ecf8e13 100644 --- a/packages/ai/test/openai-completions-empty-tools.test.ts +++ b/packages/ai/test/openai-completions-empty-tools.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { streamSimple } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { streamSimple } from "../src/stream.ts"; // Empty tools arrays must NOT be serialized as `tools: []` — some OpenAI-compatible // backends (e.g. DashScope / Aliyun Qwen via compatible-mode) reject the request with diff --git a/packages/ai/test/openai-completions-response-model.test.ts b/packages/ai/test/openai-completions-response-model.test.ts index d8e4d2f9..ec2abea1 100644 --- a/packages/ai/test/openai-completions-response-model.test.ts +++ b/packages/ai/test/openai-completions-response-model.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { complete } from "../src/stream.ts"; +import { complete } from "../src/index.ts"; import type { Model } from "../src/types.ts"; // Router/virtual ids (e.g. OpenRouter `auto`) keep `model` pinned to the diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index 8ca86025..373b1b78 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -1,8 +1,8 @@ import { Type } from "typebox"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { stream, streamSimple } from "../src/index.ts"; import { getModel } from "../src/models.ts"; import { convertMessages } from "../src/providers/openai-completions.ts"; -import { stream, streamSimple } from "../src/stream.ts"; import type { AssistantMessage, Model, Tool, ToolResultMessage } from "../src/types.ts"; const mockState = vi.hoisted(() => ({ diff --git a/packages/ai/test/openai-responses-cache-affinity-e2e.test.ts b/packages/ai/test/openai-responses-cache-affinity-e2e.test.ts index d173694f..0d247b0b 100644 --- a/packages/ai/test/openai-responses-cache-affinity-e2e.test.ts +++ b/packages/ai/test/openai-responses-cache-affinity-e2e.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { complete } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { complete } from "../src/stream.ts"; import type { Context } from "../src/types.ts"; describe.skipIf(!process.env.OPENAI_API_KEY)("openai responses cache affinity e2e", () => { diff --git a/packages/ai/test/openai-responses-reasoning-replay-e2e.test.ts b/packages/ai/test/openai-responses-reasoning-replay-e2e.test.ts index aa6f2e23..87945bc2 100644 --- a/packages/ai/test/openai-responses-reasoning-replay-e2e.test.ts +++ b/packages/ai/test/openai-responses-reasoning-replay-e2e.test.ts @@ -1,7 +1,7 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; +import { complete, getEnvApiKey } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { complete, getEnvApiKey } from "../src/stream.ts"; import type { AssistantMessage, Context, Message, Tool, ToolCall } from "../src/types.ts"; const testToolSchema = Type.Object({ diff --git a/packages/ai/test/openrouter-cache-write-repro.test.ts b/packages/ai/test/openrouter-cache-write-repro.test.ts index 4bdeb286..2cf2a791 100644 --- a/packages/ai/test/openrouter-cache-write-repro.test.ts +++ b/packages/ai/test/openrouter-cache-write-repro.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { completeSimple } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { completeSimple } from "../src/stream.ts"; function createLongSystemPrompt(): string { const nonce = `${Date.now()}-${Math.random()}`; diff --git a/packages/ai/test/openrouter-images.test.ts b/packages/ai/test/openrouter-images.test.ts index 31882bec..5811e6f4 100644 --- a/packages/ai/test/openrouter-images.test.ts +++ b/packages/ai/test/openrouter-images.test.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { generateImages } from "../src/images.ts"; +import { clearImagesApiProviders, getImagesApiProvider } from "../src/images-api-registry.ts"; +import { register as registerOpenRouterImages } from "../src/providers/images/openrouter.ts"; +import { registerBuiltInImagesApiProviders } from "../src/providers/register-builtins.ts"; import type { ImagesContext, ImagesModel } from "../src/types.ts"; const mockState = vi.hoisted(() => ({ @@ -62,6 +65,15 @@ describe("openrouter images", () => { beforeEach(() => { mockState.lastParams = undefined; mockState.lastRequestOptions = undefined; + clearImagesApiProviders(); + registerBuiltInImagesApiProviders(); + }); + + it("registers the direct OpenRouter images transport explicitly", () => { + clearImagesApiProviders(); + expect(getImagesApiProvider("openrouter-images")).toBeUndefined(); + registerOpenRouterImages(); + expect(getImagesApiProvider("openrouter-images")?.api).toBe("openrouter-images"); }); it("returns text plus images in final output", async () => { diff --git a/packages/ai/test/responseid.test.ts b/packages/ai/test/responseid.test.ts index d250f5f4..bc5d6c54 100644 --- a/packages/ai/test/responseid.test.ts +++ b/packages/ai/test/responseid.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { complete } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { complete } from "../src/stream.ts"; import type { Api, Context, Model, StreamOptions } from "../src/types.ts"; import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.ts"; import { resolveApiKey } from "./oauth.ts"; diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 9a70b6c2..3a381966 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -4,8 +4,8 @@ import { dirname, join } from "path"; import { Type } from "typebox"; import { fileURLToPath } from "url"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { complete, stream } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { complete, stream } from "../src/stream.ts"; import type { Api, Context, ImageContent, Model, StreamOptions, Tool, ToolResultMessage } from "../src/types.ts"; type StreamOptionsWithExtras = StreamOptions & Record; diff --git a/packages/ai/test/tokens.test.ts b/packages/ai/test/tokens.test.ts index 676f5a29..fdf45b94 100644 --- a/packages/ai/test/tokens.test.ts +++ b/packages/ai/test/tokens.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { stream } from "../src/index.ts"; import { getModel, getModels } from "../src/models.ts"; -import { stream } from "../src/stream.ts"; import type { Api, Context, Model, StreamOptions } from "../src/types.ts"; type StreamOptionsWithExtras = StreamOptions & Record; diff --git a/packages/ai/test/tool-call-id-normalization.test.ts b/packages/ai/test/tool-call-id-normalization.test.ts index 0672181b..622e5127 100644 --- a/packages/ai/test/tool-call-id-normalization.test.ts +++ b/packages/ai/test/tool-call-id-normalization.test.ts @@ -12,8 +12,8 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; +import { completeSimple, getEnvApiKey } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { completeSimple, getEnvApiKey } from "../src/stream.ts"; import type { AssistantMessage, Message, Tool, ToolResultMessage } from "../src/types.ts"; import { resolveApiKey } from "./oauth.ts"; diff --git a/packages/ai/test/tool-call-without-result.test.ts b/packages/ai/test/tool-call-without-result.test.ts index 11b832f3..b21c1ce8 100644 --- a/packages/ai/test/tool-call-without-result.test.ts +++ b/packages/ai/test/tool-call-without-result.test.ts @@ -1,7 +1,7 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; +import { complete } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { complete } from "../src/stream.ts"; import type { Api, Context, Model, StreamOptions, Tool } from "../src/types.ts"; type StreamOptionsWithExtras = StreamOptions & Record; diff --git a/packages/ai/test/total-tokens.test.ts b/packages/ai/test/total-tokens.test.ts index 972913a7..4df4b2e1 100644 --- a/packages/ai/test/total-tokens.test.ts +++ b/packages/ai/test/total-tokens.test.ts @@ -13,8 +13,8 @@ */ import { describe, expect, it } from "vitest"; +import { complete } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { complete } from "../src/stream.ts"; import type { Api, Context, Model, StreamOptions, Usage } from "../src/types.ts"; type StreamOptionsWithExtras = StreamOptions & Record; diff --git a/packages/ai/test/unicode-surrogate.test.ts b/packages/ai/test/unicode-surrogate.test.ts index 9cdddefa..d8b02392 100644 --- a/packages/ai/test/unicode-surrogate.test.ts +++ b/packages/ai/test/unicode-surrogate.test.ts @@ -1,7 +1,7 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; +import { complete } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { complete } from "../src/stream.ts"; import type { Api, Context, Model, StreamOptions, ToolResultMessage } from "../src/types.ts"; type StreamOptionsWithExtras = StreamOptions & Record; diff --git a/packages/ai/test/xhigh.test.ts b/packages/ai/test/xhigh.test.ts index 3c279863..1bda5c14 100644 --- a/packages/ai/test/xhigh.test.ts +++ b/packages/ai/test/xhigh.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { stream } from "../src/index.ts"; import { getModel } from "../src/models.ts"; -import { stream } from "../src/stream.ts"; import type { Context, Model } from "../src/types.ts"; function makeContext(): Context { diff --git a/packages/ai/test/xiaomi-token-plan-ams-anthropic-empty-signature-smoke.test.ts b/packages/ai/test/xiaomi-token-plan-ams-anthropic-empty-signature-smoke.test.ts index ab2fec30..9a371b60 100644 --- a/packages/ai/test/xiaomi-token-plan-ams-anthropic-empty-signature-smoke.test.ts +++ b/packages/ai/test/xiaomi-token-plan-ams-anthropic-empty-signature-smoke.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { completeSimple, getEnvApiKey, streamSimple } from "../src/stream.ts"; +import { completeSimple, getEnvApiKey, streamSimple } from "../src/index.ts"; import type { AssistantMessage, Context, Model } from "../src/types.ts"; const provider = "xiaomi-token-plan-ams"; diff --git a/packages/ai/test/zen.test.ts b/packages/ai/test/zen.test.ts index 8ca80014..82a2079f 100644 --- a/packages/ai/test/zen.test.ts +++ b/packages/ai/test/zen.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { complete } from "../src/index.ts"; import { MODELS } from "../src/models.generated.ts"; -import { complete } from "../src/stream.ts"; import type { Model } from "../src/types.ts"; describe.skipIf(!process.env.OPENCODE_API_KEY)("OpenCode Models Smoke Test", () => { diff --git a/packages/coding-agent/vitest.config.ts b/packages/coding-agent/vitest.config.ts index 67ce0fca..5759e3c4 100644 --- a/packages/coding-agent/vitest.config.ts +++ b/packages/coding-agent/vitest.config.ts @@ -2,8 +2,11 @@ import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; const aiSrcIndex = fileURLToPath(new URL("../ai/src/index.ts", import.meta.url)); +const aiSrcBase = fileURLToPath(new URL("../ai/src/base.ts", import.meta.url)); const aiSrcOAuth = fileURLToPath(new URL("../ai/src/oauth.ts", import.meta.url)); +const aiOpenRouterImages = fileURLToPath(new URL("../ai/src/providers/images/openrouter.ts", import.meta.url)); const agentSrcIndex = fileURLToPath(new URL("../agent/src/index.ts", import.meta.url)); +const agentSrcBase = fileURLToPath(new URL("../agent/src/base.ts", import.meta.url)); const tuiSrcIndex = fileURLToPath(new URL("../tui/src/index.ts", import.meta.url)); export default defineConfig({ @@ -20,12 +23,18 @@ export default defineConfig({ resolve: { alias: [ { find: /^@earendil-works\/pi-ai$/, replacement: aiSrcIndex }, + { find: /^@earendil-works\/pi-ai\/base$/, replacement: aiSrcBase }, { find: /^@earendil-works\/pi-ai\/oauth$/, replacement: aiSrcOAuth }, + { find: /^@earendil-works\/pi-ai\/openrouter-images$/, replacement: aiOpenRouterImages }, { find: /^@earendil-works\/pi-agent-core$/, replacement: agentSrcIndex }, + { find: /^@earendil-works\/pi-agent-core\/base$/, replacement: agentSrcBase }, { find: /^@earendil-works\/pi-tui$/, replacement: tuiSrcIndex }, { find: /^@mariozechner\/pi-ai$/, replacement: aiSrcIndex }, + { find: /^@mariozechner\/pi-ai\/base$/, replacement: aiSrcBase }, { find: /^@mariozechner\/pi-ai\/oauth$/, replacement: aiSrcOAuth }, + { find: /^@mariozechner\/pi-ai\/openrouter-images$/, replacement: aiOpenRouterImages }, { find: /^@mariozechner\/pi-agent-core$/, replacement: agentSrcIndex }, + { find: /^@mariozechner\/pi-agent-core\/base$/, replacement: agentSrcBase }, { find: /^@mariozechner\/pi-tui$/, replacement: tuiSrcIndex }, ], }, diff --git a/scripts/check-browser-smoke.mjs b/scripts/check-browser-smoke.mjs index d590988d..906eccd3 100644 --- a/scripts/check-browser-smoke.mjs +++ b/scripts/check-browser-smoke.mjs @@ -4,7 +4,21 @@ import { join } from "node:path"; import { build } from "esbuild"; const outputPath = join(tmpdir(), "pi-browser-smoke.js"); +const baseOutputPath = join(tmpdir(), "pi-browser-base-smoke.js"); +const selectiveOutputPath = join(tmpdir(), "pi-browser-selective-smoke.js"); const errorLogPath = join(tmpdir(), "pi-browser-smoke-errors.log"); +const providerImplementationInputs = [ + "packages/ai/src/providers/amazon-bedrock.ts", + "packages/ai/src/providers/anthropic.ts", + "packages/ai/src/providers/azure-openai-responses.ts", + "packages/ai/src/providers/google.ts", + "packages/ai/src/providers/google-vertex.ts", + "packages/ai/src/providers/images/openrouter.ts", + "packages/ai/src/providers/mistral.ts", + "packages/ai/src/providers/openai-codex-responses.ts", + "packages/ai/src/providers/openai-completions.ts", + "packages/ai/src/providers/openai-responses.ts", +]; try { await build({ @@ -15,6 +29,36 @@ try { logLevel: "silent", outfile: outputPath, }); + const baseBuild = await build({ + stdin: { + contents: `import { complete } from "@earendil-works/pi-ai/base";\nimport { Agent } from "@earendil-works/pi-agent-core/base";\nconsole.log(typeof complete, typeof Agent);\n`, + resolveDir: process.cwd(), + sourcefile: "pi-browser-base-smoke-entry.ts", + }, + bundle: true, + platform: "browser", + format: "esm", + logLevel: "silent", + metafile: true, + outfile: baseOutputPath, + }); + const bundledInputs = new Set(Object.keys(baseBuild.metafile.inputs)); + const reachableProviderImplementations = providerImplementationInputs.filter((input) => bundledInputs.has(input)); + if (reachableProviderImplementations.length > 0) { + throw new Error(`Base browser bundle reached provider implementations:\n${reachableProviderImplementations.join("\n")}`); + } + await build({ + stdin: { + contents: `import { register as registerAnthropic } from "@earendil-works/pi-ai/anthropic";\nimport { register as registerOpenAICompletions } from "@earendil-works/pi-ai/openai-completions";\nimport { register as registerOpenRouterImages } from "@earendil-works/pi-ai/openrouter-images";\nconsole.log(typeof registerAnthropic, typeof registerOpenAICompletions, typeof registerOpenRouterImages);\n`, + resolveDir: process.cwd(), + sourcefile: "pi-browser-selective-smoke-entry.ts", + }, + bundle: true, + platform: "browser", + format: "esm", + logLevel: "silent", + outfile: selectiveOutputPath, + }); process.exit(0); } catch (error) { let detailedErrors = ""; diff --git a/tsconfig.json b/tsconfig.json index 903f80f3..bcb12eee 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "*": ["./*"], "@earendil-works/pi-ai": ["./packages/ai/src/index.ts"], "@earendil-works/pi-ai/oauth": ["./packages/ai/src/oauth.ts"], + "@earendil-works/pi-ai/openrouter-images": ["./packages/ai/src/providers/images/openrouter.ts"], "@earendil-works/pi-ai/*": ["./packages/ai/src/*.ts", "./packages/ai/src/providers/*.ts"], "@earendil-works/pi-ai/dist/*": ["./packages/ai/src/*"], "@earendil-works/pi-agent-core": ["./packages/agent/src/index.ts"], From ea65a51a15c6c5965d1ffa87fee357758fba5e7f Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 19 Jun 2026 00:42:53 +0200 Subject: [PATCH 329/352] fix: update vulnerable dependencies --- package-lock.json | 3307 ++++++++---------- package.json | 2 +- packages/agent/package.json | 4 +- packages/ai/package.json | 2 +- packages/coding-agent/npm-shrinkwrap.json | 37 +- packages/coding-agent/package.json | 4 +- scripts/generate-coding-agent-shrinkwrap.mjs | 2 +- 7 files changed, 1547 insertions(+), 1811 deletions(-) diff --git a/package-lock.json b/package-lock.json index d1abbf97..ce9dd552 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "@biomejs/biome": "2.3.5", "@types/node": "22.19.19", "@typescript/native-preview": "7.0.0-dev.20260120.1", - "esbuild": "0.28.0", + "esbuild": "0.28.1", "husky": "9.1.7", "jiti": "2.7.0", "shx": "0.4.0", @@ -31,20 +31,6 @@ "node": ">=22.19.0" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@anthropic-ai/sandbox-runtime": { "version": "0.0.26", "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.26.tgz", @@ -811,10 +797,44 @@ "resolved": "packages/tui", "link": true }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -829,9 +849,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -846,9 +866,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -863,9 +883,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -880,9 +900,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -897,9 +917,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -914,9 +934,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -931,9 +951,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -948,9 +968,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -965,9 +985,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -982,9 +1002,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -999,9 +1019,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -1016,9 +1036,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -1033,9 +1053,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -1050,9 +1070,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -1067,9 +1087,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -1084,9 +1104,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -1101,9 +1121,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -1118,9 +1138,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -1135,9 +1155,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -1152,9 +1172,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -1169,9 +1189,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -1186,9 +1206,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -1203,9 +1223,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -1220,9 +1240,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -1237,9 +1257,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -1277,130 +1297,6 @@ } } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", - "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1643,6 +1539,25 @@ } } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@nodable/entities": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", @@ -1711,15 +1626,14 @@ "node": ">=14" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "dev": true, "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" + "funding": { + "url": "https://github.com/sponsors/Boshen" } }, "node_modules/@pondwader/socks5-server": { @@ -1747,9 +1661,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { @@ -1767,12 +1681,6 @@ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", - "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", - "license": "BSD-3-Clause" - }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", @@ -1791,24 +1699,10 @@ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", - "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", - "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" ], @@ -1817,12 +1711,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", - "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], @@ -1831,12 +1728,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", - "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" ], @@ -1845,26 +1745,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", - "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", - "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" ], @@ -1873,12 +1762,15 @@ "optional": true, "os": [ "freebsd" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", - "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" ], @@ -1887,194 +1779,135 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", - "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", - "cpu": [ - "arm" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", - "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", - "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", - "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", - "cpu": [ - "loong64" + "libc": [ + "musl" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", - "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", - "cpu": [ - "loong64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", - "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ "ppc64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", - "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", - "cpu": [ - "ppc64" + "libc": [ + "glibc" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", - "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", - "cpu": [ - "riscv64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", - "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", - "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", - "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", - "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", - "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", - "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "cpu": [ "arm64" ], @@ -2083,12 +1916,34 @@ "optional": true, "os": [ "openharmony" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", - "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ "arm64" ], @@ -2097,26 +1952,15 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", - "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", - "cpu": [ - "ia32" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", - "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "cpu": [ "x64" ], @@ -2125,21 +1969,17 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", - "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", - "cpu": [ - "x64" ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "license": "MIT" }, "node_modules/@silvia-odwyer/photon-node": { "version": "0.3.4", @@ -2267,6 +2107,24 @@ "node": ">=14.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -2488,155 +2346,6 @@ "win32" ] }, - "node_modules/@vitest/coverage-v8": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", - "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@bcoe/v8-coverage": "^1.0.2", - "ast-v8-to-istanbul": "^0.3.3", - "debug": "^4.4.1", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^5.0.6", - "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.17", - "magicast": "^0.3.5", - "std-env": "^3.9.0", - "test-exclude": "^7.0.1", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "3.2.4", - "vitest": "3.2.4" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, - "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@xterm/headless": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.5.0.tgz", @@ -2653,32 +2362,6 @@ "node": ">= 14" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/asn1": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", @@ -2698,18 +2381,6 @@ "node": ">=12" } }, - "node_modules/ast-v8-to-istanbul": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", - "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", - "estree-walker": "^3.0.3", - "js-tokens": "^10.0.0" - } - }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -2840,16 +2511,6 @@ "node": ">=10.0.0" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/canvas": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.3.tgz", @@ -2877,23 +2538,6 @@ "node": ">=20" } }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -2906,16 +2550,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -2923,26 +2557,6 @@ "dev": true, "license": "ISC" }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -2952,6 +2566,13 @@ "node": ">=18" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cpu-features": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", @@ -3022,16 +2643,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -3061,13 +2672,6 @@ "node": ">=0.3.1" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -3077,13 +2681,6 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -3104,17 +2701,10 @@ "node": ">= 0.4" } }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, "node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3125,32 +2715,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/estree-walker": { @@ -3381,36 +2971,6 @@ "node": ">=8" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -3750,16 +3310,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3837,21 +3387,6 @@ "node": ">=8" } }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/istanbul-reports": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", @@ -3866,22 +3401,6 @@ "node": ">=8" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -3941,6 +3460,279 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lodash-es": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", @@ -3953,13 +3745,6 @@ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "11.4.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", @@ -3979,18 +3764,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/magicast": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", - "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.25.4", - "@babel/types": "^7.25.4", - "source-map-js": "^1.2.0" - } - }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -4233,6 +4006,20 @@ "node": ">=4" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -4293,13 +4080,6 @@ "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", "license": "MIT" }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/partial-json": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", @@ -4360,16 +4140,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/pi-extension-custom-provider-anthropic": { "resolved": "packages/coding-agent/examples/extensions/custom-provider-anthropic", "link": true @@ -4411,9 +4181,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -4431,7 +4201,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4488,24 +4258,23 @@ } }, "node_modules/protobufjs": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.9.tgz", - "integrity": "sha512-Od4muIm3HW1AouyHF5lONOf1FWo3hY1NbFDoy191X9GzhpgW1clCoaFjfVs2rKJNFYpTNJbje4cbAIDBZJ63ZA==", + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -4628,58 +4397,40 @@ "node": ">=0.10.0" } }, - "node_modules/rollup": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", - "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.4", - "@rollup/rollup-android-arm64": "4.60.4", - "@rollup/rollup-darwin-arm64": "4.60.4", - "@rollup/rollup-darwin-x64": "4.60.4", - "@rollup/rollup-freebsd-arm64": "4.60.4", - "@rollup/rollup-freebsd-x64": "4.60.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", - "@rollup/rollup-linux-arm-musleabihf": "4.60.4", - "@rollup/rollup-linux-arm64-gnu": "4.60.4", - "@rollup/rollup-linux-arm64-musl": "4.60.4", - "@rollup/rollup-linux-loong64-gnu": "4.60.4", - "@rollup/rollup-linux-loong64-musl": "4.60.4", - "@rollup/rollup-linux-ppc64-gnu": "4.60.4", - "@rollup/rollup-linux-ppc64-musl": "4.60.4", - "@rollup/rollup-linux-riscv64-gnu": "4.60.4", - "@rollup/rollup-linux-riscv64-musl": "4.60.4", - "@rollup/rollup-linux-s390x-gnu": "4.60.4", - "@rollup/rollup-linux-x64-gnu": "4.60.4", - "@rollup/rollup-linux-x64-musl": "4.60.4", - "@rollup/rollup-openbsd-x64": "4.60.4", - "@rollup/rollup-openharmony-arm64": "4.60.4", - "@rollup/rollup-win32-arm64-msvc": "4.60.4", - "@rollup/rollup-win32-ia32-msvc": "4.60.4", - "@rollup/rollup-win32-x64-gnu": "4.60.4", - "@rollup/rollup-win32-x64-msvc": "4.60.4", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, - "node_modules/rollup/node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -4905,13 +4656,6 @@ "dev": true, "license": "MIT" }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -4922,64 +4666,6 @@ "safe-buffer": "~5.2.0" } }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-eof": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", @@ -5000,26 +4686,6 @@ "node": ">=0.10.0" } }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, "node_modules/strnum": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", @@ -5075,100 +4741,6 @@ "node": ">=6" } }, - "node_modules/test-exclude": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", - "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^10.2.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/test-exclude/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/test-exclude/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/test-exclude/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -5176,17 +4748,10 @@ "dev": true, "license": "MIT" }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -5231,36 +4796,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5345,9 +4880,9 @@ } }, "node_modules/undici": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", - "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", + "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", "license": "MIT", "engines": { "node": ">=22.19.0" @@ -5367,18 +4902,17 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.3.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", - "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -5394,9 +4928,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -5409,15 +4944,18 @@ "@types/node": { "optional": true }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, "jiti": { "optional": true }, "less": { "optional": true }, - "lightningcss": { - "optional": true - }, "sass": { "optional": true }, @@ -5441,531 +4979,6 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" - } - }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/vite/node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", @@ -5979,92 +4992,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/debug": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -6106,25 +5033,6 @@ "node": ">=8" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -6133,9 +5041,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -6213,9 +5121,9 @@ }, "devDependencies": { "@types/node": "24.12.4", - "@vitest/coverage-v8": "3.2.4", + "@vitest/coverage-v8": "4.1.9", "typescript": "5.9.3", - "vitest": "3.2.4" + "vitest": "4.1.9" }, "engines": { "node": ">=22.19.0" @@ -6231,6 +5139,204 @@ "undici-types": "~7.16.0" } }, + "packages/agent/node_modules/@vitest/coverage-v8": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.9", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "packages/agent/node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/agent/node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/agent/node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/agent/node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/agent/node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/agent/node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/agent/node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "packages/agent/node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "packages/agent/node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "packages/agent/node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "packages/agent/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "packages/agent/node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "packages/agent/node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "packages/agent/node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "packages/agent/node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -6238,6 +5344,123 @@ "dev": true, "license": "MIT" }, + "packages/agent/node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "packages/agent/node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "packages/ai": { "name": "@earendil-works/pi-ai", "version": "0.79.7", @@ -6261,7 +5484,7 @@ "devDependencies": { "@types/node": "24.12.4", "canvas": "3.2.3", - "vitest": "3.2.4" + "vitest": "4.1.9" }, "engines": { "node": ">=22.19.0" @@ -6277,6 +5500,176 @@ "undici-types": "~7.16.0" } }, + "packages/ai/node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/ai/node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "packages/ai/node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/ai/node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/ai/node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/ai/node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/ai/node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/ai/node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "packages/ai/node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "packages/ai/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "packages/ai/node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "packages/ai/node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "packages/ai/node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "packages/ai/node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -6284,6 +5677,96 @@ "dev": true, "license": "MIT" }, + "packages/ai/node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", "version": "0.79.7", @@ -6305,7 +5788,7 @@ "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.1.38", - "undici": "8.3.0", + "undici": "8.5.0", "yaml": "2.9.0" }, "bin": { @@ -6321,7 +5804,7 @@ "@types/semver": "7.7.1", "shx": "0.4.0", "typescript": "5.9.3", - "vitest": "3.2.4" + "vitest": "4.1.9" }, "engines": { "node": ">=22.19.0" @@ -6384,6 +5867,149 @@ "undici-types": "~7.16.0" } }, + "packages/coding-agent/node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/coding-agent/node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/coding-agent/node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/coding-agent/node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/coding-agent/node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/coding-agent/node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/coding-agent/node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "packages/coding-agent/node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "packages/coding-agent/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "packages/coding-agent/node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "packages/coding-agent/node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "packages/coding-agent/node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "packages/coding-agent/node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -6391,6 +6017,123 @@ "dev": true, "license": "MIT" }, + "packages/coding-agent/node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "packages/coding-agent/node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "packages/tui": { "name": "@earendil-works/pi-tui", "version": "0.79.7", diff --git a/package.json b/package.json index c88d7123..0deabeca 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "@biomejs/biome": "2.3.5", "@types/node": "22.19.19", "@typescript/native-preview": "7.0.0-dev.20260120.1", - "esbuild": "0.28.0", + "esbuild": "0.28.1", "husky": "9.1.7", "jiti": "2.7.0", "shx": "0.4.0", diff --git a/packages/agent/package.json b/packages/agent/package.json index 48934f8f..ee24151f 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -57,8 +57,8 @@ }, "devDependencies": { "@types/node": "24.12.4", - "@vitest/coverage-v8": "3.2.4", + "@vitest/coverage-v8": "4.1.9", "typescript": "5.9.3", - "vitest": "3.2.4" + "vitest": "4.1.9" } } diff --git a/packages/ai/package.json b/packages/ai/package.json index 1b34843a..68165eba 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -114,6 +114,6 @@ "devDependencies": { "@types/node": "24.12.4", "canvas": "3.2.3", - "vitest": "3.2.4" + "vitest": "4.1.9" } } diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index ad672bf2..934827aa 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -25,7 +25,7 @@ "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.1.38", - "undici": "8.3.0", + "undici": "8.5.0", "yaml": "2.9.0" }, "optionalDependencies": { @@ -810,9 +810,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { @@ -830,12 +830,6 @@ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", - "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", - "license": "BSD-3-Clause" - }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", @@ -1613,23 +1607,22 @@ } }, "node_modules/protobufjs": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.9.tgz", - "integrity": "sha512-Od4muIm3HW1AouyHF5lONOf1FWo3hY1NbFDoy191X9GzhpgW1clCoaFjfVs2rKJNFYpTNJbje4cbAIDBZJ63ZA==", + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -1735,9 +1728,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", - "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", + "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", "license": "MIT", "engines": { "node": ">=22.19.0" @@ -1774,9 +1767,9 @@ } }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "peerDependencies": { "bufferutil": "^4.0.1", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index c873bb8d..3d890c7a 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -52,7 +52,7 @@ "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.1.38", - "undici": "8.3.0", + "undici": "8.5.0", "yaml": "2.9.0" }, "overrides": { @@ -74,7 +74,7 @@ "@types/semver": "7.7.1", "shx": "0.4.0", "typescript": "5.9.3", - "vitest": "3.2.4" + "vitest": "4.1.9" }, "keywords": [ "coding-agent", diff --git a/scripts/generate-coding-agent-shrinkwrap.mjs b/scripts/generate-coding-agent-shrinkwrap.mjs index 7bcf5681..1971869c 100644 --- a/scripts/generate-coding-agent-shrinkwrap.mjs +++ b/scripts/generate-coding-agent-shrinkwrap.mjs @@ -12,7 +12,7 @@ const shrinkwrapPath = join(codingAgentDir, "npm-shrinkwrap.json"); const internalPackagePrefix = "@earendil-works/pi-"; const allowedInstallScriptPackages = new Map([ ["@google/genai@1.52.0", "preinstall is a no-op in the published package"], - ["protobufjs@7.5.9", "postinstall only warns about protobufjs version scheme mismatches"], + ["protobufjs@7.6.4", "postinstall only warns about protobufjs version scheme mismatches"], ]); const args = new Set(process.argv.slice(2)); From a2f70e5f2817fd37433109c4aa7128019fd9bf97 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 19 Jun 2026 09:22:13 +0200 Subject: [PATCH 330/352] fix(coding-agent): reset tool test mocks --- packages/coding-agent/test/tools.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index c5e8fa6f..f187da62 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -44,6 +44,7 @@ describe("Coding Agent Tools", () => { }); afterEach(() => { + vi.restoreAllMocks(); // Clean up test directory rmSync(testDir, { recursive: true, force: true }); }); From 74677bbfbf7fd089b47b4197b8ef21468d5f2878 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 19 Jun 2026 09:45:03 +0200 Subject: [PATCH 331/352] docs: audit unreleased changelogs --- packages/agent/CHANGELOG.md | 4 ++++ packages/ai/CHANGELOG.md | 1 + packages/coding-agent/CHANGELOG.md | 3 +++ 3 files changed, 8 insertions(+) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 3050117f..d9a204c6 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `@earendil-works/pi-agent-core/base` for bundlers that want to pair the agent core with selective `@earendil-works/pi-ai/base` provider registration ([#5348](https://github.com/earendil-works/pi/pull/5348) by [@FredKSchott](https://github.com/FredKSchott)). + ## [0.79.7] - 2026-06-18 ## [0.79.6] - 2026-06-16 diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index c4c63d8b..2aab174a 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added `@earendil-works/pi-ai/base` and direct provider registration exports for bundlers that want selective provider transports without root built-in registration ([#5348](https://github.com/earendil-works/pi/pull/5348) by [@FredKSchott](https://github.com/FredKSchott)). - Added prompt caching for Mistral requests using the pi session ID as `prompt_cache_key`, including cached-token usage and cost accounting ([#5854](https://github.com/earendil-works/pi/issues/5854)). - Added the OpenRouter Fusion alias as `openrouter/fusion` ([#5866](https://github.com/earendil-works/pi/pull/5866) by [@dannote](https://github.com/dannote)). diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 06ab4f9b..2f0ecb2a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,18 +4,21 @@ ### New Features +- **Selective provider base entry points** - SDK users can pair `@earendil-works/pi-ai/base` and `@earendil-works/pi-agent-core/base` with explicit provider registration to keep bundled applications from including unused provider transports. See [`pi-ai` Base Entry Point](../ai/README.md#base-entry-point) and [`pi-agent-core` Base Entry Point](../agent/README.md#base-entry-point). - **Mistral prompt caching** - Mistral sessions now use provider-side prompt caching with session affinity and cached-token usage/cost accounting. See [API Keys](docs/providers.md#api-keys) and [Environment Variables](docs/usage.md#environment-variables). - **Post-compaction token estimates** - Compact results and compaction events now include estimated post-compaction token counts so clients can show the approximate context reduction. See [RPC compact](docs/rpc.md#compact) and [compaction events](docs/rpc.md#compaction_start--compaction_end). - **OpenRouter Fusion alias** - `openrouter/fusion` is available as a built-in OpenRouter model alias. See [API Keys](docs/providers.md#api-keys). ### Added +- Added inherited `@earendil-works/pi-ai/base` and `@earendil-works/pi-agent-core/base` entry points for selective provider registration in bundled applications ([#5348](https://github.com/earendil-works/pi/pull/5348) by [@FredKSchott](https://github.com/FredKSchott)). - Added inherited Mistral prompt caching using the pi session ID as `prompt_cache_key`, including cached-token usage and cost accounting ([#5854](https://github.com/earendil-works/pi/issues/5854)). - Added estimated post-compaction token counts to compact results and compaction events ([#5877](https://github.com/earendil-works/pi/issues/5877)). - Added the inherited OpenRouter Fusion alias as `openrouter/fusion` ([#5866](https://github.com/earendil-works/pi/pull/5866) by [@dannote](https://github.com/dannote)). ### Fixed +- Updated vulnerable runtime dependencies, including `undici` and the packaged `protobufjs` transitive dependency. - Fixed compaction to refuse sessions with no eligible messages instead of producing empty summaries ([#4811](https://github.com/earendil-works/pi/issues/4811)). - Fixed successful overflow-triggered auto-compaction to avoid retrying completed assistant responses ([#5720](https://github.com/earendil-works/pi/issues/5720)). From 8eb9704b3e75391dcc4f584b7fc1a03c9e04e383 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 19 Jun 2026 09:53:43 +0200 Subject: [PATCH 332/352] Release v0.79.8 --- package-lock.json | 26 +++++------ packages/agent/CHANGELOG.md | 2 +- packages/agent/package.json | 4 +- packages/ai/CHANGELOG.md | 2 +- packages/ai/package.json | 2 +- packages/ai/src/models.generated.ts | 45 +++++++++++++------ 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 +- packages/coding-agent/npm-shrinkwrap.json | 24 +++++----- packages/coding-agent/package.json | 8 ++-- packages/tui/CHANGELOG.md | 2 +- packages/tui/package.json | 2 +- 20 files changed, 81 insertions(+), 64 deletions(-) diff --git a/package-lock.json b/package-lock.json index ce9dd552..1f9d4e66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5111,10 +5111,10 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.79.7", + "version": "0.79.8", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.79.7", + "@earendil-works/pi-ai": "^0.79.8", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -5463,7 +5463,7 @@ }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.79.7", + "version": "0.79.8", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -5769,12 +5769,12 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "0.79.7", + "version": "0.79.8", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.7", - "@earendil-works/pi-ai": "^0.79.7", - "@earendil-works/pi-tui": "^0.79.7", + "@earendil-works/pi-agent-core": "^0.79.8", + "@earendil-works/pi-ai": "^0.79.8", + "@earendil-works/pi-tui": "^0.79.8", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -5815,32 +5815,32 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.79.7", + "version": "0.79.8", "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.79.7" + "version": "0.79.8" }, "packages/coding-agent/examples/extensions/gondolin": { "name": "pi-extension-gondolin", - "version": "0.79.7", + "version": "0.79.8", "dependencies": { "@earendil-works/gondolin": "0.12.0" } }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.9.7", + "version": "1.9.8", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.79.7", + "version": "0.79.8", "dependencies": { "ms": "2.1.3" }, @@ -6136,7 +6136,7 @@ }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.79.7", + "version": "0.79.8", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index d9a204c6..8adfae20 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.8] - 2026-06-19 ### Added diff --git a/packages/agent/package.json b/packages/agent/package.json index ee24151f..b3aa9bae 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "0.79.7", + "version": "0.79.8", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -33,7 +33,7 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "^0.79.7", + "@earendil-works/pi-ai": "^0.79.8", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 2aab174a..f3d696fd 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.8] - 2026-06-19 ### Added diff --git a/packages/ai/package.json b/packages/ai/package.json index 68165eba..830df19e 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.79.7", + "version": "0.79.8", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 82d0e137..8639bf13 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -6067,7 +6067,7 @@ export const MODELS = { cost: { input: 1.5, output: 7.5, - cacheRead: 0.15, + cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, @@ -10573,13 +10573,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.68, - output: 3.41, - cacheRead: 0.34, + input: 0.67, + output: 3.5, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262142, + maxTokens: 262144, } satisfies Model<"openai-completions">, "moonshotai/kimi-k2.7-code": { id: "moonshotai/kimi-k2.7-code", @@ -11556,7 +11556,7 @@ export const MODELS = { cost: { input: 0.075, output: 0.3, - cacheRead: 0.037, + cacheRead: 0.0375, cacheWrite: 0, }, contextWindow: 131072, @@ -11749,6 +11749,23 @@ export const MODELS = { contextWindow: 200000, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openrouter/fusion": { + id: "openrouter/fusion", + name: "OpenRouter: Fusion", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 30000, + } satisfies Model<"openai-completions">, "openrouter/owl-alpha": { id: "openrouter/owl-alpha", name: "Owl Alpha", @@ -13067,13 +13084,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 1.4, - output: 4.4, - cacheRead: 0.7, + input: 1.2, + output: 4.1, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 1048576, - maxTokens: 524288, + maxTokens: 131072, } satisfies Model<"openai-completions">, "~anthropic/claude-fable-latest": { id: "~anthropic/claude-fable-latest", @@ -13186,13 +13203,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.68, - output: 3.41, - cacheRead: 0.34, + input: 0.67, + output: 3.5, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262142, + maxTokens: 262144, } satisfies Model<"openai-completions">, "~openai/gpt-latest": { id: "~openai/gpt-latest", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2f0ecb2a..e52073bc 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.8] - 2026-06-19 ### 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 c3f35b25..436f151f 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.79.7", + "version": "0.79.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "0.79.7", + "version": "0.79.8", "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 c817a94e..aa509051 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.79.7", + "version": "0.79.8", "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 12363537..51de298b 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.79.7", + "version": "0.79.8", "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 ea80b459..3f126430 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.79.7", + "version": "0.79.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-gondolin", - "version": "0.79.7", + "version": "0.79.8", "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 89705d97..1d42d841 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.79.7", + "version": "0.79.8", "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 41b01437..2a587f88 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.9.7", + "version": "1.9.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.9.7", + "version": "1.9.8", "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 a80abe4a..c9d4aa3f 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.9.7", + "version": "1.9.8", "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 b45b2214..2dc3506b 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.79.7", + "version": "0.79.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "0.79.7", + "version": "0.79.8", "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 02abd397..1c409ca4 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.79.7", + "version": "0.79.8", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 934827aa..13ff9161 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -1,17 +1,17 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.79.7", + "version": "0.79.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent", - "version": "0.79.7", + "version": "0.79.8", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.7", - "@earendil-works/pi-ai": "^0.79.7", - "@earendil-works/pi-tui": "^0.79.7", + "@earendil-works/pi-agent-core": "^0.79.8", + "@earendil-works/pi-ai": "^0.79.8", + "@earendil-works/pi-tui": "^0.79.8", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -474,11 +474,11 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.79.7", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.7.tgz", + "version": "0.79.8", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.8.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.79.7", + "@earendil-works/pi-ai": "^0.79.8", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -488,8 +488,8 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.79.7", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.7.tgz", + "version": "0.79.8", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.8.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -512,8 +512,8 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.79.7", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.7.tgz", + "version": "0.79.8", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.8.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 3d890c7a..4aa30dd2 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.79.7", + "version": "0.79.8", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -36,9 +36,9 @@ "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" }, "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.7", - "@earendil-works/pi-ai": "^0.79.7", - "@earendil-works/pi-tui": "^0.79.7", + "@earendil-works/pi-agent-core": "^0.79.8", + "@earendil-works/pi-ai": "^0.79.8", + "@earendil-works/pi-tui": "^0.79.8", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 4db80b91..9f54a3e3 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.8] - 2026-06-19 ## [0.79.7] - 2026-06-18 diff --git a/packages/tui/package.json b/packages/tui/package.json index 24590a18..36f002a7 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-tui", - "version": "0.79.7", + "version": "0.79.8", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", From 56b22768db0bf06ba09719ea9a62f0b29065aaad Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 19 Jun 2026 09:53:45 +0200 Subject: [PATCH 333/352] Add [Unreleased] section for next cycle --- packages/agent/CHANGELOG.md | 2 ++ packages/ai/CHANGELOG.md | 2 ++ packages/coding-agent/CHANGELOG.md | 2 ++ packages/tui/CHANGELOG.md | 2 ++ 4 files changed, 8 insertions(+) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 8adfae20..0ac61b2f 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.8] - 2026-06-19 ### Added diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index f3d696fd..63e7bba7 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.8] - 2026-06-19 ### Added diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e52073bc..53f60f55 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.8] - 2026-06-19 ### New Features diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 9f54a3e3..2e9f6d31 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.8] - 2026-06-19 ## [0.79.7] - 2026-06-18 From 783571a615b08b286247b86f383197d43990c2a9 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 19 Jun 2026 11:03:11 +0200 Subject: [PATCH 334/352] feat: track auto-closed issue triage --- .github/workflows/issue-gate.yml | 7 ++ .github/workflows/issue-triage-labels.yml | 120 ++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 .github/workflows/issue-triage-labels.yml diff --git a/.github/workflows/issue-gate.yml b/.github/workflows/issue-gate.yml index 62663940..a8103fd1 100644 --- a/.github/workflows/issue-gate.yml +++ b/.github/workflows/issue-gate.yml @@ -111,6 +111,13 @@ jobs: body: message, }); + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['untriaged'], + }); + await github.rest.issues.update({ owner: context.repo.owner, repo: context.repo.repo, diff --git a/.github/workflows/issue-triage-labels.yml b/.github/workflows/issue-triage-labels.yml new file mode 100644 index 00000000..0be1e669 --- /dev/null +++ b/.github/workflows/issue-triage-labels.yml @@ -0,0 +1,120 @@ +name: Issue Triage Labels + +on: + issues: + types: [reopened, labeled] + +jobs: + update-labels: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Update triage labels + uses: actions/github-script@v7 + with: + script: | + const UNTRIAGED_LABEL = 'untriaged'; + const NO_ACTION_LABEL = 'no-action'; + const LAST_READ_LABEL = 'last-read'; + + function issueHasLabel(issue, labelName) { + return (issue.labels ?? []).some((label) => label.name === labelName); + } + + async function removeLabelIfPresent(issueNumber, issue, labelName) { + if (!issueHasLabel(issue, labelName)) { + console.log(`Issue #${issueNumber} does not have ${labelName}`); + return; + } + + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: labelName, + }); + console.log(`Removed ${labelName} from #${issueNumber}`); + } catch (error) { + if (error.status === 404) { + console.log(`Label ${labelName} was already absent from #${issueNumber}`); + return; + } + throw error; + } + } + + if (context.payload.action === 'reopened') { + await removeLabelIfPresent(context.issue.number, context.payload.issue, UNTRIAGED_LABEL); + await removeLabelIfPresent(context.issue.number, context.payload.issue, NO_ACTION_LABEL); + return; + } + + if (context.payload.action !== 'labeled' || context.payload.label?.name !== LAST_READ_LABEL) { + console.log('Not a last-read label event'); + return; + } + + const currentIssueNumber = context.issue.number; + const lastReadIssues = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'all', + labels: LAST_READ_LABEL, + per_page: 100, + }); + + const previousIssueNumbers = lastReadIssues + .filter((issue) => !issue.pull_request) + .map((issue) => issue.number) + .filter((issueNumber) => issueNumber !== currentIssueNumber); + + if (previousIssueNumbers.length === 0) { + console.log('No previous last-read issue found'); + return; + } + + const previousIssueNumber = Math.max(...previousIssueNumbers); + if (currentIssueNumber <= previousIssueNumber) { + console.log( + `Last-read was added to old issue #${currentIssueNumber}; latest last-read is #${previousIssueNumber}`, + ); + return; + } + + const untriagedIssues = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'all', + labels: UNTRIAGED_LABEL, + per_page: 100, + }); + + const issuesToMark = untriagedIssues + .filter((issue) => !issue.pull_request) + .filter((issue) => issue.number >= previousIssueNumber && issue.number <= currentIssueNumber) + .sort((a, b) => a.number - b.number); + + if (issuesToMark.length === 0) { + console.log(`No untriaged issues found from #${previousIssueNumber} to #${currentIssueNumber}`); + return; + } + + for (const issue of issuesToMark) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: [NO_ACTION_LABEL], + }); + console.log(`Added ${NO_ACTION_LABEL} to #${issue.number}`); + + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + name: UNTRIAGED_LABEL, + }); + console.log(`Removed ${UNTRIAGED_LABEL} from #${issue.number}`); + } From 47d1d90a8ed156d7d0ca90265f4ac60c75f56621 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 19 Jun 2026 11:08:16 +0200 Subject: [PATCH 335/352] fix: close no-action issues as not planned --- .github/workflows/issue-triage-labels.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/issue-triage-labels.yml b/.github/workflows/issue-triage-labels.yml index 0be1e669..4a9ea9d7 100644 --- a/.github/workflows/issue-triage-labels.yml +++ b/.github/workflows/issue-triage-labels.yml @@ -110,6 +110,15 @@ jobs: }); console.log(`Added ${NO_ACTION_LABEL} to #${issue.number}`); + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + state: 'closed', + state_reason: 'not_planned', + }); + console.log(`Closed #${issue.number} as not planned`); + await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, From 373cd6ae52b6a8954ee530b887bd39a2dc820931 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Fri, 19 Jun 2026 12:25:02 +0200 Subject: [PATCH 336/352] fix(coding-agent): prioritize provider matches in model selector closes #5892 --- packages/coding-agent/CHANGELOG.md | 4 ++++ .../src/modes/interactive/components/model-selector.ts | 4 ++-- .../coding-agent/src/modes/interactive/model-search.ts | 10 ++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 53f60f55..07529966 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `/model` selector search to rank exact provider-prefixed matches before proxy-provider model ID matches ([#5892](https://github.com/earendil-works/pi/issues/5892)). + ## [0.79.8] - 2026-06-19 ### New Features diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts index a3cdf7d5..32711929 100644 --- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts @@ -11,7 +11,7 @@ import { } from "@earendil-works/pi-tui"; import type { ModelRegistry } from "../../../core/model-registry.ts"; import type { SettingsManager } from "../../../core/settings-manager.ts"; -import { getModelSearchText } from "../model-search.ts"; +import { getModelSelectorSearchText } from "../model-search.ts"; import { theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { keyHint } from "./keybinding-hints.ts"; @@ -219,7 +219,7 @@ export class ModelSelectorComponent extends Container implements Focusable { private filterModels(query: string): void { this.filteredModels = query ? fuzzyFilter(this.activeModels, query, ({ id, provider, model }) => - getModelSearchText({ id, provider, name: model.name }), + getModelSelectorSearchText({ id, provider, name: model.name }), ) : this.activeModels; this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1)); diff --git a/packages/coding-agent/src/modes/interactive/model-search.ts b/packages/coding-agent/src/modes/interactive/model-search.ts index f1dbc73c..bab9c5a5 100644 --- a/packages/coding-agent/src/modes/interactive/model-search.ts +++ b/packages/coding-agent/src/modes/interactive/model-search.ts @@ -9,3 +9,13 @@ export function getModelSearchText(item: ModelSearchItem): string { const name = item.name ? ` ${item.name}` : ""; return `${id} ${provider} ${provider}/${id} ${provider} ${id}${name}`; } + +/** + * The /model selector search should rank exact provider-prefixed queries before proxy-provider IDs + * like openrouter/openai/gpt-5, so keep the bare model ID out of the leading position. + */ +export function getModelSelectorSearchText(item: ModelSearchItem): string { + const { id, provider } = item; + const name = item.name ? ` ${item.name}` : ""; + return `${provider} ${provider}/${id} ${provider} ${id}${name}`; +} From 226a316824dc1835ffc34acad274488f5fe298f6 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 19 Jun 2026 12:46:31 +0200 Subject: [PATCH 337/352] fix: mark auto-closed issues not planned --- .github/workflows/issue-gate.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/issue-gate.yml b/.github/workflows/issue-gate.yml index a8103fd1..d2bf830e 100644 --- a/.github/workflows/issue-gate.yml +++ b/.github/workflows/issue-gate.yml @@ -123,4 +123,5 @@ jobs: repo: context.repo.repo, issue_number: context.issue.number, state: 'closed', + state_reason: 'not_planned', }); From 6e6ce70caf3328683517b0e308fdbbc6d1c1abc9 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Fri, 19 Jun 2026 13:16:51 +0200 Subject: [PATCH 338/352] fix(ai): filter Copilot models by account availability closes #5897 --- packages/ai/CHANGELOG.md | 4 + packages/ai/src/utils/oauth/github-copilot.ts | 83 +++++++++++++++++-- packages/ai/test/github-copilot-oauth.test.ts | 70 +++++++++++++++- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/test/model-registry.test.ts | 19 +++++ 5 files changed, 169 insertions(+), 8 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 63e7bba7..4f316c3b 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed GitHub Copilot OAuth model availability to use the authenticated account's model picker catalog ([#5897](https://github.com/earendil-works/pi/issues/5897)). + ## [0.79.8] - 2026-06-19 ### Added diff --git a/packages/ai/src/utils/oauth/github-copilot.ts b/packages/ai/src/utils/oauth/github-copilot.ts index 6a27b9d1..bb78a93a 100644 --- a/packages/ai/src/utils/oauth/github-copilot.ts +++ b/packages/ai/src/utils/oauth/github-copilot.ts @@ -9,6 +9,7 @@ import type { OAuthCredentials, OAuthDeviceCodeInfo, OAuthLoginCallbacks, OAuthP type CopilotCredentials = OAuthCredentials & { enterpriseUrl?: string; + availableModelIds: string[]; }; const decode = (s: string) => atob(s); @@ -20,6 +21,7 @@ const COPILOT_HEADERS = { "Editor-Plugin-Version": "copilot-chat/0.35.0", "Copilot-Integration-Id": "vscode-chat", } as const; +const COPILOT_API_VERSION = "2026-06-01"; type DeviceCodeResponse = { device_code: string; @@ -88,6 +90,48 @@ export function getGitHubCopilotBaseUrl(token?: string, enterpriseDomain?: strin return "https://api.individual.githubcopilot.com"; } +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" ? (value as Record) : undefined; +} + +function isSelectableCopilotModel(item: Record): boolean { + const policy = asRecord(item.policy); + const capabilities = asRecord(item.capabilities); + const supports = asRecord(capabilities?.supports); + return item.model_picker_enabled === true && policy?.state !== "disabled" && supports?.tool_calls !== false; +} + +function parseAvailableCopilotModelIds(raw: unknown): string[] { + const data = asRecord(raw)?.data; + if (!Array.isArray(data)) { + throw new Error("Invalid Copilot models response"); + } + + const ids: string[] = []; + for (const rawItem of data) { + const item = asRecord(rawItem); + const id = item?.id; + if (typeof id === "string" && item && isSelectableCopilotModel(item)) { + ids.push(id); + } + } + return ids; +} + +async function fetchAvailableGitHubCopilotModelIds(copilotToken: string, enterpriseDomain?: string): Promise { + const baseUrl = getGitHubCopilotBaseUrl(copilotToken, enterpriseDomain); + const raw = await fetchJson(`${baseUrl}/models`, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${copilotToken}`, + ...COPILOT_HEADERS, + "X-GitHub-Api-Version": COPILOT_API_VERSION, + }, + signal: AbortSignal.timeout(5000), + }); + return parseAvailableCopilotModelIds(raw); +} + async function fetchJson(url: string, init: RequestInit): Promise { const response = await fetch(url, init); if (!response.ok) { @@ -201,10 +245,7 @@ async function pollForGitHubAccessToken( }); } -/** - * Refresh GitHub Copilot token - */ -export async function refreshGitHubCopilotToken( +async function refreshGitHubCopilotAccessToken( refreshToken: string, enterpriseDomain?: string, ): Promise { @@ -238,6 +279,20 @@ export async function refreshGitHubCopilotToken( }; } +/** + * Refresh GitHub Copilot token + */ +export async function refreshGitHubCopilotToken( + refreshToken: string, + enterpriseDomain?: string, +): Promise { + const credentials = await refreshGitHubCopilotAccessToken(refreshToken, enterpriseDomain); + return { + ...credentials, + availableModelIds: await fetchAvailableGitHubCopilotModelIds(credentials.access, enterpriseDomain), + }; +} + /** * Enable a model for the user's GitHub Copilot account. * This is required for some models (like Claude, Grok) before they can be used. @@ -322,12 +377,18 @@ export async function loginGitHubCopilot(options: { }); const githubAccessToken = await pollForGitHubAccessToken(domain, device, options.signal); - const credentials = await refreshGitHubCopilotToken(githubAccessToken, enterpriseDomain ?? undefined); + const credentials = await refreshGitHubCopilotAccessToken(githubAccessToken, enterpriseDomain ?? undefined); // Enable all models after successful login options.onProgress?.("Enabling models..."); await enableAllGitHubCopilotModels(credentials.access, enterpriseDomain ?? undefined); - return credentials; + + // Fetch availability after policy enable so newly enabled models are included, + // while unavailable models are still filtered out. + return { + ...credentials, + availableModelIds: await fetchAvailableGitHubCopilotModelIds(credentials.access, enterpriseDomain ?? undefined), + }; } export const githubCopilotOAuthProvider: OAuthProviderInterface = { @@ -356,6 +417,14 @@ export const githubCopilotOAuthProvider: OAuthProviderInterface = { const creds = credentials as CopilotCredentials; const domain = creds.enterpriseUrl ? (normalizeDomain(creds.enterpriseUrl) ?? undefined) : undefined; const baseUrl = getGitHubCopilotBaseUrl(creds.access, domain); - return models.map((m) => (m.provider === "github-copilot" ? { ...m, baseUrl } : m)); + // Older stored Pi auth entries do not have account-specific model IDs yet; + // keep their existing generated-catalog behavior until the next refresh/login. + const availableModelIds = "availableModelIds" in creds ? new Set(creds.availableModelIds) : undefined; + + return models.flatMap((m) => { + if (m.provider !== "github-copilot") return [m]; + if (availableModelIds && !availableModelIds.has(m.id)) return []; + return [{ ...m, baseUrl }]; + }); }, }; diff --git a/packages/ai/test/github-copilot-oauth.test.ts b/packages/ai/test/github-copilot-oauth.test.ts index c94370da..2aba3084 100644 --- a/packages/ai/test/github-copilot-oauth.test.ts +++ b/packages/ai/test/github-copilot-oauth.test.ts @@ -1,5 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { loginGitHubCopilot } from "../src/utils/oauth/github-copilot.ts"; +import { getModels } from "../src/models.ts"; +import { + githubCopilotOAuthProvider, + loginGitHubCopilot, + refreshGitHubCopilotToken, +} from "../src/utils/oauth/github-copilot.ts"; function jsonResponse(body: unknown, status: number = 200): Response { return new Response(JSON.stringify(body), { @@ -29,6 +34,57 @@ describe("GitHub Copilot OAuth device flow", () => { vi.useRealTimers(); }); + it("filters models to the authenticated account picker catalog", async () => { + const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise => { + const url = getUrl(input); + + if (url.includes("/copilot_internal/v2/token")) { + return jsonResponse({ + token: "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;", + expires_at: 9999999999, + }); + } + + if (url === "https://api.individual.githubcopilot.com/models") { + expect(init?.headers).toMatchObject({ + Authorization: "Bearer tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;", + }); + return jsonResponse({ + data: [ + { + id: "gpt-4.1", + model_picker_enabled: true, + capabilities: { supports: { tool_calls: true } }, + }, + { + id: "claude-opus-4.7", + model_picker_enabled: true, + policy: { state: "disabled" }, + capabilities: { supports: { tool_calls: true } }, + }, + { + id: "gpt-5.4-nano", + model_picker_enabled: false, + capabilities: { supports: { tool_calls: true } }, + }, + ], + }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + vi.stubGlobal("fetch", fetchMock); + + const credentials = await refreshGitHubCopilotToken("ghu_refresh_token"); + expect(credentials.availableModelIds).toEqual(["gpt-4.1"]); + + const modifiedModels = githubCopilotOAuthProvider.modifyModels?.(getModels("github-copilot"), credentials) ?? []; + expect(modifiedModels.filter((model) => model.provider === "github-copilot").map((model) => model.id)).toEqual([ + "gpt-4.1", + ]); + }); + it("reports device-code details through onDeviceCode", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-09T00:00:00Z")); @@ -57,6 +113,10 @@ describe("GitHub Copilot OAuth device flow", () => { }); } + if (url.endsWith("/models")) { + return jsonResponse({ data: [] }); + } + if (url.includes("/models/") && url.endsWith("/policy")) { return new Response("", { status: 200 }); } @@ -146,6 +206,10 @@ describe("GitHub Copilot OAuth device flow", () => { }); } + if (url.endsWith("/models")) { + return jsonResponse({ data: [] }); + } + if (url.includes("/models/") && url.endsWith("/policy")) { return new Response("", { status: 200 }); } @@ -231,6 +295,10 @@ describe("GitHub Copilot OAuth device flow", () => { }); } + if (url.endsWith("/models")) { + return jsonResponse({ data: [] }); + } + if (url.includes("/models/") && url.endsWith("/policy")) { return new Response("", { status: 200 }); } diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 07529966..d1245f20 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed `/model` to hide GitHub Copilot models that are unavailable to the authenticated account ([#5897](https://github.com/earendil-works/pi/issues/5897)). - Fixed `/model` selector search to rank exact provider-prefixed matches before proxy-provider model ID matches ([#5892](https://github.com/earendil-works/pi/issues/5892)). ## [0.79.8] - 2026-06-19 diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index fe8609f2..1d720628 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -1669,6 +1669,25 @@ describe("ModelRegistry", () => { expect(count).toBe(0); }); + test("getAvailable filters GitHub Copilot OAuth models to account picker availability", () => { + authStorage.set("github-copilot", { + type: "oauth", + refresh: "github-access-token", + access: "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;", + expires: Date.now() + 60_000, + availableModelIds: ["gpt-4.1"], + }); + + const registry = ModelRegistry.create(authStorage, modelsJsonPath); + + expect( + registry + .getAvailable() + .filter((m) => m.provider === "github-copilot") + .map((m) => m.id), + ).toEqual(["gpt-4.1"]); + }); + test("getApiKeyAndHeaders resolves authHeader on every request", async () => { const tokenFile = join(tempDir, "token"); writeFileSync(tokenFile, "token-1"); From 1287b69fe026a9c3f9cec8a220ad9405851f7dc3 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Fri, 19 Jun 2026 15:05:16 +0200 Subject: [PATCH 339/352] fix(coding-agent): run legacy WSL bash commands via stdin closes #5893 --- packages/agent/CHANGELOG.md | 4 ++ packages/agent/src/harness/env/nodejs.ts | 52 +++++++++++++----- .../agent/test/harness/nodejs-env.test.ts | 35 +++++++++++- packages/coding-agent/CHANGELOG.md | 1 + .../src/core/resolve-config-value.ts | 8 ++- packages/coding-agent/src/core/tools/bash.ts | 11 +++- packages/coding-agent/src/utils/shell.ts | 20 +++++-- .../coding-agent/test/auth-storage.test.ts | 27 ++++++++- packages/coding-agent/test/tools.test.ts | 50 +++++++++++++++++ scripts/repro-5893-wsl-bash.mjs | 55 +++++++++++++++++++ 10 files changed, 235 insertions(+), 28 deletions(-) create mode 100644 scripts/repro-5893-wsl-bash.mjs diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 0ac61b2f..18d0e561 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Node execution environment commands through legacy WSL `bash.exe` to pass scripts over stdin so shell variables expand in the target bash ([#5893](https://github.com/earendil-works/pi/issues/5893)). + ## [0.79.8] - 2026-06-19 ### Added diff --git a/packages/agent/src/harness/env/nodejs.ts b/packages/agent/src/harness/env/nodejs.ts index e56e7aeb..3d929c82 100644 --- a/packages/agent/src/harness/env/nodejs.ts +++ b/packages/agent/src/harness/env/nodejs.ts @@ -144,12 +144,25 @@ async function findBashOnPath(): Promise { return firstMatch && (await pathExists(firstMatch)) ? firstMatch : null; } -async function getShellConfig( - customShellPath?: string, -): Promise> { +interface ShellConfig { + shell: string; + args: string[]; + commandTransport?: "argv" | "stdin"; +} + +function isLegacyWslBashPath(path: string): boolean { + const normalized = path.replace(/\//g, "\\").toLowerCase(); + return /^[a-z]:\\windows\\(?:system32|sysnative)\\bash\.exe$/.test(normalized); +} + +function getBashShellConfig(shell: string): ShellConfig { + return isLegacyWslBashPath(shell) ? { shell, args: ["-s"], commandTransport: "stdin" } : { shell, args: ["-c"] }; +} + +async function getShellConfig(customShellPath?: string): Promise> { if (customShellPath) { if (await pathExists(customShellPath)) { - return ok({ shell: customShellPath, args: ["-c"] }); + return ok(getBashShellConfig(customShellPath)); } return err(new ExecutionError("shell_unavailable", `Custom shell path not found: ${customShellPath}`)); } @@ -161,22 +174,22 @@ async function getShellConfig( if (programFilesX86) candidates.push(`${programFilesX86}\\Git\\bin\\bash.exe`); for (const candidate of candidates) { if (await pathExists(candidate)) { - return ok({ shell: candidate, args: ["-c"] }); + return ok(getBashShellConfig(candidate)); } } const bashOnPath = await findBashOnPath(); if (bashOnPath) { - return ok({ shell: bashOnPath, args: ["-c"] }); + return ok(getBashShellConfig(bashOnPath)); } return err(new ExecutionError("shell_unavailable", "No bash shell found")); } if (await pathExists("/bin/bash")) { - return ok({ shell: "/bin/bash", args: ["-c"] }); + return ok(getBashShellConfig("/bin/bash")); } const bashOnPath = await findBashOnPath(); if (bashOnPath) { - return ok({ shell: bashOnPath, args: ["-c"] }); + return ok(getBashShellConfig(bashOnPath)); } return ok({ shell: "sh", args: ["-c"] }); } @@ -274,13 +287,22 @@ export class NodeExecutionEnv implements ExecutionEnv { }; try { - child = spawn(shellConfig.value.shell, [...shellConfig.value.args, command], { - cwd, - detached: process.platform !== "win32", - env: getShellEnv(this.shellEnv, options?.env), - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - }); + const commandFromStdin = shellConfig.value.commandTransport === "stdin"; + child = spawn( + shellConfig.value.shell, + commandFromStdin ? shellConfig.value.args : [...shellConfig.value.args, command], + { + cwd, + detached: process.platform !== "win32", + env: getShellEnv(this.shellEnv, options?.env), + stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "pipe"], + windowsHide: true, + }, + ); + if (commandFromStdin) { + child.stdin?.on("error", () => {}); + child.stdin?.end(command); + } } catch (error) { const cause = toError(error); settle(err(new ExecutionError("spawn_error", cause.message, cause))); diff --git a/packages/agent/test/harness/nodejs-env.test.ts b/packages/agent/test/harness/nodejs-env.test.ts index 758d5f59..d2d33a6f 100644 --- a/packages/agent/test/harness/nodejs-env.test.ts +++ b/packages/agent/test/harness/nodejs-env.test.ts @@ -1,5 +1,5 @@ import { access, chmod, realpath, symlink } from "node:fs/promises"; -import { join } from "node:path"; +import { delimiter, join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; import { FileError, getOrThrow } from "../../src/harness/types.ts"; @@ -201,6 +201,39 @@ describe("NodeExecutionEnv", () => { expect(result).toEqual({ stdout: `${await realpath(root)}:ok`, stderr: "", exitCode: 0 }); }); + it("uses stdin command transport for legacy WSL bash paths", async () => { + if (process.platform === "win32") return; + const root = createTempDir(); + const shellPath = "C:\\Windows\\System32\\bash.exe"; + const env = new NodeExecutionEnv({ cwd: root }); + getOrThrow(await env.writeFile(shellPath, '#!/bin/sh\nprintf \'args:%s\\n\' "$*" >&2\nexec /bin/bash "$@"\n')); + await chmod(join(root, shellPath), 0o755); + + const originalCwd = process.cwd(); + const originalPath = process.env.PATH; + const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + try { + process.chdir(root); + process.env.PATH = `${root}${delimiter}${originalPath ?? ""}`; + Object.defineProperty(process, "platform", { + configurable: true, + value: "win32", + }); + + const wslEnv = new NodeExecutionEnv({ cwd: root, shellPath }); + const nameExpansion = "$" + "{name}"; + const result = getOrThrow(await wslEnv.exec(`name='World'; echo "Hello, ${nameExpansion}!"`)); + + expect(result).toEqual({ stdout: "Hello, World!\n", stderr: "args:-s\n", exitCode: 0 }); + } finally { + process.chdir(originalCwd); + process.env.PATH = originalPath; + if (platformDescriptor) { + Object.defineProperty(process, "platform", platformDescriptor); + } + } + }); + it("streams stdout and stderr chunks", async () => { const root = createTempDir(); const env = new NodeExecutionEnv({ cwd: root }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d1245f20..c5aae0f9 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed bash commands through legacy WSL `bash.exe` to pass scripts over stdin so shell variables expand in the target bash ([#5893](https://github.com/earendil-works/pi/issues/5893)). - Fixed `/model` to hide GitHub Copilot models that are unavailable to the authenticated account ([#5897](https://github.com/earendil-works/pi/issues/5897)). - Fixed `/model` selector search to rank exact provider-prefixed matches before proxy-provider model ID matches ([#5892](https://github.com/earendil-works/pi/issues/5892)). diff --git a/packages/coding-agent/src/core/resolve-config-value.ts b/packages/coding-agent/src/core/resolve-config-value.ts index 9d47cff3..6d75b001 100644 --- a/packages/coding-agent/src/core/resolve-config-value.ts +++ b/packages/coding-agent/src/core/resolve-config-value.ts @@ -152,11 +152,13 @@ export function resolveConfigValue(config: string, env?: Record) function executeWithConfiguredShell(command: string): { executed: boolean; value: string | undefined } { try { - const { shell, args } = getShellConfig(); - const result = spawnSync(shell, [...args, command], { + const { shell, args, commandTransport } = getShellConfig(); + const commandFromStdin = commandTransport === "stdin"; + const result = spawnSync(shell, commandFromStdin ? args : [...args, command], { encoding: "utf-8", + input: commandFromStdin ? command : undefined, timeout: 10000, - stdio: ["ignore", "pipe", "ignore"], + stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "ignore"], shell: false, windowsHide: true, }); diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index e56291bb..da6934e7 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -66,7 +66,7 @@ export interface BashOperations { export function createLocalBashOperations(options?: { shellPath?: string }): BashOperations { return { exec: async (command, cwd, { onData, signal, timeout, env }) => { - const { shell, args } = getShellConfig(options?.shellPath); + const shellConfig = getShellConfig(options?.shellPath); try { await fsAccess(cwd, constants.F_OK); } catch { @@ -76,13 +76,18 @@ export function createLocalBashOperations(options?: { shellPath?: string }): Bas throw new Error("aborted"); } - const child = spawn(shell, [...args, command], { + const commandFromStdin = shellConfig.commandTransport === "stdin"; + const child = spawn(shellConfig.shell, commandFromStdin ? shellConfig.args : [...shellConfig.args, command], { cwd, detached: process.platform !== "win32", env: env ?? getShellEnv(), - stdio: ["ignore", "pipe", "pipe"], + stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "pipe"], windowsHide: true, }); + if (commandFromStdin) { + child.stdin?.on("error", () => {}); + child.stdin?.end(command); + } if (child.pid) trackDetachedChildPid(child.pid); let timedOut = false; let timeoutHandle: NodeJS.Timeout | undefined; diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts index 817ba4f9..2cafa595 100644 --- a/packages/coding-agent/src/utils/shell.ts +++ b/packages/coding-agent/src/utils/shell.ts @@ -6,11 +6,21 @@ import { getBinDir } from "../config.ts"; export interface ShellConfig { shell: string; args: string[]; + commandTransport?: "argv" | "stdin"; } /** * Find bash executable on PATH (cross-platform) */ +function isLegacyWslBashPath(path: string): boolean { + const normalized = path.replace(/\//g, "\\").toLowerCase(); + return /^[a-z]:\\windows\\(?:system32|sysnative)\\bash\.exe$/.test(normalized); +} + +function getBashShellConfig(shell: string): ShellConfig { + return isLegacyWslBashPath(shell) ? { shell, args: ["-s"], commandTransport: "stdin" } : { shell, args: ["-c"] }; +} + function findBashOnPath(): string | null { if (process.platform === "win32") { // Windows: Use 'where' and verify file exists (where can return non-existent paths) @@ -58,7 +68,7 @@ export function getShellConfig(customShellPath?: string): ShellConfig { // 1. Check user-specified shell path if (customShellPath) { if (existsSync(customShellPath)) { - return { shell: customShellPath, args: ["-c"] }; + return getBashShellConfig(customShellPath); } throw new Error(`Custom shell path not found: ${customShellPath}`); } @@ -77,14 +87,14 @@ export function getShellConfig(customShellPath?: string): ShellConfig { for (const path of paths) { if (existsSync(path)) { - return { shell: path, args: ["-c"] }; + return getBashShellConfig(path); } } // 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.) const bashOnPath = findBashOnPath(); if (bashOnPath) { - return { shell: bashOnPath, args: ["-c"] }; + return getBashShellConfig(bashOnPath); } throw new Error( @@ -98,12 +108,12 @@ export function getShellConfig(customShellPath?: string): ShellConfig { // Unix: try /bin/bash, then bash on PATH, then fallback to sh if (existsSync("/bin/bash")) { - return { shell: "/bin/bash", args: ["-c"] }; + return getBashShellConfig("/bin/bash"); } const bashOnPath = findBashOnPath(); if (bashOnPath) { - return { shell: bashOnPath, args: ["-c"] }; + return getBashShellConfig(bashOnPath); } return { shell: "sh", args: ["-c"] }; diff --git a/packages/coding-agent/test/auth-storage.test.ts b/packages/coding-agent/test/auth-storage.test.ts index bcc353c1..651f9a17 100644 --- a/packages/coding-agent/test/auth-storage.test.ts +++ b/packages/coding-agent/test/auth-storage.test.ts @@ -5,7 +5,8 @@ import { registerOAuthProvider } from "@earendil-works/pi-ai/oauth"; import lockfile from "proper-lockfile"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.ts"; -import { clearConfigValueCache } from "../src/core/resolve-config-value.ts"; +import { clearConfigValueCache, resolveConfigValueUncached } from "../src/core/resolve-config-value.ts"; +import * as shellModule from "../src/utils/shell.ts"; describe("AuthStorage", () => { let tempDir: string; @@ -321,6 +322,30 @@ describe("AuthStorage", () => { expect(apiKey).toBe("hello-world"); }); + test("command config uses stdin when configured shell requires it", () => { + if (process.platform === "win32") return; + const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + vi.spyOn(shellModule, "getShellConfig").mockReturnValue({ + shell: "/bin/bash", + args: ["-s"], + commandTransport: "stdin", + }); + + try { + Object.defineProperty(process, "platform", { + configurable: true, + value: "win32", + }); + const nameExpansion = "$" + "{name}"; + + expect(resolveConfigValueUncached(`!name='World'; echo "Hello, ${nameExpansion}!"`)).toBe("Hello, World!"); + } finally { + if (platformDescriptor) { + Object.defineProperty(process, "platform", platformDescriptor); + } + } + }); + describe("caching", () => { test("command is only executed once per process", async () => { // Use a command that writes to a file to count invocations diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index f187da62..b06590f7 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -536,6 +536,56 @@ describe("Coding Agent Tools", () => { expect(getShellConfigSpy).toHaveBeenCalledWith("/custom/bash"); }); + it("should send commands over stdin when shell resolution requires it", async () => { + vi.spyOn(shellModule, "getShellConfig").mockReturnValue({ + shell: process.execPath, + args: [ + "-e", + 'let input = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => { input += chunk; }); process.stdin.on("end", () => { process.stdout.write(input); });', + ], + commandTransport: "stdin", + }); + const chunks: Buffer[] = []; + const ops = createLocalBashOperations({ shellPath: "C:\\Windows\\System32\\bash.exe" }); + const nameExpansion = "$" + "{name}"; + const countExpansion = "$" + "{count}"; + const iExpansion = "$" + "{i}"; + const command = `name='World'; echo "Hello, ${nameExpansion}!"; count=3; for i in $(seq 1 ${countExpansion}); do echo "Iteration ${iExpansion} of ${countExpansion}"; done`; + + const result = await ops.exec(command, testDir, { + onData: (data) => chunks.push(data), + }); + + expect(result.exitCode).toBe(0); + expect(Buffer.concat(chunks).toString("utf-8")).toBe(command); + }); + + it("should resolve legacy WSL bash.exe to stdin command transport", () => { + if (process.platform === "win32") return; + const originalCwd = process.cwd(); + const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + const shellPath = "C:\\Windows\\System32\\bash.exe"; + writeFileSync(join(testDir, shellPath), ""); + try { + process.chdir(testDir); + Object.defineProperty(process, "platform", { + configurable: true, + value: "win32", + }); + + expect(shellModule.getShellConfig(shellPath)).toEqual({ + shell: shellPath, + args: ["-s"], + commandTransport: "stdin", + }); + } finally { + process.chdir(originalCwd); + if (platformDescriptor) { + Object.defineProperty(process, "platform", platformDescriptor); + } + } + }); + it("should prepend command prefix when configured", async () => { const bashWithPrefix = createBashTool(testDir, { commandPrefix: "export TEST_VAR=hello", diff --git a/scripts/repro-5893-wsl-bash.mjs b/scripts/repro-5893-wsl-bash.mjs new file mode 100644 index 00000000..b5048de7 --- /dev/null +++ b/scripts/repro-5893-wsl-bash.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +import { existsSync } from "node:fs"; +import { createBashTool } from "../packages/coding-agent/src/core/tools/bash.ts"; + +const shellPath = "C:\\Windows\\System32\\bash.exe"; +const nameExpansion = "$" + "{name}"; +const countExpansion = "$" + "{count}"; +const iExpansion = "$" + "{i}"; + +function getTextOutput(result) { + return result.content + .filter((content) => content.type === "text") + .map((content) => content.text ?? "") + .join("\n"); +} + +async function runCase(label, command, expectedOutput) { + const tool = createBashTool(process.cwd(), { shellPath }); + const result = await tool.execute(label, { command }); + const output = getTextOutput(result).trimEnd(); + if (output !== expectedOutput) { + throw new Error( + [ + `${label} failed`, + "Expected:", + expectedOutput, + "Actual:", + output, + ].join("\n"), + ); + } + console.log(output); +} + +if (process.platform !== "win32") { + throw new Error("This repro must run from Windows PowerShell/CMD, not macOS/Linux or inside WSL."); +} + +if (!existsSync(shellPath)) { + throw new Error(`WSL bash launcher not found at ${shellPath}. Install/enable WSL first.`); +} + +await runCase( + "issue-5893-simple-variable", + `name='World'; echo "Hello, ${nameExpansion}!"`, + "Hello, World!", +); + +await runCase( + "issue-5893-loop-variable", + `count=3; for i in $(seq 1 ${countExpansion}); do echo "Iteration ${iExpansion} of ${countExpansion}"; done`, + "Iteration 1 of 3\nIteration 2 of 3\nIteration 3 of 3", +); + +console.log("issue #5893 WSL bash repro passed"); From 128330e36f277452f6d07ae749643ee77f4ca4e8 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 19 Jun 2026 20:16:05 +0200 Subject: [PATCH 340/352] fix(coding-agent): preserve untouched lines in fuzzy edit closes #5899 --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/tools/edit-diff.ts | 157 +++++++++++++++--- packages/coding-agent/test/tools.test.ts | 51 ++++++ 3 files changed, 184 insertions(+), 25 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c5aae0f9..fdc99e61 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed fuzzy `edit` matches to preserve untouched line blocks instead of rewriting the whole file through normalized content ([#5899](https://github.com/earendil-works/pi/issues/5899)). - Fixed bash commands through legacy WSL `bash.exe` to pass scripts over stdin so shell variables expand in the target bash ([#5893](https://github.com/earendil-works/pi/issues/5893)). - Fixed `/model` to hide GitHub Copilot models that are unavailable to the authenticated account ([#5897](https://github.com/earendil-works/pi/issues/5897)). - Fixed `/model` selector search to rank exact provider-prefixed matches before proxy-provider model ID matches ([#5892](https://github.com/earendil-works/pi/issues/5892)). diff --git a/packages/coding-agent/src/core/tools/edit-diff.ts b/packages/coding-agent/src/core/tools/edit-diff.ts index cc94c202..5a4d966b 100644 --- a/packages/coding-agent/src/core/tools/edit-diff.ts +++ b/packages/coding-agent/src/core/tools/edit-diff.ts @@ -53,6 +53,124 @@ export function normalizeForFuzzyMatch(text: string): string { ); } +function splitLinesWithEndings(content: string): string[] { + return content.match(/[^\n]*\n|[^\n]+/g) ?? []; +} + +interface LineSpan { + start: number; + end: number; +} + +interface MatchedEdit { + editIndex: number; + matchIndex: number; + matchLength: number; + newText: string; +} + +type TextReplacement = Pick; + +function getLineSpans(content: string): LineSpan[] { + let offset = 0; + return splitLinesWithEndings(content).map((line) => { + const span = { start: offset, end: offset + line.length }; + offset = span.end; + return span; + }); +} + +function getReplacementLineRange(lines: LineSpan[], replacement: TextReplacement) { + const replacementStart = replacement.matchIndex; + const replacementEnd = replacement.matchIndex + replacement.matchLength; + + let startLine = -1; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (replacementStart >= line.start && replacementStart < line.end) { + startLine = i; + break; + } + } + if (startLine === -1) { + throw new Error("Replacement range is outside the base content."); + } + + let endLine = startLine; + while (endLine < lines.length && lines[endLine].end < replacementEnd) { + endLine++; + } + if (endLine >= lines.length) { + throw new Error("Replacement range is outside the base content."); + } + + return { startLine, endLine: endLine + 1 }; +} + +function applyReplacements(content: string, replacements: TextReplacement[], offset = 0): string { + let result = content; + for (let i = replacements.length - 1; i >= 0; i--) { + const replacement = replacements[i]; + const matchIndex = replacement.matchIndex - offset; + result = + result.substring(0, matchIndex) + replacement.newText + result.substring(matchIndex + replacement.matchLength); + } + return result; +} + +/** + * Apply replacements matched against `baseContent` to `originalContent` while + * preserving unchanged line blocks from the original. + * + * This is useful when `baseContent` is a normalized view of the original. Each + * replacement is widened to the lines it actually touches, those touched lines + * are rewritten from the normalized base, and all other lines are copied back + * from `originalContent`. The actual replacement ranges drive preservation so + * duplicate normalized lines cannot be aligned to the wrong occurrence. + */ +export function applyReplacementsPreservingUnchangedLines( + originalContent: string, + baseContent: string, + replacements: TextReplacement[], +): string { + const originalLines = splitLinesWithEndings(originalContent); + const baseLines = getLineSpans(baseContent); + if (originalLines.length !== baseLines.length) { + throw new Error("Cannot preserve unchanged lines because the base content has a different line count."); + } + + const groups: Array<{ startLine: number; endLine: number; replacements: TextReplacement[] }> = []; + const sortedReplacements = [...replacements].sort((a, b) => a.matchIndex - b.matchIndex); + for (const replacement of sortedReplacements) { + const range = getReplacementLineRange(baseLines, replacement); + const current = groups[groups.length - 1]; + if (current && range.startLine < current.endLine) { + current.endLine = Math.max(current.endLine, range.endLine); + current.replacements.push(replacement); + continue; + } + groups.push({ ...range, replacements: [replacement] }); + } + + let originalLineIndex = 0; + let result = ""; + for (const group of groups) { + result += originalLines.slice(originalLineIndex, group.startLine).join(""); + + const groupStartOffset = baseLines[group.startLine].start; + const groupEndOffset = baseLines[group.endLine - 1].end; + result += applyReplacements( + baseContent.slice(groupStartOffset, groupEndOffset), + group.replacements, + groupStartOffset, + ); + originalLineIndex = group.endLine; + } + result += originalLines.slice(originalLineIndex).join(""); + + return result; +} + export interface FuzzyMatchResult { /** Whether a match was found */ found: boolean; @@ -74,13 +192,6 @@ export interface Edit { newText: string; } -interface MatchedEdit { - editIndex: number; - matchIndex: number; - matchLength: number; - newText: string; -} - export interface AppliedEditsResult { baseContent: string; newContent: string; @@ -120,9 +231,9 @@ export function fuzzyFindText(content: string, oldText: string): FuzzyMatchResul }; } - // When fuzzy matching, we work in the normalized space for replacement. - // This means the output will have normalized whitespace/quotes/dashes, - // which is acceptable since we're fixing minor formatting differences anyway. + // When fuzzy matching, return offsets in normalized space. Callers can use + // the normalized content to compute replacements, then decide how much of + // that normalized output should be written back. return { found: true, index: fuzzyIndex, @@ -186,8 +297,9 @@ function getNoChangeError(path: string, totalEdits: number): Error { * * All edits are matched against the same original content. Replacements are * then applied in reverse order so offsets remain stable. If any edit needs - * fuzzy matching, the operation runs in fuzzy-normalized content space to - * preserve current single-edit behavior. + * fuzzy matching, the operation runs in fuzzy-normalized content space and then + * overlays those line-level changes onto the original content so unchanged line + * blocks keep their original bytes. */ export function applyEditsToNormalizedContent( normalizedContent: string, @@ -206,19 +318,18 @@ export function applyEditsToNormalizedContent( } const initialMatches = normalizedEdits.map((edit) => fuzzyFindText(normalizedContent, edit.oldText)); - const baseContent = initialMatches.some((match) => match.usedFuzzyMatch) - ? normalizeForFuzzyMatch(normalizedContent) - : normalizedContent; + const usedFuzzyMatch = initialMatches.some((match) => match.usedFuzzyMatch); + const replacementBaseContent = usedFuzzyMatch ? normalizeForFuzzyMatch(normalizedContent) : normalizedContent; const matchedEdits: MatchedEdit[] = []; for (let i = 0; i < normalizedEdits.length; i++) { const edit = normalizedEdits[i]; - const matchResult = fuzzyFindText(baseContent, edit.oldText); + const matchResult = fuzzyFindText(replacementBaseContent, edit.oldText); if (!matchResult.found) { throw getNotFoundError(path, i, normalizedEdits.length); } - const occurrences = countOccurrences(baseContent, edit.oldText); + const occurrences = countOccurrences(replacementBaseContent, edit.oldText); if (occurrences > 1) { throw getDuplicateError(path, i, normalizedEdits.length, occurrences); } @@ -242,14 +353,10 @@ export function applyEditsToNormalizedContent( } } - let newContent = baseContent; - for (let i = matchedEdits.length - 1; i >= 0; i--) { - const edit = matchedEdits[i]; - newContent = - newContent.substring(0, edit.matchIndex) + - edit.newText + - newContent.substring(edit.matchIndex + edit.matchLength); - } + const baseContent = normalizedContent; + const newContent = usedFuzzyMatch + ? applyReplacementsPreservingUnchangedLines(normalizedContent, replacementBaseContent, matchedEdits) + : applyReplacements(replacementBaseContent, matchedEdits); if (baseContent === newContent) { throw getNoChangeError(path, normalizedEdits.length); diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index b06590f7..f8b09c90 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -1031,6 +1031,57 @@ describe("edit tool fuzzy matching", () => { expect(readFileSync(testFile, "utf-8")).toBe("console.log('world');\nhello universe\n"); }); + + it("should preserve the correct occurrence when fuzzy replacement equals a nearby line", async () => { + const testFile = join(testDir, "fuzzy-preserve-duplicate-line.txt"); + const originalContent = ["replace me\u0020\u0020\u0020", "after\u0020\u0020\u0020", ""].join("\n"); + writeFileSync(testFile, originalContent); + + const result = await editTool.execute("test-fuzzy-preserve-duplicate-line", { + path: testFile, + edits: [{ oldText: "replace me\n", newText: "after\n" }], + }); + + const expectedContent = ["after", "after\u0020\u0020\u0020", ""].join("\n"); + expect(readFileSync(testFile, "utf-8")).toBe(expectedContent); + expect(applyPatch(originalContent, result.details?.patch ?? "")).toBe(expectedContent); + }); + + it("should preserve untouched lines and produce an applicable patch for fuzzy multi-edits", async () => { + const testFile = join(testDir, "fuzzy-preserve-multi.txt"); + const originalContent = [ + "keep before\u0020\u0020", + "first target\u0020\u0020", + "first after", + "keep middle\u0020\u0020\u0020", + "second target\u0020\u0020", + "second after", + "keep after\u0020\u0020", + "", + ].join("\n"); + writeFileSync(testFile, originalContent); + + const result = await editTool.execute("test-fuzzy-preserve-multi", { + path: testFile, + edits: [ + { oldText: "first target\nfirst after", newText: "FIRST\nFIRST2" }, + { oldText: "second target\nsecond after", newText: "SECOND\nSECOND2" }, + ], + }); + + const expectedContent = [ + "keep before\u0020\u0020", + "FIRST", + "FIRST2", + "keep middle\u0020\u0020\u0020", + "SECOND", + "SECOND2", + "keep after\u0020\u0020", + "", + ].join("\n"); + expect(readFileSync(testFile, "utf-8")).toBe(expectedContent); + expect(applyPatch(originalContent, result.details?.patch ?? "")).toBe(expectedContent); + }); }); describe("edit tool CRLF handling", () => { From 8b97e75c6b149fdd4dec95fe3321d1e94fd5c1d4 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 19 Jun 2026 23:34:17 +0200 Subject: [PATCH 341/352] feat(ai): add chat-template thinking compat closes #5673 --- packages/ai/CHANGELOG.md | 4 + packages/ai/README.md | 3 +- .../ai/src/providers/openai-completions.ts | 48 ++++++++ packages/ai/src/types.ts | 14 ++- ...penai-completions-thinking-as-text.test.ts | 1 + .../openai-completions-tool-choice.test.ts | 114 +++++++++++++++++- ...nai-completions-tool-result-images.test.ts | 1 + packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/docs/custom-provider.md | 7 +- packages/coding-agent/docs/models.md | 5 +- .../coding-agent/src/core/model-registry.ts | 18 +++ .../coding-agent/test/model-registry.test.ts | 37 ++++++ 12 files changed, 248 insertions(+), 8 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 4f316c3b..dc1a931b 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added configurable `chat-template` thinking support for OpenAI-compatible providers that use `chat_template_kwargs`, such as DeepSeek models behind vLLM ([#5673](https://github.com/earendil-works/pi/issues/5673)). + ### Fixed - Fixed GitHub Copilot OAuth model availability to use the authenticated account's model picker catalog ([#5897](https://github.com/earendil-works/pi/issues/5897)). diff --git a/packages/ai/README.md b/packages/ai/README.md index 8137ccd4..bec437aa 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -967,7 +967,8 @@ interface OpenAICompletionsCompat { requiresAssistantAfterToolResult?: boolean; // Whether tool results must be followed by an assistant message (default: false) requiresThinkingAsText?: boolean; // Whether thinking blocks must be converted to text (default: false) requiresReasoningContentOnAssistantMessages?: boolean; // Whether all replayed assistant messages must include empty reasoning_content when reasoning is enabled (default: auto-detected for DeepSeek) - thinkingFormat?: 'openai' | 'openrouter' | 'deepseek' | 'together' | 'zai' | 'qwen' | 'qwen-chat-template' | 'string-thinking' | 'ant-ling'; // Format for reasoning param: 'openai' uses reasoning_effort, 'openrouter' uses reasoning: { effort }, 'deepseek' uses thinking: { type } plus reasoning_effort when supported, 'together' uses reasoning: { enabled } plus reasoning_effort when supported, 'zai' uses enable_thinking, 'qwen' uses enable_thinking, 'qwen-chat-template' uses chat_template_kwargs.enable_thinking, 'string-thinking' uses top-level thinking, 'ant-ling' uses reasoning: { effort } only for mapped efforts (default: openai) + thinkingFormat?: 'openai' | 'openrouter' | 'deepseek' | 'together' | 'zai' | 'qwen' | 'chat-template' | 'qwen-chat-template' | 'string-thinking' | 'ant-ling'; // Format for reasoning param: 'openai' uses reasoning_effort, 'openrouter' uses reasoning: { effort }, 'deepseek' uses thinking: { type } plus reasoning_effort when supported, 'together' uses reasoning: { enabled } plus reasoning_effort when supported, 'zai' uses thinking: { type }, 'qwen' uses enable_thinking, 'chat-template' uses configurable chat_template_kwargs, 'qwen-chat-template' uses chat_template_kwargs.enable_thinking and preserve_thinking, 'string-thinking' uses top-level thinking, 'ant-ling' uses reasoning: { effort } only for mapped efforts (default: openai) + chatTemplateKwargs?: Record; // chat_template_kwargs values; use $var for pi-controlled thinking values cacheControlFormat?: 'anthropic'; // Anthropic-style cache_control on system prompt, last tool, and last user/assistant text content openRouterRouting?: OpenRouterRouting; // OpenRouter routing preferences (default: {}) vercelGatewayRouting?: VercelGatewayRouting; // Vercel AI Gateway routing preferences (default: {}) diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 2f5edb62..10c8e7e6 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -15,6 +15,7 @@ import { calculateCost, clampThinkingLevel } from "../models.ts"; import type { AssistantMessage, CacheRetention, + ChatTemplateKwargValue, Context, ImageContent, Message, @@ -91,6 +92,8 @@ type ResolvedOpenAICompletionsCompat = Omit, " cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"]; }; +type ResolvedChatTemplateKwargValue = string | number | boolean | null; + type ChatCompletionInstructionMessageParam = ChatCompletionDeveloperMessageParam | ChatCompletionSystemMessageParam; type ChatCompletionTextPartWithCacheControl = ChatCompletionContentPartText & { @@ -585,6 +588,11 @@ function buildParams( enable_thinking: !!options?.reasoningEffort, preserve_thinking: true, }; + } else if (compat.thinkingFormat === "chat-template" && model.reasoning) { + const chatTemplateKwargs = buildChatTemplateKwargs(model, options, compat); + if (chatTemplateKwargs) { + (params as any).chat_template_kwargs = chatTemplateKwargs; + } } else if (compat.thinkingFormat === "deepseek" && model.reasoning) { if (options?.reasoningEffort) { (params as any).thinking = { type: "enabled" }; @@ -655,6 +663,44 @@ function buildParams( return params; } +function buildChatTemplateKwargs( + model: Model<"openai-completions">, + options: OpenAICompletionsOptions | undefined, + compat: ResolvedOpenAICompletionsCompat, +): Record | undefined { + const kwargs: Record = {}; + + for (const [key, value] of Object.entries(compat.chatTemplateKwargs)) { + const resolved = resolveChatTemplateKwargValue(model, options, value); + if (resolved !== undefined) { + kwargs[key] = resolved; + } + } + + return Object.keys(kwargs).length > 0 ? kwargs : undefined; +} + +function resolveChatTemplateKwargValue( + model: Model<"openai-completions">, + options: OpenAICompletionsOptions | undefined, + value: ChatTemplateKwargValue, +): ResolvedChatTemplateKwargValue | undefined { + if (typeof value !== "object" || value === null) { + return value; + } + + const reasoningEffort = options?.reasoningEffort; + if (!reasoningEffort && value.omitWhenOff) { + return undefined; + } + if (value.$var === "thinking.enabled") { + return !!reasoningEffort; + } + + const mappedValue = reasoningEffort ? model.thinkingLevelMap?.[reasoningEffort] : model.thinkingLevelMap?.off; + return mappedValue === undefined ? reasoningEffort : typeof mappedValue === "string" ? mappedValue : undefined; +} + function getCompatCacheControl( compat: ResolvedOpenAICompletionsCompat, cacheRetention: CacheRetention, @@ -1167,6 +1213,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet : "openai", openRouterRouting: {}, vercelGatewayRouting: {}, + chatTemplateKwargs: {}, zaiToolStream: false, supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia, cacheControlFormat, @@ -1205,6 +1252,7 @@ function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompletion thinkingFormat: model.compat.thinkingFormat ?? detected.thinkingFormat, openRouterRouting: model.compat.openRouterRouting ?? {}, vercelGatewayRouting: model.compat.vercelGatewayRouting ?? detected.vercelGatewayRouting, + chatTemplateKwargs: model.compat.chatTemplateKwargs ?? detected.chatTemplateKwargs, zaiToolStream: model.compat.zaiToolStream ?? detected.zaiToolStream, supportsStrictMode: model.compat.supportsStrictMode ?? detected.supportsStrictMode, cacheControlFormat: model.compat.cacheControlFormat ?? detected.cacheControlFormat, diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index cedac6e8..54f44685 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -65,6 +65,15 @@ export type ImagesProvider = KnownImagesProvider | string; export type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh"; export type ModelThinkingLevel = "off" | ThinkingLevel; export type ThinkingLevelMap = Partial>; +export type ChatTemplateKwargValue = + | string + | number + | boolean + | null + | { + $var: "thinking.enabled" | "thinking.effort"; + omitWhenOff?: boolean; + }; /** Token budgets for each thinking level (token-based providers only) */ export interface ThinkingBudgets { @@ -403,7 +412,7 @@ export interface OpenAICompletionsCompat { requiresThinkingAsText?: boolean; /** Whether all replayed assistant messages must include an empty reasoning_content field when reasoning is enabled. Default: auto-detected from URL. */ requiresReasoningContentOnAssistantMessages?: boolean; - /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort when supported, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses thinking: { type }, "qwen" uses top-level enable_thinking: boolean, "qwen-chat-template" uses chat_template_kwargs.enable_thinking, "string-thinking" uses top-level thinking: string, and "ant-ling" uses reasoning: { effort } only when the mapped effort is non-null. Default: "openai". */ + /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort when supported, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses thinking: { type }, "qwen" uses top-level enable_thinking: boolean, "qwen-chat-template" uses chat_template_kwargs.enable_thinking and preserve_thinking, "chat-template" uses configurable chat_template_kwargs, "string-thinking" uses top-level thinking: string, and "ant-ling" uses reasoning: { effort } only when the mapped effort is non-null. Default: "openai". */ thinkingFormat?: | "openai" | "openrouter" @@ -411,9 +420,12 @@ export interface OpenAICompletionsCompat { | "together" | "zai" | "qwen" + | "chat-template" | "qwen-chat-template" | "string-thinking" | "ant-ling"; + /** Kwargs to send as `chat_template_kwargs` when `thinkingFormat` is `chat-template`. Use `{ "$var": "thinking.enabled" }` or `{ "$var": "thinking.effort" }` for pi-controlled thinking values. */ + chatTemplateKwargs?: Record; /** OpenRouter-compatible routing preferences sent as the `provider` request field. */ openRouterRouting?: OpenRouterRouting; /** Vercel AI Gateway routing preferences. Only used when baseUrl points to Vercel AI Gateway. */ diff --git a/packages/ai/test/openai-completions-thinking-as-text.test.ts b/packages/ai/test/openai-completions-thinking-as-text.test.ts index 1c49aad3..138eb3e3 100644 --- a/packages/ai/test/openai-completions-thinking-as-text.test.ts +++ b/packages/ai/test/openai-completions-thinking-as-text.test.ts @@ -34,6 +34,7 @@ const compat = { thinkingFormat: "openai", openRouterRouting: {}, vercelGatewayRouting: {}, + chatTemplateKwargs: {}, zaiToolStream: false, supportsStrictMode: true, cacheControlFormat: undefined, diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index 373b1b78..41601281 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { stream, streamSimple } from "../src/index.ts"; import { getModel } from "../src/models.ts"; import { convertMessages } from "../src/providers/openai-completions.ts"; -import type { AssistantMessage, Model, Tool, ToolResultMessage } from "../src/types.ts"; +import type { AssistantMessage, Model, SimpleStreamOptions, Tool, ToolResultMessage } from "../src/types.ts"; const mockState = vi.hoisted(() => ({ lastParams: undefined as unknown, @@ -64,6 +64,46 @@ vi.mock("openai", () => { return { default: FakeOpenAI }; }); +const localOpenAICompletionsModel = { + api: "openai-completions", + provider: "local-vllm", + baseUrl: "http://localhost:8000/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, +} satisfies Omit, "id" | "name" | "compat">; + +type CapturedParams = { + chat_template_kwargs?: Record; + thinking?: unknown; + reasoning_effort?: string; +}; + +async function captureSimpleParams( + model: Model<"openai-completions">, + reasoning?: SimpleStreamOptions["reasoning"], +): Promise { + let payload: unknown; + + await streamSimple( + model, + { + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + reasoning, + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + return (payload ?? mockState.lastParams) as CapturedParams; +} + describe("openai-completions tool_choice", () => { beforeEach(() => { mockState.lastParams = undefined; @@ -1142,6 +1182,7 @@ describe("openai-completions tool_choice", () => { thinkingFormat: "openai", openRouterRouting: {}, vercelGatewayRouting: {}, + chatTemplateKwargs: {}, zaiToolStream: false, supportsStrictMode: true, sendSessionAffinityHeaders: false, @@ -1449,6 +1490,77 @@ describe("openai-completions tool_choice", () => { expect(params.reasoning_effort).toBeUndefined(); }); + it("uses configurable chat template boolean thinking kwargs", async () => { + const model = { + ...localOpenAICompletionsModel, + id: "deepseek-ai/DeepSeek-V3.1", + name: "DeepSeek V3.1 via vLLM", + compat: { + thinkingFormat: "chat-template", + supportsReasoningEffort: false, + chatTemplateKwargs: { thinking: { $var: "thinking.enabled" } }, + }, + } satisfies Model<"openai-completions">; + + for (const testCase of [ + { reasoning: "high" as const, expected: true }, + { reasoning: undefined, expected: false }, + ]) { + const params = await captureSimpleParams(model, testCase.reasoning); + + expect(params.chat_template_kwargs).toEqual({ thinking: testCase.expected }); + expect(params.thinking).toBeUndefined(); + expect(params.reasoning_effort).toBeUndefined(); + } + }); + + it("uses qwen chat template thinking kwargs", async () => { + const model = { + ...localOpenAICompletionsModel, + id: "Qwen/Qwen3-Coder", + name: "Qwen3 Coder via vLLM", + compat: { + thinkingFormat: "qwen-chat-template", + supportsReasoningEffort: false, + }, + } satisfies Model<"openai-completions">; + + for (const testCase of [ + { reasoning: "high" as const, expected: true }, + { reasoning: undefined, expected: false }, + ]) { + const params = await captureSimpleParams(model, testCase.reasoning); + + expect(params.chat_template_kwargs).toEqual({ + enable_thinking: testCase.expected, + preserve_thinking: true, + }); + expect(params.reasoning_effort).toBeUndefined(); + } + }); + + it("uses configurable chat template effort kwargs with static kwargs", async () => { + const model = { + ...localOpenAICompletionsModel, + id: "unsloth/gpt-oss-120b-GGUF", + name: "GPT OSS via vLLM", + thinkingLevelMap: { xhigh: "max" }, + compat: { + thinkingFormat: "chat-template", + supportsReasoningEffort: false, + chatTemplateKwargs: { + preserve_thinking: true, + reasoning_effort: { $var: "thinking.effort", omitWhenOff: true }, + }, + }, + } satisfies Model<"openai-completions">; + + const params = await captureSimpleParams(model, "xhigh"); + + expect(params.chat_template_kwargs).toEqual({ preserve_thinking: true, reasoning_effort: "max" }); + expect(params.reasoning_effort).toBeUndefined(); + }); + it("uses Ant Ling compatibility metadata", async () => { const model = getModel("ant-ling", "Ring-2.6-1T")!; let payload: unknown; diff --git a/packages/ai/test/openai-completions-tool-result-images.test.ts b/packages/ai/test/openai-completions-tool-result-images.test.ts index 3510f396..4458e51f 100644 --- a/packages/ai/test/openai-completions-tool-result-images.test.ts +++ b/packages/ai/test/openai-completions-tool-result-images.test.ts @@ -32,6 +32,7 @@ const compat: Required = { thinkingFormat: "openai", openRouterRouting: {}, vercelGatewayRouting: {}, + chatTemplateKwargs: {}, zaiToolStream: false, supportsStrictMode: true, cacheControlFormat: "anthropic", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index fdc99e61..8813170b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added inherited configurable `chat-template` thinking support for OpenAI-compatible providers that use `chat_template_kwargs`, such as DeepSeek models behind vLLM ([#5673](https://github.com/earendil-works/pi/issues/5673)). + ### Fixed - Fixed fuzzy `edit` matches to preserve untouched line blocks instead of rewriting the whole file through normalized content ([#5899](https://github.com/earendil-works/pi/issues/5899)). diff --git a/packages/coding-agent/docs/custom-provider.md b/packages/coding-agent/docs/custom-provider.md index c2bf4455..612d1e60 100644 --- a/packages/coding-agent/docs/custom-provider.md +++ b/packages/coding-agent/docs/custom-provider.md @@ -229,7 +229,7 @@ models: [{ }] ``` -Use `openrouter` for OpenRouter-style `reasoning: { effort }` controls. Use `together` for Together-style `reasoning: { enabled }` controls; with `supportsReasoningEffort`, it also sends `reasoning_effort`. Use `qwen-chat-template` instead for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking`. +Use `openrouter` for OpenRouter-style `reasoning: { effort }` controls. Use `together` for Together-style `reasoning: { enabled }` controls; with `supportsReasoningEffort`, it also sends `reasoning_effort`. Use `qwen-chat-template` for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking` and need `preserve_thinking`. Use `cacheControlFormat: "anthropic"` for OpenAI-compatible providers that expose Anthropic-style prompt caching via `cache_control` on the system prompt, last tool definition, and last user/assistant text content. For Anthropic-compatible providers using `api: "anthropic-messages"`, set `compat.forceAdaptiveThinking: true` on models or providers whose upstream model requires adaptive thinking (`thinking.type: "adaptive"` plus `output_config.effort`). Built-in adaptive Claude models set this automatically. Set `compat.allowEmptySignature: true` only for providers that emit empty thinking signatures and expect `signature: ""` on replay. @@ -718,7 +718,8 @@ interface ProviderModelConfig { requiresAssistantAfterToolResult?: boolean; requiresThinkingAsText?: boolean; requiresReasoningContentOnAssistantMessages?: boolean; - thinkingFormat?: "openai" | "openrouter" | "deepseek" | "together" | "zai" | "qwen" | "qwen-chat-template"; + thinkingFormat?: "openai" | "openrouter" | "deepseek" | "together" | "zai" | "qwen" | "chat-template" | "qwen-chat-template" | "string-thinking" | "ant-ling"; + chatTemplateKwargs?: Record; cacheControlFormat?: "anthropic"; // anthropic-messages @@ -732,5 +733,5 @@ interface ProviderModelConfig { } ``` -`openrouter` sends `reasoning: { effort }`. `deepseek` sends `thinking: { type: "enabled" | "disabled" }` and `reasoning_effort` when enabled. `together` sends `reasoning: { enabled }` and also `reasoning_effort` when `supportsReasoningEffort` is enabled. `qwen` is for DashScope-style top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking`. +`openrouter` sends `reasoning: { effort }`. `deepseek` sends `thinking: { type: "enabled" | "disabled" }` and `reasoning_effort` when enabled. `together` sends `reasoning: { enabled }` and also `reasoning_effort` when `supportsReasoningEffort` is enabled. `qwen` is for DashScope-style top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking` and need `preserve_thinking`. Use `chat-template` for configurable `chat_template_kwargs`, for example DeepSeek V3.x behind vLLM with `chatTemplateKwargs: { "thinking": { "$var": "thinking.enabled" } }`. `cacheControlFormat: "anthropic"` applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user/assistant text content. diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index 830eaacd..5679981a 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -399,14 +399,15 @@ For providers with partial OpenAI compatibility, use the `compat` field. | `requiresAssistantAfterToolResult` | Insert an assistant message before a user message after tool results | | `requiresThinkingAsText` | Convert thinking blocks to plain text | | `requiresReasoningContentOnAssistantMessages` | Include empty `reasoning_content` on all replayed assistant messages when reasoning is enabled | -| `thinkingFormat` | Use `reasoning_effort`, `openrouter`, `deepseek`, `together`, `zai`, `qwen`, or `qwen-chat-template` thinking parameters | +| `thinkingFormat` | Use `reasoning_effort`, `openrouter`, `deepseek`, `together`, `zai`, `qwen`, `chat-template`, or `qwen-chat-template` thinking parameters | +| `chatTemplateKwargs` | `chat_template_kwargs` values for `thinkingFormat: "chat-template"`; use `{ "$var": "thinking.enabled" }` or `{ "$var": "thinking.effort" }` for pi-controlled thinking values | | `cacheControlFormat` | Use Anthropic-style `cache_control` markers on the system prompt, last tool definition, and last user/assistant text content. Currently only `anthropic` is supported. | | `supportsStrictMode` | Include the `strict` field in tool definitions | | `supportsLongCacheRetention` | Whether the provider accepts long cache retention when cache retention is `long`: `prompt_cache_retention: "24h"` for OpenAI prompt caching, or `cache_control.ttl: "1h"` when `cacheControlFormat` is `anthropic`. Default: `true`. | | `openRouterRouting` | OpenRouter provider routing preferences. This object is sent as-is in the `provider` field of the [OpenRouter API request](https://openrouter.ai/docs/guides/routing/provider-selection). | | `vercelGatewayRouting` | Vercel AI Gateway routing config for provider selection (`only`, `order`) | -`openrouter` uses `reasoning: { effort }`. `together` uses `reasoning: { enabled }` and also `reasoning_effort` when `supportsReasoningEffort` is enabled. `qwen` uses top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that require `chat_template_kwargs.enable_thinking`. +`openrouter` uses `reasoning: { effort }`. `together` uses `reasoning: { enabled }` and also `reasoning_effort` when `supportsReasoningEffort` is enabled. `qwen` uses top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that require `chat_template_kwargs.enable_thinking` and `preserve_thinking`. Use `chat-template` for vLLM/Hugging Face chat templates that need configurable `chat_template_kwargs`, such as `chatTemplateKwargs: { "thinking": { "$var": "thinking.enabled" } }` for DeepSeek V3.x templates. `cacheControlFormat: "anthropic"` is for OpenAI-compatible providers that expose Anthropic-style prompt caching through `cache_control` markers on text content and tool definitions. diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index 60f9001f..305dca92 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -96,6 +96,13 @@ const ThinkingLevelMapSchema = Type.Object({ xhigh: Type.Optional(ThinkingLevelMapValueSchema), }); +const ChatTemplateKwargScalarSchema = Type.Union([Type.String(), Type.Number(), Type.Boolean(), Type.Null()]); +const ChatTemplateKwargVariableSchema = Type.Object({ + $var: Type.Union([Type.Literal("thinking.enabled"), Type.Literal("thinking.effort")]), + omitWhenOff: Type.Optional(Type.Boolean()), +}); +const ChatTemplateKwargSchema = Type.Union([ChatTemplateKwargScalarSchema, ChatTemplateKwargVariableSchema]); + const OpenAICompletionsCompatSchema = Type.Object({ supportsStore: Type.Optional(Type.Boolean()), supportsDeveloperRole: Type.Optional(Type.Boolean()), @@ -114,9 +121,13 @@ const OpenAICompletionsCompatSchema = Type.Object({ Type.Literal("deepseek"), Type.Literal("zai"), Type.Literal("qwen"), + Type.Literal("chat-template"), Type.Literal("qwen-chat-template"), + Type.Literal("string-thinking"), + Type.Literal("ant-ling"), ]), ), + chatTemplateKwargs: Type.Optional(Type.Record(Type.String(), ChatTemplateKwargSchema)), cacheControlFormat: Type.Optional(Type.Literal("anthropic")), openRouterRouting: Type.Optional(OpenRouterRoutingSchema), vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema), @@ -289,6 +300,13 @@ function mergeCompat( }; } + if (baseCompletions?.chatTemplateKwargs || overrideCompletions.chatTemplateKwargs) { + mergedCompletions.chatTemplateKwargs = { + ...baseCompletions?.chatTemplateKwargs, + ...overrideCompletions.chatTemplateKwargs, + }; + } + return merged as Model["compat"]; } diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 1d720628..1479808a 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -434,6 +434,43 @@ describe("ModelRegistry", () => { expect(compat?.cacheControlFormat).toBe("anthropic"); }); + test("compat schema accepts chat template thinking configuration", () => { + writeRawModelsJson({ + demo: { + baseUrl: "https://example.com/v1", + apiKey: "DEMO_KEY", + api: "openai-completions", + models: [ + { + id: "demo-model", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000, + maxTokens: 100, + compat: { + thinkingFormat: "chat-template", + chatTemplateKwargs: { + preserve_thinking: true, + thinking: { $var: "thinking.enabled" }, + }, + }, + }, + ], + }, + }); + + const registry = ModelRegistry.create(authStorage, modelsJsonPath); + const compat = registry.find("demo", "demo-model")?.compat as OpenAICompletionsCompat | undefined; + + expect(registry.getError()).toBeUndefined(); + expect(compat?.thinkingFormat).toBe("chat-template"); + expect(compat?.chatTemplateKwargs).toEqual({ + preserve_thinking: true, + thinking: { $var: "thinking.enabled" }, + }); + }); + test("compat schema accepts Anthropic eager tool input streaming flag", () => { writeRawModelsJson({ demo: { From 3095977d13961e3fb64770c53ca4f9f9f8baf7df Mon Sep 17 00:00:00 2001 From: Alexey Zaytsev Date: Sat, 20 Jun 2026 07:58:30 -0500 Subject: [PATCH 342/352] fix(tui): stabilize streaming code fence rendering (#5846) --- packages/tui/src/components/markdown.ts | 26 +++++++++++++++ packages/tui/test/markdown.test.ts | 43 +++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 83f15aa5..1034cb47 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -22,6 +22,31 @@ class StrictStrikethroughTokenizer extends Tokenizer { } } +function trimPartialClosingFences(tokens: readonly Token[]): void { + const token = tokens[tokens.length - 1]; + if (token?.type === "list") { + trimPartialClosingFences(token.items[token.items.length - 1]?.tokens ?? []); + return; + } + if (token?.type === "blockquote") { + trimPartialClosingFences(token.tokens ?? []); + return; + } + if (token?.type !== "code") { + return; + } + + // Trim streamed partial closing fences so code blocks do not shrink/flicker + // when the final fence character arrives. See https://github.com/earendil-works/pi/issues/5825. + const marker = /^(`{3,}|~{3,})/.exec(token.raw)?.[1]; + const lastLine = token.raw.split("\n").pop(); + if (!marker || !lastLine || lastLine.length >= marker.length || lastLine !== marker[0]?.repeat(lastLine.length)) { + return; + } + + token.text = token.text.slice(0, -lastLine.length).replace(/\n$/, ""); +} + const markdownParser = new Marked(); markdownParser.setOptions({ tokenizer: new StrictStrikethroughTokenizer(), @@ -145,6 +170,7 @@ export class Markdown implements Component { // Parse markdown to HTML-like tokens const tokens = markdownParser.lexer(normalizedText); + trimPartialClosingFences(tokens); // Convert tokens to styled terminal output const renderedLines: string[] = []; diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index dc595b11..47bc0a81 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -1376,4 +1376,47 @@ bar`, ); }); }); + + describe("Streaming code fences", () => { + it("stabilizes partial closing fence rendering", () => { + const cases = [ + { + input: "```ts\nconst x = 1;\n``", + expected: ["```ts", " const x = 1;", "```"], + }, + { + input: "```md\nnot a closing fence:\n``\n```", + expected: ["```md", " not a closing fence:", " ``", "```"], + }, + { + input: "```ts\n``", + expected: ["```ts", "", "```"], + }, + { + input: "````\n```", + expected: ["```", "", "```"], + }, + { + input: "~~~~~\n~~~~", + expected: ["```", "", "```"], + }, + { + input: "```md\nnot a closing fence:\n``\n```\n\nafter", + expected: ["```md", " not a closing fence:", " ``", "```", "", "after"], + }, + ]; + + for (const { input, expected } of cases) { + const markdown = new Markdown(input, 0, 0, defaultMarkdownTheme); + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, expected); + } + + const partial = new Markdown("```ts\nconst x = 1;\n``", 0, 0, defaultMarkdownTheme); + const complete = new Markdown("```ts\nconst x = 1;\n```", 0, 0, defaultMarkdownTheme); + + assert.strictEqual(partial.render(80).length, complete.render(80).length); + }); + }); }); From 416c673dfe7f2c1fea051a10a74affa1a84f7ad1 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 20 Jun 2026 16:13:13 +0200 Subject: [PATCH 343/352] fix: skip no-action for to-discuss issues --- .github/workflows/issue-triage-labels.yml | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/issue-triage-labels.yml b/.github/workflows/issue-triage-labels.yml index 4a9ea9d7..cf17c39e 100644 --- a/.github/workflows/issue-triage-labels.yml +++ b/.github/workflows/issue-triage-labels.yml @@ -17,6 +17,7 @@ jobs: const UNTRIAGED_LABEL = 'untriaged'; const NO_ACTION_LABEL = 'no-action'; const LAST_READ_LABEL = 'last-read'; + const TO_DISCUSS_LABEL = 'to-discuss'; function issueHasLabel(issue, labelName) { return (issue.labels ?? []).some((label) => label.name === labelName); @@ -102,13 +103,17 @@ jobs: } for (const issue of issuesToMark) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: [NO_ACTION_LABEL], - }); - console.log(`Added ${NO_ACTION_LABEL} to #${issue.number}`); + if (issueHasLabel(issue, TO_DISCUSS_LABEL)) { + console.log(`Skipped ${NO_ACTION_LABEL} for #${issue.number} because it has ${TO_DISCUSS_LABEL}`); + } else { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: [NO_ACTION_LABEL], + }); + console.log(`Added ${NO_ACTION_LABEL} to #${issue.number}`); + } await github.rest.issues.update({ owner: context.repo.owner, From 8597ebafd9f8930fc8e7251946999e21274694d4 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 20 Jun 2026 18:47:42 +0200 Subject: [PATCH 344/352] fix(ai): expose OpenRouter GLM-5.2 xhigh effort closes #5770 --- packages/ai/CHANGELOG.md | 1 + packages/ai/scripts/generate-models.ts | 3 + packages/ai/src/models.generated.ts | 79 +++++++------------------- 3 files changed, 26 insertions(+), 57 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index dc1a931b..567a2273 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed OpenRouter GLM-5.2 metadata to expose `xhigh` reasoning and send OpenRouter's native `xhigh` effort ([#5770](https://github.com/earendil-works/pi/issues/5770)). - Fixed GitHub Copilot OAuth model availability to use the authenticated account's model picker catalog ([#5897](https://github.com/earendil-works/pi/issues/5897)). ## [0.79.8] - 2026-06-19 diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index c1fddc92..ad9ca95b 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -374,6 +374,9 @@ function applyThinkingLevelMetadata(model: Model): void { // Pi's low/medium/high pass through verbatim; OpenRouter normalizes to Mercury's vocabulary. mergeThinkingLevelMap(model, { off: null }); } + if (model.provider === "openrouter" && model.id === "z-ai/glm-5.2") { + mergeThinkingLevelMap(model, { xhigh: "xhigh" }); + } if (model.provider === "opencode-go" && model.id === "kimi-k2.6") { // OpenCode Go exposes Kimi K2.6 thinking as on/off, not distinct effort tiers. mergeThinkingLevelMap(model, { minimal: null, low: null, medium: null }); diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 8639bf13..497f766a 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -4851,42 +4851,6 @@ export const MODELS = { contextWindow: 262144, maxTokens: 32768, } satisfies Model<"google-generative-ai">, - "gemma-4-E2B-it": { - id: "gemma-4-E2B-it", - name: "Gemma 4 E2B IT", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - thinkingLevelMap: {"off":null,"minimal":"MINIMAL","low":null,"medium":null,"high":"HIGH"}, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"google-generative-ai">, - "gemma-4-E4B-it": { - id: "gemma-4-E4B-it", - name: "Gemma 4 E4B IT", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - thinkingLevelMap: {"off":null,"minimal":"MINIMAL","low":null,"medium":null,"high":"HIGH"}, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"google-generative-ai">, }, "google-vertex": { "gemini-2.5-flash": { @@ -9190,13 +9154,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.22, - output: 0.85, + input: 0.25, + output: 0.8, cacheRead: 0.06, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 80000, } satisfies Model<"openai-completions">, "arcee-ai/trinity-mini": { id: "arcee-ai/trinity-mini", @@ -10573,13 +10537,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.67, + input: 0.66, output: 3.5, - cacheRead: 0.2, + cacheRead: 0.33, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 262142, } satisfies Model<"openai-completions">, "moonshotai/kimi-k2.7-code": { id: "moonshotai/kimi-k2.7-code", @@ -10590,13 +10554,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.74, - output: 3.5, - cacheRead: 0.15, + input: 0.612, + output: 3.069, + cacheRead: 0.1296, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 16384, + maxTokens: 262144, } satisfies Model<"openai-completions">, "nex-agi/nex-n2-pro:free": { id: "nex-agi/nex-n2-pro:free", @@ -12761,13 +12725,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.066, - output: 0.26, - cacheRead: 0.029, + input: 0.063, + output: 0.21, + cacheRead: 0.021, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 4096, } satisfies Model<"openai-completions">, "thedrummer/rocinante-12b": { id: "thedrummer/rocinante-12b", @@ -13069,11 +13033,11 @@ export const MODELS = { cost: { input: 0.98, output: 3.08, - cacheRead: 0.182, + cacheRead: 0.49, cacheWrite: 0, }, contextWindow: 202752, - maxTokens: 4096, + maxTokens: 65535, } satisfies Model<"openai-completions">, "z-ai/glm-5.2": { id: "z-ai/glm-5.2", @@ -13082,6 +13046,7 @@ export const MODELS = { provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text"], cost: { input: 1.2, @@ -13203,13 +13168,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.67, + input: 0.66, output: 3.5, - cacheRead: 0.2, + cacheRead: 0.33, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 262142, } satisfies Model<"openai-completions">, "~openai/gpt-latest": { id: "~openai/gpt-latest", @@ -15981,8 +15946,8 @@ export const MODELS = { cost: { input: 0.09, output: 0.3, - cacheRead: 0, - cacheWrite: 0.02, + cacheRead: 0.02, + cacheWrite: 0, }, contextWindow: 262114, maxTokens: 262114, From a1da88aed4a1584f4ad4e58bba2e3391760785a7 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 20 Jun 2026 20:22:53 +0200 Subject: [PATCH 345/352] fix(coding-agent): make session path traversal linear Avoid quadratic path construction when walking deep session branches. On a 600k-entry pathological session, path construction improved from about 20.3s with Array.unshift() to about 35ms with push() plus reverse(). Refs #5804. --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/core/session-manager.ts | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8813170b..1e2a54d6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed deep session branches taking quadratic time to build context or branch paths ([#5909](https://github.com/earendil-works/pi/issues/5909)). - Fixed fuzzy `edit` matches to preserve untouched line blocks instead of rewriting the whole file through normalized content ([#5899](https://github.com/earendil-works/pi/issues/5899)). - Fixed bash commands through legacy WSL `bash.exe` to pass scripts over stdin so shell variables expand in the target bash ([#5893](https://github.com/earendil-works/pi/issues/5893)). - Fixed `/model` to hide GitHub Copilot models that are unavailable to the authenticated account ([#5897](https://github.com/earendil-works/pi/issues/5897)). diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 62942480..b07968b3 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -357,9 +357,10 @@ export function buildSessionContext( const path: SessionEntry[] = []; let current: SessionEntry | undefined = leaf; while (current) { - path.unshift(current); + path.push(current); current = current.parentId ? byId.get(current.parentId) : undefined; } + path.reverse(); // Extract settings and find compaction let thinkingLevel = "off"; @@ -1152,9 +1153,10 @@ export class SessionManager { const startId = fromId ?? this.leafId; let current = startId ? this.byId.get(startId) : undefined; while (current) { - path.unshift(current); + path.push(current); current = current.parentId ? this.byId.get(current.parentId) : undefined; } + path.reverse(); return path; } From 5505316ea28720574f493bb4cac1b910ad705f44 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 20 Jun 2026 21:05:55 +0200 Subject: [PATCH 346/352] fix(coding-agent): cache extension imports for session switches Closes #5905. --- packages/coding-agent/CHANGELOG.md | 1 + .../src/core/extensions/loader.ts | 84 ++++++++++- .../coding-agent/src/core/resource-loader.ts | 20 ++- .../extension-factory-cache.test.ts | 131 ++++++++++++++++++ 4 files changed, 226 insertions(+), 10 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/extension-factory-cache.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1e2a54d6..31f4b5f0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed same-directory session switches to reuse imported extension modules while preserving fresh extension instances and lifecycle events ([#5905](https://github.com/earendil-works/pi/issues/5905)). - Fixed deep session branches taking quadratic time to build context or branch paths ([#5909](https://github.com/earendil-works/pi/issues/5909)). - Fixed fuzzy `edit` matches to preserve untouched line blocks instead of rewriting the whole file through normalized content ([#5899](https://github.com/earendil-works/pi/issues/5899)). - Fixed bash commands through legacy WSL `bash.exe` to pass scripts over stdin so shell variables expand in the target bash ([#5893](https://github.com/earendil-works/pi/issues/5893)). diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index 081d2d11..dcaf389f 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -117,6 +117,30 @@ function getAliases(): Record { type HandlerFn = (...args: unknown[]) => Promise; +let extensionCacheCwd: string | undefined; +let extensionCacheGeneration = 0; +const extensionCache = new Map(); + +interface ExtensionCacheToken { + cwd: string; + generation: number; +} + +export function clearExtensionCache(): void { + extensionCache.clear(); + extensionCacheCwd = undefined; + extensionCacheGeneration++; +} + +function useExtensionCacheCwd(cwd: string): ExtensionCacheToken { + const resolvedCwd = resolvePath(cwd); + if (extensionCacheCwd !== undefined && extensionCacheCwd !== resolvedCwd) { + clearExtensionCache(); + } + extensionCacheCwd = resolvedCwd; + return { cwd: resolvedCwd, generation: extensionCacheGeneration }; +} + /** * Create a runtime with throwing stubs for action methods. * Runner.bindCore() replaces these with real implementations. @@ -328,7 +352,22 @@ function createExtensionAPI( return api; } -async function loadExtensionModule(extensionPath: string) { +function isCurrentCacheToken(cacheToken: ExtensionCacheToken | undefined): cacheToken is ExtensionCacheToken { + return ( + cacheToken !== undefined && + extensionCacheCwd === cacheToken.cwd && + extensionCacheGeneration === cacheToken.generation + ); +} + +async function loadExtensionModule(extensionPath: string, cacheToken?: ExtensionCacheToken) { + if (isCurrentCacheToken(cacheToken)) { + const cachedFactory = extensionCache.get(extensionPath); + if (cachedFactory) { + return cachedFactory; + } + } + const jiti = createJiti(import.meta.url, { moduleCache: false, // In Bun binary: use virtualModules for bundled packages (no filesystem resolution) @@ -339,7 +378,13 @@ async function loadExtensionModule(extensionPath: string) { const module = await jiti.import(extensionPath, { default: true }); const factory = module as ExtensionFactory; - return typeof factory !== "function" ? undefined : factory; + if (typeof factory !== "function") { + return undefined; + } + if (isCurrentCacheToken(cacheToken)) { + extensionCache.set(extensionPath, factory); + } + return factory; } /** @@ -370,11 +415,12 @@ async function loadExtension( cwd: string, eventBus: EventBus, runtime: ExtensionRuntime, + cacheToken?: ExtensionCacheToken, ): Promise<{ extension: Extension | null; error: string | null }> { const resolvedPath = resolvePath(extensionPath, cwd, { normalizeUnicodeSpaces: true }); try { - const factory = await loadExtensionModule(resolvedPath); + const factory = await loadExtensionModule(resolvedPath, cacheToken); if (!factory) { return { extension: null, error: `Extension does not export a valid factory function: ${extensionPath}` }; } @@ -410,20 +456,28 @@ export async function loadExtensionFromFactory( /** * Load extensions from paths. */ -export async function loadExtensions( +async function loadExtensionsInternal( paths: string[], cwd: string, eventBus?: EventBus, runtime?: ExtensionRuntime, + useCache = false, ): Promise { const extensions: Extension[] = []; const errors: Array<{ path: string; error: string }> = []; - const resolvedCwd = resolvePath(cwd); + const cacheToken = useCache ? useExtensionCacheCwd(cwd) : undefined; + const resolvedCwd = cacheToken?.cwd ?? resolvePath(cwd); const resolvedEventBus = eventBus ?? createEventBus(); const resolvedRuntime = runtime ?? createExtensionRuntime(); for (const extPath of paths) { - const { extension, error } = await loadExtension(extPath, resolvedCwd, resolvedEventBus, resolvedRuntime); + const { extension, error } = await loadExtension( + extPath, + resolvedCwd, + resolvedEventBus, + resolvedRuntime, + cacheToken, + ); if (error) { errors.push({ path: extPath, error }); @@ -442,6 +496,24 @@ export async function loadExtensions( }; } +export async function loadExtensions( + paths: string[], + cwd: string, + eventBus?: EventBus, + runtime?: ExtensionRuntime, +): Promise { + return loadExtensionsInternal(paths, cwd, eventBus, runtime); +} + +export async function loadExtensionsCached( + paths: string[], + cwd: string, + eventBus?: EventBus, + runtime?: ExtensionRuntime, +): Promise { + return loadExtensionsInternal(paths, cwd, eventBus, runtime, true); +} + interface PiManifest { extensions?: string[]; themes?: string[]; diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index b35787af..18486ead 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -9,7 +9,12 @@ export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.ts"; import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts"; import { createEventBus, type EventBus } from "./event-bus.ts"; -import { createExtensionRuntime, loadExtensionFromFactory, loadExtensions } from "./extensions/loader.ts"; +import { + clearExtensionCache, + createExtensionRuntime, + loadExtensionFromFactory, + loadExtensionsCached, +} from "./extensions/loader.ts"; import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.ts"; import { DefaultPackageManager, type PathMetadata, type ResolvedResource } from "./package-manager.ts"; import type { PromptTemplate } from "./prompt-templates.ts"; @@ -206,6 +211,7 @@ export class DefaultResourceLoader implements ResourceLoader { private extensionThemeSourceInfos: Map; private lastPromptPaths: string[]; private lastThemePaths: string[]; + private loaded: boolean; constructor(options: DefaultResourceLoaderOptions) { this.cwd = resolvePath(options.cwd); @@ -252,6 +258,7 @@ export class DefaultResourceLoader implements ResourceLoader { this.extensionThemeSourceInfos = new Map(); this.lastPromptPaths = []; this.lastThemePaths = []; + this.loaded = false; } getExtensions(): LoadExtensionsResult { @@ -331,6 +338,10 @@ export class DefaultResourceLoader implements ResourceLoader { } async reload(options?: ResourceLoaderReloadOptions): Promise { + if (this.loaded) { + clearExtensionCache(); + } + let preTrustExtensions: LoadExtensionsResult | undefined; if (options?.resolveProjectTrust) { preTrustExtensions = await this.loadProjectTrustExtensions(); @@ -475,6 +486,7 @@ export class DefaultResourceLoader implements ResourceLoader { this.appendSystemPrompt = this.appendSystemPromptOverride ? this.appendSystemPromptOverride(baseAppend) : baseAppend; + this.loaded = true; } private async loadCurrentExtensionSet(options: { includeInlineFactories: boolean }): Promise { @@ -487,7 +499,7 @@ export class DefaultResourceLoader implements ResourceLoader { const extensionPaths = this.noExtensions ? cliEnabledExtensions : this.mergePaths(cliEnabledExtensions, enabledExtensions); - const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus); + const extensionsResult = await loadExtensionsCached(extensionPaths, this.cwd, this.eventBus); if (!options.includeInlineFactories) { return extensionsResult; } @@ -507,7 +519,7 @@ export class DefaultResourceLoader implements ResourceLoader { preTrustExtensions: LoadExtensionsResult | undefined, ): Promise { if (!preTrustExtensions) { - const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus); + const extensionsResult = await loadExtensionsCached(extensionPaths, this.cwd, this.eventBus); const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime); extensionsResult.extensions.push(...inlineExtensions.extensions); extensionsResult.errors.push(...inlineExtensions.errors); @@ -527,7 +539,7 @@ export class DefaultResourceLoader implements ResourceLoader { const resolvedPath = this.resolveExtensionLoadPath(path); return !preloadedByPath.has(resolvedPath) && !failedPreloadPaths.has(resolvedPath); }); - const remainingExtensions = await loadExtensions( + const remainingExtensions = await loadExtensionsCached( remainingPaths, this.cwd, this.eventBus, diff --git a/packages/coding-agent/test/suite/regressions/extension-factory-cache.test.ts b/packages/coding-agent/test/suite/regressions/extension-factory-cache.test.ts new file mode 100644 index 00000000..ced01224 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/extension-factory-cache.test.ts @@ -0,0 +1,131 @@ +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { clearExtensionCache, loadExtensions, loadExtensionsCached } from "../../../src/core/extensions/loader.ts"; +import { DefaultResourceLoader } from "../../../src/core/resource-loader.ts"; + +interface TestState { + moduleLoads?: number; + factoryRuns?: number; +} + +function state(): TestState { + const global = globalThis as typeof globalThis & { __extensionFactoryCacheTest?: TestState }; + if (!global.__extensionFactoryCacheTest) { + global.__extensionFactoryCacheTest = {}; + } + return global.__extensionFactoryCacheTest; +} + +function resetState(): void { + delete (globalThis as typeof globalThis & { __extensionFactoryCacheTest?: TestState }).__extensionFactoryCacheTest; +} + +function writeCountingExtension(filePath: string): void { + writeFileSync( + filePath, + ` +const state = (globalThis.__extensionFactoryCacheTest ??= {}); +state.moduleLoads = (state.moduleLoads ?? 0) + 1; + +export default function () { + state.factoryRuns = (state.factoryRuns ?? 0) + 1; +} +`, + "utf-8", + ); +} + +describe("extension factory cache", () => { + const roots: string[] = []; + + function fixture(name: string) { + const root = join(tmpdir(), `pi-extension-cache-${name}-${Date.now()}-${Math.random().toString(36).slice(2)}`); + const cwd = join(root, "project"); + const agentDir = join(root, "agent"); + mkdirSync(cwd, { recursive: true }); + mkdirSync(agentDir, { recursive: true }); + roots.push(root); + return { root, cwd, agentDir }; + } + + beforeEach(() => { + resetState(); + clearExtensionCache(); + }); + + afterEach(() => { + while (roots.length > 0) { + const root = roots.pop(); + if (root && existsSync(root)) { + rmSync(root, { recursive: true, force: true }); + } + } + resetState(); + clearExtensionCache(); + }); + + it("caches extension modules for cached same-cwd loads but reruns factories", async () => { + const { root, cwd } = fixture("same-cwd"); + const extensionPath = join(root, "counting.ts"); + writeCountingExtension(extensionPath); + + const first = await loadExtensionsCached([extensionPath], cwd); + const second = await loadExtensionsCached([extensionPath], cwd); + + expect(state().moduleLoads).toBe(1); + expect(state().factoryRuns).toBe(2); + expect(first.extensions[0]).not.toBe(second.extensions[0]); + expect(first.runtime).not.toBe(second.runtime); + }); + + it("does not cache direct loadExtensions calls", async () => { + const { root, cwd } = fixture("direct"); + const extensionPath = join(root, "counting.ts"); + writeCountingExtension(extensionPath); + + await loadExtensions([extensionPath], cwd); + await loadExtensions([extensionPath], cwd); + + expect(state().moduleLoads).toBe(2); + expect(state().factoryRuns).toBe(2); + }); + + it("clears the cache on resource loader reload", async () => { + const { cwd, agentDir } = fixture("reload"); + const extensionDir = join(agentDir, "extensions"); + mkdirSync(extensionDir, { recursive: true }); + writeCountingExtension(join(extensionDir, "counting.ts")); + const loader = new DefaultResourceLoader({ + cwd, + agentDir, + noSkills: true, + noPromptTemplates: true, + noThemes: true, + }); + + await loader.reload(); + await loader.reload(); + + expect(state().moduleLoads).toBe(2); + expect(state().factoryRuns).toBe(2); + }); + + it("keeps the cache scoped to one cwd", async () => { + const { root } = fixture("cross-cwd"); + const firstCwd = join(root, "first"); + const secondCwd = join(root, "second"); + mkdirSync(firstCwd, { recursive: true }); + mkdirSync(secondCwd, { recursive: true }); + const extensionPath = join(root, "counting.ts"); + writeCountingExtension(extensionPath); + + await loadExtensionsCached([extensionPath], firstCwd); + await loadExtensionsCached([extensionPath], secondCwd); + await loadExtensionsCached([extensionPath], secondCwd); + + expect(state().moduleLoads).toBe(2); + expect(state().factoryRuns).toBe(3); + }); +}); From 500b568b04cc54109480920ab391e76e525339c0 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 20 Jun 2026 21:31:31 +0200 Subject: [PATCH 347/352] fix(ai): use OpenAI endpoint for Fireworks GLM-5.2 closes #5923 --- packages/ai/CHANGELOG.md | 1 + packages/ai/scripts/generate-models.ts | 9 ++++++++- packages/ai/src/models.generated.ts | 9 +++++---- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 567a2273..90a1b3bb 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed Fireworks GLM-5.2 metadata to use the OpenAI-compatible Chat Completions endpoint with `reasoning_effort` support ([#5923](https://github.com/earendil-works/pi/issues/5923)). - Fixed OpenRouter GLM-5.2 metadata to expose `xhigh` reasoning and send OpenRouter's native `xhigh` effort ([#5770](https://github.com/earendil-works/pi/issues/5770)). - Fixed GitHub Copilot OAuth model availability to use the authenticated account's model picker catalog ([#5897](https://github.com/earendil-works/pi/issues/5897)). diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index ad9ca95b..3e85db82 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -377,6 +377,9 @@ function applyThinkingLevelMetadata(model: Model): void { if (model.provider === "openrouter" && model.id === "z-ai/glm-5.2") { mergeThinkingLevelMap(model, { xhigh: "xhigh" }); } + if (model.provider === "fireworks" && model.id === "accounts/fireworks/models/glm-5p2") { + mergeThinkingLevelMap(model, { off: "none", minimal: null, low: "high", medium: "high", xhigh: "max" }); + } if (model.provider === "opencode-go" && model.id === "kimi-k2.6") { // OpenCode Go exposes Kimi K2.6 thinking as on/off, not distinct effort tiers. mergeThinkingLevelMap(model, { minimal: null, low: null, medium: null }); @@ -1514,7 +1517,11 @@ async function generateModels() { candidate.cost.output = 1.9; candidate.cost.cacheRead = 0.119; } - + if (candidate.provider === "fireworks" && candidate.id === "accounts/fireworks/models/glm-5p2") { + candidate.api = "openai-completions"; + candidate.baseUrl = "https://api.fireworks.ai/inference/v1"; + candidate.compat = { supportsStore: false, supportsDeveloperRole: false }; + } } diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 497f766a..32894f60 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -3930,11 +3930,12 @@ export const MODELS = { "accounts/fireworks/models/glm-5p2": { id: "accounts/fireworks/models/glm-5p2", name: "GLM 5.2", - api: "anthropic-messages", + api: "openai-completions", provider: "fireworks", - baseUrl: "https://api.fireworks.ai/inference", - compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, + baseUrl: "https://api.fireworks.ai/inference/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false}, reasoning: true, + thinkingLevelMap: {"off":"none","minimal":null,"low":"high","medium":"high","xhigh":"max"}, input: ["text"], cost: { input: 1.4, @@ -3944,7 +3945,7 @@ export const MODELS = { }, contextWindow: 1048576, maxTokens: 131072, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, "accounts/fireworks/models/gpt-oss-120b": { id: "accounts/fireworks/models/gpt-oss-120b", name: "GPT OSS 120B", From 350ac3f3440367368510151c60f4cbb18ff6a7fe Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 20 Jun 2026 21:48:37 +0200 Subject: [PATCH 348/352] fix: remove inprogress from auto-closed issues --- .github/workflows/issue-triage-labels.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/issue-triage-labels.yml b/.github/workflows/issue-triage-labels.yml index cf17c39e..ebe5500c 100644 --- a/.github/workflows/issue-triage-labels.yml +++ b/.github/workflows/issue-triage-labels.yml @@ -18,6 +18,7 @@ jobs: const NO_ACTION_LABEL = 'no-action'; const LAST_READ_LABEL = 'last-read'; const TO_DISCUSS_LABEL = 'to-discuss'; + const INPROGRESS_LABEL = 'inprogress'; function issueHasLabel(issue, labelName) { return (issue.labels ?? []).some((label) => label.name === labelName); @@ -124,6 +125,8 @@ jobs: }); console.log(`Closed #${issue.number} as not planned`); + await removeLabelIfPresent(issue.number, issue, INPROGRESS_LABEL); + await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, From 1aa79b9b29faacea6158ab958725032b16ef346f Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 20 Jun 2026 22:00:00 +0200 Subject: [PATCH 349/352] docs: update unreleased changelog audit --- packages/coding-agent/CHANGELOG.md | 8 ++++++++ packages/tui/CHANGELOG.md | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 31f4b5f0..5e0b229b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,14 +2,22 @@ ## [Unreleased] +### New Features + +- **Chat-template thinking compatibility** - OpenAI-compatible custom providers can map Pi thinking levels into `chat_template_kwargs`, enabling vLLM/Hugging Face chat-template models such as DeepSeek to use provider-native thinking controls. See [Custom Provider API Types](docs/custom-provider.md#api-types) and [OpenAI Compatibility](docs/models.md#openai-compatibility). +- **GLM-5.2 provider improvements** - GLM-5.2 now has corrected Fireworks OpenAI-compatible routing and OpenRouter `xhigh` thinking support, improving `/model` behavior and high-effort reasoning for GLM-5.2 users. See [Model Options](docs/usage.md#model-options). + ### Added - Added inherited configurable `chat-template` thinking support for OpenAI-compatible providers that use `chat_template_kwargs`, such as DeepSeek models behind vLLM ([#5673](https://github.com/earendil-works/pi/issues/5673)). ### Fixed +- Fixed inherited Fireworks GLM-5.2 metadata to use the OpenAI-compatible Chat Completions endpoint with `reasoning_effort` support ([#5923](https://github.com/earendil-works/pi/issues/5923)). - Fixed same-directory session switches to reuse imported extension modules while preserving fresh extension instances and lifecycle events ([#5905](https://github.com/earendil-works/pi/issues/5905)). - Fixed deep session branches taking quadratic time to build context or branch paths ([#5909](https://github.com/earendil-works/pi/issues/5909)). +- Fixed inherited OpenRouter GLM-5.2 metadata to expose `xhigh` reasoning and send OpenRouter's native `xhigh` effort ([#5770](https://github.com/earendil-works/pi/issues/5770)). +- Fixed inherited Markdown streaming code fence rendering so partial closing fences no longer make code blocks shrink or flicker while content streams ([#5846](https://github.com/earendil-works/pi/pull/5846) by [@xl0](https://github.com/xl0)). - Fixed fuzzy `edit` matches to preserve untouched line blocks instead of rewriting the whole file through normalized content ([#5899](https://github.com/earendil-works/pi/issues/5899)). - Fixed bash commands through legacy WSL `bash.exe` to pass scripts over stdin so shell variables expand in the target bash ([#5893](https://github.com/earendil-works/pi/issues/5893)). - Fixed `/model` to hide GitHub Copilot models that are unavailable to the authenticated account ([#5897](https://github.com/earendil-works/pi/issues/5897)). diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 2e9f6d31..065f1321 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Markdown streaming code fence rendering so partial closing fences no longer make code blocks shrink or flicker while content streams ([#5846](https://github.com/earendil-works/pi/pull/5846) by [@xl0](https://github.com/xl0)). + ## [0.79.8] - 2026-06-19 ## [0.79.7] - 2026-06-18 From 615bf2f87444c0f48756d487323519349304906c Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 20 Jun 2026 22:15:37 +0200 Subject: [PATCH 350/352] Release v0.79.9 --- package-lock.json | 26 ++++++------- packages/agent/CHANGELOG.md | 2 +- packages/agent/package.json | 4 +- packages/ai/CHANGELOG.md | 2 +- packages/ai/package.json | 2 +- packages/ai/src/models.generated.ts | 38 +++++++++---------- 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 +- packages/coding-agent/npm-shrinkwrap.json | 24 ++++++------ packages/coding-agent/package.json | 8 ++-- packages/tui/CHANGELOG.md | 2 +- packages/tui/package.json | 2 +- 20 files changed, 69 insertions(+), 69 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1f9d4e66..9bec652f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5111,10 +5111,10 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.79.8", + "version": "0.79.9", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.79.8", + "@earendil-works/pi-ai": "^0.79.9", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -5463,7 +5463,7 @@ }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.79.8", + "version": "0.79.9", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -5769,12 +5769,12 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "0.79.8", + "version": "0.79.9", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.8", - "@earendil-works/pi-ai": "^0.79.8", - "@earendil-works/pi-tui": "^0.79.8", + "@earendil-works/pi-agent-core": "^0.79.9", + "@earendil-works/pi-ai": "^0.79.9", + "@earendil-works/pi-tui": "^0.79.9", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -5815,32 +5815,32 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.79.8", + "version": "0.79.9", "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.79.8" + "version": "0.79.9" }, "packages/coding-agent/examples/extensions/gondolin": { "name": "pi-extension-gondolin", - "version": "0.79.8", + "version": "0.79.9", "dependencies": { "@earendil-works/gondolin": "0.12.0" } }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.9.8", + "version": "1.9.9", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.79.8", + "version": "0.79.9", "dependencies": { "ms": "2.1.3" }, @@ -6136,7 +6136,7 @@ }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.79.8", + "version": "0.79.9", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 18d0e561..6c98303b 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.9] - 2026-06-20 ### Fixed diff --git a/packages/agent/package.json b/packages/agent/package.json index b3aa9bae..75a73cb8 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "0.79.8", + "version": "0.79.9", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -33,7 +33,7 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "^0.79.8", + "@earendil-works/pi-ai": "^0.79.9", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 90a1b3bb..2c7ff556 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.9] - 2026-06-20 ### Added diff --git a/packages/ai/package.json b/packages/ai/package.json index 830df19e..77272079 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.79.8", + "version": "0.79.9", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 32894f60..afdea285 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -9657,7 +9657,7 @@ export const MODELS = { cacheRead: 0.2, cacheWrite: 0.375, }, - contextWindow: 65536, + contextWindow: 131072, maxTokens: 32768, } satisfies Model<"openai-completions">, "google/gemini-3.1-flash-lite": { @@ -9845,7 +9845,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 8192, + maxTokens: 32768, } satisfies Model<"openai-completions">, "ibm-granite/granite-4.1-8b": { id: "ibm-granite/granite-4.1-8b", @@ -9947,8 +9947,8 @@ export const MODELS = { cacheRead: 0.06, cacheWrite: 0, }, - contextWindow: 256000, - maxTokens: 80000, + contextWindow: 262144, + maxTokens: 144000, } satisfies Model<"openai-completions">, "liquid/lfm-2.5-1.2b-thinking:free": { id: "liquid/lfm-2.5-1.2b-thinking:free", @@ -10169,7 +10169,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 1048576, - maxTokens: 512000, + maxTokens: 4096, } satisfies Model<"openai-completions">, "mistralai/codestral-2508": { id: "mistralai/codestral-2508", @@ -10505,7 +10505,7 @@ export const MODELS = { cost: { input: 0.6, output: 2.5, - cacheRead: 0, + cacheRead: 0.6, cacheWrite: 0, }, contextWindow: 262144, @@ -10884,7 +10884,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 1047576, - maxTokens: 32768, + maxTokens: 4096, } satisfies Model<"openai-completions">, "openai/gpt-4.1-nano": { id: "openai/gpt-4.1-nano", @@ -10914,7 +10914,7 @@ export const MODELS = { cost: { input: 2.5, output: 10, - cacheRead: 0, + cacheRead: 1.25, cacheWrite: 0, }, contextWindow: 128000, @@ -11050,11 +11050,11 @@ export const MODELS = { cost: { input: 0.25, output: 2, - cacheRead: 0.025, + cacheRead: 0.03, cacheWrite: 0, }, contextWindow: 400000, - maxTokens: 128000, + maxTokens: 4096, } satisfies Model<"openai-completions">, "openai/gpt-5-nano": { id: "openai/gpt-5-nano", @@ -11118,11 +11118,11 @@ export const MODELS = { cost: { input: 1.25, output: 10, - cacheRead: 0.13, + cacheRead: 0.125, cacheWrite: 0, }, contextWindow: 128000, - maxTokens: 32000, + maxTokens: 16384, } satisfies Model<"openai-completions">, "openai/gpt-5.1-codex": { id: "openai/gpt-5.1-codex", @@ -11209,7 +11209,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 128000, - maxTokens: 16384, + maxTokens: 32000, } satisfies Model<"openai-completions">, "openai/gpt-5.2-codex": { id: "openai/gpt-5.2-codex", @@ -12422,11 +12422,11 @@ export const MODELS = { cost: { input: 0.14, output: 1, - cacheRead: 0, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 81920, } satisfies Model<"openai-completions">, "qwen/qwen3.5-397b-a17b": { id: "qwen/qwen3.5-397b-a17b", @@ -12459,8 +12459,8 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 262144, - maxTokens: 262144, + contextWindow: 256000, + maxTokens: 32768, } satisfies Model<"openai-completions">, "qwen/qwen3.5-flash-02-23": { id: "qwen/qwen3.5-flash-02-23", @@ -13034,11 +13034,11 @@ export const MODELS = { cost: { input: 0.98, output: 3.08, - cacheRead: 0.49, + cacheRead: 0.182, cacheWrite: 0, }, contextWindow: 202752, - maxTokens: 65535, + maxTokens: 4096, } satisfies Model<"openai-completions">, "z-ai/glm-5.2": { id: "z-ai/glm-5.2", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5e0b229b..48f64d23 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.9] - 2026-06-20 ### New Features diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json index 436f151f..9a131279 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.79.8", + "version": "0.79.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "0.79.8", + "version": "0.79.9", "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 aa509051..8b4182e7 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.79.8", + "version": "0.79.9", "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 51de298b..779d519c 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.79.8", + "version": "0.79.9", "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 3f126430..01ee2f61 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.79.8", + "version": "0.79.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-gondolin", - "version": "0.79.8", + "version": "0.79.9", "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 1d42d841..67a9ac1e 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.79.8", + "version": "0.79.9", "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 2a587f88..01aad382 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.9.8", + "version": "1.9.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.9.8", + "version": "1.9.9", "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 c9d4aa3f..b8dc74ab 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.9.8", + "version": "1.9.9", "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 2dc3506b..89961b12 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.79.8", + "version": "0.79.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "0.79.8", + "version": "0.79.9", "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 1c409ca4..3ed465d2 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.79.8", + "version": "0.79.9", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 13ff9161..c311b556 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -1,17 +1,17 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.79.8", + "version": "0.79.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent", - "version": "0.79.8", + "version": "0.79.9", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.8", - "@earendil-works/pi-ai": "^0.79.8", - "@earendil-works/pi-tui": "^0.79.8", + "@earendil-works/pi-agent-core": "^0.79.9", + "@earendil-works/pi-ai": "^0.79.9", + "@earendil-works/pi-tui": "^0.79.9", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -474,11 +474,11 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.79.8", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.8.tgz", + "version": "0.79.9", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.9.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.79.8", + "@earendil-works/pi-ai": "^0.79.9", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -488,8 +488,8 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.79.8", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.8.tgz", + "version": "0.79.9", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.9.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -512,8 +512,8 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.79.8", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.8.tgz", + "version": "0.79.9", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.9.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 4aa30dd2..490a7751 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.79.8", + "version": "0.79.9", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -36,9 +36,9 @@ "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" }, "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.8", - "@earendil-works/pi-ai": "^0.79.8", - "@earendil-works/pi-tui": "^0.79.8", + "@earendil-works/pi-agent-core": "^0.79.9", + "@earendil-works/pi-ai": "^0.79.9", + "@earendil-works/pi-tui": "^0.79.9", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 065f1321..e7cf9b0f 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.79.9] - 2026-06-20 ### Fixed diff --git a/packages/tui/package.json b/packages/tui/package.json index 36f002a7..d9d53e0e 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-tui", - "version": "0.79.8", + "version": "0.79.9", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", From b4f31408b67bde605e9c2fe0eb365ef2b73690f9 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 20 Jun 2026 22:15:40 +0200 Subject: [PATCH 351/352] Add [Unreleased] section for next cycle --- packages/agent/CHANGELOG.md | 2 ++ packages/ai/CHANGELOG.md | 2 ++ packages/coding-agent/CHANGELOG.md | 2 ++ packages/tui/CHANGELOG.md | 2 ++ 4 files changed, 8 insertions(+) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 6c98303b..ff48ad2c 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.9] - 2026-06-20 ### Fixed diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 2c7ff556..586260cf 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.9] - 2026-06-20 ### Added diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 48f64d23..ba9b1ae7 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.9] - 2026-06-20 ### New Features diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index e7cf9b0f..96ff6d5c 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.9] - 2026-06-20 ### Fixed From d93b92baca8fe2f9eac048185669a403d4e5ba8b Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 20 Jun 2026 22:23:25 +0200 Subject: [PATCH 352/352] fix(coding-agent): show changelog URL in update notice --- packages/coding-agent/src/modes/interactive/interactive-mode.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 962c414f..613d588c 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -3714,7 +3714,7 @@ export class InteractiveMode { const updateInstruction = theme.fg("muted", `New version ${release.version} is available. Run `) + action; const changelogUrl = "https://pi.dev/changelog"; const changelogLink = getCapabilities().hyperlinks - ? hyperlink(theme.fg("accent", "open changelog"), changelogUrl) + ? hyperlink(theme.fg("accent", changelogUrl), changelogUrl) : theme.fg("accent", changelogUrl); const changelogLine = theme.fg("muted", "Changelog: ") + changelogLink; const note = release.note?.trim();