This commit is contained in:
2026-07-26 14:02:37 +07:00
parent bc56546b49
commit 367ebc1c7f
171 changed files with 4617 additions and 10402 deletions
+61 -3
View File
@@ -2,14 +2,73 @@
## [Unreleased]
## [0.82.1] - 2026-07-25
### New Features
- **Claude Opus 5** — Available on Anthropic and Amazon Bedrock with adaptive thinking (including `xhigh`), inference profiles, and prompt caching. See [Providers](docs/providers.md#api-keys).
- **Anthropic gateway bearer auth**`ANTHROPIC_AUTH_TOKEN` authenticates against Anthropic-compatible gateways that require `Authorization: Bearer`, including compaction and branch summaries. See [Environment Variables or Auth File](docs/providers.md#environment-variables-or-auth-file).
- **Faster, more resilient model catalogs** — pi.dev catalogs revalidate with `If-None-Match` so unchanged providers answer with an empty `304`, and llama.cpp models stay listed across restarts. See [llama.cpp](docs/llama-cpp.md).
### Added
- Exposed `PI_SESSION_ID`, `PI_SESSION_FILE`, `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL` to commands run by built-in and factory-created bash tools.
- Exposed the `outputPad` setting to custom message renderers. See [Extensions](docs/extensions.md) ([#7045](https://github.com/earendil-works/pi/pull/7045) by [@xl0](https://github.com/xl0)).
- Added inherited `ANTHROPIC_AUTH_TOKEN` bearer authentication for Anthropic-compatible gateways. See [Providers](docs/providers.md#environment-variables-or-auth-file) ([#5871](https://github.com/earendil-works/pi/issues/5871)).
- Added inherited Claude Opus 5 support for Anthropic and Amazon Bedrock with adaptive thinking, inference profiles, prompt caching, and preserved AWS validation messages ([#7081](https://github.com/earendil-works/pi/pull/7081) by [@unexge](https://github.com/unexge), [#7083](https://github.com/earendil-works/pi/pull/7083) by [@davidbrai](https://github.com/davidbrai)).
### Changed
- Changed pi.dev model catalog refreshes to revalidate with `If-None-Match`, so unchanged provider catalogs answer with an empty `304` instead of a full download.
- Changed inherited Radius OAuth device authorization, token exchange, and refresh requests to use the configured gateway directly.
- Changed inherited model loading errors to append the underlying cause, so auth failures such as `OAuth refresh failed for openai-codex` report the provider response instead of a bare wrapper message.
### Fixed
- Fixed compaction and branch-summary requests to use fresh routing session IDs with prompt caching disabled where supported.
- Fixed compaction and branch summaries for providers whose authentication resolves entirely to request headers ([#5871](https://github.com/earendil-works/pi/issues/5871))
- Fixed unavailable scoped models being hidden from `/models`, allowing them to be removed without editing settings manually ([#6949](https://github.com/earendil-works/pi/issues/6949), [#7032](https://github.com/earendil-works/pi/pull/7032) by [@christianklotz](https://github.com/christianklotz)).
- Fixed startup context file discovery to skip directories that match context file names such as `AGENTS.md`, which produced `EISDIR` warnings ([#7106](https://github.com/earendil-works/pi/pull/7106) by [@mrexodia](https://github.com/mrexodia)).
- Fixed the llama.cpp extension to persist its model catalog, so llama.cpp models stay listed before the first successful refresh. See [llama.cpp](docs/llama-cpp.md) ([#7072](https://github.com/earendil-works/pi/pull/7072) by [@davidbrai](https://github.com/davidbrai)).
## [0.82.0] - 2026-07-24
### New Features
- **Constrained tool sampling** — Tools can prefer or require strict JSON Schema sampling or use OpenAI Lark/regex grammars, with model capability metadata preventing unsupported requests. See [Constrained Sampling for Tools](../ai/README.md#constrained-sampling-for-tools).
- **OpenRouter and Kimi Code sign-in** — Use `/login` to authorize OpenRouter or a Kimi Code subscription without manually configuring API keys. See [OpenRouter](docs/providers.md#openrouter).
- **Session-aware, streaming bash integrations** — Bash tools receive current session/model metadata, while direct RPC bash commands stream correlated output. See [Bash Tool Session Environment](docs/environment-variables.md#bash-tool-session-environment) and [RPC bash events](docs/rpc.md#bash_execution_update).
### Added
- Added inherited `Tool.constrainedSampling` with strict JSON Schema (`prefer`/`require`) and OpenAI Lark/regex grammar variants across OpenAI, Anthropic, Amazon Bedrock, Google Gemini, and Mistral. See [Constrained Sampling for Tools](../ai/README.md#constrained-sampling-for-tools).
- Added inherited `supportsGrammarTools` and `supportsStrictTools` compatibility flags, expanded `supportsStrictMode` coverage, and generated model capability metadata to gate constrained sampling.
- Added inherited Kimi Code subscription OAuth login for the Kimi For Coding provider, including device authorization and automatic token refresh ([#6935](https://github.com/earendil-works/pi/pull/6935) by [@zaycruz](https://github.com/zaycruz)).
- Added inherited OpenRouter OAuth PKCE login through `/login`, minting a user-controlled API key. See [OpenRouter](docs/providers.md#openrouter) ([#6927](https://github.com/earendil-works/pi/pull/6927) by [@rsaryev](https://github.com/rsaryev)).
- Exposed `PI_SESSION_ID`, `PI_SESSION_FILE`, `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL` to commands run by built-in and factory-created bash tools. See [Bash Tool Session Environment](docs/environment-variables.md#bash-tool-session-environment).
- Added streaming `bash_execution_update` events for direct RPC bash commands, correlated with request IDs. See [RPC bash events](docs/rpc.md#bash_execution_update) ([#6971](https://github.com/earendil-works/pi/pull/6971) by [@ananthakumaran](https://github.com/ananthakumaran)).
### Changed
- Changed inherited generated model catalogs to expose only provider-verified reasoning effort levels from models.dev ([#6928](https://github.com/earendil-works/pi/pull/6928) by [@davidbrai](https://github.com/davidbrai)).
### Fixed
- Fixed inherited DNS lookup failures such as `getaddrinfo`, `ENOTFOUND`, and `EAI_AGAIN` to trigger automatic assistant retries ([#6946](https://github.com/earendil-works/pi/pull/6946) by [@christianklotz](https://github.com/christianklotz)).
- Fixed inherited OpenRouter Anthropic cache breakpoints to advance through tool results and enabled cache control for `~anthropic/*-latest` aliases ([#6941](https://github.com/earendil-works/pi/pull/6941) by [@mteam88](https://github.com/mteam88)).
- Fixed inherited OpenAI Codex WebSocket sessions to retry once without a missing previous-response continuation after `previous_response_not_found` errors ([#6955](https://github.com/earendil-works/pi/pull/6955) by [@davidbrai](https://github.com/davidbrai)).
- Fixed TUI debug and crash logs to respect custom agent directories instead of always writing under `~/.pi/agent` ([#6958](https://github.com/earendil-works/pi/pull/6958) by [@davidbrai](https://github.com/davidbrai)).
- Fixed slow Ctrl+G external-editor startup when the system temporary directory contains many entries ([#6903](https://github.com/earendil-works/pi/pull/6903) by [@christianklotz](https://github.com/christianklotz)).
- Fixed startup resource display to preserve relative paths for sibling npm extensions loaded by a package ([#6964](https://github.com/earendil-works/pi/pull/6964) by [@davidbrai](https://github.com/davidbrai)).
- Fixed compaction and branch-summary requests to use fresh routing session IDs with prompt caching disabled where supported ([#6618](https://github.com/earendil-works/pi/pull/6618) by [@tmustier](https://github.com/tmustier)).
- Fixed explicit self-updates when `PI_SKIP_VERSION_CHECK` is set ([#6977](https://github.com/earendil-works/pi/issues/6977)).
- Fixed scoped model IDs containing brackets to resolve as literal exact matches before glob matching ([#6210](https://github.com/earendil-works/pi/issues/6210)).
- Fixed inherited OpenAI and Anthropic provider retry waits to honor abort signals and configured delay limits ([#6980](https://github.com/earendil-works/pi/pull/6980) by [@petrroll](https://github.com/petrroll)).
- Fixed fresh installs from preferring bundled model catalogs over newer remote catalogs because package file mtimes were newer ([#7016](https://github.com/earendil-works/pi/pull/7016) by [@davidbrai](https://github.com/davidbrai)).
- Fixed inherited editor scroll indicators overflowing narrow terminals ([#7015](https://github.com/earendil-works/pi/pull/7015) by [@christianklotz](https://github.com/christianklotz)).
- Fixed llama.cpp models to use the loaded context window as their output token limit instead of capping it at 16K ([#7034](https://github.com/earendil-works/pi/pull/7034) by [@christianklotz](https://github.com/christianklotz)).
- Fixed release source archives to include the generated provider model data used to build standalone binaries.
- Updated the packaged `protobufjs` dependency to 7.6.5 to address GHSA-j3f2-48v5-ccww ([#7005](https://github.com/earendil-works/pi/issues/7005)).
- Fixed `/copy` on Wayland to fall back to X11 or OSC 52 when `wl-copy` fails ([#7009](https://github.com/earendil-works/pi/pull/7009) by [@rkfshakti](https://github.com/rkfshakti)).
- Fixed `/model` to reload updated `models.json` configuration when opening the model picker ([#6999](https://github.com/earendil-works/pi/issues/6999)).
## [0.81.1] - 2026-07-21
@@ -66,7 +125,6 @@
- Fixed llama.cpp router download progress updates and removed redundant wording from model action confirmations.
- Moved automatic model catalog network refresh out of startup initialization and into the running interactive and RPC modes.
- Fixed persisted sessions being read and parsed twice when opened, reducing startup latency for large sessions ([#6793](https://github.com/earendil-works/pi/issues/6793)).
- Fixed slow Ctrl+G external-editor startup when the system temporary directory contains many entries ([#6774](https://github.com/earendil-works/pi/issues/6774)).
- Fixed prompt-template defaults for all arguments (`${@:-default}` and `${ARGUMENTS:-default}`) ([#6695](https://github.com/earendil-works/pi/issues/6695)).
- Fixed obsolete custom UI, custom tool, and custom editor examples in the extension documentation ([#6735](https://github.com/earendil-works/pi/issues/6735)).
- Fixed Kimi Coding sessions to show API-equivalent implied costs with the subscription indicator.
+1 -1
View File
@@ -20,7 +20,7 @@ Pi has two summarization mechanisms:
| Compaction | Context exceeds threshold, or `/compact` | Summarize old messages to free up context |
| Branch summarization | `/tree` navigation | Preserve context when switching branches |
Both use the same structured summary format and track file operations cumulatively.
Both use the same structured summary format and track file operations cumulatively. Compaction and branch-summary requests use fresh routing session IDs and, where supported by the provider, disable prompt-cache writes because these one-off prompts are unlikely to be reused.
## Compaction
@@ -737,6 +737,8 @@ interface ProviderModelConfig {
supportsDeveloperRole?: boolean;
supportsReasoningEffort?: boolean;
supportsUsageInStreaming?: boolean;
supportsStrictMode?: boolean;
supportsOpenAIGrammarTools?: boolean; // openai-completions/openai-responses; false falls back to normal function tools
maxTokensField?: "max_completion_tokens" | "max_tokens";
requiresToolResultName?: boolean;
requiresAssistantAfterToolResult?: boolean;
@@ -755,6 +757,7 @@ interface ProviderModelConfig {
supportsCacheControlOnTools?: boolean;
forceAdaptiveThinking?: boolean;
allowEmptySignature?: boolean;
supportsStrictTools?: boolean;
};
}
```
+2 -2
View File
@@ -2791,7 +2791,7 @@ Register a custom renderer for messages with your `customType`. Use message rend
import { Text } from "@earendil-works/pi-tui";
pi.registerMessageRenderer("my-extension", (message, options, theme) => {
const { expanded } = options;
const { expanded, outputPad } = options;
let text = theme.fg("accent", `[${message.customType}] `);
text += message.content;
@@ -2799,7 +2799,7 @@ pi.registerMessageRenderer("my-extension", (message, options, theme) => {
text += "\n" + theme.fg("dim", JSON.stringify(message.details, null, 2));
}
return new Text(text, 0, 0);
return new Text(text, outputPad, 0);
});
```
+5 -1
View File
@@ -375,6 +375,8 @@ Some Anthropic models require adaptive thinking (`thinking.type: "adaptive"` plu
Some Anthropic-compatible providers emit thinking blocks with empty signatures and still expect them on replay. Set `allowEmptySignature` to `true` only for those providers; real Anthropic rejects empty thinking signatures.
Built-in Anthropic models enable `supportsStrictTools` in their model metadata. Custom Anthropic-compatible models must set it to `true` when their endpoint accepts strict JSON-schema tool definitions.
```json
{
"providers": {
@@ -408,6 +410,7 @@ Some Anthropic-compatible providers emit thinking blocks with empty signatures a
| `supportsCacheControlOnTools` | Whether the provider accepts Anthropic-style `cache_control` markers on tool definitions. Default: `true`. |
| `forceAdaptiveThinking` | Whether to send adaptive thinking (`thinking.type: "adaptive"` plus `output_config.effort`) for this model. Built-in adaptive models set this automatically. Default: `false`. |
| `allowEmptySignature` | Whether to replay empty thinking signatures as `signature: ""` instead of converting thinking to text. Default: `false`. |
| `supportsStrictTools` | Whether the provider accepts strict JSON-schema tool definitions. Default: `false`; built-in Anthropic models enable it in generated metadata. |
## OpenAI Compatibility
@@ -448,7 +451,8 @@ For providers with partial OpenAI compatibility, use the `compat` field.
| `cacheControlFormat` | Use Anthropic-style `cache_control` markers on the system prompt, last tool definition, and last user, assistant, or tool-result text content. Currently only `anthropic` is supported. |
| `sendSessionAffinityHeaders` | For `openai-completions`, send session-affinity headers from the session id when caching is enabled. Default: `false`. |
| `sessionAffinityFormat` | For `openai-completions` and `openai-responses`, the session-affinity header format: `openai` sends `session_id`/`x-client-request-id` (completions also `x-session-affinity`), `openai-nosession` omits the underscore-containing `session_id` header, `openrouter` sends `x-session-id`. Does not affect the `prompt_cache_key` body param. Default: auto-detected. |
| `supportsStrictMode` | Include the `strict` field in tool definitions |
| `supportsStrictMode` | Whether the provider accepts strict JSON-schema function tool definitions. Defaults depend on the API; built-in OpenAI models carry explicit capability metadata. |
| `supportsOpenAIGrammarTools` | Whether OpenAI-compatible APIs emit custom Lark/regex grammar tools. When `false`, grammar-constrained tools fall back to normal function tools. Default: `false`; the built-in model catalog enables it for GPT-5+ models on OpenAI, OpenAI Codex, Azure OpenAI, GitHub Copilot, opencode, and Cloudflare AI Gateway. |
| `deferredToolsMode` | Use provider-specific deferred tool serialization. Currently only `"kimi"` is supported for Kimi's OpenAI-compatible Chat Completions format. |
| `supportsLongCacheRetention` | Whether the provider accepts long cache retention when cache retention is `long`: `prompt_cache_retention: "24h"` for OpenAI prompt caching, or `cache_control.ttl: "1h"` when `cacheControlFormat` is `anthropic`. Default: `true`. |
| `openRouterRouting` | OpenRouter provider routing preferences. This object is sent as-is in the `provider` field of the [OpenRouter API request](https://openrouter.ai/docs/guides/routing/provider-selection). |
+23 -6
View File
@@ -23,7 +23,7 @@ Common options:
- **Responses**: JSON objects with `type: "response"` indicating command success/failure
- **Events**: Agent events streamed to stdout as JSON lines
All commands support an optional `id` field for request/response correlation. If provided, the corresponding response will include the same `id`.
All commands support an optional `id` field for request/response correlation. If provided, the corresponding response will include the same `id`. `bash_execution_update` events also include the `id` of their originating `bash` command.
### Framing
@@ -455,15 +455,18 @@ Response:
#### bash
Execute a shell command and add output to conversation context.
Execute a shell command and add output to conversation context. Output streams as `bash_execution_update` events while the command runs; the response contains the final result.
```json
{"type": "bash", "command": "ls -la"}
{"id": "req-1", "type": "bash", "command": "ls -la"}
```
Include an `id` to associate streamed `bash_execution_update` events with this command.
Response:
```json
{
"id": "req-1",
"type": "response",
"command": "bash",
"success": true,
@@ -494,7 +497,7 @@ If output was truncated, includes `fullOutputPath`:
**How bash results reach the LLM:**
The `bash` command executes immediately and returns a `BashResult`. Internally, a `BashExecutionMessage` is created and stored in the agent's message state. This message does NOT emit an event.
The `bash` command executes immediately and returns a `BashResult`. Internally, a `BashExecutionMessage` is created and stored in the agent's message state.
When the next `prompt` command is sent, all messages (including `BashExecutionMessage`) are transformed before being sent to the LLM. The `BashExecutionMessage` is converted to a `UserMessage` with this format:
@@ -509,7 +512,6 @@ drwxr-xr-x ...
This means:
1. Bash output is included in the LLM context on the **next prompt**, not immediately
2. Multiple bash commands can be executed before a prompt; all outputs will be included
3. No event is emitted for the `BashExecutionMessage` itself
#### abort_bash
@@ -829,7 +831,7 @@ Each command has:
## Events
Events are streamed to stdout as JSON lines during agent operation. Events do NOT include an `id` field (only responses do).
Events are streamed to stdout as JSON lines during agent operation. Events do not generally include an `id` field; `bash_execution_update` includes the `id` of its originating `bash` command when one was provided.
### Event Types
@@ -843,6 +845,7 @@ Events are streamed to stdout as JSON lines during agent operation. Events do NO
| `message_start` | Message begins |
| `message_update` | Streaming update (text/thinking/toolcall deltas) |
| `message_end` | Message completes |
| `bash_execution_update` | Direct RPC bash command output chunk |
| `tool_execution_start` | Tool begins execution |
| `tool_execution_update` | Tool execution progress (streaming output) |
| `tool_execution_end` | Tool completes |
@@ -951,6 +954,20 @@ Example streaming a text response:
{"type":"message_update","message":{...},"assistantMessageEvent":{"type":"text_end","contentIndex":0,"content":"Hello world","partial":{...}}}
```
### bash_execution_update
Emitted once for each output chunk from a direct `bash` command. `id` matches the command's `id`, allowing clients to associate output with the correct command.
Events stream all output while the command runs, even if the final `bash` response's `output` is truncated.
```json
{
"type": "bash_execution_update",
"id": "req-1",
"delta": "total 48\n"
}
```
### tool_execution_start / tool_execution_update / tool_execution_end
Emitted when a tool begins, streams progress, and completes execution.
+1 -1
View File
@@ -142,7 +142,7 @@ Set `PI_SKIP_VERSION_CHECK=1` to disable the Pi version update check. Use `--off
| `retry.provider.maxRetries` | number | `0` | Provider/SDK retry attempts |
| `retry.provider.maxRetryDelayMs` | number | `60000` | Max server-requested delay before failing (60s) |
When a provider requests a retry delay longer than `retry.provider.maxRetryDelayMs` (e.g., Google's "quota will reset after 5h"), the request fails immediately with an informative error instead of waiting silently. Set to `0` to disable the cap.
When a provider requests a retry delay longer than `retry.provider.maxRetryDelayMs`, the request fails immediately with an informative error instead of waiting silently. Set it to `0` to disable the limit.
Keep `retry.provider.maxRetries` at `0` unless provider-level retries are explicitly needed. Setting it above `0` can make SDK/provider retries handle out-of-usage-limit errors before Pi sees them, which may block the agent until the provider quota resets in some circumstances.
@@ -13,6 +13,7 @@
* pi --extension examples/extensions/custom-compaction.ts
*/
import { uuidv7 } from "@earendil-works/pi-ai";
import { complete } from "@earendil-works/pi-ai/compat";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
@@ -96,6 +97,8 @@ ${conversationText}
env: auth.env,
maxTokens: 8192,
signal,
cacheRetention: "none",
sessionId: uuidv7(),
},
);
@@ -1,12 +1,12 @@
{
"name": "pi-extension-custom-provider",
"version": "0.81.1",
"version": "0.82.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-custom-provider",
"version": "0.81.1",
"version": "0.82.1",
"dependencies": {
"@anthropic-ai/sdk": "^0.52.0"
}
@@ -1,7 +1,7 @@
{
"name": "pi-extension-custom-provider-anthropic",
"private": true,
"version": "0.81.1",
"version": "0.82.1",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
@@ -1,7 +1,7 @@
{
"name": "pi-extension-custom-provider-gitlab-duo",
"private": true,
"version": "0.81.1",
"version": "0.82.1",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
@@ -1,12 +1,12 @@
{
"name": "pi-extension-gondolin",
"version": "0.81.1",
"version": "0.82.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-gondolin",
"version": "0.81.1",
"version": "0.82.1",
"dependencies": {
"@earendil-works/gondolin": "0.12.0"
}
@@ -1,7 +1,7 @@
{
"name": "pi-extension-gondolin",
"private": true,
"version": "0.81.1",
"version": "0.82.1",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
@@ -13,6 +13,7 @@
*/
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import { uuidv7 } from "@earendil-works/pi-ai";
import { complete, type Message } from "@earendil-works/pi-ai/compat";
import type { ExtensionAPI, SessionEntry } from "@earendil-works/pi-coding-agent";
import { BorderedLoader, convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
@@ -136,7 +137,14 @@ export default function (pi: ExtensionAPI) {
const response = await complete(
ctx.model!,
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
{ apiKey: auth.apiKey, headers: auth.headers, env: auth.env, signal: loader.signal },
{
apiKey: auth.apiKey,
headers: auth.headers,
env: auth.env,
signal: loader.signal,
cacheRetention: "none",
sessionId: uuidv7(),
},
);
if (response.stopReason === "aborted") {
@@ -12,7 +12,7 @@ import { Box, Text } from "@earendil-works/pi-tui";
export default function (pi: ExtensionAPI) {
// Register custom renderer for "status-update" messages
pi.registerMessageRenderer("status-update", (message, { expanded }, theme) => {
pi.registerMessageRenderer("status-update", (message, { expanded, outputPad }, theme) => {
const details = message.details as { level: string; timestamp: number } | undefined;
const level = details?.level ?? "info";
@@ -29,7 +29,7 @@ export default function (pi: ExtensionAPI) {
}
// Use Box with customMessageBg for consistent styling
const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
const box = new Box(outputPad, 1, (t) => theme.bg("customMessageBg", t));
box.addChild(new Text(text, 0, 0));
return box;
});
@@ -1,12 +1,12 @@
{
"name": "pi-extension-sandbox",
"version": "1.11.1",
"version": "1.12.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-sandbox",
"version": "1.11.1",
"version": "1.12.1",
"dependencies": {
"@anthropic-ai/sandbox-runtime": "^0.0.26"
}
@@ -1,7 +1,7 @@
{
"name": "pi-extension-sandbox",
"private": true,
"version": "1.11.1",
"version": "1.12.1",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
@@ -1,3 +1,4 @@
import { uuidv7 } from "@earendil-works/pi-ai";
import { complete, getModel } from "@earendil-works/pi-ai/compat";
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
import { DynamicBorder, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
@@ -193,6 +194,8 @@ export default function (pi: ExtensionAPI) {
headers: auth.headers,
env: auth.env,
reasoningEffort: "high",
cacheRetention: "none",
sessionId: uuidv7(),
},
);
@@ -1,12 +1,12 @@
{
"name": "pi-extension-with-deps",
"version": "0.81.1",
"version": "0.82.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-with-deps",
"version": "0.81.1",
"version": "0.82.1",
"dependencies": {
"ms": "^2.1.3"
},
@@ -1,7 +1,7 @@
{
"name": "pi-extension-with-deps",
"private": true,
"version": "0.81.1",
"version": "0.82.1",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
+18 -18
View File
@@ -1,14 +1,14 @@
{
"name": "@earendil-works/pi-coding-agent-install",
"version": "0.81.1",
"version": "0.82.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@earendil-works/pi-coding-agent-install",
"version": "0.81.1",
"version": "0.82.1",
"dependencies": {
"@earendil-works/pi-coding-agent": "0.81.1"
"@earendil-works/pi-coding-agent": "0.82.1"
},
"engines": {
"node": ">=22.19.0"
@@ -450,11 +450,11 @@
}
},
"node_modules/@earendil-works/pi-agent-core": {
"version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.1.tgz",
"version": "0.82.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.82.1.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-ai": "^0.81.1",
"@earendil-works/pi-ai": "^0.82.1",
"diff": "8.0.4",
"ignore": "7.0.5",
"typebox": "1.1.38",
@@ -465,8 +465,8 @@
}
},
"node_modules/@earendil-works/pi-ai": {
"version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.1.tgz",
"version": "0.82.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.82.1.tgz",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "0.91.1",
@@ -489,13 +489,13 @@
}
},
"node_modules/@earendil-works/pi-coding-agent": {
"version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.81.1.tgz",
"version": "0.82.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.82.1.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-agent-core": "^0.81.1",
"@earendil-works/pi-ai": "^0.81.1",
"@earendil-works/pi-tui": "^0.81.1",
"@earendil-works/pi-agent-core": "^0.82.1",
"@earendil-works/pi-ai": "^0.82.1",
"@earendil-works/pi-tui": "^0.82.1",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
"cross-spawn": "7.0.6",
@@ -523,8 +523,8 @@
}
},
"node_modules/@earendil-works/pi-tui": {
"version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.1.tgz",
"version": "0.82.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.82.1.tgz",
"license": "MIT",
"dependencies": {
"get-east-asian-width": "1.6.0",
@@ -1603,9 +1603,9 @@
}
},
"node_modules/protobufjs": {
"version": "7.6.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz",
"integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==",
"version": "7.6.5",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
"integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
@@ -1,12 +1,13 @@
{
"name": "@earendil-works/pi-coding-agent-install",
"version": "0.81.1",
"version": "0.82.1",
"private": true,
"description": "Lockfile root used by the Pi installer and updater.",
"dependencies": {
"@earendil-works/pi-coding-agent": "0.81.1"
"@earendil-works/pi-coding-agent": "0.82.1"
},
"overrides": {
"protobufjs": "7.6.5",
"rimraf": "6.1.2",
"gaxios": {
"rimraf": "6.1.2"
+15 -15
View File
@@ -1,17 +1,17 @@
{
"name": "@earendil-works/pi-coding-agent",
"version": "0.81.1",
"version": "0.82.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@earendil-works/pi-coding-agent",
"version": "0.81.1",
"version": "0.82.1",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-agent-core": "^0.81.1",
"@earendil-works/pi-ai": "^0.81.1",
"@earendil-works/pi-tui": "^0.81.1",
"@earendil-works/pi-agent-core": "^0.82.1",
"@earendil-works/pi-ai": "^0.82.1",
"@earendil-works/pi-tui": "^0.82.1",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
"cross-spawn": "7.0.6",
@@ -474,11 +474,11 @@
}
},
"node_modules/@earendil-works/pi-agent-core": {
"version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.1.tgz",
"version": "0.82.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.82.1.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-ai": "^0.81.1",
"@earendil-works/pi-ai": "^0.82.1",
"diff": "8.0.4",
"ignore": "7.0.5",
"typebox": "1.1.38",
@@ -489,8 +489,8 @@
}
},
"node_modules/@earendil-works/pi-ai": {
"version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.1.tgz",
"version": "0.82.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.82.1.tgz",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "0.91.1",
@@ -513,8 +513,8 @@
}
},
"node_modules/@earendil-works/pi-tui": {
"version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.1.tgz",
"version": "0.82.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.82.1.tgz",
"license": "MIT",
"dependencies": {
"get-east-asian-width": "1.6.0",
@@ -1593,9 +1593,9 @@
}
},
"node_modules/protobufjs": {
"version": "7.6.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz",
"integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==",
"version": "7.6.5",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
"integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
+5 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-coding-agent",
"version": "0.81.1",
"version": "0.82.1",
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
"type": "module",
"piConfig": {
@@ -39,9 +39,9 @@
"prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap"
},
"dependencies": {
"@earendil-works/pi-agent-core": "^0.81.1",
"@earendil-works/pi-ai": "^0.81.1",
"@earendil-works/pi-tui": "^0.81.1",
"@earendil-works/pi-agent-core": "^0.82.1",
"@earendil-works/pi-ai": "^0.82.1",
"@earendil-works/pi-tui": "^0.82.1",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
"cross-spawn": "7.0.6",
@@ -59,6 +59,7 @@
"yaml": "2.9.0"
},
"overrides": {
"protobufjs": "7.6.5",
"rimraf": "6.1.2",
"gaxios": {
"rimraf": "6.1.2"
+1
View File
@@ -333,6 +333,7 @@ ${chalk.bold("Examples:")}
${APP_NAME} --export session.jsonl output.html
${chalk.bold("Environment Variables:")}
ANTHROPIC_AUTH_TOKEN - Anthropic bearer auth token
ANTHROPIC_API_KEY - Anthropic Claude API key
ANTHROPIC_OAUTH_TOKEN - Anthropic OAuth token (alternative to API key)
ANT_LING_API_KEY - Ant Ling API key
+12 -10
View File
@@ -176,7 +176,9 @@ export type AgentSessionEvent =
source: "compaction";
reason: "manual" | "threshold" | "overflow";
}
| { type: "summarization_retry_finished" };
| { type: "summarization_retry_finished" }
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
| { type: "bash_execution_update"; id?: string; delta: string };
/** Listener function for agent session events */
export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
@@ -403,7 +405,7 @@ export class AgentSession {
}
private async _getRequiredRequestAuth(model: Model<any>): Promise<{
apiKey: string;
apiKey?: string;
headers?: Record<string, string>;
env?: Record<string, string>;
}> {
@@ -417,7 +419,7 @@ export class AgentSession {
}
throw error;
}
if (result?.auth.apiKey) {
if (result && (result.auth.apiKey || result.auth.headers)) {
return {
apiKey: result.auth.apiKey,
headers: withoutDeletedHeaders(result.auth.headers),
@@ -2055,11 +2057,7 @@ export class AgentSession {
let headers: Record<string, string> | undefined;
let env: Record<string, string> | undefined;
if (this.agent.streamFunction === streamSimple) {
const authResult = await this._modelRuntime.getAuth(this.model);
if (!authResult?.auth.apiKey) return false;
apiKey = authResult.auth.apiKey;
headers = withoutDeletedHeaders(authResult.auth.headers);
env = authResult.env;
({ apiKey, headers, env } = await this._getRequiredRequestAuth(this.model));
} else {
({ apiKey, headers, env } = await this._getSummarizationRequestAuth(this.model));
}
@@ -2760,12 +2758,13 @@ export class AgentSession {
* @param command The bash command to execute
* @param onChunk Optional streaming callback for output
* @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix)
* @param options.id Optional identifier included in bash execution update events
* @param options.operations Custom BashOperations for remote execution
*/
async executeBash(
command: string,
onChunk?: (chunk: string) => void,
options?: { excludeFromContext?: boolean; operations?: BashOperations },
options?: { excludeFromContext?: boolean; id?: string; operations?: BashOperations },
): Promise<BashResult> {
this._bashAbortController = new AbortController();
@@ -2780,7 +2779,10 @@ export class AgentSession {
this.sessionManager.getCwd(),
options?.operations ?? createLocalBashOperations({ shellPath }),
{
onChunk,
onChunk: (delta) => {
onChunk?.(delta);
this._emit({ type: "bash_execution_update", id: options?.id, delta });
},
signal: this._bashAbortController.signal,
},
);
@@ -19,6 +19,7 @@ import type {
Api,
AssistantMessageEvent,
AssistantMessageEventStream,
ConstrainedSamplingConfig,
Context,
ImageContent,
Model,
@@ -452,6 +453,8 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
promptGuidelines?: string[];
/** Parameter schema (TypeBox) */
parameters: TParams;
/** Optional provider-side constrained sampling request for this tool. Set false to explicitly disable it, equivalent to leaving it undefined. */
constrainedSampling?: false | ConstrainedSamplingConfig;
/** Controls whether ToolExecutionComponent renders the standard colored shell or the tool renders its own framing. */
renderShell?: "default" | "self";
@@ -1126,6 +1129,8 @@ export interface SessionBeforeTreeResult {
export interface MessageRenderOptions {
expanded: boolean;
/** Horizontal padding configured by the outputPad setting. */
outputPad: number;
}
export interface EntryRenderOptions {
@@ -97,6 +97,7 @@ const OpenAICompletionsCompatSchema = Type.Object({
cacheControlFormat: Type.Optional(Type.Literal("anthropic")),
openRouterRouting: Type.Optional(OpenRouterRoutingSchema),
vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema),
supportsOpenAIGrammarTools: Type.Optional(Type.Boolean()),
supportsStrictMode: Type.Optional(Type.Boolean()),
sendSessionAffinityHeaders: Type.Optional(Type.Boolean()),
deferredToolsMode: Type.Optional(Type.Literal("kimi")),
@@ -112,6 +113,8 @@ const OpenAIResponsesCompatSchema = Type.Object({
Type.Union([Type.Literal("openai"), Type.Literal("openai-nosession"), Type.Literal("openrouter")]),
),
supportsLongCacheRetention: Type.Optional(Type.Boolean()),
supportsStrictMode: Type.Optional(Type.Boolean()),
supportsOpenAIGrammarTools: Type.Optional(Type.Boolean()),
supportsToolSearch: Type.Optional(Type.Boolean()),
});
@@ -120,7 +123,10 @@ const AnthropicMessagesCompatSchema = Type.Object({
supportsLongCacheRetention: Type.Optional(Type.Boolean()),
sendSessionAffinityHeaders: Type.Optional(Type.Boolean()),
supportsCacheControlOnTools: Type.Optional(Type.Boolean()),
supportsTemperature: Type.Optional(Type.Boolean()),
forceAdaptiveThinking: Type.Optional(Type.Boolean()),
allowEmptySignature: Type.Optional(Type.Boolean()),
supportsStrictTools: Type.Optional(Type.Boolean()),
supportsToolReferences: Type.Optional(Type.Boolean()),
});
@@ -25,8 +25,8 @@ export class ModelRegistry {
}
/** Reload models.json asynchronously. Await before making synchronous registry reads. */
refresh(): Promise<void> {
return this.runtime.reloadConfig();
async refresh(): Promise<void> {
await this.runtime.refresh();
}
getError(): string | undefined {
@@ -260,6 +260,7 @@ export function parseModelPattern(
*/
export interface ModelScopeDiagnostic {
type: "warning";
code: "no-match" | "invalid-thinking-level";
message: string;
pattern: string;
}
@@ -293,6 +294,14 @@ export async function resolveModelScopeWithDiagnostics(
}
}
const exactMatch = findExactModelReferenceMatch(globPattern, availableModels);
if (exactMatch) {
if (!scopedModels.find((sm) => modelsAreEqual(sm.model, exactMatch))) {
scopedModels.push({ model: exactMatch, thinkingLevel });
}
continue;
}
// Match against "provider/modelId" format OR just model ID
// This allows "*sonnet*" to match without requiring "anthropic/*sonnet*"
const matchingModels = availableModels.filter((m) => {
@@ -301,7 +310,12 @@ export async function resolveModelScopeWithDiagnostics(
});
if (matchingModels.length === 0) {
diagnostics.push({ type: "warning", message: `No models match pattern "${pattern}"`, pattern });
diagnostics.push({
type: "warning",
code: "no-match",
message: `No models match pattern "${pattern}"`,
pattern,
});
continue;
}
@@ -316,11 +330,16 @@ export async function resolveModelScopeWithDiagnostics(
const { model, thinkingLevel, warning } = parseModelPattern(pattern, availableModels);
if (warning) {
diagnostics.push({ type: "warning", message: warning, pattern });
diagnostics.push({ type: "warning", code: "invalid-thinking-level", message: warning, pattern });
}
if (!model) {
diagnostics.push({ type: "warning", message: `No models match pattern "${pattern}"`, pattern });
diagnostics.push({
type: "warning",
code: "no-match",
message: `No models match pattern "${pattern}"`,
pattern,
});
continue;
}
@@ -140,18 +140,13 @@ export class ModelRuntime implements Models {
(modelsPath
? new FileModelsStore(options.modelsStorePath ?? join(dirname(modelsPath), "models-store.json"))
: new InMemoryCodingAgentModelsStore());
const builtinModelDataGeneratedAt = builtinProviderCatalog.getBuiltinModelDataGeneratedAt();
const providers = builtinProviderCatalog
.builtinProviders()
.map((provider) =>
provider.id === "radius"
? provider
: withRemoteCatalog(
provider,
options.catalogBaseUrl,
builtinProviderCatalog.getBuiltinModelDataUrl(
provider.id as builtinProviderCatalog.BuiltinProvider,
),
),
: withRemoteCatalog(provider, options.catalogBaseUrl, builtinModelDataGeneratedAt),
);
const runtime = new ModelRuntime(
credentials,
@@ -518,14 +513,10 @@ export class ModelRuntime implements Models {
await this.refresh({ allowNetwork: this.modelNetworkEnabled });
}
async reloadConfig(): Promise<void> {
async refresh(options: ModelsRefreshOptions = {}): Promise<ModelsRefreshResult> {
this.config = await ModelConfig.load(this.modelsPath);
this.configureRadiusProviders();
this.rebuildProviders();
await this.refresh({ allowNetwork: this.modelNetworkEnabled });
}
async refresh(options: ModelsRefreshOptions = {}): Promise<ModelsRefreshResult> {
const refreshOptions = {
...options,
allowNetwork: options.allowNetwork ?? this.modelNetworkEnabled,
@@ -1,4 +1,3 @@
import { stat } from "node:fs/promises";
import type { Api, Model, ModelsStoreEntry, Provider } from "@earendil-works/pi-ai";
import { VERSION } from "../config.ts";
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
@@ -32,13 +31,10 @@ function parseCatalog(providerId: string, value: unknown): Model<Api>[] {
function remoteModels(
entry: ModelsStoreEntry | undefined,
localLastModified: number | undefined,
localGeneratedAt: number | undefined,
): readonly Model<Api>[] {
if (!entry) return [];
if (
localLastModified !== undefined &&
(entry.lastModified === undefined || entry.lastModified <= localLastModified)
) {
if (localGeneratedAt !== undefined && (entry.lastModified === undefined || entry.lastModified <= localGeneratedAt)) {
return [];
}
return entry.models;
@@ -48,7 +44,7 @@ function remoteModels(
export function withRemoteCatalog(
provider: Provider,
catalogBaseUrl: string = DEFAULT_CATALOG_BASE_URL,
localCatalogUrl?: URL,
localGeneratedAt?: number,
): Provider {
let dynamicModels: readonly Model<Api>[] = [];
let inflightRefresh: Promise<void> | undefined;
@@ -59,16 +55,8 @@ export function withRemoteCatalog(
refreshModels: (context) => {
inflightRefresh ??= (async () => {
try {
const localLastModified = localCatalogUrl
? await stat(localCatalogUrl).then(
(value) => value.mtimeMs,
() => undefined,
)
: undefined;
const stored = await context.store.read();
dynamicModels = remoteModels(stored, localLastModified).filter(
(model) => model.provider === provider.id,
);
dynamicModels = remoteModels(stored, localGeneratedAt).filter((model) => model.provider === provider.id);
if (!context.allowNetwork || context.signal?.aborted) return;
if (
!context.force &&
@@ -79,21 +67,38 @@ export function withRemoteCatalog(
return;
}
// Only revalidate when a cached body backs the validator, so a 304 can never
// leave the overlay empty.
const validator = stored?.models.length ? stored.etag : undefined;
const url = new URL(`/api/models/providers/${encodeURIComponent(provider.id)}`, catalogBaseUrl);
const response = await fetch(url, {
headers: {
accept: "application/json",
"User-Agent": getPiUserAgent(VERSION),
...(validator ? { "if-none-match": validator } : {}),
},
signal: context.signal,
});
if (context.signal?.aborted) return;
const checkedAt = Date.now();
// Unchanged: dynamicModels already holds the stored overlay, so only the
// freshness window moves.
if (response.status === 304 && stored) {
await context.store.write({ ...stored, checkedAt });
return;
}
if (response.status === 404 || response.status === 501) {
await context.store.write({ ...(stored ?? { models: [] }), checkedAt, lastModified: 0 });
await context.store.write({
...(stored ?? { models: [] }),
checkedAt,
lastModified: 0,
etag: undefined,
});
return;
}
if (!response.ok) {
// Transient failure: the cached body and its validator stay valid, so keep the
// etag and let the next refresh revalidate instead of downloading the catalog.
await context.store.write({ ...(stored ?? { models: [] }), checkedAt });
throw new Error(`Model catalog request failed for ${provider.id}: ${response.status}`);
}
@@ -104,8 +109,9 @@ export function withRemoteCatalog(
models: refreshed,
checkedAt,
lastModified: Number.isNaN(lastModified) ? 0 : lastModified,
etag: response.headers.get("etag") ?? undefined,
};
dynamicModels = remoteModels(entry, localLastModified);
dynamicModels = remoteModels(entry, localGeneratedAt);
await context.store.write(entry);
} finally {
inflightRefresh = undefined;
@@ -70,6 +70,9 @@ function loadContextFileFromDir(dir: string): { path: string; content: string }
const filePath = join(dir, filename);
if (existsSync(filePath)) {
try {
if (!statSync(filePath).isFile()) {
continue;
}
return {
path: filePath,
content: readFileSync(filePath, "utf-8"),
@@ -11,6 +11,7 @@ export function wrapToolDefinition<TDetails = unknown>(
label: definition.label,
description: definition.description,
parameters: definition.parameters,
constrainedSampling: definition.constrainedSampling,
prepareArguments: definition.prepareArguments,
executionMode: definition.executionMode,
execute: (toolCallId, params, signal, onUpdate, ctx?: ExtensionContext) =>
@@ -38,6 +39,7 @@ export function createToolDefinitionFromAgentTool(tool: AgentTool<any>): ToolDef
label: tool.label,
description: tool.description,
parameters: tool.parameters as any,
constrainedSampling: tool.constrainedSampling,
prepareArguments: tool.prepareArguments,
executionMode: tool.executionMode,
execute: async (toolCallId, params, signal, onUpdate) => tool.execute(toolCallId, params, signal, onUpdate),
@@ -12,8 +12,6 @@ import { LlamaClient, type LlamaModelInfo, llamaInferenceUrl, normalizeLlamaServ
export const LLAMA_PROVIDER_ID = "llama.cpp";
export const DEFAULT_LLAMA_SERVER_URL = "http://127.0.0.1:8080";
const DEFAULT_MAX_TOKENS = 16384;
function credentialServerUrl(credential: ApiKeyCredential | undefined): string | undefined {
const value = credential?.env?.LLAMA_BASE_URL;
return typeof value === "string" && value.trim() ? normalizeLlamaServerUrl(value) : undefined;
@@ -40,7 +38,7 @@ function toPiModel(model: LlamaModelInfo, serverUrl: string): Model<"openai-comp
input: model.architecture?.input_modalities?.includes("image") ? ["text", "image"] : ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow,
maxTokens: Math.min(DEFAULT_MAX_TOKENS, contextWindow),
maxTokens: contextWindow,
compat: {
supportsStore: false,
supportsDeveloperRole: false,
@@ -113,11 +111,20 @@ export function createLlamaProvider(): LlamaProviderController {
},
getModels: () => models,
refreshModels: async (context: RefreshModelsContext): Promise<void> => {
const stored = await context.store.read();
if (stored) {
models = stored.models.filter(
(model): model is Model<"openai-completions"> =>
model.provider === LLAMA_PROVIDER_ID && model.api === "openai-completions",
);
}
if (!context.allowNetwork || context.signal?.aborted || context.credential?.type !== "api_key") return;
const serverUrl = credentialServerUrl(context.credential);
if (!serverUrl) return;
const catalog = await new LlamaClient(serverUrl, context.credential.key).list({ signal: context.signal });
setCatalog(catalog, serverUrl);
if (!context.signal?.aborted) await context.store.write({ models, checkedAt: Date.now() });
},
stream: (model, context, options) => stream(model, context, options as ProviderStreamOptions | undefined),
streamSimple: (model, context, options) => streamSimple(model, context, options),
@@ -16,16 +16,19 @@ export class CustomMessageComponent extends Container {
private customComponent?: Component;
private markdownTheme: MarkdownTheme;
private _expanded = false;
private outputPad: number;
constructor(
message: CustomMessage<unknown>,
customRenderer?: MessageRenderer,
markdownTheme: MarkdownTheme = getMarkdownTheme(),
outputPad = 1,
) {
super();
this.message = message;
this.customRenderer = customRenderer;
this.markdownTheme = markdownTheme;
this.outputPad = outputPad;
this.addChild(new Spacer(1));
@@ -42,6 +45,13 @@ export class CustomMessageComponent extends Container {
}
}
setOutputPad(outputPad: number): void {
if (this.outputPad !== outputPad) {
this.outputPad = outputPad;
this.rebuild();
}
}
override invalidate(): void {
super.invalidate();
this.rebuild();
@@ -58,7 +68,11 @@ export class CustomMessageComponent extends Container {
// Try custom renderer first - it handles its own styling
if (this.customRenderer) {
try {
const component = this.customRenderer(this.message, { expanded: this._expanded }, theme);
const component = this.customRenderer(
this.message,
{ expanded: this._expanded, outputPad: this.outputPad },
theme,
);
if (component) {
// Custom renderer provides its own styled component
this.customComponent = component;
@@ -36,7 +36,7 @@ function enableAll(enabledIds: EnabledIds, allIds: string[], targetIds?: string[
for (const id of targets) {
if (!result.includes(id)) result.push(id);
}
return result.length === allIds.length ? null : result;
return result.length === allIds.length && result.every((id) => allIds.includes(id)) ? null : result;
}
function clearAll(enabledIds: EnabledIds, allIds: string[], targetIds?: string[]): EnabledIds {
@@ -67,7 +67,7 @@ function getSortedIds(enabledIds: EnabledIds, allIds: string[]): string[] {
interface ModelItem {
fullId: string;
model: Model<any>;
model: Model<any> | undefined;
enabled: boolean;
}
@@ -152,20 +152,20 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl
}
private buildItems(): ModelItem[] {
// Filter out IDs that no longer have a corresponding model (e.g., after logout)
return getSortedIds(this.enabledIds, this.allIds)
.filter((id) => this.modelsById.has(id))
.map((id) => ({
fullId: id,
model: this.modelsById.get(id)!,
enabled: isEnabled(this.enabledIds, id),
}));
return getSortedIds(this.enabledIds, this.allIds).map((id) => ({
fullId: id,
model: this.modelsById.get(id),
enabled: isEnabled(this.enabledIds, id),
}));
}
private getFooterText(): string {
const enabledCount = this.enabledIds?.length ?? this.allIds.length;
const enabledCount = this.enabledIds?.filter((id) => this.modelsById.has(id)).length ?? this.allIds.length;
const unavailableCount = this.enabledIds?.filter((id) => !this.modelsById.has(id)).length ?? 0;
const allEnabled = this.enabledIds === null;
const countText = allEnabled ? "all enabled" : `${enabledCount}/${this.allIds.length} enabled`;
const countText = allEnabled
? "all enabled"
: `${enabledCount}/${this.allIds.length} enabled${unavailableCount ? ` · ${unavailableCount} unavailable` : ""}`;
const parts = [
`${keyText("tui.select.confirm")} toggle`,
`${keyText("app.models.enableAll")} all`,
@@ -184,8 +184,10 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl
const query = this.searchInput.getValue();
const items = this.buildItems();
this.filteredItems = query
? fuzzyFilter(items, query, (i) =>
getModelSearchText({ id: i.model.id, provider: i.model.provider, name: i.model.name }),
? fuzzyFilter(items, query, (item) =>
item.model
? getModelSearchText({ id: item.model.id, provider: item.model.provider, name: item.model.name })
: item.fullId,
)
: items;
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1));
@@ -216,9 +218,16 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl
const item = this.filteredItems[i]!;
const isSelected = i === this.selectedIndex;
const prefix = isSelected ? theme.fg("accent", "→ ") : " ";
const modelText = isSelected ? theme.fg("accent", item.model.id) : item.model.id;
const providerBadge = theme.fg("muted", ` [${item.model.provider}]`);
const status = allEnabled ? "" : item.enabled ? theme.fg("success", " ✓") : theme.fg("dim", " ✗");
const id = item.model?.id ?? item.fullId;
const modelText = isSelected ? theme.fg("accent", id) : id;
const providerBadge = theme.fg("muted", item.model ? ` [${item.model.provider}]` : " [unavailable]");
const status = item.model
? allEnabled
? ""
: item.enabled
? theme.fg("success", " ✓")
: theme.fg("dim", " ✗")
: theme.fg("dim", " ✗");
this.listContainer.addChild(new Text(`${prefix}${modelText}${providerBadge}${status}`, 0, 0));
}
@@ -232,7 +241,13 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl
if (this.filteredItems.length > 0) {
const selected = this.filteredItems[this.selectedIndex];
this.listContainer.addChild(new Spacer(1));
this.listContainer.addChild(new Text(theme.fg("muted", ` Model Name: ${selected.model.name}`), 0, 0));
this.listContainer.addChild(
new Text(
theme.fg("muted", ` ${selected.model ? `Model Name: ${selected.model.name}` : "Model unavailable"}`),
0,
0,
),
);
}
}
@@ -310,7 +325,7 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl
// Toggle provider of current item
if (kb.matches(data, "app.models.toggleProvider")) {
const item = this.filteredItems[this.selectedIndex];
if (item) {
if (item?.model) {
const provider = item.model.provider;
const providerIds = this.allIds.filter((id) => this.modelsById.get(id)!.provider === provider);
const allEnabled = providerIds.every((id) => isEnabled(this.enabledIds, id));
@@ -76,7 +76,12 @@ import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/
import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/http-dispatcher.ts";
import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.ts";
import { createCompactionSummaryMessage } from "../../core/messages.ts";
import { defaultModelPerProvider, findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.ts";
import {
defaultModelPerProvider,
findExactModelReferenceMatch,
resolveModelScope,
resolveModelScopeWithDiagnostics,
} from "../../core/model-resolver.ts";
import { DefaultPackageManager } from "../../core/package-manager.ts";
import type { ResourceDiagnostic } from "../../core/resource-loader.ts";
import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts";
@@ -2985,6 +2990,10 @@ export class InteractiveMode {
this.ui.requestRender();
break;
case "bash_execution_update":
// The bash execution callback handles TUI output rendering.
break;
case "tool_execution_start": {
let component = this.pendingTools.get(event.toolCallId);
if (!component) {
@@ -3233,7 +3242,12 @@ export class InteractiveMode {
case "custom": {
if (message.display) {
const renderer = this.session.extensionRunner.getMessageRenderer(message.customType);
const component = new CustomMessageComponent(message, renderer, this.getMarkdownThemeWithSettings());
const component = new CustomMessageComponent(
message,
renderer,
this.getMarkdownThemeWithSettings(),
this.outputPad,
);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
}
@@ -4248,7 +4262,11 @@ export class InteractiveMode {
this.outputPad = padding;
if (this.streamingComponent || this.session.isStreaming) {
for (const child of this.chatContainer.children) {
if (child instanceof AssistantMessageComponent || child instanceof UserMessageComponent) {
if (
child instanceof AssistantMessageComponent ||
child instanceof CustomMessageComponent ||
child instanceof UserMessageComponent
) {
child.setOutputPad(padding);
}
}
@@ -4459,14 +4477,20 @@ export class InteractiveMode {
// Get all available models
await this.session.modelRuntime.refresh();
const allModels = [...(await this.session.modelRuntime.getAvailable())];
const allModelIds = new Set(allModels.map((model) => `${model.provider}/${model.id}`));
const configuredPatterns = this.settingsManager.getEnabledModels();
const sessionScopedModels = this.session.scopedModels;
if (allModels.length === 0) {
if (allModels.length === 0 && !configuredPatterns?.length && sessionScopedModels.length === 0) {
this.showStatus("No models available");
return;
}
const configuredScope = configuredPatterns?.length
? await resolveModelScopeWithDiagnostics(configuredPatterns, this.session.modelRuntime)
: undefined;
// Check if session has scoped models (from previous session-only changes or CLI --models)
const sessionScopedModels = this.session.scopedModels;
const hasSessionScope = sessionScopedModels.length > 0;
// Build enabled model IDs from session state or settings
@@ -4475,19 +4499,25 @@ export class InteractiveMode {
if (hasSessionScope) {
// Use current session's scoped models
currentEnabledIds = sessionScopedModels.map((scoped) => `${scoped.model.provider}/${scoped.model.id}`);
} else {
// Fall back to settings
const patterns = this.settingsManager.getEnabledModels();
if (patterns !== undefined && patterns.length > 0) {
const scopedModels = await resolveModelScope(patterns, this.session.modelRuntime);
currentEnabledIds = scopedModels.map((scoped) => `${scoped.model.provider}/${scoped.model.id}`);
}
} else if (configuredScope) {
currentEnabledIds = configuredScope.scopedModels.map(
(scoped) => `${scoped.model.provider}/${scoped.model.id}`,
);
}
for (const diagnostic of configuredScope?.diagnostics ?? []) {
if (diagnostic.code !== "no-match") continue;
currentEnabledIds ??= [];
if (!currentEnabledIds.includes(diagnostic.pattern)) currentEnabledIds.push(diagnostic.pattern);
}
// Helper to update session's scoped models (session-only, no persist)
const updateSessionModels = async (enabledIds: string[] | null) => {
currentEnabledIds = enabledIds === null ? null : [...enabledIds];
if (enabledIds && enabledIds.length > 0 && enabledIds.length < allModels.length) {
const hasEnabledAvailableModel = enabledIds?.some((id) => allModelIds.has(id)) ?? false;
const allAvailableModelsEnabled =
enabledIds !== null && [...allModelIds].every((id) => enabledIds.includes(id));
if (enabledIds && hasEnabledAvailableModel && !allAvailableModelsEnabled) {
const newScopedModels = await resolveModelScope(enabledIds, this.session.modelRuntime);
this.session.setScopedModels(
newScopedModels.map((sm) => ({
@@ -4515,10 +4545,11 @@ export class InteractiveMode {
},
onPersist: (enabledIds) => {
// Persist to settings
const newPatterns =
enabledIds === null || enabledIds.length === allModels.length
? undefined // All enabled = clear filter
: enabledIds;
const allEnabled =
enabledIds !== null &&
enabledIds.length === allModels.length &&
enabledIds.every((id) => allModelIds.has(id));
const newPatterns = enabledIds === null || allEnabled ? undefined : enabledIds;
this.settingsManager.setEnabledModels(newPatterns ? [...newPatterns] : undefined);
this.showStatus("Model selection saved to settings");
},
@@ -558,6 +558,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
case "bash": {
const result = await session.executeBash(command.command, undefined, {
excludeFromContext: command.excludeFromContext,
id,
});
return success(id, "bash", result);
}
+18 -8
View File
@@ -104,15 +104,25 @@ export async function copyToClipboard(text: string): Promise<void> {
try {
// Verify wl-copy exists (spawn errors are async and won't be caught)
execSync("which wl-copy", { stdio: "ignore" });
// wl-copy with execSync hangs due to fork behavior; use spawn instead
const proc = spawn("wl-copy", [], { stdio: ["pipe", "ignore", "ignore"] });
proc.stdin.on("error", () => {
// Ignore EPIPE errors if wl-copy exits early
// wl-copy with execSync hangs due to fork behavior; use spawn instead.
// Await the exit code and only claim success on a clean exit, so a
// failed wl-copy falls through to the xclip/OSC 52 fallbacks.
const wlCopyExit = await new Promise<number>((resolve) => {
const proc = spawn("wl-copy", [], { stdio: ["pipe", "ignore", "ignore"] });
proc.on("error", () => resolve(1));
proc.on("close", (code) => resolve(code ?? 1));
proc.stdin.on("error", () => {
// Ignore EPIPE errors if wl-copy exits early
});
proc.stdin.write(text);
proc.stdin.end();
});
proc.stdin.write(text);
proc.stdin.end();
proc.unref();
copied = true;
if (wlCopyExit === 0) {
copied = true;
} else if (hasX11Display) {
copyToX11Clipboard(options);
copied = true;
}
} catch {
if (hasX11Display) {
copyToX11Clipboard(options);
@@ -0,0 +1,44 @@
import { Text } from "@earendil-works/pi-tui";
import { describe, expect, test } from "vitest";
import type { MessageRenderer, MessageRenderOptions } from "../src/core/extensions/types.ts";
import type { CustomMessage } from "../src/core/messages.ts";
import { CustomMessageComponent } from "../src/modes/interactive/components/custom-message.ts";
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../src/utils/ansi.ts";
describe("CustomMessageComponent", () => {
test("provides output padding to custom renderers and updates it", () => {
initTheme("dark");
const optionsSeen: MessageRenderOptions[] = [];
const renderer: MessageRenderer = (_message, options) => {
optionsSeen.push(options);
return new Text("custom", options.outputPad, 0);
};
const message: CustomMessage = {
role: "custom",
customType: "test",
content: "custom",
display: true,
timestamp: Date.now(),
};
const component = new CustomMessageComponent(message, renderer, undefined, 1);
expect(optionsSeen).toEqual([{ expanded: false, outputPad: 1 }]);
expect(
component
.render(40)
.map(stripAnsi)
.some((line) => line.startsWith(" custom")),
).toBe(true);
component.setOutputPad(0);
expect(optionsSeen.at(-1)).toEqual({ expanded: false, outputPad: 0 });
expect(
component
.render(40)
.map(stripAnsi)
.some((line) => line.startsWith("custom")),
).toBe(true);
});
});
@@ -1,7 +1,7 @@
import { once } from "node:events";
import { createServer, type RequestListener, type Server, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import type { AuthContext, AuthPrompt } from "@earendil-works/pi-ai";
import type { AuthContext, AuthPrompt, ModelsStoreEntry } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import { createEventBus } from "../src/core/event-bus.ts";
import { createExtensionRuntime, loadExtensionFromFactory } from "../src/core/extensions/loader.ts";
@@ -67,7 +67,7 @@ describe("llama.cpp extension", () => {
id: "loaded",
status: { value: "loaded", args: ["llama-server", "--n-gpu-layers", "999"] },
architecture: { input_modalities: ["text", "image"] },
meta: { n_ctx: 16384, n_ctx_train: 131072 },
meta: { n_ctx: 65536, n_ctx_train: 131072 },
},
{ id: "unloaded", status: { value: "unloaded" } },
{ id: "loading", status: { value: "loading" } },
@@ -79,13 +79,57 @@ describe("llama.cpp extension", () => {
expect.objectContaining({
id: "loaded",
baseUrl: "http://localhost:8080/v1",
contextWindow: 16384,
maxTokens: 16384,
contextWindow: 65536,
maxTokens: 65536,
input: ["text", "image"],
}),
]);
});
it("persists and restores loaded models for cache-only startup refreshes", async () => {
let cachedEntry: ModelsStoreEntry | undefined;
const store = {
read: async () => cachedEntry,
write: async (entry: ModelsStoreEntry) => {
cachedEntry = structuredClone(entry);
},
delete: async () => {
cachedEntry = undefined;
},
};
const { url } = await listen((request, response) => {
if (request.url === "/models") {
json(response, {
data: [
{ id: "loaded", status: { value: "loaded" }, meta: { n_ctx: 32768 } },
{ id: "unloaded", status: { value: "unloaded" } },
],
});
return;
}
response.writeHead(404).end();
});
const first = createLlamaProvider();
await first.provider.refreshModels?.({
credential: { type: "api_key", key: "local", env: { LLAMA_BASE_URL: url } },
store,
allowNetwork: true,
});
expect(first.provider.getModels().map((model) => model.id)).toEqual(["loaded"]);
expect(cachedEntry?.models.map((model) => model.id)).toEqual(["loaded"]);
const second = createLlamaProvider();
await second.provider.refreshModels?.({
credential: { type: "api_key", key: "local", env: { LLAMA_BASE_URL: url } },
store,
allowNetwork: false,
});
expect(second.provider.getModels()).toEqual([
expect.objectContaining({ id: "loaded", baseUrl: `${url}/v1`, contextWindow: 32768 }),
]);
});
it("stays dormant until configured and stores URL plus optional key", async () => {
const { provider } = createLlamaProvider();
const auth = provider.auth.apiKey!;
@@ -225,11 +225,13 @@ describe("resolveModelScopeWithDiagnostics", () => {
{
type: "warning",
message: 'Invalid thinking level "invalid" in pattern "gpt-4o:invalid". Using default instead.',
code: "invalid-thinking-level",
pattern: "gpt-4o:invalid",
},
{
type: "warning",
message: 'No models match pattern "missing"',
code: "no-match",
pattern: "missing",
},
]);
@@ -255,6 +257,53 @@ describe("resolveModelScopeWithDiagnostics", () => {
warn.mockRestore();
}
});
test("resolves bracketed model ids as exact references before glob matching", async () => {
const bracketedModel: Model<"anthropic-messages"> = {
id: "bracketed-model[1m]",
name: "Bracketed Model",
api: "anthropic-messages",
provider: "custom",
baseUrl: "https://example.invalid",
reasoning: true,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 },
contextWindow: 128000,
maxTokens: 8192,
};
const registry = {
getAvailable: () => [...allModels, bracketedModel],
} as unknown as Parameters<typeof resolveModelScopeWithDiagnostics>[1];
const result = await resolveModelScopeWithDiagnostics(["custom/bracketed-model[1m]"], registry);
expect(result.scopedModels.map((scoped) => scoped.model.id)).toEqual(["bracketed-model[1m]"]);
expect(result.diagnostics).toEqual([]);
});
test("resolves bracketed model ids with thinking levels as exact references before glob matching", async () => {
const bracketedModel: Model<"anthropic-messages"> = {
id: "bracketed-model[1m]",
name: "Bracketed Model",
api: "anthropic-messages",
provider: "custom",
baseUrl: "https://example.invalid",
reasoning: true,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 },
contextWindow: 128000,
maxTokens: 8192,
};
const registry = {
getAvailable: () => [...allModels, bracketedModel],
} as unknown as Parameters<typeof resolveModelScopeWithDiagnostics>[1];
const result = await resolveModelScopeWithDiagnostics(["custom/bracketed-model[1m]:high"], registry);
expect(result.scopedModels.map((scoped) => scoped.model.id)).toEqual(["bracketed-model[1m]"]);
expect(result.scopedModels[0].thinkingLevel).toBe("high");
expect(result.diagnostics).toEqual([]);
});
});
describe("resolveCliModel", () => {
@@ -1,4 +1,3 @@
import { statSync } from "node:fs";
import {
createProvider,
InMemoryModelsStore,
@@ -25,7 +24,7 @@ function model(id: string): Model<"openai-completions"> {
};
}
function testProvider(localCatalogUrl?: URL) {
function testProvider(localGeneratedAt?: number) {
return withRemoteCatalog(
createProvider({
id: "test-provider",
@@ -41,7 +40,7 @@ function testProvider(localCatalogUrl?: URL) {
},
}),
"https://pi.dev",
localCatalogUrl,
localGeneratedAt,
);
}
@@ -80,19 +79,18 @@ describe("remote catalog provider", () => {
});
it("prefers the newer of the generated and remote catalogs", async () => {
const localCatalogUrl = new URL(import.meta.url);
const localMtime = statSync(localCatalogUrl).mtimeMs;
const newerHeader = new Date(localMtime + 60_000).toUTCString();
const localGeneratedAt = Date.parse("2026-07-23T10:00:00.000Z");
const newerHeader = new Date(localGeneratedAt + 60_000).toUTCString();
const responses = [
new Response(JSON.stringify({ old: model("old") }), {
headers: { "last-modified": new Date(localMtime - 60_000).toUTCString() },
headers: { "last-modified": new Date(localGeneratedAt - 60_000).toUTCString() },
}),
new Response(JSON.stringify({ newer: model("newer") }), {
headers: { "last-modified": newerHeader },
}),
];
vi.spyOn(globalThis, "fetch").mockImplementation(async () => responses.shift() as Response);
const provider = testProvider(localCatalogUrl);
const provider = testProvider(localGeneratedAt);
const store = new InMemoryModelsStore();
const refresh = { credential: { type: "api_key" } as const, store: scopedStore(store), allowNetwork: true };
@@ -104,6 +102,76 @@ describe("remote catalog provider", () => {
expect(await store.read(provider.id)).toMatchObject({ lastModified: Date.parse(newerHeader) });
});
it("revalidates a stored catalog with its etag and keeps the overlay on 304", async () => {
const responses = [
new Response(JSON.stringify({ dynamic: model("dynamic") }), {
headers: { "content-type": "application/json", etag: '"catalog-1"' },
}),
new Response(null, { status: 304, headers: { etag: '"catalog-1"' } }),
];
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => responses.shift() as Response);
const provider = testProvider();
const store = new InMemoryModelsStore();
const refresh = { credential: { type: "api_key" } as const, store: scopedStore(store), allowNetwork: true };
await provider.refreshModels?.(refresh);
expect(fetchSpy.mock.calls[0]?.[1]?.headers).not.toHaveProperty("if-none-match");
expect(await store.read(provider.id)).toMatchObject({ etag: '"catalog-1"' });
const checkedAt = (await store.read(provider.id))?.checkedAt;
await provider.refreshModels?.({ ...refresh, force: true });
expect(fetchSpy.mock.calls[1]?.[1]?.headers).toMatchObject({ "if-none-match": '"catalog-1"' });
expect(provider.getModels().map((entry) => entry.id)).toEqual(["static", "dynamic"]);
const stored = await store.read(provider.id);
expect(stored?.models.map((entry) => entry.id)).toEqual(["dynamic"]);
expect(stored?.etag).toBe('"catalog-1"');
expect(stored?.checkedAt).toBeGreaterThanOrEqual(checkedAt ?? 0);
});
it("drops a stale etag when the overlay becomes unavailable", async () => {
const responses = [
new Response(JSON.stringify({ dynamic: model("dynamic") }), {
headers: { "content-type": "application/json", etag: '"catalog-1"' },
}),
new Response("not implemented", { status: 501 }),
];
vi.spyOn(globalThis, "fetch").mockImplementation(async () => responses.shift() as Response);
const provider = testProvider();
const store = new InMemoryModelsStore();
const refresh = { credential: { type: "api_key" } as const, store: scopedStore(store), allowNetwork: true };
await provider.refreshModels?.(refresh);
await provider.refreshModels?.({ ...refresh, force: true });
expect((await store.read(provider.id))?.etag).toBeUndefined();
});
it("keeps the etag and overlay after a transient failure", async () => {
const responses = [
new Response(JSON.stringify({ dynamic: model("dynamic") }), {
headers: { "content-type": "application/json", etag: '"catalog-1"' },
}),
new Response("rate limited", { status: 429 }),
new Response(null, { status: 304, headers: { etag: '"catalog-1"' } }),
];
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => responses.shift() as Response);
const provider = testProvider();
const store = new InMemoryModelsStore();
const refresh = { credential: { type: "api_key" } as const, store: scopedStore(store), allowNetwork: true };
await provider.refreshModels?.(refresh);
await expect(provider.refreshModels?.({ ...refresh, force: true })).rejects.toThrow(/429/);
const stored = await store.read(provider.id);
expect(stored?.etag).toBe('"catalog-1"');
expect(stored?.models.map((entry) => entry.id)).toEqual(["dynamic"]);
await provider.refreshModels?.({ ...refresh, force: true });
expect(fetchSpy.mock.calls[2]?.[1]?.headers).toMatchObject({ "if-none-match": '"catalog-1"' });
expect(provider.getModels().map((entry) => entry.id)).toEqual(["static", "dynamic"]);
});
it("treats unimplemented pi.dev catalog routes as an unavailable overlay", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("not implemented", { status: 501 }));
const provider = testProvider();
@@ -2,7 +2,7 @@ import { mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "nod
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ExtensionRunner } from "../src/core/extensions/runner.ts";
import { DefaultResourceLoader } from "../src/core/resource-loader.ts";
@@ -355,6 +355,22 @@ Content`,
expect(agentsFiles.some((f) => f.path.includes("AGENTS.md"))).toBe(true);
});
it("should ignore context file candidates that are directories", async () => {
mkdirSync(join(cwd, "AGENTS.md"));
writeFileSync(join(cwd, "CLAUDE.md"), "Fallback instructions");
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
const loader = new DefaultResourceLoader({ cwd, agentDir });
await loader.reload();
expect(loader.getAgentsFiles().agentsFiles).toContainEqual({
path: join(cwd, "CLAUDE.md"),
content: "Fallback instructions",
});
expect(consoleError).not.toHaveBeenCalledWith(expect.stringContaining(join(cwd, "AGENTS.md")));
consoleError.mockRestore();
});
it("should skip AGENTS.md and CLAUDE.md discovery when noContextFiles is true", async () => {
writeFileSync(join(cwd, "AGENTS.md"), "# Project Guidelines\n\nBe helpful.");
writeFileSync(join(cwd, "CLAUDE.md"), "# Claude Guidelines\n\nBe helpful.");
@@ -12,7 +12,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { createAgentSession } from "../src/core/sdk.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
import { type Settings, SettingsManager } from "../src/core/settings-manager.ts";
import { createModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
@@ -76,7 +76,7 @@ describe("createAgentSession stream options", () => {
async function captureStreamOptions(
api: Api,
settings: { httpIdleTimeoutMs?: number; websocketConnectTimeoutMs?: number },
settings: Partial<Settings>,
requestOptions: SimpleStreamOptions = {},
extensionSource?: string,
): Promise<SimpleStreamOptions | undefined> {
@@ -161,6 +161,15 @@ describe("createAgentSession stream options", () => {
expect(options?.websocketConnectTimeoutMs).toBe(0);
});
it("forwards provider retry settings", async () => {
const options = await captureStreamOptions("openai-completions", {
retry: { provider: { maxRetries: 2, maxRetryDelayMs: 3000 } },
});
expect(options?.maxRetries).toBe(2);
expect(options?.maxRetryDelayMs).toBe(3000);
});
it("runs before_provider_headers on assembled headers without forwarding the transform", async () => {
const options = await captureStreamOptions(
"openai-completions",
@@ -239,4 +239,35 @@ describe("AgentSession bash and persistence characterization", () => {
expect(result.output).toContain("hello from custom ops");
expect(harness.session.messages[harness.session.messages.length - 1]?.role).toBe("bashExecution");
});
it("streams bash output to the callback and session events", async () => {
const harness = await createHarness();
harnesses.push(harness);
const callbackDeltas: string[] = [];
const eventUpdates: Array<{ id: string | undefined; delta: string }> = [];
const unsubscribe = harness.session.subscribe((event) => {
if (event.type === "bash_execution_update") {
eventUpdates.push({ id: event.id, delta: event.delta });
}
});
const operations: BashOperations = {
exec: async (_command, _cwd, options) => {
options.onData(Buffer.from("hello "));
options.onData(Buffer.from("world"));
return { exitCode: 0 };
},
};
await harness.session.executeBash("custom", (delta) => callbackDeltas.push(delta), {
id: "bash-1",
operations,
});
unsubscribe();
expect(callbackDeltas).toEqual(["hello ", "world"]);
expect(eventUpdates).toEqual([
{ id: "bash-1", delta: "hello " },
{ id: "bash-1", delta: "world" },
]);
});
});
@@ -175,6 +175,41 @@ describe("AgentSession compaction characterization", () => {
expect(getStreamCallCount()).toBe(1);
});
it("manually compacts with provider-resolved bearer auth", async () => {
const harness = await createHarness({ withConfiguredAuth: false });
harnesses.push(harness);
const model = harness.getModel();
harness.session.modelRuntime.registerNativeProvider({
id: model.provider,
name: "Faux bearer provider",
auth: {
apiKey: {
name: "Faux bearer token",
resolve: async () => ({
auth: { headers: { Authorization: "Bearer ambient-token" } },
source: "ambient bearer token",
}),
},
},
getModels: () => harness.models,
stream: () => createAssistantMessageEventStream(),
streamSimple: () => createAssistantMessageEventStream(),
});
seedCompactableSession(harness);
harness.setResponses([
(_context, options) => {
expect(options?.apiKey).toBeUndefined();
expect(options?.headers).toEqual({ Authorization: "Bearer ambient-token" });
return fauxAssistantMessage("summary with bearer auth");
},
]);
const result = await harness.session.compact();
expect(result.summary).toContain("summary with bearer auth");
expect(harness.faux.state.callCount).toBe(1);
});
it("persists usage from pi-generated manual compaction", async () => {
const harness = await createHarness({ withConfiguredAuth: false });
harnesses.push(harness);
+8 -3
View File
@@ -1,9 +1,9 @@
import { createInMemoryModelRegistry, getModelRuntime } from "../model-runtime-test-utils.ts";
import { createInMemoryModelRegistry, createModelRegistry, getModelRuntime } from "../model-runtime-test-utils.ts";
/**
* Local test harness for the new coding-agent test suite.
*/
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AgentMessage, AgentTool } from "@earendil-works/pi-agent-core";
@@ -71,6 +71,7 @@ export interface HarnessOptions {
resourceLoader?: ResourceLoader;
extensionFactories?: Array<InlineExtension | CreateTestExtensionsResultInput>;
withConfiguredAuth?: boolean;
modelsJson?: Record<string, unknown>;
}
export interface Harness {
@@ -115,7 +116,11 @@ export async function createHarness(options: HarnessOptions = {}): Promise<Harne
if (withConfiguredAuth) {
await authStorage.modify(model.provider, async () => ({ type: "api_key", key: "faux-key" }));
}
const modelRegistry = await createInMemoryModelRegistry(authStorage);
const modelsPath = options.modelsJson === undefined ? undefined : join(tempDir, "models.json");
if (modelsPath) writeFileSync(modelsPath, JSON.stringify(options.modelsJson));
const modelRegistry = modelsPath
? await createModelRegistry(authStorage, modelsPath)
: await createInMemoryModelRegistry(authStorage);
if (withConfiguredAuth) {
modelRegistry.registerProvider(model.provider, {
baseUrl: model.baseUrl,
@@ -0,0 +1,160 @@
import type { Api, Model } from "@earendil-works/pi-ai";
import { setKeybindings } from "@earendil-works/pi-tui";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { KeybindingsManager } from "../../../src/core/keybindings.ts";
import { ScopedModelsSelectorComponent } from "../../../src/modes/interactive/components/scoped-models-selector.ts";
import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts";
import { initTheme } from "../../../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../../../src/utils/ansi.ts";
import { createHarness, type Harness } from "../harness.ts";
function createInteractiveContext(options: {
allModels: Model<Api>[];
enabledModelIds: string[];
scopedModels?: Array<{ model: Model<Api> }>;
}) {
let selector: ScopedModelsSelectorComponent | undefined;
const setScopedModels = vi.fn();
const getAvailable = vi.fn().mockResolvedValue(options.allModels);
const context = {
session: {
modelRuntime: {
refresh: vi.fn(),
getAvailable,
},
scopedModels: options.scopedModels ?? [],
setScopedModels,
},
settingsManager: {
getEnabledModels: () => options.enabledModelIds,
setEnabledModels: vi.fn(),
},
showStatus: vi.fn(),
showSelector: (factory: (done: () => void) => { component: ScopedModelsSelectorComponent }) => {
selector = factory(() => {}).component;
},
updateAvailableProviderCount: vi.fn(),
ui: { requestRender: vi.fn() },
};
return { context, getAvailable, getSelector: () => selector, setScopedModels };
}
async function showModelsSelector(context: object): Promise<void> {
const show = Reflect.get(InteractiveMode.prototype, "showModelsSelector") as (this: object) => Promise<void>;
await show.call(context);
}
describe("issue #6949 unavailable scoped models", () => {
const harnesses: Harness[] = [];
beforeAll(() => {
initTheme("dark");
});
beforeEach(() => {
setKeybindings(new KeybindingsManager());
});
afterEach(() => {
while (harnesses.length > 0) harnesses.pop()?.cleanup();
});
it("shows and removes an enabled model without a catalog entry", async () => {
const harness = await createHarness({ models: [{ id: "available", name: "Available" }] });
harnesses.push(harness);
const availableId = `${harness.models[0].provider}/${harness.models[0].id}`;
const unavailableId = `${harness.models[0].provider}/unavailable`;
const changes: Array<string[] | null> = [];
const persisted: Array<string[] | null> = [];
const selector = new ScopedModelsSelectorComponent(
{
allModels: [...harness.models],
enabledModelIds: [unavailableId, availableId],
},
{
onChange: (enabledIds) => {
changes.push(enabledIds);
},
onPersist: (enabledIds) => {
persisted.push(enabledIds);
},
onCancel: () => {},
},
);
expect(stripAnsi(selector.render(100).join("\n"))).toContain(`${unavailableId} [unavailable] ✗`);
selector.handleInput("\r");
expect(changes).toEqual([[availableId]]);
selector.handleInput("\x13");
expect(persisted).toEqual([[availableId]]);
});
it("passes unmatched settings patterns to the selector with one combined resolution", async () => {
const harness = await createHarness({ models: [{ id: "available", name: "Available" }] });
harnesses.push(harness);
const unavailableIds = ["unavailable-one", "unavailable-two"].map((id) => `${harness.models[0].provider}/${id}`);
const { context, getAvailable, getSelector } = createInteractiveContext({
allModels: [],
enabledModelIds: unavailableIds,
});
await showModelsSelector(context);
const selector = getSelector();
if (!selector) throw new Error("Expected scoped-model selector to open");
const rendered = stripAnsi(selector.render(100).join("\n"));
for (const unavailableId of unavailableIds) {
expect(rendered).toContain(`${unavailableId} [unavailable] ✗`);
}
expect(getAvailable).toHaveBeenCalledTimes(2);
});
it("opens when only a session-scoped model is unavailable", async () => {
const harness = await createHarness({ models: [{ id: "unavailable", name: "Unavailable" }] });
harnesses.push(harness);
const model = harness.models[0];
const fullId = `${model.provider}/${model.id}`;
const { context, getSelector } = createInteractiveContext({
allModels: [],
enabledModelIds: [],
scopedModels: [{ model }],
});
await showModelsSelector(context);
const selector = getSelector();
if (!selector) throw new Error("Expected scoped-model selector to open");
expect(stripAnsi(selector.render(100).join("\n"))).toContain(`${fullId} [unavailable] ✗`);
});
it("does not clear a partial scope when an enabled model is unavailable", async () => {
const harness = await createHarness({
models: [
{ id: "one", name: "One" },
{ id: "two", name: "Two" },
{ id: "three", name: "Three" },
],
});
harnesses.push(harness);
const [one, two] = harness.models;
const enabledIds = [one, two].map((model) => `${model.provider}/${model.id}`);
const unavailableId = `${one.provider}/unavailable`;
const { context, getSelector, setScopedModels } = createInteractiveContext({
allModels: [...harness.models],
enabledModelIds: [...enabledIds, unavailableId],
scopedModels: [{ model: one }, { model: two }],
});
await showModelsSelector(context);
const selector = getSelector();
if (!selector) throw new Error("Expected scoped-model selector to open");
selector.handleInput("\x1b[1;3B");
await vi.waitFor(() => {
expect(setScopedModels).toHaveBeenLastCalledWith([
{ model: two, thinkingLevel: undefined },
{ model: one, thinkingLevel: undefined },
]);
});
});
});
@@ -0,0 +1,68 @@
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { setKeybindings, type TUI } from "@earendil-works/pi-tui";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { KeybindingsManager } from "../../../src/core/keybindings.ts";
import { ModelSelectorComponent } from "../../../src/modes/interactive/components/model-selector.ts";
import { initTheme } from "../../../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../../../src/utils/ansi.ts";
import { createHarness, type Harness } from "../harness.ts";
function createFakeTui(): TUI {
return {
requestRender: () => {},
} as unknown as TUI;
}
function modelsJson(provider: string, model: string): Record<string, unknown> {
return {
providers: {
[provider]: {
baseUrl: "https://example.test/v1",
api: "openai-completions",
apiKey: "test-key",
models: [{ id: model }],
},
},
};
}
describe("issue #6999 models.json hot reload", () => {
let harness: Harness | undefined;
beforeAll(() => {
initTheme("dark");
});
beforeEach(() => {
setKeybindings(new KeybindingsManager());
});
afterEach(() => {
harness?.cleanup();
harness = undefined;
});
it("reloads models.json when opening /model", async () => {
harness = await createHarness({ modelsJson: modelsJson("old-provider", "old-model") });
expect(harness.session.modelRuntime.getModel("old-provider", "old-model")).toBeDefined();
writeFileSync(join(harness.tempDir, "models.json"), JSON.stringify(modelsJson("new-provider", "new-model")));
const selector = new ModelSelectorComponent(
createFakeTui(),
harness.getModel(),
harness.settingsManager,
harness.session.modelRuntime,
[],
() => {},
() => {},
);
await vi.waitFor(() => {
const rendered = stripAnsi(selector.render(120).join("\n"));
expect(rendered).toContain("new-model [new-provider]");
expect(rendered).toContain("Model catalogs refreshed.");
});
expect(harness.session.modelRuntime.getModel("old-provider", "old-model")).toBeUndefined();
});
});
+25 -35
View File
@@ -1,38 +1,28 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
import { defineConfig, mergeConfig } from "vitest/config";
import baseConfig, { workspaceSourcePaths } from "../../vitest.base.ts";
const aiSrcIndex = fileURLToPath(new URL("../ai/src/index.ts", import.meta.url));
const aiSrcCompat = fileURLToPath(new URL("../ai/src/compat.ts", import.meta.url));
const aiSrcOAuth = fileURLToPath(new URL("../ai/src/oauth.ts", import.meta.url));
const aiSrcProviders = fileURLToPath(new URL("../ai/src/providers", 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: {
globals: true,
environment: "node",
testTimeout: 30000,
reporters: process.env.GITHUB_ACTIONS ? ["dot", "github-actions"] : ["dot"],
silent: "passed-only",
server: {
deps: {
external: [/@silvia-odwyer\/photon-node/],
export default mergeConfig(
baseConfig,
defineConfig({
test: {
globals: true,
environment: "node",
testTimeout: 30000,
reporters: process.env.GITHUB_ACTIONS ? ["dot", "github-actions"] : ["dot"],
silent: "passed-only",
server: {
deps: {
external: [/@silvia-odwyer\/photon-node/],
},
},
},
},
resolve: {
alias: [
{ find: /^@earendil-works\/pi-ai$/, replacement: aiSrcIndex },
{ find: /^@earendil-works\/pi-ai\/compat$/, replacement: aiSrcCompat },
{ find: /^@earendil-works\/pi-ai\/oauth$/, replacement: aiSrcOAuth },
{ find: /^@earendil-works\/pi-ai\/providers\/(.+)$/, replacement: `${aiSrcProviders}/$1.ts` },
{ 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 },
],
},
});
resolve: {
alias: [
{ find: /^@mariozechner\/pi-ai$/, replacement: workspaceSourcePaths.aiIndex },
{ find: /^@mariozechner\/pi-ai\/oauth$/, replacement: workspaceSourcePaths.aiOAuth },
{ find: /^@mariozechner\/pi-agent-core$/, replacement: workspaceSourcePaths.agentIndex },
{ find: /^@mariozechner\/pi-tui$/, replacement: workspaceSourcePaths.tuiIndex },
],
},
}),
);