diff --git a/package-lock.json b/package-lock.json index 7aa3cfc4c..7ba661ca1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index bb3e89b62..4393b17cd 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -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: diff --git a/packages/agent/package.json b/packages/agent/package.json index 032092ad8..371ad64c5 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -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", diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 1f96bd87e..4e1b21ead 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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 diff --git a/packages/ai/README.md b/packages/ai/README.md index 34237bd2b..7395672ff 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -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) { diff --git a/packages/ai/package.json b/packages/ai/package.json index 111739b95..856f01197 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -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", diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index ba4a49373..7b54f34d3 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -2030,12 +2030,7 @@ async function loadModelsDevData(): Promise[]> { 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, }); diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 4ac0b6206..f5a1bc668 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -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", diff --git a/packages/ai/test/model-catalog-types.test.ts b/packages/ai/test/model-catalog-types.test.ts index 57f02e0f2..449764a93 100644 --- a/packages/ai/test/model-catalog-types.test.ts +++ b/packages/ai/test/model-catalog-types.test.ts @@ -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">(); }); diff --git a/packages/ai/test/openai-completions-tool-result-images.test.ts b/packages/ai/test/openai-completions-tool-result-images.test.ts index 6e48efe67..4d303865e 100644 --- a/packages/ai/test/openai-completions-tool-result-images.test.ts +++ b/packages/ai/test/openai-completions-tool-result-images.test.ts @@ -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"> = { diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 362f30143..eb9afa226 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -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); diff --git a/packages/ai/test/xai-responses.test.ts b/packages/ai/test/xai-responses.test.ts index d88c35a4d..1aa570177 100644 --- a/packages/ai/test/xai-responses.test.ts +++ b/packages/ai/test/xai-responses.test.ts @@ -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" }, diff --git a/packages/chord/package.json b/packages/chord/package.json index f7509c375..fa50e17e4 100644 --- a/packages/chord/package.json +++ b/packages/chord/package.json @@ -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", diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md index 86eb37b82..3c6e239b0 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [0.87.0] - 2026-09-21 + ## [0.86.1] - 2026-09-20 ## [0.86.0] - 2026-09-19 diff --git a/packages/client/package.json b/packages/client/package.json index d313018d0..673c364b8 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -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", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e16245f9e..cda0b2c65 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index d58304c3b..89afa4863 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -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. diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json index 3eba4a83e..00832cb38 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-custom-provider", - "version": "0.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" } diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json index c0a973be7..5ade8ee7f 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-anthropic", "private": true, - "version": "0.86.1", + "version": "0.87.0", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json index 3c4339b34..ef4c42601 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-gitlab-duo", "private": true, - "version": "0.86.1", + "version": "0.87.0", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/gondolin/package-lock.json b/packages/coding-agent/examples/extensions/gondolin/package-lock.json index b71238fc2..5d7f6addf 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package-lock.json +++ b/packages/coding-agent/examples/extensions/gondolin/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-gondolin", - "version": "0.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" } diff --git a/packages/coding-agent/examples/extensions/gondolin/package.json b/packages/coding-agent/examples/extensions/gondolin/package.json index df48e2aff..d692f0f8a 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package.json +++ b/packages/coding-agent/examples/extensions/gondolin/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-gondolin", "private": true, - "version": "0.86.1", + "version": "0.87.0", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/sandbox/package-lock.json b/packages/coding-agent/examples/extensions/sandbox/package-lock.json index bf3e01022..ac33d7af1 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package-lock.json +++ b/packages/coding-agent/examples/extensions/sandbox/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-sandbox", - "version": "1.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" } diff --git a/packages/coding-agent/examples/extensions/sandbox/package.json b/packages/coding-agent/examples/extensions/sandbox/package.json index 8847140cc..852d97a44 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package.json +++ b/packages/coding-agent/examples/extensions/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-sandbox", "private": true, - "version": "1.16.1", + "version": "1.17.0", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/with-deps/package-lock.json b/packages/coding-agent/examples/extensions/with-deps/package-lock.json index c4811dd38..b5d8f1bd2 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package-lock.json +++ b/packages/coding-agent/examples/extensions/with-deps/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-with-deps", - "version": "0.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" }, diff --git a/packages/coding-agent/examples/extensions/with-deps/package.json b/packages/coding-agent/examples/extensions/with-deps/package.json index 41f636fe1..0d9f944c6 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package.json +++ b/packages/coding-agent/examples/extensions/with-deps/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-with-deps", "private": true, - "version": "0.86.1", + "version": "0.87.0", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index 5f4757662..aedadcb8a 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -1,14 +1,14 @@ { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.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", diff --git a/packages/coding-agent/install-lock/package.json b/packages/coding-agent/install-lock/package.json index 810b8d2bf..6f2e26c58 100644 --- a/packages/coding-agent/install-lock/package.json +++ b/packages/coding-agent/install-lock/package.json @@ -1,10 +1,10 @@ { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.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" diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 4a82f9cd1..11743c285 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -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", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index d1b6634ff..eae863020 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.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", diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 284888d13..a35879807 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -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") { diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index 57b2e77bf..ad0287629 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -57,6 +57,7 @@ export type { // Event Results ContextEventResult, ContextUsage, + ContextWithSystemEvent, CustomEntryDraft, CustomMessageEntryDraft, CustomToolCallEvent, diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 44576a1bb..b0e99f8ff 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -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 { 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; } diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index d10078701..41bc4e672 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -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): () => void; on(event: "context", handler: ExtensionHandler): () => void; + on(event: "context_with_system", handler: ExtensionHandler): () => void; on( event: "cache_warming_decision", handler: ExtensionHandler, diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts index 178ed7105..fe82f5691 100644 --- a/packages/coding-agent/src/core/index.ts +++ b/packages/coding-agent/src/core/index.ts @@ -51,6 +51,7 @@ export { type CompactionEntryDraft, type ContextEditEntryDraft, type ContextEvent, + type ContextWithSystemEvent, type CustomEntryDraft, type CustomMessageEntryDraft, defineTool, diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 64aa93608..44f22ad8f 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -32,7 +32,7 @@ export const defaultModelPerProvider: Record = { "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", diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 86907ebba..8746a894b 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -81,6 +81,7 @@ export type { ContextEvent, ContextEventResult, ContextUsage, + ContextWithSystemEvent, CustomEntryDraft, CustomMessageEntryDraft, CustomToolCallEvent, diff --git a/packages/coding-agent/test/agent-session-concurrent.test.ts b/packages/coding-agent/test/agent-session-concurrent.test.ts index 0df97e0d9..1fb8b4517 100644 --- a/packages/coding-agent/test/agent-session-concurrent.test.ts +++ b/packages/coding-agent/test/agent-session-concurrent.test.ts @@ -632,6 +632,7 @@ describe("AgentSession concurrent prompt guard", () => { "user", "assistant", "toolResult", + "system", "assistant", ]); }); diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index 710ed7b09..37e6407d5 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -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"]); diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 17ba7a182..ccf925f09 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -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"); }); diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 8f37b7054..ebed94c33 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -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", () => { diff --git a/packages/coding-agent/test/suite/regressions/9789-context-handler-system-messages.test.ts b/packages/coding-agent/test/suite/regressions/9789-context-handler-system-messages.test.ts new file mode 100644 index 000000000..aa3816dcb --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/9789-context-handler-system-messages.test.ts @@ -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 { + 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: "\nPlan only.\n" }); + }); + + 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/), + ]); + }); +}); diff --git a/packages/durable/CHANGELOG.md b/packages/durable/CHANGELOG.md index 39d2de378..8717705e3 100644 --- a/packages/durable/CHANGELOG.md +++ b/packages/durable/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [0.87.0] - 2026-09-21 + ## [0.86.1] - 2026-09-20 ## [0.86.0] - 2026-09-19 diff --git a/packages/durable/package.json b/packages/durable/package.json index e85d868cd..c060d2608 100644 --- a/packages/durable/package.json +++ b/packages/durable/package.json @@ -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", diff --git a/packages/evals/package.json b/packages/evals/package.json index b89d946d1..20183dc83 100644 --- a/packages/evals/package.json +++ b/packages/evals/package.json @@ -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", diff --git a/packages/protocol/CHANGELOG.md b/packages/protocol/CHANGELOG.md index 6147cd8f5..fe62bde3d 100644 --- a/packages/protocol/CHANGELOG.md +++ b/packages/protocol/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [0.87.0] - 2026-09-21 + ## [0.86.1] - 2026-09-20 ## [0.86.0] - 2026-09-19 diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 602af9be5..883a0a1d2 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -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": { diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md index 38e73caf1..bda8d300f 100644 --- a/packages/server/CHANGELOG.md +++ b/packages/server/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [0.87.0] - 2026-09-21 + ## [0.86.1] - 2026-09-20 ## [0.86.0] - 2026-09-19 diff --git a/packages/server/package.json b/packages/server/package.json index 30a2f9a8d..3829f33ad 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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", diff --git a/packages/session-backends/sqlite-node/CHANGELOG.md b/packages/session-backends/sqlite-node/CHANGELOG.md index d915d6703..5ab3077bf 100644 --- a/packages/session-backends/sqlite-node/CHANGELOG.md +++ b/packages/session-backends/sqlite-node/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [0.87.0] - 2026-09-21 + ## [0.86.1] - 2026-09-20 ## [0.86.0] - 2026-09-19 diff --git a/packages/session-backends/sqlite-node/package.json b/packages/session-backends/sqlite-node/package.json index e080c830b..f825a38eb 100644 --- a/packages/session-backends/sqlite-node/package.json +++ b/packages/session-backends/sqlite-node/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-session-backend-sqlite-node", - "version": "0.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", diff --git a/packages/telemetry/CHANGELOG.md b/packages/telemetry/CHANGELOG.md index 11c036040..b1af6f4b2 100644 --- a/packages/telemetry/CHANGELOG.md +++ b/packages/telemetry/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [0.87.0] - 2026-09-21 + ## [0.86.1] - 2026-09-20 ## [0.86.0] - 2026-09-19 diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index de9ea1aa2..99aba7649 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -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", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 26e29a42e..9da53a3a1 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [0.87.0] - 2026-09-21 + ## [0.86.1] - 2026-09-20 ## [0.86.0] - 2026-09-19 diff --git a/packages/tui/package.json b/packages/tui/package.json index 35d12c859..3b476a509 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -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", diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index 1b3d2614d..cc9a04f43 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -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 }; diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index fa007d2ad..e79169127 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -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();