Merge remote-tracking branch 'origin/main'

This commit is contained in:
Mario Zechner 2026-09-22 13:56:53 +02:00
commit d201760ffe
57 changed files with 719 additions and 198 deletions

80
package-lock.json generated
View file

@ -5280,12 +5280,12 @@
},
"packages/agent": {
"name": "@earendil-works/pi-agent-core",
"version": "0.86.1",
"version": "0.87.0",
"license": "MIT",
"dependencies": {
"@earendil-works/chord": "^0.86.1",
"@earendil-works/pi-ai": "^0.86.1",
"@earendil-works/pi-telemetry": "^0.86.1",
"@earendil-works/chord": "^0.87.0",
"@earendil-works/pi-ai": "^0.87.0",
"@earendil-works/pi-telemetry": "^0.87.0",
"diff": "8.0.4",
"ignore": "7.0.8",
"typebox": "1.3.27",
@ -5310,12 +5310,12 @@
},
"packages/ai": {
"name": "@earendil-works/pi-ai",
"version": "0.86.1",
"version": "0.87.0",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "0.124.0",
"@aws-sdk/client-bedrock-runtime": "3.1127.0",
"@earendil-works/pi-telemetry": "^0.86.1",
"@earendil-works/pi-telemetry": "^0.87.0",
"@google/genai": "2.21.0",
"@smithy/node-http-handler": "4.12.1",
"http-proxy-agent": "9.1.0",
@ -5361,7 +5361,7 @@
},
"packages/chord": {
"name": "@earendil-works/chord",
"version": "0.86.1",
"version": "0.87.0",
"license": "MIT",
"dependencies": {
"esbuild": "0.28.2"
@ -5376,11 +5376,11 @@
},
"packages/client": {
"name": "@earendil-works/pi-client",
"version": "0.86.1",
"version": "0.87.0",
"license": "MIT",
"dependencies": {
"@earendil-works/chord": "^0.86.1",
"@earendil-works/pi-protocol": "^0.86.1"
"@earendil-works/chord": "^0.87.0",
"@earendil-works/pi-protocol": "^0.87.0"
},
"devDependencies": {
"shx": "0.4.0",
@ -5392,13 +5392,13 @@
},
"packages/coding-agent": {
"name": "@earendil-works/pi-coding-agent",
"version": "0.86.1",
"version": "0.87.0",
"license": "MIT",
"dependencies": {
"@earendil-works/chord": "^0.86.1",
"@earendil-works/pi-agent-core": "^0.86.1",
"@earendil-works/pi-ai": "^0.86.1",
"@earendil-works/pi-tui": "^0.86.1",
"@earendil-works/chord": "^0.87.0",
"@earendil-works/pi-agent-core": "^0.87.0",
"@earendil-works/pi-ai": "^0.87.0",
"@earendil-works/pi-tui": "^0.87.0",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "6.0.0",
"cross-spawn": "7.0.6",
@ -5419,9 +5419,9 @@
"pi": "dist/bundle/cli.js"
},
"devDependencies": {
"@earendil-works/pi-client": "^0.86.1",
"@earendil-works/pi-protocol": "^0.86.1",
"@earendil-works/pi-server": "^0.86.1",
"@earendil-works/pi-client": "^0.87.0",
"@earendil-works/pi-protocol": "^0.87.0",
"@earendil-works/pi-server": "^0.87.0",
"@types/cross-spawn": "6.0.6",
"@types/hosted-git-info": "3.0.5",
"@types/node": "22.19.19",
@ -5438,32 +5438,32 @@
},
"packages/coding-agent/examples/extensions/custom-provider-anthropic": {
"name": "pi-extension-custom-provider-anthropic",
"version": "0.86.1",
"version": "0.87.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.86.1"
"version": "0.87.0"
},
"packages/coding-agent/examples/extensions/gondolin": {
"name": "pi-extension-gondolin",
"version": "0.86.1",
"version": "0.87.0",
"dependencies": {
"@earendil-works/gondolin": "0.12.0"
}
},
"packages/coding-agent/examples/extensions/sandbox": {
"name": "pi-extension-sandbox",
"version": "1.16.1",
"version": "1.17.0",
"dependencies": {
"@anthropic-ai/sandbox-runtime": "0.0.26"
}
},
"packages/coding-agent/examples/extensions/with-deps": {
"name": "pi-extension-with-deps",
"version": "0.86.1",
"version": "0.87.0",
"dependencies": {
"ms": "2.1.3"
},
@ -5517,11 +5517,11 @@
},
"packages/durable": {
"name": "@earendil-works/pi-durable",
"version": "0.86.1",
"version": "0.87.0",
"license": "MIT",
"dependencies": {
"@earendil-works/chord": "^0.86.1",
"@earendil-works/pi-ai": "^0.86.1"
"@earendil-works/chord": "^0.87.0",
"@earendil-works/pi-ai": "^0.87.0"
},
"devDependencies": {
"shx": "0.4.0",
@ -5533,10 +5533,10 @@
},
"packages/evals": {
"name": "@earendil-works/pi-evals",
"version": "0.86.1",
"version": "0.87.0",
"devDependencies": {
"@earendil-works/pi-ai": "^0.86.1",
"@earendil-works/pi-coding-agent": "^0.86.1",
"@earendil-works/pi-ai": "^0.87.0",
"@earendil-works/pi-coding-agent": "^0.87.0",
"@types/node": "22.19.19",
"@vitest-evals/core": "0.15.0",
"autoevals": "0.3.0",
@ -5548,10 +5548,10 @@
},
"packages/protocol": {
"name": "@earendil-works/pi-protocol",
"version": "0.86.1",
"version": "0.87.0",
"license": "MIT",
"dependencies": {
"@earendil-works/chord": "^0.86.1",
"@earendil-works/chord": "^0.87.0",
"typebox": "1.3.27"
},
"devDependencies": {
@ -5564,12 +5564,12 @@
},
"packages/server": {
"name": "@earendil-works/pi-server",
"version": "0.86.1",
"version": "0.87.0",
"license": "MIT",
"dependencies": {
"@earendil-works/chord": "^0.86.1",
"@earendil-works/pi-agent-core": "^0.86.1",
"@earendil-works/pi-protocol": "^0.86.1"
"@earendil-works/chord": "^0.87.0",
"@earendil-works/pi-agent-core": "^0.87.0",
"@earendil-works/pi-protocol": "^0.87.0"
},
"devDependencies": {
"shx": "0.4.0",
@ -5581,11 +5581,11 @@
},
"packages/session-backends/sqlite-node": {
"name": "@earendil-works/pi-session-backend-sqlite-node",
"version": "0.86.1",
"version": "0.87.0",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-agent-core": "^0.86.1",
"@earendil-works/pi-ai": "^0.86.1"
"@earendil-works/pi-agent-core": "^0.87.0",
"@earendil-works/pi-ai": "^0.87.0"
},
"devDependencies": {
"@vitest/coverage-v8": "4.1.9",
@ -5597,7 +5597,7 @@
},
"packages/telemetry": {
"name": "@earendil-works/pi-telemetry",
"version": "0.86.1",
"version": "0.87.0",
"license": "MIT",
"devDependencies": {
"@types/node": "22.19.19",
@ -5609,7 +5609,7 @@
},
"packages/tui": {
"name": "@earendil-works/pi-tui",
"version": "0.86.1",
"version": "0.87.0",
"license": "MIT",
"dependencies": {
"get-east-asian-width": "1.6.0",

View file

@ -2,6 +2,8 @@
## [Unreleased]
## [0.87.0] - 2026-09-21
### Breaking Changes
- Removed `AgentOptions.shouldStopAfterTurn` and `AgentLoopConfig.shouldStopAfterTurn`. Use `finishTurn` and return `{ action: "end" }` to stop after the completed turn:

View file

@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-agent-core",
"version": "0.86.1",
"version": "0.87.0",
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
"type": "module",
"main": "./dist/index.js",
@ -59,9 +59,9 @@
"prepublishOnly": "npm run build"
},
"dependencies": {
"@earendil-works/chord": "^0.86.1",
"@earendil-works/pi-ai": "^0.86.1",
"@earendil-works/pi-telemetry": "^0.86.1",
"@earendil-works/chord": "^0.87.0",
"@earendil-works/pi-ai": "^0.87.0",
"@earendil-works/pi-telemetry": "^0.87.0",
"diff": "8.0.4",
"ignore": "7.0.8",
"typebox": "1.3.27",

View file

@ -4,6 +4,16 @@
### Added
- Added Grok 4.7 to the built-in xAI model catalog with long-context pricing metadata.
### Fixed
- Fixed image-only user messages being rejected by some OpenAI-compatible providers because they included an empty text part ([#9797](https://github.com/earendil-works/pi/issues/9797))
## [0.87.0] - 2026-09-21
### Added
- Added model image-input limit and cache-safe resize metadata to the generated catalog ([#9631](https://github.com/earendil-works/pi/issues/9631)).
### Fixed

View file

@ -833,7 +833,7 @@ Many models support thinking/reasoning capabilities where they can show their in
const model = models.getModel('anthropic', 'claude-sonnet-4-5')!;
// or models.getModel('openai', 'gpt-5-mini');
// or models.getModel('google', 'gemini-2.5-flash');
// or models.getModel('xai', 'grok-4.6');
// or models.getModel('xai', 'grok-4.7');
// Check if model supports reasoning
if (model.reasoning) {

View file

@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-ai",
"version": "0.86.1",
"version": "0.87.0",
"description": "Unified LLM API with automatic model discovery and provider configuration",
"type": "module",
"main": "./dist/index.js",
@ -66,7 +66,7 @@
"dependencies": {
"@anthropic-ai/sdk": "0.124.0",
"@aws-sdk/client-bedrock-runtime": "3.1127.0",
"@earendil-works/pi-telemetry": "^0.86.1",
"@earendil-works/pi-telemetry": "^0.87.0",
"@google/genai": "2.21.0",
"@smithy/node-http-handler": "4.12.1",
"http-proxy-agent": "9.1.0",

View file

@ -2030,12 +2030,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
compat: { ...XAI_RESPONSES_COMPAT },
reasoning: m.reasoning === true,
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
cost: {
input: m.cost?.input || 0,
output: m.cost?.output || 0,
cacheRead: m.cost?.cache_read || 0,
cacheWrite: m.cost?.cache_write || 0,
},
cost: getModelsDevCost(m.cost),
contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096,
});

View file

@ -1257,21 +1257,23 @@ export function convertMessages(
content: sanitizeSurrogates(msg.content),
});
} else {
const content: ChatCompletionContentPart[] = msg.content.map((item): ChatCompletionContentPart => {
if (item.type === "text") {
return {
type: "text",
text: sanitizeSurrogates(item.text),
} satisfies ChatCompletionContentPartText;
} else {
return {
type: "image_url",
image_url: {
url: `data:${item.mimeType};base64,${item.data}`,
},
} satisfies ChatCompletionContentPartImage;
}
});
const content: ChatCompletionContentPart[] = msg.content
.filter((item) => item.type !== "text" || item.text.length > 0)
.map((item): ChatCompletionContentPart => {
if (item.type === "text") {
return {
type: "text",
text: sanitizeSurrogates(item.text),
} satisfies ChatCompletionContentPartText;
} else {
return {
type: "image_url",
image_url: {
url: `data:${item.mimeType};base64,${item.data}`,
},
} satisfies ChatCompletionContentPartImage;
}
});
if (content.length === 0) continue;
params.push({
role: "user",

View file

@ -8,6 +8,8 @@ it("derives model API, ID, and provider literals from grouped model data", () =>
expectTypeOf(XAI_MODELS["grok-4.5"].provider).toEqualTypeOf<"xai">();
expectTypeOf(XAI_MODELS["grok-4.6"].api).toEqualTypeOf<"openai-responses">();
expectTypeOf(XAI_MODELS["grok-4.6"].id).toEqualTypeOf<"grok-4.6">();
expectTypeOf(XAI_MODELS["grok-4.7"].api).toEqualTypeOf<"openai-responses">();
expectTypeOf(XAI_MODELS["grok-4.7"].id).toEqualTypeOf<"grok-4.7">();
expectTypeOf(XAI_MODELS["grok-4.3"].api).toEqualTypeOf<"openai-responses">();
});

View file

@ -69,6 +69,35 @@ function buildEmptyToolResult(toolCallId: string, timestamp: number): ToolResult
}
describe("openai-completions convertMessages", () => {
// Regression test for https://github.com/earendil-works/pi/issues/9797
it("omits empty text parts from user messages with images", () => {
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini");
const model: Model<"openai-completions"> = {
...baseModel,
api: "openai-completions",
input: ["text", "image"],
};
const context = normalizeContext({
messages: [
{
role: "user",
content: [
{ type: "text", text: "" },
{ type: "image", data: "ZmFrZQ==", mimeType: "image/png" },
],
timestamp: Date.now(),
},
],
});
expect(convertMessages(model, context, compat)).toEqual([
{
role: "user",
content: [{ type: "image_url", image_url: { url: "data:image/png;base64,ZmFrZQ==" } }],
},
]);
});
it("batches tool-result images after consecutive tool results", () => {
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini");
const model: Model<"openai-completions"> = {

View file

@ -540,8 +540,8 @@ describe("Generate E2E Tests", () => {
});
});
describe.skipIf(!process.env.XAI_API_KEY)("xAI Provider (grok-4.3 via OpenAI Responses)", () => {
const llm = getModel("xai", "grok-4.3");
describe.skipIf(!process.env.XAI_API_KEY)("xAI Provider (grok-4.7 via OpenAI Responses)", () => {
const llm = getModel("xai", "grok-4.7");
it("should complete basic text generation", { retry: 3 }, async () => {
await basicTextGeneration(llm);

View file

@ -132,9 +132,35 @@ describe("xAI Responses provider", () => {
}
expect(getSupportedThinkingLevels(XAI_MODELS["grok-4.5"])).toEqual(["low", "medium", "high"]);
expect(getSupportedThinkingLevels(XAI_MODELS["grok-4.6"])).toEqual(["low", "medium", "high", "xhigh"]);
expect(getSupportedThinkingLevels(XAI_MODELS["grok-4.7"])).toEqual(["low", "medium", "high", "xhigh"]);
expect(getSupportedThinkingLevels(XAI_MODELS["grok-4.3"])).toEqual(["off", "low", "medium", "high"]);
});
it("includes Grok 4.7 capabilities and long-context pricing", () => {
expect(XAI_MODELS["grok-4.7"]).toMatchObject({
api: "openai-responses",
reasoning: true,
input: ["text", "image"],
contextWindow: 500000,
maxTokens: 500000,
cost: {
input: 2,
output: 6,
cacheRead: 0.5,
cacheWrite: 0,
tiers: [
{
inputTokensAbove: 200000,
input: 4,
output: 12,
cacheRead: 1,
cacheWrite: 0,
},
],
},
});
});
it("uses /responses with bearer auth and xAI-compatible request fields", async () => {
const captured = await captureRequest(
XAI_MODELS["grok-4.5"],
@ -188,9 +214,9 @@ describe("xAI Responses provider", () => {
expect(captured.body).not.toHaveProperty("reasoning");
});
it("uses /responses for Grok 4.6 with xhigh effort and encrypted reasoning", async () => {
it("uses /responses for Grok 4.7 with xhigh effort and encrypted reasoning", async () => {
const captured = await captureRequest(
XAI_MODELS["grok-4.6"],
XAI_MODELS["grok-4.7"],
{
systemPrompt: "You are a careful coding assistant.",
messages: [{ role: "user", content: "hello", timestamp: 1 }],
@ -203,7 +229,7 @@ describe("xAI Responses provider", () => {
expect(captured.url).toBe("https://api.x.ai/v1/responses");
expect(captured.body).toMatchObject({
model: "grok-4.6",
model: "grok-4.7",
store: false,
stream: true,
reasoning: { effort: "xhigh" },

View file

@ -1,6 +1,6 @@
{
"name": "@earendil-works/chord",
"version": "0.86.1",
"version": "0.87.0",
"description": "Application composition runtime for services, replicated state, RPC, and plugins",
"type": "module",
"main": "./dist/index.js",

View file

@ -2,6 +2,8 @@
## [Unreleased]
## [0.87.0] - 2026-09-21
## [0.86.1] - 2026-09-20
## [0.86.0] - 2026-09-19

View file

@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-client",
"version": "0.86.1",
"version": "0.87.0",
"description": "Transport-neutral client for remote pi sessions over framed CBOR bytes",
"type": "module",
"main": "./dist/index.js",
@ -47,8 +47,8 @@
"node": ">=22.19.0"
},
"dependencies": {
"@earendil-works/chord": "^0.86.1",
"@earendil-works/pi-protocol": "^0.86.1"
"@earendil-works/chord": "^0.87.0",
"@earendil-works/pi-protocol": "^0.87.0"
},
"devDependencies": {
"shx": "0.4.0",

View file

@ -2,8 +2,20 @@
## [Unreleased]
### Changed
- Changed the default xAI model to Grok 4.7.
### Fixed
- Fixed missing or invalid `--mode` values being silently ignored instead of reporting an error and exiting with a nonzero status ([#9045](https://github.com/earendil-works/pi/issues/9045)).
## [0.87.0] - 2026-09-21
### New Features
- **Canonical session context and extension boundaries** — Edit model context without rewriting history and add actionable lifecycle hooks. See [ContextEditEntry](docs/session-format.md#contexteditentry) and [extension events](docs/extensions.md#extension-events).
- **Full-transcript context extensions** — Use `context_with_system` for per-request system-message transformations. See [`context_with_system`](docs/extensions.md#context_with_system).
- **Per-model image input limits** — Configure cache-safe image resizing per model for attachments, `read`, and tool-result images. See [Image Input Limits](docs/models.md#image-input-limits).
### Breaking Changes
@ -19,6 +31,7 @@
- Added append-only model-context edits. For example, `sessionManager.appendContextEdit(entryId, null)` omits one message from future provider context without changing raw history, usage, or UI history.
- Added actionable `turn_end` and `agent_before_settle` extension boundaries. Return `{ entries: [...event.entries, draft], continue: true }` to persist structural entries in order and ensure one next provider request without changing steering or follow-up scheduling.
- Added retain-none compaction input: `sessionManager.appendCompaction(summary, null, tokensBefore)` stores the compaction's own ID as its kept boundary.
- Added the `context_with_system` extension event, which runs after `context` handlers on the full transcript including system messages and sends its result verbatim. See [`context_with_system`](docs/extensions.md#context_with_system).
- Added per-model image resize profiles through `inputLimits.images.resize` in `models.json`, applied to file attachments, image reads, and tool-result images ([#9631](https://github.com/earendil-works/pi/issues/9631)).
### Fixed
@ -27,11 +40,12 @@
- Fixed context-invisible boundary metadata and replacement edits causing newly appended or replaced input to be summarized before its first provider request.
- Fixed edited-context accounting both discarding valid assistant usage captured after the latest context edit and reusing that usage after a later compaction made it stale.
- Fixed selected error retries and final length/overflow recovery retaining abandoned model attempts in future provider context; post-run recovery omissions are now persisted without hiding raw transcript history or changing queue scheduling.
- Fixed `/bug` allowing uploads in offline mode while preserving local zip exports.
- Fixed `context` handlers that filter or slice messages dropping the prompt and tool declarations, which after extension-driven compaction left requests without built-in tools or made Codex emit raw tool-call text. Handlers no longer see system messages; Pi restores the prompt and tool state after they run. See [`context`](docs/extensions.md#context) ([#9789](https://github.com/earendil-works/pi/issues/9789), [#9822](https://github.com/earendil-works/pi/issues/9822)).
- Fixed `/bug` allowing uploads in offline mode while preserving local zip exports ([#9841](https://github.com/earendil-works/pi/pull/9841) by [@christianklotz](https://github.com/christianklotz)).
- Fixed idle prompt-cache warming rebuilding expired caches when its timer or an extension decision is delayed.
- Improved crash diagnostics with hints identifying loaded extensions that appear in the stack trace.
- Fixed text files beginning with `GIF` being misclassified as images and omitted from `read` and CLI `@file` input ([#9755](https://github.com/earendil-works/pi/issues/9755)).
- Fixed malformed prompt template frontmatter being silently ignored instead of reported as a resource warning ([#9830](https://github.com/earendil-works/pi/pull/9830)).
- Fixed malformed prompt template frontmatter being silently ignored instead of reported as a resource warning ([#9830](https://github.com/earendil-works/pi/pull/9830) by [@christianklotz](https://github.com/christianklotz)).
- Fixed inherited unknown OpenAI-compatible Chat Completions endpoints receiving strict tool schemas unless they explicitly advertise support ([#9816](https://github.com/earendil-works/pi/issues/9816)).
## [0.86.1] - 2026-09-20

View file

@ -294,7 +294,8 @@ user sends prompt ────────────────────
│ ┌─── turn (repeats while LLM calls tools) ───┐ │
│ │ │ │
│ ├─► turn_start │ │
│ ├─► context (can modify messages) │ │
│ ├─► context (can modify conversation messages) │
│ ├─► context_with_system (can modify the full transcript)
│ ├─► before_provider_headers (can mutate headers) |
│ ├─► before_provider_request (can inspect or replace payload)
│ ├─► after_provider_response (status + headers, before stream consume)
@ -725,12 +726,31 @@ Fired before each LLM call. Modify messages non-destructively. See [Session Form
```typescript
pi.on("context", async (event, ctx) => {
// event.messages - deep copy, safe to modify
// event.messages - deep copy without system messages, safe to modify
const filtered = event.messages.filter(m => !shouldPrune(m));
return { messages: filtered };
});
```
`event.messages` holds the conversation without system messages. The prompt and tool declarations belong to Pi and are not part of this hook: when the handler returns a changed list, Pi replays the current prompt sections and tool declarations into one leading system message ahead of the returned messages. Filtering, windowing, or slicing from a compaction summary therefore cannot drop the prompt or the tools. An unchanged list keeps mid-conversation system messages in place, so models that accept them retain their cached prefix. System messages a handler adds are kept after Pi's head. To change the prompt or the tool set durably, use [`before_agent_start`](#before_agent_start) or `pi.setActiveTools()`; to edit system messages for one request, use [`context_with_system`](#context_with_system).
#### context_with_system
Fired before each LLM call, after every `context` handler has run and Pi has restored the prompt and tool state. `event.messages` is the full transcript, including the leading system message and any mid-conversation prompt or tool patches (see [Session Format](session-format.md#sessionmessageentry)). The returned messages are sent as they are: this hook owns the prompt and tool declarations for the request.
```typescript
import { getCurrentSystemMessage } from "@earendil-works/pi-ai";
pi.on("context_with_system", async (event, ctx) => {
const cut = findCutIndex(event.messages);
// Fold the dropped prefix so its prompt and tool state survives as the new head.
const head = getCurrentSystemMessage(event.messages.slice(0, cut));
return { messages: head ? [head, ...event.messages.slice(cut)] : event.messages.slice(cut) };
});
```
Rules: keep a system message at index 0 (providers read the prompt and initial tool declarations there; Pi reports an error if a handler drops it). Removing a system message removes the tool declarations and section patches it carries. Check your output with `getCurrentSystemPrompt()` and `getCurrentTools()` from `@earendil-works/pi-ai`. Handlers run in extension load order; a `systemPrompt` forced from `before_agent_start` is still projected onto the request afterwards.
#### before_provider_headers
Fired after the outgoing HTTP headers are assembled. Use it to add, override, or remove request headers.

View file

@ -1,12 +1,12 @@
{
"name": "pi-extension-custom-provider",
"version": "0.86.1",
"version": "0.87.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-custom-provider",
"version": "0.86.1",
"version": "0.87.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.52.0"
}

View file

@ -1,7 +1,7 @@
{
"name": "pi-extension-custom-provider-anthropic",
"private": true,
"version": "0.86.1",
"version": "0.87.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",

View file

@ -1,7 +1,7 @@
{
"name": "pi-extension-custom-provider-gitlab-duo",
"private": true,
"version": "0.86.1",
"version": "0.87.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",

View file

@ -1,12 +1,12 @@
{
"name": "pi-extension-gondolin",
"version": "0.86.1",
"version": "0.87.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-gondolin",
"version": "0.86.1",
"version": "0.87.0",
"dependencies": {
"@earendil-works/gondolin": "0.12.0"
}

View file

@ -1,7 +1,7 @@
{
"name": "pi-extension-gondolin",
"private": true,
"version": "0.86.1",
"version": "0.87.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",

View file

@ -1,12 +1,12 @@
{
"name": "pi-extension-sandbox",
"version": "1.16.1",
"version": "1.17.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-sandbox",
"version": "1.16.1",
"version": "1.17.0",
"dependencies": {
"@anthropic-ai/sandbox-runtime": "^0.0.26"
}

View file

@ -1,7 +1,7 @@
{
"name": "pi-extension-sandbox",
"private": true,
"version": "1.16.1",
"version": "1.17.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",

View file

@ -1,12 +1,12 @@
{
"name": "pi-extension-with-deps",
"version": "0.86.1",
"version": "0.87.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-with-deps",
"version": "0.86.1",
"version": "0.87.0",
"dependencies": {
"ms": "^2.1.3"
},

View file

@ -1,7 +1,7 @@
{
"name": "pi-extension-with-deps",
"private": true,
"version": "0.86.1",
"version": "0.87.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",

View file

@ -1,14 +1,14 @@
{
"name": "@earendil-works/pi-coding-agent-install",
"version": "0.86.1",
"version": "0.87.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@earendil-works/pi-coding-agent-install",
"version": "0.86.1",
"version": "0.87.0",
"dependencies": {
"@earendil-works/pi-coding-agent": "0.86.1"
"@earendil-works/pi-coding-agent": "0.87.0"
},
"engines": {
"node": ">=22.19.0"
@ -386,8 +386,8 @@
}
},
"node_modules/@earendil-works/chord": {
"version": "0.86.1",
"resolved": "https://registry.npmjs.org/@earendil-works/chord/-/chord-0.86.1.tgz",
"version": "0.87.0",
"resolved": "https://registry.npmjs.org/@earendil-works/chord/-/chord-0.87.0.tgz",
"license": "MIT",
"dependencies": {
"esbuild": "0.28.2"
@ -397,13 +397,13 @@
}
},
"node_modules/@earendil-works/pi-agent-core": {
"version": "0.86.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.86.1.tgz",
"version": "0.87.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.87.0.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/chord": "^0.86.1",
"@earendil-works/pi-ai": "^0.86.1",
"@earendil-works/pi-telemetry": "^0.86.1",
"@earendil-works/chord": "^0.87.0",
"@earendil-works/pi-ai": "^0.87.0",
"@earendil-works/pi-telemetry": "^0.87.0",
"diff": "8.0.4",
"ignore": "7.0.8",
"typebox": "1.3.27",
@ -414,13 +414,13 @@
}
},
"node_modules/@earendil-works/pi-ai": {
"version": "0.86.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.86.1.tgz",
"version": "0.87.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.87.0.tgz",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "0.124.0",
"@aws-sdk/client-bedrock-runtime": "3.1127.0",
"@earendil-works/pi-telemetry": "^0.86.1",
"@earendil-works/pi-telemetry": "^0.87.0",
"@google/genai": "2.21.0",
"@smithy/node-http-handler": "4.12.1",
"http-proxy-agent": "9.1.0",
@ -460,14 +460,14 @@
}
},
"node_modules/@earendil-works/pi-coding-agent": {
"version": "0.86.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.86.1.tgz",
"version": "0.87.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.87.0.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/chord": "^0.86.1",
"@earendil-works/pi-agent-core": "^0.86.1",
"@earendil-works/pi-ai": "^0.86.1",
"@earendil-works/pi-tui": "^0.86.1",
"@earendil-works/chord": "^0.87.0",
"@earendil-works/pi-agent-core": "^0.87.0",
"@earendil-works/pi-ai": "^0.87.0",
"@earendil-works/pi-tui": "^0.87.0",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "6.0.0",
"cross-spawn": "7.0.6",
@ -492,16 +492,16 @@
}
},
"node_modules/@earendil-works/pi-telemetry": {
"version": "0.86.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.86.1.tgz",
"version": "0.87.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.87.0.tgz",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-tui": {
"version": "0.86.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.86.1.tgz",
"version": "0.87.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.87.0.tgz",
"license": "MIT",
"dependencies": {
"get-east-asian-width": "1.6.0",

View file

@ -1,10 +1,10 @@
{
"name": "@earendil-works/pi-coding-agent-install",
"version": "0.86.1",
"version": "0.87.0",
"private": true,
"description": "Lockfile root used by the Pi installer and updater.",
"dependencies": {
"@earendil-works/pi-coding-agent": "0.86.1"
"@earendil-works/pi-coding-agent": "0.87.0"
},
"overrides": {
"protobufjs": "7.6.6"

View file

@ -1,18 +1,18 @@
{
"name": "@earendil-works/pi-coding-agent",
"version": "0.86.1",
"version": "0.87.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@earendil-works/pi-coding-agent",
"version": "0.86.1",
"version": "0.87.0",
"license": "MIT",
"dependencies": {
"@earendil-works/chord": "^0.86.1",
"@earendil-works/pi-agent-core": "^0.86.1",
"@earendil-works/pi-ai": "^0.86.1",
"@earendil-works/pi-tui": "^0.86.1",
"@earendil-works/chord": "^0.87.0",
"@earendil-works/pi-agent-core": "^0.87.0",
"@earendil-works/pi-ai": "^0.87.0",
"@earendil-works/pi-tui": "^0.87.0",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "6.0.0",
"cross-spawn": "7.0.6",
@ -408,8 +408,8 @@
}
},
"node_modules/@earendil-works/chord": {
"version": "0.86.1",
"resolved": "https://registry.npmjs.org/@earendil-works/chord/-/chord-0.86.1.tgz",
"version": "0.87.0",
"resolved": "https://registry.npmjs.org/@earendil-works/chord/-/chord-0.87.0.tgz",
"license": "MIT",
"dependencies": {
"esbuild": "0.28.2"
@ -419,13 +419,13 @@
}
},
"node_modules/@earendil-works/pi-agent-core": {
"version": "0.86.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.86.1.tgz",
"version": "0.87.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.87.0.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/chord": "^0.86.1",
"@earendil-works/pi-ai": "^0.86.1",
"@earendil-works/pi-telemetry": "^0.86.1",
"@earendil-works/chord": "^0.87.0",
"@earendil-works/pi-ai": "^0.87.0",
"@earendil-works/pi-telemetry": "^0.87.0",
"diff": "8.0.4",
"ignore": "7.0.8",
"typebox": "1.3.27",
@ -436,13 +436,13 @@
}
},
"node_modules/@earendil-works/pi-ai": {
"version": "0.86.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.86.1.tgz",
"version": "0.87.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.87.0.tgz",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "0.124.0",
"@aws-sdk/client-bedrock-runtime": "3.1127.0",
"@earendil-works/pi-telemetry": "^0.86.1",
"@earendil-works/pi-telemetry": "^0.87.0",
"@google/genai": "2.21.0",
"@smithy/node-http-handler": "4.12.1",
"http-proxy-agent": "9.1.0",
@ -482,16 +482,16 @@
}
},
"node_modules/@earendil-works/pi-telemetry": {
"version": "0.86.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.86.1.tgz",
"version": "0.87.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.87.0.tgz",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-tui": {
"version": "0.86.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.86.1.tgz",
"version": "0.87.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.87.0.tgz",
"license": "MIT",
"dependencies": {
"get-east-asian-width": "1.6.0",

View file

@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-coding-agent",
"version": "0.86.1",
"version": "0.87.0",
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
"type": "module",
"piConfig": {
@ -49,10 +49,10 @@
"prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap"
},
"dependencies": {
"@earendil-works/chord": "^0.86.1",
"@earendil-works/pi-agent-core": "^0.86.1",
"@earendil-works/pi-ai": "^0.86.1",
"@earendil-works/pi-tui": "^0.86.1",
"@earendil-works/chord": "^0.87.0",
"@earendil-works/pi-agent-core": "^0.87.0",
"@earendil-works/pi-ai": "^0.87.0",
"@earendil-works/pi-tui": "^0.87.0",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "6.0.0",
"cross-spawn": "7.0.6",
@ -73,9 +73,9 @@
"protobufjs": "7.6.6"
},
"devDependencies": {
"@earendil-works/pi-client": "^0.86.1",
"@earendil-works/pi-protocol": "^0.86.1",
"@earendil-works/pi-server": "^0.86.1",
"@earendil-works/pi-client": "^0.87.0",
"@earendil-works/pi-protocol": "^0.87.0",
"@earendil-works/pi-server": "^0.87.0",
"@types/cross-spawn": "6.0.6",
"@types/hosted-git-info": "3.0.5",
"@types/node": "22.19.19",

View file

@ -92,11 +92,21 @@ export function parseArgs(args: string[]): Args {
result.help = true;
} else if (arg === "--version" || arg === "-v") {
result.version = true;
} else if (arg === "--mode" && i + 1 < args.length) {
const mode = args[++i];
if (mode === "text" || mode === "json" || mode === "rpc") {
result.mode = mode;
} else if (arg === "--mode") {
const mode = args[i + 1];
if (mode === undefined || mode.startsWith("-")) {
result.diagnostics.push({ type: "error", message: "--mode requires text, json, or rpc" });
continue;
}
i++;
if (mode !== "text" && mode !== "json" && mode !== "rpc") {
result.diagnostics.push({
type: "error",
message: `Invalid mode "${mode}". Valid values: text, json, rpc`,
});
continue;
}
result.mode = mode;
} else if (arg === "--continue" || arg === "-c") {
result.continue = true;
} else if (arg === "--resume" || arg === "-r") {

View file

@ -57,6 +57,7 @@ export type {
// Event Results
ContextEventResult,
ContextUsage,
ContextWithSystemEvent,
CustomEntryDraft,
CustomMessageEntryDraft,
CustomToolCallEvent,

View file

@ -3,7 +3,13 @@
*/
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { ImageContent, Model, Provider, ProviderHeaders } from "@earendil-works/pi-ai";
import {
getCurrentSystemMessage,
type ImageContent,
type Model,
type Provider,
type ProviderHeaders,
} from "@earendil-works/pi-ai";
import type { KeyId } from "@earendil-works/pi-tui";
import { type Theme, theme } from "../../modes/interactive/theme/theme.ts";
import type { CacheWarmingAction } from "../cache-warmer.ts";
@ -32,6 +38,7 @@ import type {
ContextEvent,
ContextEventResult,
ContextUsage,
ContextWithSystemEvent,
EntryRenderer,
Extension,
ExtensionActions,
@ -169,6 +176,7 @@ type RunnerEmitEvent = Exclude<
| ToolResultEvent
| UserBashEvent
| ContextEvent
| ContextWithSystemEvent
| CacheWarmingDecisionEvent
| BeforeProviderRequestEvent
| BeforeProviderHeadersEvent
@ -258,6 +266,28 @@ function snapshotEventHandlers(extensions: Extension[], event: ExtensionEvent["t
return extensions.map((ext) => ({ ext, handlers: ext.handlers.get(event)?.slice() ?? [] }));
}
function sameMessages(left: AgentMessage[], right: AgentMessage[]): boolean {
return left.length === right.length && left.every((message, index) => message === right[index]);
}
/**
* Re-attach the prompt and tool state after a `context` handler. Handlers only see the
* conversation; the system messages belong to Pi. An unchanged conversation keeps every
* system message in place, so models with mid-conversation support keep their cached
* prefix. A changed one gets the replayed prompt sections and tool declarations as one
* leading system message, so pruning, windowing, or slicing from a compaction summary
* cannot drop them.
*/
function restoreSystemMessages(
current: AgentMessage[],
visible: AgentMessage[],
returned: AgentMessage[],
): AgentMessage[] {
if (sameMessages(returned, visible)) return current;
const head = getCurrentSystemMessage(current);
return head ? [head, ...returned] : returned;
}
export async function emitProjectTrustEvent(
extensionsResult: LoadExtensionsResult,
event: ProjectTrustEvent,
@ -1152,6 +1182,11 @@ export class ExtensionRunner {
return undefined;
}
/**
* Run the request-time transforms in two phases. `context` handlers see the conversation
* only and Pi restores the prompt and tool state after each; `context_with_system`
* handlers then see the full transcript and their output is used as returned.
*/
async emitContext(messages: AgentMessage[]): Promise<AgentMessage[]> {
const ctx = this.createContext();
let currentMessages = structuredClone(messages);
@ -1159,12 +1194,17 @@ export class ExtensionRunner {
for (const { ext, handlers } of snapshotEventHandlers(this.extensions, "context")) {
for (const handler of handlers) {
try {
const event: ContextEvent = { type: "context", messages: currentMessages };
const handlerResult = await handler(event, ctx);
const visibleMessages = currentMessages.filter((message) => message.role !== "system");
const visibleSnapshot = visibleMessages.slice();
const event: ContextEvent = { type: "context", messages: visibleMessages };
const handlerResult = (await handler(event, ctx)) as ContextEventResult | undefined;
if (handlerResult && (handlerResult as ContextEventResult).messages) {
currentMessages = (handlerResult as ContextEventResult).messages!;
}
// Handlers may return a new list or edit event.messages in place.
const returned =
handlerResult?.messages ??
(sameMessages(visibleMessages, visibleSnapshot) ? undefined : visibleMessages);
if (!returned) continue;
currentMessages = restoreSystemMessages(currentMessages, visibleSnapshot, returned);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
@ -1178,6 +1218,35 @@ export class ExtensionRunner {
}
}
for (const { ext, handlers } of snapshotEventHandlers(this.extensions, "context_with_system")) {
for (const handler of handlers) {
try {
const hadLeadingSystemMessage = currentMessages[0]?.role === "system";
const event: ContextWithSystemEvent = { type: "context_with_system", messages: currentMessages };
const handlerResult = (await handler(event, ctx)) as ContextEventResult | undefined;
currentMessages = handlerResult?.messages ?? currentMessages;
// Providers read the prompt and initial tools from the leading system message.
// Losing it is never intended; report it but honor the handler's output.
if (hadLeadingSystemMessage && currentMessages[0]?.role !== "system") {
this.emitError({
extensionPath: ext.path,
event: "context_with_system",
error: "Handler removed the leading system message; the request has no prompt or initial tool declarations. Keep it at index 0 or replace a dropped prefix with getCurrentSystemMessage().",
});
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
this.emitError({
extensionPath: ext.path,
event: "context_with_system",
error: message,
stack,
});
}
}
}
return currentMessages;
}

View file

@ -688,12 +688,28 @@ export type SessionEvent =
// Agent Events
// ============================================================================
/** Fired before each LLM call. Can modify messages. */
/**
* Fired before each LLM call. Can modify messages.
*
* `messages` holds the conversation without system messages. The prompt and tool state
* belong to Pi: it restores them after the handler returns, so a handler cannot drop
* them and does not need to preserve them.
*/
export interface ContextEvent {
type: "context";
messages: AgentMessage[];
}
/**
* Fired before each LLM call, after every `context` handler has run and Pi has restored
* the prompt and tool state. `messages` is the full transcript including system messages,
* and the result is sent as returned: the handler owns the prompt and tool declarations.
*/
export interface ContextWithSystemEvent {
type: "context_with_system";
messages: AgentMessage[];
}
/** Fired before a provider request is sent. Can replace the payload. */
export interface BeforeProviderRequestEvent {
type: "before_provider_request";
@ -1156,6 +1172,7 @@ export type ExtensionEvent =
| ResourcesDiscoverEvent
| SessionEvent
| ContextEvent
| ContextWithSystemEvent
| CacheWarmingDecisionEvent
| BeforeProviderRequestEvent
| BeforeProviderHeadersEvent
@ -1362,6 +1379,7 @@ export interface ExtensionAPI {
): () => void;
on(event: "session_tree", handler: ExtensionHandler<SessionTreeEvent>): () => void;
on(event: "context", handler: ExtensionHandler<ContextEvent, ContextEventResult>): () => void;
on(event: "context_with_system", handler: ExtensionHandler<ContextWithSystemEvent, ContextEventResult>): () => void;
on(
event: "cache_warming_decision",
handler: ExtensionHandler<CacheWarmingDecisionEvent, CacheWarmingDecisionEventResult>,

View file

@ -51,6 +51,7 @@ export {
type CompactionEntryDraft,
type ContextEditEntryDraft,
type ContextEvent,
type ContextWithSystemEvent,
type CustomEntryDraft,
type CustomMessageEntryDraft,
defineTool,

View file

@ -32,7 +32,7 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
"github-copilot": "gpt-5.4",
openrouter: "moonshotai/kimi-k2.6",
"vercel-ai-gateway": "zai/glm-5.1",
xai: "grok-4.6",
xai: "grok-4.7",
groq: "openai/gpt-oss-120b",
cerebras: "gpt-oss-120b",
zai: "glm-5.3",

View file

@ -81,6 +81,7 @@ export type {
ContextEvent,
ContextEventResult,
ContextUsage,
ContextWithSystemEvent,
CustomEntryDraft,
CustomMessageEntryDraft,
CustomToolCallEvent,

View file

@ -632,6 +632,7 @@ describe("AgentSession concurrent prompt guard", () => {
"user",
"assistant",
"toolResult",
"system",
"assistant",
]);
});

View file

@ -115,16 +115,6 @@ describe("parseArgs", () => {
expect(result.appendSystemPrompt).toEqual(["Context A", "Context B"]);
});
test("parses --mode", () => {
const result = parseArgs(["--mode", "json"]);
expect(result.mode).toBe("json");
});
test("parses --mode rpc", () => {
const result = parseArgs(["--mode", "rpc"]);
expect(result.mode).toBe("rpc");
});
test("parses --session", () => {
const result = parseArgs(["--session", "/path/to/session.jsonl"]);
expect(result.session).toBe("/path/to/session.jsonl");
@ -157,6 +147,48 @@ describe("parseArgs", () => {
});
});
// Issue #9045
describe("--mode flag", () => {
test.each(["text", "json", "rpc"] as const)("parses --mode %s", (mode) => {
const result = parseArgs(["--mode", mode]);
expect(result.mode).toBe(mode);
expect(result.diagnostics).toEqual([]);
});
test.each(["yaml", ""])("rejects invalid --mode value %j", (mode) => {
const result = parseArgs(["--mode", mode, "--version"]);
expect(result.mode).toBeUndefined();
expect(result.version).toBe(true);
expect(result.messages).toEqual([]);
expect(result.unknownFlags.size).toBe(0);
expect(result.diagnostics).toEqual([
{ type: "error", message: `Invalid mode "${mode}". Valid values: text, json, rpc` },
]);
});
test("reports a missing --mode value", () => {
const result = parseArgs(["--mode"]);
expect(result.mode).toBeUndefined();
expect(result.unknownFlags.size).toBe(0);
expect(result.diagnostics).toEqual([{ type: "error", message: "--mode requires text, json, or rpc" }]);
});
test("does not consume another option as a --mode value", () => {
const result = parseArgs(["--mode", "--version"]);
expect(result.mode).toBeUndefined();
expect(result.version).toBe(true);
expect(result.unknownFlags.size).toBe(0);
expect(result.diagnostics).toEqual([{ type: "error", message: "--mode requires text, json, or rpc" }]);
});
test("reports an invalid --mode value after a valid one", () => {
const result = parseArgs(["--mode", "json", "--mode", "yaml"]);
expect(result.diagnostics).toEqual([
{ type: "error", message: 'Invalid mode "yaml". Valid values: text, json, rpc' },
]);
});
});
describe("--name flag", () => {
test("parses --name flag with value", () => {
const result = parseArgs(["--name", "my-session"]);

View file

@ -704,7 +704,7 @@ describe("ModelRegistry", () => {
expect(sonnet?.name).toBe("Custom Sonnet Name");
// Other models should be unchanged
const opus = models.find((m) => m.id === "anthropic/claude-opus-4");
const opus = models.find((m) => m.id === "anthropic/claude-opus-4.1");
expect(opus?.name).not.toBe("Custom Sonnet Name");
});
@ -783,7 +783,7 @@ describe("ModelRegistry", () => {
expect(sonnet?.samplingParams).toEqual({ top_p: 0.9 });
// Models without sampling config keep it unset.
const opus = models.find((m) => m.id === "anthropic/claude-opus-4");
const opus = models.find((m) => m.id === "anthropic/claude-opus-4.1");
expect(opus?.samplingParams).toBeUndefined();
});
@ -810,7 +810,7 @@ describe("ModelRegistry", () => {
expect(registry.getError()).toBeUndefined();
expect(openrouter.find((m) => m.id === "custom/cached-model")?.promptCache).toEqual({ short: 120 });
expect(openrouter.find((m) => m.id === "anthropic/claude-sonnet-4")?.promptCache).toEqual({ short: 300 });
expect(openrouter.find((m) => m.id === "anthropic/claude-opus-4")?.promptCache).toBeUndefined();
expect(openrouter.find((m) => m.id === "anthropic/claude-opus-4.1")?.promptCache).toBeUndefined();
// Overrides merge per tier with the built-in catalog.
expect(registry.find("anthropic", "claude-sonnet-4-6")?.promptCache).toEqual({ short: 300, long: 1800 });
});
@ -898,7 +898,7 @@ describe("ModelRegistry", () => {
const registry = await createModelRegistry(authStorage, modelsJsonPath);
const models = getModelsForProvider(registry, "openrouter");
const sonnet = models.find((model) => model.id === "anthropic/claude-sonnet-4");
const opus = models.find((model) => model.id === "anthropic/claude-opus-4");
const opus = models.find((model) => model.id === "anthropic/claude-opus-4.1");
expect((sonnet?.compat as OpenAICompletionsCompat | undefined)?.supportsFinishReason).toBe(false);
expect((opus?.compat as OpenAICompletionsCompat | undefined)?.supportsFinishReason).toBe(true);
@ -933,7 +933,7 @@ describe("ModelRegistry", () => {
"anthropic/claude-sonnet-4": {
compat: { openRouterRouting: { only: ["amazon-bedrock"] } },
},
"anthropic/claude-opus-4": {
"anthropic/claude-opus-4.1": {
compat: { openRouterRouting: { only: ["anthropic"] } },
},
},
@ -944,7 +944,7 @@ describe("ModelRegistry", () => {
const models = getModelsForProvider(registry, "openrouter");
const sonnet = models.find((m) => m.id === "anthropic/claude-sonnet-4");
const opus = models.find((m) => m.id === "anthropic/claude-opus-4");
const opus = models.find((m) => m.id === "anthropic/claude-opus-4.1");
const sonnetCompat = sonnet?.compat as OpenAICompletionsCompat | undefined;
const opusCompat = opus?.compat as OpenAICompletionsCompat | undefined;
@ -973,7 +973,7 @@ describe("ModelRegistry", () => {
expect(sonnet?.name).toBe("Proxied Sonnet");
// Other models should have the baseUrl but not the name override
const opus = models.find((m) => m.id === "anthropic/claude-opus-4");
const opus = models.find((m) => m.id === "anthropic/claude-opus-4.1");
expect(opus?.baseUrl).toBe("https://my-proxy.example.com/v1");
expect(opus?.name).not.toBe("Proxied Sonnet");
});

View file

@ -735,7 +735,7 @@ describe("default model selection", () => {
});
test("xai default tracks current model", () => {
expect(defaultModelPerProvider.xai).toBe("grok-4.6");
expect(defaultModelPerProvider.xai).toBe("grok-4.7");
});
test("qwen token plan individual default tracks current model", () => {

View file

@ -0,0 +1,226 @@
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import {
fauxAssistantMessage,
getCurrentSystemPrompt,
getCurrentTools,
type TranscriptContext,
} from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import type { ExtensionFactory } from "../../../src/index.ts";
import { createHarness, type Harness } from "../harness.ts";
/** Compaction supplied by an extension hook, as in the reported sessions. */
const compactViaHook: ExtensionFactory = (pi) => {
pi.on("session_before_compact", async (event) => ({
compaction: {
summary: "extension summary",
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: event.preparation.tokensBefore,
details: { source: "test" },
},
}));
};
function captureRequest(harness: Harness, text: string): () => TranscriptContext {
let request: TranscriptContext | undefined;
harness.setResponses([
(context) => {
request = context;
return fauxAssistantMessage(text);
},
]);
return () => {
if (!request) throw new Error("expected a provider request");
return request;
};
}
function toolNames(context: TranscriptContext): string[] {
return getCurrentTools(context.messages).map((tool) => tool.name);
}
async function compactSession(harness: Harness): Promise<void> {
harness.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } });
harness.setResponses([fauxAssistantMessage("one"), fauxAssistantMessage("two")]);
await harness.session.prompt("first");
await harness.session.prompt("second");
await harness.session.compact();
expect(harness.session.messages.map((message) => message.role).slice(0, 2)).toEqual(["system", "compactionSummary"]);
}
describe("context handlers and system messages", () => {
const harnesses: Harness[] = [];
afterEach(() => {
while (harnesses.length > 0) harnesses.pop()?.cleanup();
});
// Regression #9789, #9822: pruning from the compaction summary dropped the prompt and tool checkpoint.
it("keeps the prompt and tools when a handler slices from the compaction summary", async () => {
const seen: AgentMessage[][] = [];
const harness = await createHarness({
extensionFactories: [
compactViaHook,
(pi) => {
pi.on("context", async (event) => {
seen.push(event.messages);
const summary = event.messages.findIndex((message) => message.role === "compactionSummary");
return { messages: event.messages.slice(summary) };
});
},
],
});
harnesses.push(harness);
await compactSession(harness);
const getRequest = captureRequest(harness, "after compaction");
await harness.session.prompt("third");
const request = getRequest();
expect(seen.at(-1)?.some((message) => message.role === "system")).toBe(false);
expect(request.messages[0]?.role).toBe("system");
expect(toolNames(request)).toEqual(harness.session.getActiveToolNames());
expect(getCurrentSystemPrompt(request.messages)).toBe(harness.session.systemPrompt);
expect(request.messages.filter((message) => message.role === "system")).toHaveLength(1);
});
it("keeps mid-conversation system messages in place when a handler leaves the conversation unchanged", async () => {
let turn = 0;
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("before_agent_start", (event) => {
if (++turn === 2) event.systemPromptOptions.sections.plan_mode = "Plan only.";
});
pi.on("context", async (event) => ({ messages: event.messages }));
},
],
});
harnesses.push(harness);
harness.setResponses([fauxAssistantMessage("one")]);
await harness.session.prompt("first");
const getRequest = captureRequest(harness, "two");
await harness.session.prompt("second");
const systemMessages = getRequest().messages.filter((message) => message.role === "system");
expect(systemMessages).toHaveLength(2);
expect(systemMessages[1]?.sections).toEqual({ plan_mode: "<plan_mode>\nPlan only.\n</plan_mode>" });
});
it("applies in-place edits to event.messages without a return value", async () => {
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("context", async (event) => {
event.messages.splice(0, 0, {
role: "user",
content: [{ type: "text", text: "injected" }],
timestamp: 0,
});
});
},
],
});
harnesses.push(harness);
const getRequest = captureRequest(harness, "done");
await harness.session.prompt("hello");
const request = getRequest();
expect(request.messages.map((message) => message.role)).toEqual(["system", "user", "user"]);
expect(toolNames(request)).toEqual(harness.session.getActiveToolNames());
});
it("keeps system messages a handler adds after the replayed head", async () => {
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("context", async (event) => ({
messages: [{ role: "system", content: "ephemeral reminder", timestamp: 0 }, ...event.messages],
}));
},
],
});
harnesses.push(harness);
const getRequest = captureRequest(harness, "done");
await harness.session.prompt("hello");
const request = getRequest();
expect(request.messages.map((message) => message.role)).toEqual(["system", "system", "user"]);
expect(toolNames(request)).toEqual(harness.session.getActiveToolNames());
expect(getCurrentSystemPrompt(request.messages)).toContain(harness.session.systemPrompt);
expect(getCurrentSystemPrompt(request.messages)).toContain("ephemeral reminder");
});
});
describe("context_with_system handlers", () => {
const harnesses: Harness[] = [];
afterEach(() => {
while (harnesses.length > 0) harnesses.pop()?.cleanup();
});
it("runs after context handlers on the restored transcript and sends its output verbatim", async () => {
const seen: AgentMessage[][] = [];
const harness = await createHarness({
extensionFactories: [
compactViaHook,
(pi) => {
pi.on("context_with_system", async (event) => {
seen.push(event.messages);
return {
messages: event.messages.map((message) =>
message.role === "system" && message.toolsAdded
? { ...message, toolsAdded: message.toolsAdded.filter((tool) => tool.name !== "bash") }
: message,
),
};
});
// Registered after, but runs first: context handlers precede context_with_system.
pi.on("context", async (event) => {
const summary = event.messages.findIndex((message) => message.role === "compactionSummary");
return { messages: event.messages.slice(summary) };
});
},
],
});
harnesses.push(harness);
await compactSession(harness);
const getRequest = captureRequest(harness, "after compaction");
await harness.session.prompt("third");
const input = seen.at(-1);
expect(input?.[0]?.role).toBe("system");
expect(input?.[1]?.role).toBe("compactionSummary");
expect(harness.session.getActiveToolNames()).toContain("bash");
expect(toolNames(getRequest())).toEqual(harness.session.getActiveToolNames().filter((name) => name !== "bash"));
});
it("reports a handler that drops the leading system message but honors its output", async () => {
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("context_with_system", async (event) => ({
messages: event.messages.filter((message) => message.role !== "system"),
}));
},
],
});
harnesses.push(harness);
const errors: string[] = [];
harness.session.extensionRunner.onError((error) => {
errors.push(`${error.event}: ${error.error}`);
});
const getRequest = captureRequest(harness, "done");
await harness.session.prompt("hello");
expect(getRequest().messages.map((message) => message.role)).toEqual(["user"]);
expect(errors).toEqual([
expect.stringMatching(/^context_with_system: Handler removed the leading system message/),
]);
});
});

View file

@ -2,6 +2,8 @@
## [Unreleased]
## [0.87.0] - 2026-09-21
## [0.86.1] - 2026-09-20
## [0.86.0] - 2026-09-19

View file

@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-durable",
"version": "0.86.1",
"version": "0.87.0",
"description": "Durable conversation, task, and document runtime for Pi",
"type": "module",
"main": "./dist/index.js",
@ -42,8 +42,8 @@
"node": ">=22.19.0"
},
"dependencies": {
"@earendil-works/chord": "^0.86.1",
"@earendil-works/pi-ai": "^0.86.1"
"@earendil-works/chord": "^0.87.0",
"@earendil-works/pi-ai": "^0.87.0"
},
"devDependencies": {
"shx": "0.4.0",

View file

@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-evals",
"version": "0.86.1",
"version": "0.87.0",
"private": true,
"type": "module",
"scripts": {
@ -12,8 +12,8 @@
},
"devDependencies": {
"autoevals": "0.3.0",
"@earendil-works/pi-ai": "^0.86.1",
"@earendil-works/pi-coding-agent": "^0.86.1",
"@earendil-works/pi-ai": "^0.87.0",
"@earendil-works/pi-coding-agent": "^0.87.0",
"@types/node": "22.19.19",
"@vitest-evals/core": "0.15.0",
"shx": "0.4.0",

View file

@ -2,6 +2,8 @@
## [Unreleased]
## [0.87.0] - 2026-09-21
## [0.86.1] - 2026-09-20
## [0.86.0] - 2026-09-19

View file

@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-protocol",
"version": "0.86.1",
"version": "0.87.0",
"description": "Transport-neutral CBOR protocol for remote pi sessions",
"type": "module",
"main": "./dist/index.js",
@ -39,7 +39,7 @@
"node": ">=22.19.0"
},
"dependencies": {
"@earendil-works/chord": "^0.86.1",
"@earendil-works/chord": "^0.87.0",
"typebox": "1.3.27"
},
"devDependencies": {

View file

@ -2,6 +2,8 @@
## [Unreleased]
## [0.87.0] - 2026-09-21
## [0.86.1] - 2026-09-20
## [0.86.0] - 2026-09-19

View file

@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-server",
"version": "0.86.1",
"version": "0.87.0",
"description": "experimental server package for pi",
"type": "module",
"main": "./dist/index.js",
@ -47,9 +47,9 @@
"node": ">=22.19.0"
},
"dependencies": {
"@earendil-works/chord": "^0.86.1",
"@earendil-works/pi-agent-core": "^0.86.1",
"@earendil-works/pi-protocol": "^0.86.1"
"@earendil-works/chord": "^0.87.0",
"@earendil-works/pi-agent-core": "^0.87.0",
"@earendil-works/pi-protocol": "^0.87.0"
},
"devDependencies": {
"shx": "0.4.0",

View file

@ -2,6 +2,8 @@
## [Unreleased]
## [0.87.0] - 2026-09-21
## [0.86.1] - 2026-09-20
## [0.86.0] - 2026-09-19

View file

@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-session-backend-sqlite-node",
"version": "0.86.1",
"version": "0.87.0",
"description": "Node sqlite session backend for @earendil-works/pi-agent-core sessions",
"type": "module",
"main": "./dist/index.js",
@ -35,8 +35,8 @@
"node": ">=22.19.0"
},
"dependencies": {
"@earendil-works/pi-ai": "^0.86.1",
"@earendil-works/pi-agent-core": "^0.86.1"
"@earendil-works/pi-ai": "^0.87.0",
"@earendil-works/pi-agent-core": "^0.87.0"
},
"devDependencies": {
"@vitest/coverage-v8": "4.1.9",

View file

@ -2,6 +2,8 @@
## [Unreleased]
## [0.87.0] - 2026-09-21
## [0.86.1] - 2026-09-20
## [0.86.0] - 2026-09-19

View file

@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-telemetry",
"version": "0.86.1",
"version": "0.87.0",
"description": "Vendor-neutral telemetry contracts and typed schema utilities for pi",
"type": "module",
"main": "./dist/index.js",

View file

@ -2,6 +2,8 @@
## [Unreleased]
## [0.87.0] - 2026-09-21
## [0.86.1] - 2026-09-20
## [0.86.0] - 2026-09-19

View file

@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-tui",
"version": "0.86.1",
"version": "0.87.0",
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
"type": "module",
"main": "dist/index.js",

View file

@ -1630,11 +1630,14 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
const row = clip.y + clip.height - 1;
if (row >= screen.length || isImageLine(screen[row] ?? "")) return screen;
const scrollbarColumn = box ? getScrollbarGeometry(box)?.column : undefined;
const availableWidth = Math.max(0, (scrollbarColumn ?? clip.x + clip.width) - clip.x);
const text = truncateToWidth(this.scrollToEndIndicator(), availableWidth, "");
const label = truncateToWidth(this.scrollToEndIndicator(), clip.width, "");
const labelWidth = visibleWidth(label);
const column = clip.x + Math.floor((clip.width - labelWidth) / 2);
const rightEdge = scrollbarColumn ?? clip.x + clip.width;
const availableWidth = Math.max(0, rightEdge - column);
const text = truncateToWidth(label, availableWidth, "");
const textWidth = visibleWidth(text);
if (textWidth === 0) return screen;
const column = clip.x + Math.floor((availableWidth - textWidth) / 2);
const result = [...screen];
result[row] = compositeTuiLine(result[row] ?? "", text, column, textWidth, width);
this.scrollToEndIndicatorRect = { row, column, width: textWidth };

View file

@ -138,7 +138,51 @@ describe("TuiAltScreen", () => {
tui.stop();
});
it("leaves the scrollbar clickable when the jump-to-end indicator spans the transcript", async () => {
it("keeps the jump-to-end indicator centered as the auto scrollbar hides and reappears", async () => {
// Regression test for #9136: auto scrollbar visibility must not move the indicator.
const terminal = new VirtualTerminal(80, 6);
const label = " ↓ Jump to latest message · End ";
const tui = new TuiAltScreen(terminal, undefined, undefined, {
scrollToEndIndicator: () => label,
});
const transcript = new ScrollView(
new Text(Array.from({ length: 20 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0),
{ follow: "end", primary: true, scrollbar: "auto", scrollbarHideDelayMs: 0 },
);
tui.setLayoutRoot(transcript);
tui.start();
try {
await terminal.waitForRender();
// Scrolling over the track keeps the scrollbar visible until the pointer leaves.
terminal.sendInput("\x1b[<64;80;1M");
await terminal.waitForRender();
assert.strictEqual(transcript.isScrollbarVisible, true);
assert.strictEqual(transcript.isFollowingEnd, false);
const scrollTop = transcript.scrollTop;
const visibleColumn = terminal.getViewport()[5].indexOf(label);
// Leaving the track lets the auto-hide timer expire without changing the content.
terminal.sendInput("\x1b[<35;79;1M");
await terminal.waitForRender();
assert.strictEqual(transcript.isScrollbarVisible, false);
assert.strictEqual(transcript.scrollTop, scrollTop);
const hiddenColumn = terminal.getViewport()[5].indexOf(label);
terminal.sendInput("\x1b[<35;80;1M");
await terminal.waitForRender();
assert.strictEqual(transcript.isScrollbarVisible, true);
assert.strictEqual(transcript.scrollTop, scrollTop);
const revealedColumn = terminal.getViewport()[5].indexOf(label);
assert.deepStrictEqual([visibleColumn, hiddenColumn, revealedColumn], [24, 24, 24]);
} finally {
tui.stop();
}
});
it("leaves the scrollbar visible and clickable when the jump-to-end indicator spans the transcript", async () => {
// Regression coverage for #9136: centering must not paint or capture clicks over the scrollbar.
const terminal = new VirtualTerminal(30, 6);
const tui = new TuiAltScreen(terminal, undefined, undefined, {
scrollToEndIndicator: () => "↓".repeat(30),
@ -161,6 +205,7 @@ describe("TuiAltScreen", () => {
assert.strictEqual(transcript.isFollowingEnd, false);
// The indicator must not intercept a press on the scrollbar's last column.
assert.strictEqual(terminal.getViewport()[3], `${"↓".repeat(29)}┃`);
terminal.sendInput("\x1b[<0;30;4M");
terminal.sendInput("\x1b[<0;30;4m");
await terminal.waitForRender();