From c8df99a795e9d8d5d030d6b7f0327f3ed7193f5d Mon Sep 17 00:00:00 2001 From: smoose Date: Thu, 28 May 2026 10:42:03 +0800 Subject: [PATCH 01/44] 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 2527441b40d5a9e870d22a6a482251c1582d5208 Mon Sep 17 00:00:00 2001 From: Eric Henry Date: Mon, 8 Jun 2026 08:32:16 -0500 Subject: [PATCH 02/44] 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 03/44] 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 04/44] 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 05/44] 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 06/44] 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 07/44] 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 08/44] 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 09/44] 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 10/44] 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 11/44] 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 12/44] 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 13/44] 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 14/44] 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 15/44] 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 16/44] 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 17/44] 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 18/44] 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 19/44] 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 20/44] 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 21/44] 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 22/44] 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 23/44] 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 24/44] 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 25/44] 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 26/44] 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 27/44] 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 28/44] 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 29/44] 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 30/44] 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 31/44] 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 32/44] 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 33/44] 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 34/44] 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 35/44] 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 36/44] 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 37/44] 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 38/44] 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 39/44] 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 40/44] 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 41/44] 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 42/44] 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 43/44] 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 44/44] 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); + }); +});