Merge main into model-registry

This commit is contained in:
Mario Zechner
2026-06-22 14:00:18 +02:00
220 changed files with 10488 additions and 4354 deletions
+199
View File
@@ -10,6 +10,205 @@
- Added an experimental first-time setup flow behind `PI_EXPERIMENTAL=1` that asks for a dark/light theme choice (preselecting the detected appearance) and opt-in analytics data sharing on first launch with the default agent directory; opting in stores a `trackingId` in `settings.json`.
## [0.79.10] - 2026-06-22
### New Features
- **Extension compaction event context** - Extension `session_before_compact` and `session_compact` events now include `reason` and `willRetry`, so extensions can distinguish manual `/compact`, threshold auto-compaction, and overflow retry flows. See [session_before_compact / session_compact](docs/extensions.md#session_before_compact--session_compact) and [Custom Summarization via Extensions](docs/compaction.md#custom-summarization-via-extensions).
- **Safer update flow** - `pi update` installs the exact checked Pi version, and update notices show the changelog URL, making upgrades more predictable. See [Install and Manage](docs/packages.md#install-and-manage).
### Added
- Added `reason` and `willRetry` metadata to extension `session_before_compact` and `session_compact` events so extensions can distinguish manual, threshold, and overflow compaction flows ([#5962](https://github.com/earendil-works/pi/pull/5962) by [@PizzaMarinara](https://github.com/PizzaMarinara)).
### Fixed
- Fixed the `find` tool to respect nested git repository boundaries when parent `.gitignore` rules ignore the nested repo ([#5960](https://github.com/earendil-works/pi/issues/5960)).
- Fixed the usage docs slash command table to include `/trust` and `/import` ([#5959](https://github.com/earendil-works/pi/issues/5959)).
- Fixed inherited OpenAI-compatible streaming to preserve encrypted `reasoning_details` that arrive before matching tool call deltas ([#5114](https://github.com/earendil-works/pi/issues/5114)).
- Fixed broken TUI documentation links to the plan-mode extension example ([#5957](https://github.com/earendil-works/pi/issues/5957)).
- Fixed transient extension UI and session-start messages emitted during session replacement or reload so they remain visible, and kept reload input blocked until reload completes ([#5943](https://github.com/earendil-works/pi/issues/5943)).
- Fixed the plan-mode example to preserve active custom tools, skip the action prompt when no plan is found, and queue refinement/execution follow-ups correctly from `agent_end` ([#5940](https://github.com/earendil-works/pi/issues/5940)).
- Fixed `pi update` to install the exact version returned by the Pi update check, make `--force` reinstall that checked version, fail instead of falling back to an unversioned reinstall when no version is available, and report both the old and updated versions.
- Fixed update notifications to display the actual changelog URL as the hyperlink text.
## [0.79.9] - 2026-06-20
### New Features
- **Chat-template thinking compatibility** - OpenAI-compatible custom providers can map Pi thinking levels into `chat_template_kwargs`, enabling vLLM/Hugging Face chat-template models such as DeepSeek to use provider-native thinking controls. See [Custom Provider API Types](docs/custom-provider.md#api-types) and [OpenAI Compatibility](docs/models.md#openai-compatibility).
- **GLM-5.2 provider improvements** - GLM-5.2 now has corrected Fireworks OpenAI-compatible routing and OpenRouter `xhigh` thinking support, improving `/model` behavior and high-effort reasoning for GLM-5.2 users. See [Model Options](docs/usage.md#model-options).
### Added
- Added inherited configurable `chat-template` thinking support for OpenAI-compatible providers that use `chat_template_kwargs`, such as DeepSeek models behind vLLM ([#5673](https://github.com/earendil-works/pi/issues/5673)).
### Fixed
- Fixed inherited Fireworks GLM-5.2 metadata to use the OpenAI-compatible Chat Completions endpoint with `reasoning_effort` support ([#5923](https://github.com/earendil-works/pi/issues/5923)).
- Fixed same-directory session switches to reuse imported extension modules while preserving fresh extension instances and lifecycle events ([#5905](https://github.com/earendil-works/pi/issues/5905)).
- Fixed deep session branches taking quadratic time to build context or branch paths ([#5909](https://github.com/earendil-works/pi/issues/5909)).
- Fixed inherited OpenRouter GLM-5.2 metadata to expose `xhigh` reasoning and send OpenRouter's native `xhigh` effort ([#5770](https://github.com/earendil-works/pi/issues/5770)).
- Fixed inherited Markdown streaming code fence rendering so partial closing fences no longer make code blocks shrink or flicker while content streams ([#5846](https://github.com/earendil-works/pi/pull/5846) by [@xl0](https://github.com/xl0)).
- Fixed fuzzy `edit` matches to preserve untouched line blocks instead of rewriting the whole file through normalized content ([#5899](https://github.com/earendil-works/pi/issues/5899)).
- Fixed bash commands through legacy WSL `bash.exe` to pass scripts over stdin so shell variables expand in the target bash ([#5893](https://github.com/earendil-works/pi/issues/5893)).
- Fixed `/model` to hide GitHub Copilot models that are unavailable to the authenticated account ([#5897](https://github.com/earendil-works/pi/issues/5897)).
- Fixed `/model` selector search to rank exact provider-prefixed matches before proxy-provider model ID matches ([#5892](https://github.com/earendil-works/pi/issues/5892)).
## [0.79.8] - 2026-06-19
### New Features
- **Selective provider base entry points** - SDK users can pair `@earendil-works/pi-ai/base` and `@earendil-works/pi-agent-core/base` with explicit provider registration to keep bundled applications from including unused provider transports. See [`pi-ai` Base Entry Point](../ai/README.md#base-entry-point) and [`pi-agent-core` Base Entry Point](../agent/README.md#base-entry-point).
- **Mistral prompt caching** - Mistral sessions now use provider-side prompt caching with session affinity and cached-token usage/cost accounting. See [API Keys](docs/providers.md#api-keys) and [Environment Variables](docs/usage.md#environment-variables).
- **Post-compaction token estimates** - Compact results and compaction events now include estimated post-compaction token counts so clients can show the approximate context reduction. See [RPC compact](docs/rpc.md#compact) and [compaction events](docs/rpc.md#compaction_start--compaction_end).
- **OpenRouter Fusion alias** - `openrouter/fusion` is available as a built-in OpenRouter model alias. See [API Keys](docs/providers.md#api-keys).
### Added
- Added inherited `@earendil-works/pi-ai/base` and `@earendil-works/pi-agent-core/base` entry points for selective provider registration in bundled applications ([#5348](https://github.com/earendil-works/pi/pull/5348) by [@FredKSchott](https://github.com/FredKSchott)).
- Added inherited Mistral prompt caching using the pi session ID as `prompt_cache_key`, including cached-token usage and cost accounting ([#5854](https://github.com/earendil-works/pi/issues/5854)).
- Added estimated post-compaction token counts to compact results and compaction events ([#5877](https://github.com/earendil-works/pi/issues/5877)).
- Added the inherited OpenRouter Fusion alias as `openrouter/fusion` ([#5866](https://github.com/earendil-works/pi/pull/5866) by [@dannote](https://github.com/dannote)).
### Fixed
- Updated vulnerable runtime dependencies, including `undici` and the packaged `protobufjs` transitive dependency.
- Fixed compaction to refuse sessions with no eligible messages instead of producing empty summaries ([#4811](https://github.com/earendil-works/pi/issues/4811)).
- Fixed successful overflow-triggered auto-compaction to avoid retrying completed assistant responses ([#5720](https://github.com/earendil-works/pi/issues/5720)).
## [0.79.7] - 2026-06-18
### New Features
- **Automatic theme mode** - `/settings` can choose separate light and dark themes and follow terminal color-scheme changes. See [Selecting a Theme](docs/themes.md#selecting-a-theme).
- **Self-only updates by default** - `pi update` now updates pi only, with `pi update --all` for updating pi and packages together. See [Install and Manage](docs/packages.md#install-and-manage).
- **Extension API helpers** - extensions can use `CONFIG_DIR_NAME` for project config paths and import edit diff helpers for edit-style diffs. See [`ctx.cwd`](docs/extensions.md#ctxcwd) and [SDK Exports](docs/sdk.md#exports).
- **Warp inline images** - Warp terminals now get inline image rendering through Kitty graphics detection. See [Image](docs/tui.md#image).
### Added
- Added automatic theme mode so `/settings` can use separate light and dark themes and follow terminal color-scheme changes ([#5874](https://github.com/earendil-works/pi/pull/5874)).
- Added inherited Warp terminal image capability detection so inline images render through Warp's Kitty graphics support ([#5841](https://github.com/earendil-works/pi/pull/5841) by [@dodiego](https://github.com/dodiego)).
- Exported `CONFIG_DIR_NAME` from the coding-agent public API so extensions can resolve project config paths without hardcoding `.pi` ([#5869](https://github.com/earendil-works/pi/pull/5869) by [@xl0](https://github.com/xl0)).
- Exported edit diff helpers (`generateDiffString`, `generateUnifiedPatch`, and `EditDiffResult`) from the public API for extensions that need edit-style diffs ([#5756](https://github.com/earendil-works/pi/pull/5756) by [@xl0](https://github.com/xl0)).
### Changed
- Changed bare `pi update` to update only pi, added `pi update --all` for updating pi and extensions together, and clarified extension update prompts.
- Reserved `/` in theme names for automatic light/dark theme settings.
- Updated extension docs, examples, runtime help, trust prompts, and config labels to use the configured project config directory instead of hardcoded `.pi` paths.
### Fixed
- Fixed RPC unknown-command errors to include the request id so clients do not hang waiting for a response ([#5868](https://github.com/earendil-works/pi/issues/5868)).
- Fixed `/model` autocomplete and model selection searches to match provider/model queries regardless of whether the provider or model token is typed first.
- Fixed the tree navigator to horizontally pan deep entries so the selected item remains readable ([#5830](https://github.com/earendil-works/pi/issues/5830)).
## [0.79.6] - 2026-06-16
### Fixed
- Fixed HTTP dispatcher configuration to preserve a caller's deliberate `fetch` override instead of reinstalling the undici global fetch over it.
- Fixed inherited OpenCode Go DeepSeek V4 thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter.
## [0.79.5] - 2026-06-16
### New Features
- **Provider-scoped API key environments** - `auth.json` API key entries can now include `env` overrides for provider-specific Cloudflare, Azure OpenAI, Google Vertex, Amazon Bedrock, cache retention, and proxy settings without changing the project shell. See [Auth File](docs/providers.md#auth-file).
- **Global HTTP proxy setting** - Configure `httpProxy` once in global settings to apply `HTTP_PROXY` and `HTTPS_PROXY` to Pi-managed HTTP clients. See [Network](docs/settings.md#network).
- **Vercel AI Gateway attribution** - Vercel AI Gateway requests now include Pi attribution headers by default. See [API Keys](docs/providers.md#api-keys).
### Added
- Added Vercel AI Gateway request attribution headers (`http-referer` and `x-title`) for Vercel AI Gateway models ([#5798](https://github.com/earendil-works/pi/pull/5798) by [@rwachtler](https://github.com/rwachtler)).
- Added an `xp` footer marker when experimental features are enabled.
- Added a global `httpProxy` setting that applies as `HTTP_PROXY` and `HTTPS_PROXY` for Pi-managed HTTP clients ([#5790](https://github.com/earendil-works/pi/issues/5790)).
- Added `auth.json` API key `env` values so provider-specific environment overrides can be scoped to Pi and propagated to inherited provider configuration ([#5728](https://github.com/earendil-works/pi/issues/5728)).
### Changed
- Updated the vendored Markdown parser used by HTML session exports to `marked` 18.0.5.
### Fixed
- Fixed inherited OpenAI Responses streaming to tolerate null message content from OpenAI-compatible servers before tool calls ([#5819](https://github.com/earendil-works/pi/issues/5819)).
- Fixed inherited OpenCode DeepSeek V4 thinking requests to avoid sending both `thinking` and `reasoning_effort` ([#5818](https://github.com/earendil-works/pi/issues/5818)).
- Fixed device-code login to stop opening the browser automatically.
- Fixed inherited editor Cursor Up handling so non-empty drafts jump to the start of the line before browsing input history ([#5789](https://github.com/earendil-works/pi/pull/5789) by [@4h9fbZ](https://github.com/4h9fbZ)).
- Fixed inherited Z.AI GLM-5.2 thinking requests to send `reasoning_effort` with the provider's `high`/`max` effort mapping ([#5770](https://github.com/earendil-works/pi/issues/5770)).
- Fixed successful `pi update` on Windows to exit naturally instead of calling `process.exit(0)`, avoiding a Node.js/libuv assertion after version-check network requests ([#5805](https://github.com/earendil-works/pi/issues/5805)).
- Fixed inherited Google and `google-vertex` Gemini model metadata to map `latest` aliases to the current models, add Gemini 3.5 Flash for Vertex, correct Gemini 2.5 Flash Vertex cache pricing, and remove shut-down Vertex preview models ([#5761](https://github.com/earendil-works/pi/issues/5761)).
- Fixed the session selector to stay open and show the all-sessions empty state when both current-folder and all-scope session lists are empty ([#5747](https://github.com/earendil-works/pi/issues/5747)).
- Fixed inherited Moonshot AI China model metadata to include Kimi K2.7 Code, and omitted unsupported thinking-off payloads for Kimi K2.7 Code models ([#5760](https://github.com/earendil-works/pi/issues/5760)).
## [0.79.4] - 2026-06-15
### New Features
- **Automatic first-run theme selection** - pi detects the terminal background on first run and defaults to the `dark` or `light` theme. See [Selecting a Theme](docs/themes.md#selecting-a-theme).
- **Standalone binary integrity checksums** - GitHub release assets now include `SHA256SUMS` files for verifying standalone binary downloads. See [Quickstart Install](docs/quickstart.md#install).
### Added
- Added `SHA256SUMS` integrity files to standalone binary GitHub release assets ([#5739](https://github.com/earendil-works/pi/issues/5739)).
- Added first-run interactive theme detection from the terminal background ([#5385](https://github.com/earendil-works/pi/pull/5385) by [@vegarsti](https://github.com/vegarsti)).
### Fixed
- Fixed bash tool output collection to keep draining stdout/stderr after the child exits while descendants still write, avoiding truncated late output ([#5753](https://github.com/earendil-works/pi/pull/5753) by [@Mearman](https://github.com/Mearman)).
- Fixed `/tree` help rendering to show compact wrapped controls instead of truncating them on narrow terminals ([#5055](https://github.com/earendil-works/pi/issues/5055)).
- Fixed SIGTERM/SIGHUP interactive shutdown to keep signal handlers installed until terminal cleanup completes, preventing `signal-exit` from re-sending the signal and leaving the terminal in raw/Kitty keyboard mode ([#5724](https://github.com/earendil-works/pi/issues/5724)).
- Fixed extensions documentation to clarify that `pi.getActiveTools()` returns active tool names while `pi.getAllTools()` returns tool metadata ([#5729](https://github.com/earendil-works/pi/issues/5729)).
- Fixed question and questionnaire extension examples to wrap long prompt, option, and help text instead of truncating it ([#5708](https://github.com/earendil-works/pi/pull/5708) by [@xl0](https://github.com/xl0)).
- Fixed package commands such as `pi list`, `pi install`, and `pi update` to terminate after completing even if an extension leaves background handles open ([#5687](https://github.com/earendil-works/pi/issues/5687)).
- Fixed `pi update` for pnpm global installs whose configured `global-bin-dir` no longer matches the active pnpm home ([#5689](https://github.com/earendil-works/pi/issues/5689)).
- Fixed npm package specs that use ranges or tags (for example `@^1.2.7`) so installed package resources still load instead of being treated as mismatched exact pins ([#5695](https://github.com/earendil-works/pi/issues/5695)).
- Fixed inherited Anthropic 1-hour prompt-cache write cost accounting to price 1-hour cache writes at 2x input instead of the 5-minute cache-write rate ([#5738](https://github.com/earendil-works/pi/pull/5738) by [@theBucky](https://github.com/theBucky)).
- Fixed inherited GitHub Copilot Claude adaptive-thinking effort metadata to match manually checked Copilot model capabilities ([#4637](https://github.com/earendil-works/pi/issues/4637)).
- Fixed inherited OpenCode/OpenCode Go completion model metadata to omit long-retention cache fields for routes that reject `prompt_cache_retention` ([#5702](https://github.com/earendil-works/pi/issues/5702)).
- Fixed inherited overlay compositing over CJK wide characters so borders stay aligned when an overlay starts inside a full-width cell ([#5297](https://github.com/earendil-works/pi/issues/5297)).
- Fixed inherited WezTerm inline Kitty image rendering during full redraw fallbacks so image padding rows are reserved before the placement is drawn without regressing tall-image placement ([#5618](https://github.com/earendil-works/pi/issues/5618), [#4415](https://github.com/earendil-works/pi/issues/4415)).
- Fixed custom provider config so plain uppercase API key and header values remain literals instead of being treated as legacy environment references; use explicit `$ENV_VAR` syntax for environment variables ([#5661](https://github.com/earendil-works/pi/issues/5661)).
## [0.79.3] - 2026-06-13
### Fixed
- Fixed inherited OpenAI GPT-5.4/GPT-5.5 and OpenAI Codex GPT-5.4/GPT-5.4 mini/GPT-5.5 context window metadata to use the observed 272k-token Codex backend limit, avoiding a billing hazard from prompts above Codex's accepted limit (reported by [@trethore](https://github.com/trethore)).
## [0.79.2] - 2026-06-12
### New Features
- **Clearer Bedrock validation guidance** - Amazon Bedrock data retention validation errors now link to AWS data retention documentation. See [Amazon Bedrock](docs/providers.md#amazon-bedrock).
### Added
- Added an experimental first-time setup flow behind `PI_EXPERIMENTAL=1` that asks for a dark/light theme choice (preselecting the detected appearance) and opt-in analytics data sharing on first launch with the default agent directory; opting in stores a `trackingId` in `settings.json` ([#5587](https://github.com/earendil-works/pi/pull/5587) by [@vegarsti](https://github.com/vegarsti)).
- Added AWS data retention documentation links to inherited Amazon Bedrock unsupported data retention mode validation errors ([#5561](https://github.com/earendil-works/pi/pull/5561) by [@unexge](https://github.com/unexge)).
### Fixed
- Fixed project trust detection to ignore global `~/.pi/agent` state when running from `$HOME`, and made `pi update` use only saved or explicit project trust without prompting ([#5619](https://github.com/earendil-works/pi/issues/5619)).
- Fixed experimental first-time setup to skip forked sessions instead of rerunning the setup prompts ([#5627](https://github.com/earendil-works/pi/pull/5627) by [@vegarsti](https://github.com/vegarsti)).
- Fixed inherited OpenAI-compatible context overflow detection for parenthesized `maximum context length (N)` errors ([#5677](https://github.com/earendil-works/pi/issues/5677)).
- Fixed inherited OpenAI GPT-5.4/GPT-5.5 and OpenAI Codex GPT-5.4/GPT-5.4 mini/GPT-5.5 context window metadata to match current OpenAI limits ([#5644](https://github.com/earendil-works/pi/issues/5644)).
- Fixed inherited Anthropic refusal stops to preserve provider `stop_details` explanations in error messages ([#5666](https://github.com/earendil-works/pi/pull/5666) by [@rwachtler](https://github.com/rwachtler)).
- Increased the inherited OpenAI Codex Responses SSE response-header timeout to 20 seconds to reduce false-positive stalls while retaining the bounded wait introduced for zero-event hangs ([#4945](https://github.com/earendil-works/pi/issues/4945)).
- Fixed inherited Claude Fable 5 thinking-off requests to omit Anthropic's unsupported `thinking.type: "disabled"` payload ([#5567](https://github.com/earendil-works/pi/pull/5567) by [@tmustier](https://github.com/tmustier)).
- Fixed inherited late tool progress callbacks after tool settlement to be ignored instead of emitting stale `tool_execution_update` events ([#5573](https://github.com/earendil-works/pi/issues/5573)).
- Fixed inherited user-message transcript rendering so standalone `+` messages no longer render as `-` ([#5657](https://github.com/earendil-works/pi/issues/5657)).
- Fixed inherited slash-separated fuzzy queries so provider/model completions remain matchable after insertion.
- Fixed inherited WezTerm inline Kitty image rendering so reserved row clears do not erase all but the top strip of tool image previews ([#5618](https://github.com/earendil-works/pi/issues/5618)).
- Fixed inherited editor wrapping for CJK text to break at character boundaries instead of leaving large trailing gaps ([#5585](https://github.com/earendil-works/pi/pull/5585) by [@haoqixu](https://github.com/haoqixu)).
- Fixed inherited loose Markdown list rendering to preserve blank-line separation between list items ([#5562](https://github.com/earendil-works/pi/pull/5562) by [@Perlence](https://github.com/Perlence)).
- Fixed `--model` resolution for authenticated custom model IDs whose slash prefix matches an unauthenticated built-in provider ([#5643](https://github.com/earendil-works/pi/issues/5643)).
- Fixed `/fork` to keep session parent chains connected when the forked path contains labels ([#5669](https://github.com/earendil-works/pi/issues/5669)).
- Fixed `/share` and `/export` HTML exports to use the active fallback theme when the configured custom theme no longer exists ([#5596](https://github.com/earendil-works/pi/issues/5596)).
- Fixed custom fallback model IDs with `:<thinking>` suffixes to preserve the requested thinking level when the provider template model does not advertise reasoning ([#5560](https://github.com/earendil-works/pi/pull/5560) by [@haoqixu](https://github.com/haoqixu)).
## [0.79.1] - 2026-06-09
### New Features
+17 -15
View File
@@ -7,11 +7,6 @@
<a href="https://discord.com/invite/3cU7Bz4UPx"><img alt="Discord" src="https://img.shields.io/badge/discord-community-5865F2?style=flat-square&logo=discord&logoColor=white" /></a>
<a href="https://www.npmjs.com/package/@earendil-works/pi-coding-agent"><img alt="npm" src="https://img.shields.io/npm/v/@earendil-works/pi-coding-agent?style=flat-square" /></a>
</p>
<p align="center">
<a href="https://pi.dev">pi.dev</a> domain graciously donated by
<br /><br />
<a href="https://exe.dev"><img src="docs/images/exy.png" alt="Exy mascot" width="48" /><br />exe.dev</a>
</p>
> New issues and PRs from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. See [CONTRIBUTING.md](../../CONTRIBUTING.md).
@@ -191,7 +186,8 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist
| `/clone` | Duplicate the current active branch into a new session |
| `/compact [prompt]` | Manually compact context, optional custom instructions |
| `/copy` | Copy last assistant message to clipboard |
| `/export [file]` | Export session to HTML file |
| `/export [file]` | Export session to HTML or JSONL file |
| `/import <file>` | Import and resume a session from a JSONL file |
| `/share` | Upload as private GitHub gist with shareable HTML link |
| `/reload` | Reload keybindings, extensions, skills, prompts, and context files (themes hot-reload automatically) |
| `/hotkeys` | Show all keyboard shortcuts |
@@ -291,15 +287,15 @@ See [docs/settings.md](docs/settings.md) for all options.
### Project Trust
On interactive startup, pi asks before trusting a project folder that contains project-local extensions or settings and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions.
On interactive startup, pi asks before trusting a project folder that contains project-local settings, resources, or project `.agents/skills` and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions.
Before the trust decision, pi loads only context files, user/global extensions, and CLI `-e` extensions so they can handle the `project_trust` event. Project-local extensions, project package-managed extensions, and project settings are loaded only after the project is trusted. This split also applies when switching to a session from a different cwd whose trust has not been resolved in the current process.
Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore trust-gated project inputs, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run.
Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore those project resources, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run.
If no extension or saved decision applies, `defaultProjectTrust` controls the fallback behavior. Set it to `"ask"`, `"always"`, or `"never"` in `~/.pi/agent/settings.json`, or change it with `/settings`.
`pi config` and package commands use the same project trust flow. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them.
`pi config` and package commands use the same project trust flow, except `pi update` never prompts. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them.
Use `/trust` in interactive mode to save a project trust decision for future sessions, including trust for the immediate parent folder. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect.
@@ -419,7 +415,8 @@ pi install ssh://git@github.com/user/repo@v1 # tag or commit
pi remove npm:@foo/pi-tools
pi uninstall npm:@foo/pi-tools # alias for remove
pi list
pi update # update pi and packages (skips pinned packages)
pi update # update pi only
pi update --all # update pi and packages
pi update --extensions # update packages only
pi update --self # update pi only
pi update --self --force # reinstall pi even if current
@@ -427,7 +424,7 @@ pi update npm:@foo/pi-tools # update one package
pi config # enable/disable extensions, skills, prompts, themes
```
Packages install to `~/.pi/agent/git/` (git) or `~/.pi/agent/npm/` (npm). Use `-l` for project-local installs (`.pi/git/`, `.pi/npm/`). Git `@ref` values are pinned tags or commits; pinned packages are skipped by `pi update`, so use `pi install git:host/user/repo@new-ref` to move an existing package to a new ref. Git packages install dependencies with `npm install --omit=dev` by default, so runtime deps must be listed under `dependencies`; when `npmCommand` is configured, git packages use plain `install` for compatibility with wrappers. If you use a Node version manager and want package installs to reuse a stable npm context, set `npmCommand` in `settings.json`, for example `["mise", "exec", "node@20", "--", "npm"]`.
Packages install to `~/.pi/agent/git/` (git) or `~/.pi/agent/npm/` (npm). Use `-l` for project-local installs (`.pi/git/`, `.pi/npm/`). Git `@ref` values are pinned tags or commits; pinned packages are skipped by `pi update --extensions` and `pi update --all`, so use `pi install git:host/user/repo@new-ref` to move an existing package to a new ref. Git packages install dependencies with `npm install --omit=dev` by default, so runtime deps must be listed under `dependencies`; when `npmCommand` is configured, git packages use plain `install` for compatibility with wrappers. If you use a Node version manager and want package installs to reuse a stable npm context, set `npmCommand` in `settings.json`, for example `["mise", "exec", "node@20", "--", "npm"]`.
Create a package by adding a `pi` key to `package.json`:
@@ -518,7 +515,8 @@ pi [options] [@files...] [messages...]
pi install <source> [-l] # Install package, -l for project-local
pi remove <source> [-l] # Remove package
pi uninstall <source> [-l] # Alias for remove
pi update [source|self|pi] # Update pi and packages (skips pinned packages)
pi update [source|self|pi] # Update pi only, or one package source
pi update --all # Update pi and packages
pi update --extensions # Update packages only
pi update --self # Update pi only
pi update --self --force # Reinstall pi even if current
@@ -527,7 +525,7 @@ pi list # List installed packages
pi config # Enable/disable package resources
```
`pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command.
`pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command. `pi update` never prompts for project trust.
### Modes
@@ -673,8 +671,6 @@ pi --thinking high "Solve this complex problem"
See [CONTRIBUTING.md](../../CONTRIBUTING.md) for guidelines and [docs/development.md](docs/development.md) for setup, forking, and debugging.
---
## License
MIT
@@ -684,3 +680,9 @@ MIT
- [@earendil-works/pi-ai](https://www.npmjs.com/package/@earendil-works/pi-ai): Core LLM toolkit
- [@earendil-works/pi-agent-core](https://www.npmjs.com/package/@earendil-works/pi-agent-core): Agent framework
- [@earendil-works/pi-tui](https://www.npmjs.com/package/@earendil-works/pi-tui): Terminal UI components
<p align="center">
<a href="https://pi.dev">pi.dev</a> domain graciously donated by
<br /><br />
<a href="https://exe.dev"><img src="docs/images/exy.png" alt="Exy mascot" width="48" /><br />exe.dev</a>
</p>
+3 -1
View File
@@ -276,7 +276,7 @@ Fired before auto-compaction or `/compact`. Can cancel or provide custom summary
```typescript
pi.on("session_before_compact", async (event, ctx) => {
const { preparation, branchEntries, customInstructions, signal } = event;
const { preparation, branchEntries, customInstructions, reason, willRetry, signal } = event;
// preparation.messagesToSummarize - messages to summarize
// preparation.turnPrefixMessages - split turn prefix (if isSplitTurn)
@@ -287,6 +287,8 @@ pi.on("session_before_compact", async (event, ctx) => {
// preparation.settings - compaction settings
// branchEntries - all entries on current branch (for custom state)
// reason - "manual" (/compact), "threshold", or "overflow"
// willRetry - whether the aborted turn is retried after compaction (overflow recovery)
// signal - AbortSignal (pass to LLM calls)
// Cancel:
+35 -35
View File
@@ -10,46 +10,12 @@ There are two general options. You can either
| Pattern | What is isolated | Best for | Notes |
| --- | --- | --- | --- |
| OpenShell | Whole `pi` process in a policy-controlled sandbox | Local or remote managed sandbox | Requires an OpenShell gateway |
| Gondolin extension | Built-in tools and `!` commands | Local micro-VM isolation while keeping auth on host | See [`examples/extensions/gondolin/`](../examples/extensions/gondolin/). |
| Plain Docker | Whole `pi` process in a local container | Simple local isolation | Provider API keys enter the container. |
| OpenShell | Whole `pi` process in a policy-controlled sandbox | Local or remote managed sandbox | Requires an OpenShell gateway |
Extensions run wherever the `pi` process runs. If you run host `pi` with a tool-routing extension, other custom extension tools still run on the host unless they also delegate their operations.
## OpenShell
Use [NVIDIA OpenShell](https://docs.nvidia.com/openshell/about/overview) when you want a policy-controlled sandbox with filesystem, process, network, credential, and inference controls.
OpenShell can run sandboxes through a local gateway backed by Docker, Podman, or a VM runtime, or through a remote Kubernetes gateway.
Every sandbox requires an active gateway.
Register and select one before creating a sandbox:
```bash
openshell gateway add <gateway-url> --name <name>
openshell gateway select <name>
```
Launch `pi` inside an OpenShell sandbox:
```bash
openshell sandbox create --name pi-sandbox --from pi -- pi
```
In this pattern, the whole `pi` process runs inside the sandbox.
Built-in tools, `!` commands, and extension tools execute inside the OpenShell boundary.
If the gateway is remote, project files are not bind-mounted from the host, meaning writes in the sandbox are not reflected on your machine.
Clone the repository inside the sandbox or use OpenShell file transfer commands:
```bash
openshell sandbox upload pi-sandbox ./repo /workspace
openshell sandbox download pi-sandbox /workspace/repo ./repo-out
```
OpenShell providers can keep raw model API keys outside the sandbox.
When inference routing is configured, code inside the sandbox can call `https://inference.local`, and the gateway injects the configured provider credentials upstream.
Configure Pi to use the corresponding OpenAI-compatible or Anthropic-compatible endpoint if you want model traffic to use this route.
## Gondolin
[Gondolin](https://github.com/earendil-works/gondolin) is a local Linux micro-VM.
@@ -109,3 +75,37 @@ docker run --rm -it \
The `-v "$PWD:/workspace"` mounts your current directory into the container at /workspace such that reads and writes in `/workspace` inside Docker directly affect your host files, like in the Gondolin example.
Use a named volume for `/root/.pi/agent` if you want container-local settings and sessions. Mounting your host `~/.pi/agent` exposes host auth and session files to the container.
## OpenShell
Use [NVIDIA OpenShell](https://docs.nvidia.com/openshell/about/overview) when you want a policy-controlled sandbox with filesystem, process, network, credential, and inference controls.
OpenShell can run sandboxes through a local gateway backed by Docker, Podman, or a VM runtime, or through a remote Kubernetes gateway.
Every sandbox requires an active gateway.
Register and select one before creating a sandbox:
```bash
openshell gateway add <gateway-url> --name <name>
openshell gateway select <name>
```
Launch `pi` inside an OpenShell sandbox:
```bash
openshell sandbox create --name pi-sandbox --from pi -- pi
```
In this pattern, the whole `pi` process runs inside the sandbox.
Built-in tools, `!` commands, and extension tools execute inside the OpenShell boundary.
If the gateway is remote, project files are not bind-mounted from the host, meaning writes in the sandbox are not reflected on your machine.
Clone the repository inside the sandbox or use OpenShell file transfer commands:
```bash
openshell sandbox upload pi-sandbox ./repo /workspace
openshell sandbox download pi-sandbox /workspace/repo ./repo-out
```
OpenShell providers can keep raw model API keys outside the sandbox.
When inference routing is configured, code inside the sandbox can call `https://inference.local`, and the gateway injects the configured provider credentials upstream.
Configure Pi to use the corresponding OpenAI-compatible or Anthropic-compatible endpoint if you want model traffic to use this route.
@@ -229,7 +229,7 @@ models: [{
}]
```
Use `openrouter` for OpenRouter-style `reasoning: { effort }` controls. Use `together` for Together-style `reasoning: { enabled }` controls; with `supportsReasoningEffort`, it also sends `reasoning_effort`. Use `qwen-chat-template` instead for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking`.
Use `openrouter` for OpenRouter-style `reasoning: { effort }` controls. Use `together` for Together-style `reasoning: { enabled }` controls; with `supportsReasoningEffort`, it also sends `reasoning_effort`. Use `qwen-chat-template` for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking` and need `preserve_thinking`.
Use `cacheControlFormat: "anthropic"` for OpenAI-compatible providers that expose Anthropic-style prompt caching via `cache_control` on the system prompt, last tool definition, and last user/assistant text content.
For Anthropic-compatible providers using `api: "anthropic-messages"`, set `compat.forceAdaptiveThinking: true` on models or providers whose upstream model requires adaptive thinking (`thinking.type: "adaptive"` plus `output_config.effort`). Built-in adaptive Claude models set this automatically. Set `compat.allowEmptySignature: true` only for providers that emit empty thinking signatures and expect `signature: ""` on replay.
@@ -718,7 +718,8 @@ interface ProviderModelConfig {
requiresAssistantAfterToolResult?: boolean;
requiresThinkingAsText?: boolean;
requiresReasoningContentOnAssistantMessages?: boolean;
thinkingFormat?: "openai" | "openrouter" | "deepseek" | "together" | "zai" | "qwen" | "qwen-chat-template";
thinkingFormat?: "openai" | "openrouter" | "deepseek" | "together" | "zai" | "qwen" | "chat-template" | "qwen-chat-template" | "string-thinking" | "ant-ling";
chatTemplateKwargs?: Record<string, string | number | boolean | null | { "$var": "thinking.enabled" | "thinking.effort"; omitWhenOff?: boolean }>;
cacheControlFormat?: "anthropic";
// anthropic-messages
@@ -732,5 +733,5 @@ interface ProviderModelConfig {
}
```
`openrouter` sends `reasoning: { effort }`. `deepseek` sends `thinking: { type: "enabled" | "disabled" }` and `reasoning_effort` when enabled. `together` sends `reasoning: { enabled }` and also `reasoning_effort` when `supportsReasoningEffort` is enabled. `qwen` is for DashScope-style top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking`.
`openrouter` sends `reasoning: { effort }`. `deepseek` sends `thinking: { type: "enabled" | "disabled" }` and `reasoning_effort` when enabled. `together` sends `reasoning: { enabled }` and also `reasoning_effort` when `supportsReasoningEffort` is enabled. `qwen` is for DashScope-style top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking` and need `preserve_thinking`. Use `chat-template` for configurable `chat_template_kwargs`, for example DeepSeek V3.x behind vLLM with `chatTemplateKwargs: { "thinking": { "$var": "thinking.enabled" } }`.
`cacheControlFormat: "anthropic"` applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user/assistant text content.
+31 -6
View File
@@ -216,6 +216,12 @@ export default async function (pi: ExtensionAPI) {
This pattern makes the fetched models available during normal startup and to `pi --list-models`.
### Long-lived resources and shutdown
Extension factories may run in invocations that never start a session. Do not start background resources such as processes, sockets, file watchers, or timers from the factory.
Defer background resource startup until `session_start` or the command/tool/event that needs the resource. Register an idempotent `session_shutdown` handler to close any session-scoped resources you start.
### Extension Styles
**Single file** - simplest, for small extensions:
@@ -431,7 +437,10 @@ Fired on compaction. See [compaction.md](compaction.md) for details.
```typescript
pi.on("session_before_compact", async (event, ctx) => {
const { preparation, branchEntries, customInstructions, signal } = event;
const { preparation, branchEntries, customInstructions, reason, willRetry, signal } = event;
// reason - "manual" (/compact), "threshold", or "overflow"
// willRetry - whether the aborted turn is retried after compaction (overflow recovery)
// Cancel:
return { cancel: true };
@@ -449,6 +458,8 @@ pi.on("session_before_compact", async (event, ctx) => {
pi.on("session_compact", async (event, ctx) => {
// event.compactionEntry - the saved compaction
// event.fromExtension - whether extension provided it
// event.reason - "manual" (/compact), "threshold", or "overflow"
// event.willRetry - whether the aborted turn is retried after compaction (overflow recovery)
});
```
@@ -471,7 +482,7 @@ pi.on("session_tree", async (event, ctx) => {
#### session_shutdown
Fired before an extension runtime is torn down.
Fired before a started session runtime is torn down. Use this to clean up resources opened from `session_start` or other session-scoped hooks.
```typescript
pi.on("session_shutdown", async (event, ctx) => {
@@ -892,6 +903,20 @@ Current run mode: `"tui"`, `"rpc"`, `"json"`, or `"print"`. Use `ctx.mode === "t
Current working directory.
Use `CONFIG_DIR_NAME` instead of hardcoding `.pi` when constructing project-local config paths. Rebranded distributions can use a different config directory name.
```typescript
import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { join } from "node:path";
export default function (pi: ExtensionAPI) {
pi.on("session_start", (_event, ctx) => {
const projectConfigPath = join(ctx.cwd, CONFIG_DIR_NAME, "my-extension.json");
// ...
});
}
```
### ctx.isProjectTrusted()
Returns whether project-local trust is active for the current session context. This includes temporary trust decisions and CLI trust overrides, not just saved decisions in the global trust store.
@@ -1528,21 +1553,21 @@ const result = await pi.exec("git", ["status"], { signal, timeout: 5000 });
### pi.getActiveTools() / pi.getAllTools() / pi.setActiveTools(names)
Manage active tools. This works for both built-in tools and dynamically registered tools.
Manage active tools. This works for both built-in tools and dynamically registered tools. `pi.getActiveTools()` returns the active tool names as `string[]`; `pi.getAllTools()` returns metadata for all configured tools.
```typescript
const active = pi.getActiveTools();
const active = pi.getActiveTools(); // ["read", "bash", ...]
const all = pi.getAllTools();
// [{
// all = [{
// name: "read",
// description: "Read file contents...",
// parameters: ...,
// promptGuidelines: ["Use read to examine files instead of cat or sed."],
// sourceInfo: { path: "<builtin:read>", source: "builtin", scope: "temporary", origin: "top-level" }
// }, ...]
const names = all.map(t => t.name);
const builtinTools = all.filter((t) => t.sourceInfo.source === "builtin");
const extensionTools = all.filter((t) => t.sourceInfo.source !== "builtin" && t.sourceInfo.source !== "sdk");
pi.setActiveTools([...new Set([...active, "my_custom_tool"])]); // Keep current tools and enable my_custom_tool
pi.setActiveTools(["read", "bash"]); // Switch to read-only
```
+1 -1
View File
@@ -42,7 +42,7 @@ For the full first-run flow, see [Quickstart](quickstart.md).
- [Using Pi](usage.md) - interactive mode, slash commands, context files, and CLI reference.
- [Providers](providers.md) - subscription and API-key setup for built-in providers.
- [Security](security.md) - project trust, sandbox boundaries, and vulnerability reporting.
- [Containerization](containerization.md) - sandbox pi with OpenShell, Gondolin, or Docker.
- [Containerization](containerization.md) - sandbox pi with Gondolin, Docker, or OpenShell.
- [Settings](settings.md) - global and project settings.
- [Keybindings](keybindings.md) - default shortcuts and custom keybindings.
- [Sessions](sessions.md) - session management, branching, and tree navigation.
+4 -5
View File
@@ -161,13 +161,11 @@ The `apiKey` and `headers` fields support command execution, environment interpo
"apiKey": "$$literal-dollar-prefix"
"apiKey": "$!literal-bang-prefix"
```
- **Literal value:** Used directly
- **Literal value:** Used directly. Plain uppercase strings such as `MY_API_KEY` are literals; use `$MY_API_KEY` for environment variables.
```json
"apiKey": "sk-..."
```
Legacy uppercase env-var-like values such as `MY_API_KEY` are migrated to `$MY_API_KEY` on startup.
For `models.json`, shell commands are resolved at request time. pi intentionally does not apply built-in TTL, stale reuse, or recovery logic for arbitrary commands. Different commands need different caching and failure strategies, and pi cannot infer the right one.
If your command is slow, expensive, rate-limited, or should keep using a previous value on transient failures, wrap it in your own script or command that implements the caching or TTL behavior you want.
@@ -401,14 +399,15 @@ For providers with partial OpenAI compatibility, use the `compat` field.
| `requiresAssistantAfterToolResult` | Insert an assistant message before a user message after tool results |
| `requiresThinkingAsText` | Convert thinking blocks to plain text |
| `requiresReasoningContentOnAssistantMessages` | Include empty `reasoning_content` on all replayed assistant messages when reasoning is enabled |
| `thinkingFormat` | Use `reasoning_effort`, `openrouter`, `deepseek`, `together`, `zai`, `qwen`, or `qwen-chat-template` thinking parameters |
| `thinkingFormat` | Use `reasoning_effort`, `openrouter`, `deepseek`, `together`, `zai`, `qwen`, `chat-template`, or `qwen-chat-template` thinking parameters |
| `chatTemplateKwargs` | `chat_template_kwargs` values for `thinkingFormat: "chat-template"`; use `{ "$var": "thinking.enabled" }` or `{ "$var": "thinking.effort" }` for pi-controlled thinking values |
| `cacheControlFormat` | Use Anthropic-style `cache_control` markers on the system prompt, last tool definition, and last user/assistant text content. Currently only `anthropic` is supported. |
| `supportsStrictMode` | Include the `strict` field in tool definitions |
| `supportsLongCacheRetention` | Whether the provider accepts long cache retention when cache retention is `long`: `prompt_cache_retention: "24h"` for OpenAI prompt caching, or `cache_control.ttl: "1h"` when `cacheControlFormat` is `anthropic`. Default: `true`. |
| `openRouterRouting` | OpenRouter provider routing preferences. This object is sent as-is in the `provider` field of the [OpenRouter API request](https://openrouter.ai/docs/guides/routing/provider-selection). |
| `vercelGatewayRouting` | Vercel AI Gateway routing config for provider selection (`only`, `order`) |
`openrouter` uses `reasoning: { effort }`. `together` uses `reasoning: { enabled }` and also `reasoning_effort` when `supportsReasoningEffort` is enabled. `qwen` uses top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that require `chat_template_kwargs.enable_thinking`.
`openrouter` uses `reasoning: { effort }`. `together` uses `reasoning: { enabled }` and also `reasoning_effort` when `supportsReasoningEffort` is enabled. `qwen` uses top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that require `chat_template_kwargs.enable_thinking` and `preserve_thinking`. Use `chat-template` for vLLM/Hugging Face chat templates that need configurable `chat_template_kwargs`, such as `chatTemplateKwargs: { "thinking": { "$var": "thinking.enabled" } }` for DeepSeek V3.x templates.
`cacheControlFormat: "anthropic"` is for OpenAI-compatible providers that expose Anthropic-style prompt caching through `cache_control` markers on text content and tool definitions.
+5 -4
View File
@@ -28,7 +28,8 @@ pi install ./relative/path/to/package
pi remove npm:@foo/bar
pi list # show installed packages from settings
pi update # update pi, update packages, and reconcile pinned git refs
pi update # update pi only
pi update --all # update pi, update packages, and reconcile pinned git refs
pi update --extensions # update packages and reconcile pinned git refs only
pi update --self # update pi only
pi update --self --force # reinstall pi even if current
@@ -36,7 +37,7 @@ pi update npm:@foo/bar # update one package
pi update --extension npm:@foo/bar
```
These commands manage pi packages, not the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall).
These commands manage pi packages and `pi update` can update the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall).
By default, `install` and `remove` write to user settings (`~/.pi/agent/settings.json`). Use `-l` to write to project settings (`.pi/settings.json`) instead. Project settings can be shared with your team, and pi installs any missing packages automatically on startup after the project is trusted.
@@ -58,7 +59,7 @@ npm:@scope/pkg@1.2.3
npm:pkg
```
- Versioned specs are pinned and skipped by package updates (`pi update`, `pi update --extensions`).
- Versioned specs are pinned and skipped by package updates (`pi update --extensions`, `pi update --all`).
- User installs go under `~/.pi/agent/npm/`.
- Project installs go under `.pi/npm/`.
- Set `npmCommand` in `settings.json` to pin npm package lookup and install operations to a specific wrapper command such as `mise` or `asdf`.
@@ -85,7 +86,7 @@ ssh://git@github.com/user/repo@v1
- HTTPS and SSH URLs are both supported.
- SSH URLs use your configured SSH keys automatically (respects `~/.ssh/config`).
- For non-interactive runs (for example CI), you can set `GIT_TERMINAL_PROMPT=0` to disable credential prompts and set `GIT_SSH_COMMAND` (for example `ssh -o BatchMode=yes -o ConnectTimeout=5`) to fail fast.
- Refs are pinned tags or commits. `pi update` and `pi update --extensions` do not move them to newer refs, but they do reconcile an existing clone to the configured ref.
- Refs are pinned tags or commits. `pi update --extensions` and `pi update --all` do not move them to newer refs, but they do reconcile an existing clone to the configured ref.
- Use `pi install git:host/user/repo@new-ref` to update settings and move an existing package to a new pinned ref.
- Cloned to `~/.pi/agent/git/<host>/<path>` (global) or `.pi/git/<host>/<path>` (project).
- When reconciliation changes the checkout, pi resets and cleans the clone, then runs `npm install` if `package.json` exists.
+22 -4
View File
@@ -104,6 +104,24 @@ Store credentials in `~/.pi/agent/auth.json`:
The file is created with `0600` permissions (user read/write only). Auth file credentials take priority over environment variables.
API key credentials can also include provider-scoped environment values. These values are used before process environment variables when resolving the credential key, provider/model headers, and provider configuration such as Cloudflare account IDs, Azure OpenAI settings, Vertex project/location, Bedrock settings, `PI_CACHE_RETENTION`, and `HTTP_PROXY`/`HTTPS_PROXY`.
```json
{
"cloudflare-ai-gateway": {
"type": "api_key",
"key": "$CLOUDFLARE_API_KEY",
"env": {
"CLOUDFLARE_API_KEY": "...",
"CLOUDFLARE_ACCOUNT_ID": "account-id",
"CLOUDFLARE_GATEWAY_ID": "gateway-id"
}
}
}
```
Use this when pi should use different provider settings than the project shell environment.
### Key Resolution
The `key` field supports command execution, environment interpolation, and literals:
@@ -124,13 +142,13 @@ The `key` field supports command execution, environment interpolation, and liter
{ "type": "api_key", "key": "$$literal-dollar-prefix" }
{ "type": "api_key", "key": "$!literal-bang-prefix" }
```
- **Literal value:** Used directly
- **Literal value:** Used directly. Plain uppercase strings such as `MY_API_KEY` are literals; use `$MY_API_KEY` for environment variables.
```json
{ "type": "api_key", "key": "sk-ant-..." }
{ "type": "api_key", "key": "public" }
```
Legacy uppercase env-var-like values such as `MY_API_KEY` are migrated to `$MY_API_KEY` on startup. OAuth credentials are also stored here after `/login` and managed automatically.
OAuth credentials are also stored here after `/login` and managed automatically.
## Cloud Providers
@@ -194,7 +212,7 @@ export AWS_BEDROCK_FORCE_HTTP1=1
### Cloudflare AI Gateway
`CLOUDFLARE_API_KEY` can be set via `/login`. The account ID and gateway slug must be set as environment variables.
`CLOUDFLARE_API_KEY` can be set via `/login`. The account ID and gateway slug can be set as environment variables or in the API key credential's `env` object in `auth.json`.
```bash
export CLOUDFLARE_API_KEY=... # or use /login
@@ -218,7 +236,7 @@ For normal pi usage, prefer unified billing or stored BYOK. Inline BYOK requires
### Cloudflare Workers AI
`CLOUDFLARE_API_KEY` can be set via `/login`. `CLOUDFLARE_ACCOUNT_ID` must be set as an environment variable.
`CLOUDFLARE_API_KEY` can be set via `/login`. `CLOUDFLARE_ACCOUNT_ID` can be set as an environment variable or in the API key credential's `env` object in `auth.json`.
```bash
export CLOUDFLARE_API_KEY=... # or use /login
+4
View File
@@ -374,11 +374,14 @@ Response:
"summary": "Summary of conversation...",
"firstKeptEntryId": "abc123",
"tokensBefore": 150000,
"estimatedTokensAfter": 32000,
"details": {}
}
}
```
`estimatedTokensAfter` is a heuristic estimate over the rebuilt message context immediately after compaction, not a provider-exact token count.
#### set_auto_compaction
Enable or disable automatic compaction when context is nearly full.
@@ -924,6 +927,7 @@ The `reason` field is `"manual"`, `"threshold"`, or `"overflow"`.
"summary": "Summary of conversation...",
"firstKeptEntryId": "abc123",
"tokensBefore": 150000,
"estimatedTokensAfter": 32000,
"details": {}
},
"aborted": false,
+2 -1
View File
@@ -1110,7 +1110,8 @@ DefaultResourceLoader
type ResourceLoader
createEventBus
// Helpers
// Constants and helpers
CONFIG_DIR_NAME
defineTool
getAgentDir
getPackageDir
+10 -6
View File
@@ -6,14 +6,18 @@ Pi is a local coding agent. It runs with the permissions of the user account tha
Project trust controls whether pi loads project-local settings, resources, packages, and extensions. It is not a sandbox and it does not restrict what the model can ask tools to do after you start working in a directory.
Pi considers a project to have trust inputs when it finds any of these from the current working directory:
Pi considers a project to have resources that require trust when it finds any of these from the current working directory:
- `.pi/` in the current directory
- `.agents/skills` in the current directory or an ancestor directory
- `.pi/settings.json`
- `.pi/extensions`, `.pi/skills`, `.pi/prompts`, or `.pi/themes`
- `.pi/SYSTEM.md` or `.pi/APPEND_SYSTEM.md`
- project `.agents/skills` in the current directory or an ancestor directory
When an interactive session starts in a project with configs in `.pi` or `.agents/skills` and no saved decision for the current directory or a parent directory, pi follows `defaultProjectTrust` from global settings. The default value is `"ask"`, which asks whether to trust the project when UI is available. Saved decisions are stored by canonical directory in `~/.pi/agent/trust.json`, and the closest saved decision on the current or parent path applies before the global default.
A bare `.pi` directory does not count as a project resource that requires trust.
Trusting a project allows pi to load trust-gated project inputs, including:
When an interactive session starts in a project with resources that require trust and no saved decision for the current directory or a parent directory, pi follows `defaultProjectTrust` from global settings. The default value is `"ask"`, which asks whether to trust the project when UI is available. Saved decisions are stored by canonical directory in `~/.pi/agent/trust.json`, and the closest saved decision on the current or parent path applies before the global default.
Trusting a project allows pi to load project resources that require trust, including:
- `.pi/settings.json`
- `.pi` resources such as extensions, skills, prompt templates, themes, and system prompt files
@@ -38,7 +42,7 @@ For untrusted repositories, generated code you do not intend to monitor closely,
Common patterns are documented in [Containerization](containerization.md):
- run the whole `pi` process inside OpenShell or Docker
- run the whole `pi` process inside a container/sandbox
- run host pi while routing built-in tool execution into a Gondolin micro-VM
- mount only the workspace paths the agent should access
- avoid mounting host `~/.pi/agent` unless the container should access host sessions, settings, and credentials
+15 -3
View File
@@ -11,13 +11,13 @@ Edit directly or use `/settings` for common options.
## Project Trust
On interactive startup, pi asks before trusting a project folder that contains trust-gated project inputs and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions.
On interactive startup, pi asks before trusting a project folder that contains project-local settings, resources, or project `.agents/skills` and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions.
Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore trust-gated project inputs, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run.
Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore those project resources, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run.
If no extension or saved decision applies, `defaultProjectTrust` controls the fallback behavior. Set it to `"ask"`, `"always"`, or `"never"` in `~/.pi/agent/settings.json`, or change it with `/settings`.
`pi config` and package commands use the same project trust flow. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them.
`pi config` and package commands use the same project trust flow, except `pi update` never prompts. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them.
Use `/trust` in interactive mode to save a project trust decision for future sessions, including trust for the immediate parent folder. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect.
@@ -69,6 +69,18 @@ Use `/trust` in interactive mode to save a project trust decision for future ses
Set `PI_SKIP_VERSION_CHECK=1` to disable the Pi version update check. Use `--offline` or `PI_OFFLINE=1` to disable all startup network operations described here, including update checks, package update checks, and install/update telemetry.
### Network
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `httpProxy` | string | - | HTTP proxy URL applied as `HTTP_PROXY` and `HTTPS_PROXY`. Global setting only. |
```json
{
"httpProxy": "http://127.0.0.1:7890"
}
```
### Warnings
| Setting | Type | Default | Description |
+1 -1
View File
@@ -137,7 +137,7 @@ vim ~/.pi/agent/themes/my-theme.json
}
```
- `name` is required and must be unique.
- `name` is required, must be unique, and must not contain `/`.
- `vars` is optional. Define reusable colors here, then reference them in `colors`.
- `colors` must define all 51 required tokens.
+4 -4
View File
@@ -257,7 +257,7 @@ md.setText("Updated markdown");
### Image
Renders images in supported terminals (Kitty, iTerm2, Ghostty, WezTerm).
Renders images in supported terminals (Kitty, iTerm2, Ghostty, WezTerm, Warp).
```typescript
const image = new Image(
@@ -742,7 +742,7 @@ ctx.ui.setStatus("my-ext", ctx.ui.theme.fg("accent", "● active"));
ctx.ui.setStatus("my-ext", undefined);
```
**Examples:** [status-line.ts](../examples/extensions/status-line.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts)
**Examples:** [status-line.ts](../examples/extensions/status-line.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts)
### Pattern 4b: Working Indicator Customization
@@ -802,7 +802,7 @@ ctx.ui.setWidget("my-widget", (_tui, theme) => {
ctx.ui.setWidget("my-widget", undefined);
```
**Examples:** [plan-mode.ts](../examples/extensions/plan-mode.ts)
**Examples:** [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts)
### Pattern 6: Custom Footer
@@ -919,7 +919,7 @@ export default function (pi: ExtensionAPI) {
- **Selection UI**: [examples/extensions/preset.ts](../examples/extensions/preset.ts) - SelectList with DynamicBorder framing
- **Async with cancel**: [examples/extensions/qna.ts](../examples/extensions/qna.ts) - BorderedLoader for LLM calls
- **Settings toggles**: [examples/extensions/tools.ts](../examples/extensions/tools.ts) - SettingsList for tool enable/disable
- **Status indicators**: [examples/extensions/plan-mode.ts](../examples/extensions/plan-mode.ts) - setStatus and setWidget
- **Status indicators**: [examples/extensions/plan-mode/index.ts](../examples/extensions/plan-mode/index.ts) - setStatus and setWidget
- **Working indicator**: [examples/extensions/working-indicator.ts](../examples/extensions/working-indicator.ts) - setWorkingIndicator
- **Custom footer**: [examples/extensions/custom-footer.ts](../examples/extensions/custom-footer.ts) - setFooter with stats
- **Custom editor**: [examples/extensions/modal-editor.ts](../examples/extensions/modal-editor.ts) - Vim-like modal editing
+9 -6
View File
@@ -44,11 +44,13 @@ Type `/` in the editor to open command completion. Extensions can register custo
| `/name <name>` | Set session display name |
| `/session` | Show session file, ID, messages, tokens, and cost |
| `/tree` | Jump to any point in the session and continue from there |
| `/trust` | Save project trust decision for future sessions |
| `/fork` | Create a new session from a previous user message |
| `/clone` | Duplicate the current active branch into a new session |
| `/compact [prompt]` | Manually compact context, optionally with custom instructions |
| `/copy` | Copy last assistant message to clipboard |
| `/export [file]` | Export session to HTML |
| `/export [file]` | Export session to HTML or JSONL |
| `/import <file>` | Import and resume a session from a JSONL file |
| `/share` | Upload as private GitHub gist with shareable HTML link |
| `/reload` | Reload keybindings, extensions, skills, prompts, and context files |
| `/hotkeys` | Show all keyboard shortcuts |
@@ -112,15 +114,15 @@ Append to the default prompt without replacing it with `APPEND_SYSTEM.md` in eit
### Project Trust
On interactive startup, pi asks before trusting a project folder that contains project-local extensions or settings and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions.
On interactive startup, pi asks before trusting a project folder that contains project-local settings, resources, or project `.agents/skills` and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions.
Before the trust decision, pi loads only context files, user/global extensions, and CLI `-e` extensions so they can handle the `project_trust` event. Project-local extensions, project package-managed extensions, and project settings are loaded only after the project is trusted. This split also applies when switching to a session from a different cwd whose trust has not been resolved in the current process.
Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore trust-gated project inputs, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run.
Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore those project resources, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run.
If no extension or saved decision applies, `defaultProjectTrust` controls the fallback behavior. Set it to `"ask"`, `"always"`, or `"never"` in `~/.pi/agent/settings.json`, or change it with `/settings`.
`pi config` and package commands use the same project trust flow. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them.
`pi config` and package commands use the same project trust flow, except `pi update` never prompts. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them.
Use `/trust` in interactive mode to save a project trust decision for future sessions, including trust for the immediate parent folder. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect.
@@ -145,7 +147,8 @@ pi [options] [@files...] [messages...]
pi install <source> [-l] # Install package, -l for project-local
pi remove <source> [-l] # Remove package
pi uninstall <source> [-l] # Alias for remove
pi update [source|self|pi] # Update pi and packages; reconcile pinned git refs
pi update [source|self|pi] # Update pi only, or one package source
pi update --all # Update pi and packages; reconcile pinned git refs
pi update --extensions # Update packages only; reconcile pinned git refs
pi update --self # Update pi only
pi update --extension <src> # Update one package
@@ -153,7 +156,7 @@ pi list # List installed packages
pi config # Enable/disable package resources
```
These commands manage pi packages, not the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). `pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command.
These commands manage pi packages and `pi update` can update the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). `pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command. `pi update` never prompts for project trust.
See [Pi Packages](packages.md) for package sources and security notes.
@@ -1,12 +1,12 @@
{
"name": "pi-extension-custom-provider",
"version": "0.79.1",
"version": "0.79.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-custom-provider",
"version": "0.79.1",
"version": "0.79.10",
"dependencies": {
"@anthropic-ai/sdk": "^0.52.0"
}
@@ -1,7 +1,7 @@
{
"name": "pi-extension-custom-provider-anthropic",
"private": true,
"version": "0.79.1",
"version": "0.79.10",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
@@ -1,7 +1,7 @@
{
"name": "pi-extension-custom-provider-gitlab-duo",
"private": true,
"version": "0.79.1",
"version": "0.79.10",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
@@ -1,12 +1,12 @@
{
"name": "pi-extension-gondolin",
"version": "0.79.1",
"version": "0.79.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-gondolin",
"version": "0.79.1",
"version": "0.79.10",
"dependencies": {
"@earendil-works/gondolin": "0.12.0"
}
@@ -1,7 +1,7 @@
{
"name": "pi-extension-gondolin",
"private": true,
"version": "0.79.1",
"version": "0.79.10",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
@@ -4,7 +4,7 @@ Read-only exploration mode for safe code analysis.
## Features
- **Read-only tools**: Restricts available tools to read, bash, grep, find, ls, question
- **Built-in write tools disabled**: Disables edit/write while preserving other active tools
- **Bash allowlist**: Only read-only bash commands are allowed
- **Plan extraction**: Extracts numbered steps from `Plan:` sections
- **Progress tracking**: Widget shows completion status during execution
@@ -37,7 +37,8 @@ Plan:
## How It Works
### Plan Mode (Read-Only)
- Only read-only tools available
- Built-in edit/write tools disabled
- Other active tools remain available
- Bash commands filtered through allowlist
- Agent creates a plan without making changes
@@ -2,7 +2,7 @@
* Plan Mode Extension
*
* Read-only exploration mode for safe code analysis.
* When enabled, only read-only tools are available.
* When enabled, built-in write tools are disabled.
*
* Features:
* - /plan command or Ctrl+Alt+P to toggle
@@ -21,6 +21,15 @@ import { extractTodoItems, isSafeCommand, markCompletedSteps, type TodoItem } fr
// Tools
const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls", "questionnaire"];
const NORMAL_MODE_TOOLS = ["read", "bash", "edit", "write"];
const PLAN_MODE_DISABLED_TOOLS = new Set<string>(["edit", "write"]);
const PLAN_MANAGED_TOOLS = new Set<string>([...PLAN_MODE_TOOLS, ...NORMAL_MODE_TOOLS]);
interface PlanModeState {
enabled: boolean;
todos?: TodoItem[];
executing?: boolean;
toolsBeforePlanMode?: string[];
}
// Type guard for assistant messages
function isAssistantMessage(m: AgentMessage): m is AssistantMessage {
@@ -39,6 +48,7 @@ export default function planModeExtension(pi: ExtensionAPI): void {
let planModeEnabled = false;
let executionMode = false;
let todoItems: TodoItem[] = [];
let toolsBeforePlanMode: string[] | undefined;
pi.registerFlag("plan", {
description: "Start in plan mode (read-only exploration)",
@@ -73,19 +83,34 @@ export default function planModeExtension(pi: ExtensionAPI): void {
}
}
function togglePlanMode(ctx: ExtensionContext): void {
planModeEnabled = !planModeEnabled;
executionMode = false;
todoItems = [];
function uniqueToolNames(toolNames: string[]): string[] {
return [...new Set(toolNames)];
}
if (planModeEnabled) {
pi.setActiveTools(PLAN_MODE_TOOLS);
ctx.ui.notify(`Plan mode enabled. Tools: ${PLAN_MODE_TOOLS.join(", ")}`);
} else {
pi.setActiveTools(NORMAL_MODE_TOOLS);
ctx.ui.notify("Plan mode disabled. Full access restored.");
function getPlanModeTools(activeToolNames: string[]): string[] {
return uniqueToolNames([
...activeToolNames.filter((name) => !PLAN_MODE_DISABLED_TOOLS.has(name)),
...PLAN_MODE_TOOLS,
]);
}
function getNormalModeTools(activeToolNames: string[]): string[] {
return uniqueToolNames([
...NORMAL_MODE_TOOLS,
...activeToolNames.filter((name) => !PLAN_MANAGED_TOOLS.has(name)),
]);
}
function enablePlanModeTools(): void {
if (toolsBeforePlanMode === undefined) {
toolsBeforePlanMode = pi.getActiveTools();
}
updateStatus(ctx);
pi.setActiveTools(getPlanModeTools(toolsBeforePlanMode));
}
function restoreNormalModeTools(): void {
pi.setActiveTools(toolsBeforePlanMode ?? getNormalModeTools(pi.getActiveTools()));
toolsBeforePlanMode = undefined;
}
function persistState(): void {
@@ -93,9 +118,26 @@ export default function planModeExtension(pi: ExtensionAPI): void {
enabled: planModeEnabled,
todos: todoItems,
executing: executionMode,
toolsBeforePlanMode,
});
}
function togglePlanMode(ctx: ExtensionContext): void {
planModeEnabled = !planModeEnabled;
executionMode = false;
todoItems = [];
if (planModeEnabled) {
enablePlanModeTools();
ctx.ui.notify("Plan mode enabled. Built-in write tools disabled.");
} else {
restoreNormalModeTools();
ctx.ui.notify("Plan mode disabled. Full access restored.");
}
updateStatus(ctx);
persistState();
}
pi.registerCommand("plan", {
description: "Toggle plan mode (read-only exploration)",
handler: async (_args, ctx) => togglePlanMode(ctx),
@@ -165,8 +207,8 @@ export default function planModeExtension(pi: ExtensionAPI): void {
You are in plan mode - a read-only exploration mode for safe code analysis.
Restrictions:
- You can only use: read, bash, grep, find, ls, questionnaire
- You CANNOT use: edit, write (file modifications are disabled)
- Built-in edit and write tools are disabled
- Other currently active tools remain available
- Bash is restricted to an allowlist of read-only commands
Ask clarifying questions using the questionnaire tool.
@@ -228,7 +270,6 @@ After completing a step, include a [DONE:n] tag in your response.`,
);
executionMode = false;
todoItems = [];
pi.setActiveTools(NORMAL_MODE_TOOLS);
updateStatus(ctx);
persistState(); // Save cleared state so resume doesn't restore old execution mode
}
@@ -246,43 +287,51 @@ After completing a step, include a [DONE:n] tag in your response.`,
}
}
if (todoItems.length === 0) return;
persistState();
// Show plan steps and prompt for next action
if (todoItems.length > 0) {
const todoListText = todoItems.map((t, i) => `${i + 1}. ☐ ${t.text}`).join("\n");
pi.sendMessage(
{
customType: "plan-todo-list",
content: `**Plan Steps (${todoItems.length}):**\n\n${todoListText}`,
display: true,
},
{ triggerTurn: false },
);
}
const todoListText = todoItems.map((t, i) => `${i + 1}. ☐ ${t.text}`).join("\n");
const planTodoListMessage = {
customType: "plan-todo-list",
content: `**Plan Steps (${todoItems.length}):**\n\n${todoListText}`,
display: true,
};
const choice = await ctx.ui.select("Plan mode - what next?", [
todoItems.length > 0 ? "Execute the plan (track progress)" : "Execute the plan",
"Execute the plan (track progress)",
"Stay in plan mode",
"Refine the plan",
]);
if (choice?.startsWith("Execute")) {
planModeEnabled = false;
executionMode = todoItems.length > 0;
pi.setActiveTools(NORMAL_MODE_TOOLS);
updateStatus(ctx);
const firstTodoItem = todoItems[0];
if (!firstTodoItem) return;
const execMessage =
todoItems.length > 0
? `Execute the plan. Start with: ${todoItems[0].text}`
: "Execute the plan you just created.";
planModeEnabled = false;
executionMode = true;
restoreNormalModeTools();
updateStatus(ctx);
persistState();
const remainingList = todoItems.map((t) => `${t.step}. ${t.text}`).join("\n");
const execMessage = `Execute the plan.
Remaining steps:
${remainingList}
Start with: ${firstTodoItem.text}
After completing a step, include a [DONE:n] tag in your response.`;
pi.sendMessage(planTodoListMessage, { deliverAs: "followUp" });
pi.sendMessage(
{ customType: "plan-mode-execute", content: execMessage, display: true },
{ triggerTurn: true },
{ triggerTurn: true, deliverAs: "followUp" },
);
} else if (choice === "Refine the plan") {
const refinement = await ctx.ui.editor("Refine the plan:", "");
if (refinement?.trim()) {
pi.sendUserMessage(refinement.trim());
pi.sendMessage(planTodoListMessage, { deliverAs: "followUp" });
pi.sendUserMessage(refinement.trim(), { deliverAs: "followUp" });
}
}
});
@@ -298,12 +347,13 @@ After completing a step, include a [DONE:n] tag in your response.`,
// Restore persisted state
const planModeEntry = entries
.filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "plan-mode")
.pop() as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean } } | undefined;
.pop() as { data?: PlanModeState } | undefined;
if (planModeEntry?.data) {
planModeEnabled = planModeEntry.data.enabled ?? planModeEnabled;
todoItems = planModeEntry.data.todos ?? todoItems;
executionMode = planModeEntry.data.executing ?? executionMode;
toolsBeforePlanMode = planModeEntry.data.toolsBeforePlanMode ?? toolsBeforePlanMode;
}
// On resume: re-scan messages to rebuild completion state
@@ -333,7 +383,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
}
if (planModeEnabled) {
pi.setActiveTools(PLAN_MODE_TOOLS);
enablePlanModeTools();
}
updateStatus(ctx);
});
@@ -42,7 +42,7 @@ import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type { Api, Model } from "@earendil-works/pi-ai";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { DynamicBorder, getAgentDir } from "@earendil-works/pi-coding-agent";
import { CONFIG_DIR_NAME, DynamicBorder, getAgentDir } from "@earendil-works/pi-coding-agent";
import { Container, Key, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
// Preset configuration
@@ -69,7 +69,7 @@ interface PresetsConfig {
*/
function loadPresets(cwd: string): PresetsConfig {
const globalPath = join(getAgentDir(), "presets.json");
const projectPath = join(cwd, ".pi", "presets.json");
const projectPath = join(cwd, CONFIG_DIR_NAME, "presets.json");
let globalPresets: PresetsConfig = {};
let projectPresets: PresetsConfig = {};
@@ -200,7 +200,10 @@ export default function presetExtension(pi: ExtensionAPI) {
const presetNames = Object.keys(presets);
if (presetNames.length === 0) {
ctx.ui.notify("No presets defined. Add presets to ~/.pi/agent/presets.json or .pi/presets.json", "warning");
ctx.ui.notify(
`No presets defined. Add presets to ${join(getAgentDir(), "presets.json")} or ${join(ctx.cwd, CONFIG_DIR_NAME, "presets.json")}`,
"warning",
);
return;
}
@@ -308,7 +311,10 @@ export default function presetExtension(pi: ExtensionAPI) {
async function cyclePreset(ctx: ExtensionContext): Promise<void> {
const presetNames = getPresetOrder();
if (presetNames.length === 0) {
ctx.ui.notify("No presets defined. Add presets to ~/.pi/agent/presets.json or .pi/presets.json", "warning");
ctx.ui.notify(
`No presets defined. Add presets to ${join(getAgentDir(), "presets.json")} or ${join(ctx.cwd, CONFIG_DIR_NAME, "presets.json")}`,
"warning",
);
return;
}
@@ -1,18 +1,18 @@
import { appendFileSync } from "node:fs";
import { join } from "node:path";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
const logFile = join(process.cwd(), ".pi", "provider-payload.log");
pi.on("before_provider_request", (event) => {
pi.on("before_provider_request", (event, ctx) => {
const logFile = join(ctx.cwd, CONFIG_DIR_NAME, "provider-payload.log");
appendFileSync(logFile, `${JSON.stringify(event.payload, null, 2)}\n\n`, "utf8");
// Optional: replace the payload instead of only logging it.
// return { ...event.payload, temperature: 0 };
});
pi.on("after_provider_response", (event) => {
pi.on("after_provider_response", (event, ctx) => {
const logFile = join(ctx.cwd, CONFIG_DIR_NAME, "provider-payload.log");
appendFileSync(logFile, `[${event.status}] ${JSON.stringify(event.headers)}\n\n`, "utf8");
});
}
@@ -5,7 +5,15 @@
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
import {
Editor,
type EditorTheme,
Key,
matchesKey,
Text,
visibleWidth,
wrapTextWithAnsi,
} from "@earendil-works/pi-tui";
import { Type } from "typebox";
interface OptionWithDesc {
@@ -139,10 +147,27 @@ export default function question(pi: ExtensionAPI) {
if (cachedLines) return cachedLines;
const lines: string[] = [];
const add = (s: string) => lines.push(truncateToWidth(s, width));
const renderWidth = Math.max(1, width);
add(theme.fg("accent", "─".repeat(width)));
add(theme.fg("text", ` ${params.question}`));
function addWrapped(text: string) {
lines.push(...wrapTextWithAnsi(text, renderWidth));
}
function addWrappedWithPrefix(prefix: string, text: string) {
const prefixWidth = visibleWidth(prefix);
if (prefixWidth >= renderWidth) {
addWrapped(prefix + text);
return;
}
const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth);
const continuationPrefix = " ".repeat(prefixWidth);
for (let i = 0; i < wrapped.length; i++) {
lines.push(`${i === 0 ? prefix : continuationPrefix}${wrapped[i]}`);
}
}
lines.push(theme.fg("accent", "─".repeat(renderWidth)));
addWrappedWithPrefix(" ", theme.fg("text", params.question));
lines.push("");
for (let i = 0; i < allOptions.length; i++) {
@@ -150,36 +175,32 @@ export default function question(pi: ExtensionAPI) {
const selected = i === optionIndex;
const isOther = opt.isOther === true;
const prefix = selected ? theme.fg("accent", "> ") : " ";
const label = `${i + 1}. ${opt.label}${isOther && editMode ? " ✎" : ""}`;
const color = selected || (isOther && editMode) ? "accent" : "text";
if (isOther && editMode) {
add(prefix + theme.fg("accent", `${i + 1}. ${opt.label}`));
} else if (selected) {
add(prefix + theme.fg("accent", `${i + 1}. ${opt.label}`));
} else {
add(` ${theme.fg("text", `${i + 1}. ${opt.label}`)}`);
}
addWrappedWithPrefix(prefix, theme.fg(color, label));
// Show description if present
if (opt.description) {
add(` ${theme.fg("muted", opt.description)}`);
addWrappedWithPrefix(" ", theme.fg("muted", opt.description));
}
}
if (editMode) {
lines.push("");
add(theme.fg("muted", " Your answer:"));
for (const line of editor.render(width - 2)) {
add(` ${line}`);
addWrappedWithPrefix(" ", theme.fg("muted", "Your answer:"));
for (const line of editor.render(Math.max(1, renderWidth - 2))) {
lines.push(` ${line}`);
}
}
lines.push("");
if (editMode) {
add(theme.fg("dim", " Enter to submit • Esc to go back"));
addWrappedWithPrefix(" ", theme.fg("dim", "Enter to submit • Esc to go back"));
} else {
add(theme.fg("dim", " ↑↓ navigate • Enter to select • Esc to cancel"));
addWrappedWithPrefix(" ", theme.fg("dim", "↑↓ navigate • Enter to select • Esc to cancel"));
}
add(theme.fg("accent", "─".repeat(width)));
lines.push(theme.fg("accent", "─".repeat(renderWidth)));
cachedLines = lines;
return lines;
@@ -6,7 +6,15 @@
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
import {
Editor,
type EditorTheme,
Key,
matchesKey,
Text,
visibleWidth,
wrapTextWithAnsi,
} from "@earendil-works/pi-tui";
import { Type } from "typebox";
// Types
@@ -259,13 +267,28 @@ export default function questionnaire(pi: ExtensionAPI) {
if (cachedLines) return cachedLines;
const lines: string[] = [];
const renderWidth = Math.max(1, width);
const q = currentQuestion();
const opts = currentOptions();
// Helper to add truncated line
const add = (s: string) => lines.push(truncateToWidth(s, width));
function addWrapped(text: string) {
lines.push(...wrapTextWithAnsi(text, renderWidth));
}
add(theme.fg("accent", "─".repeat(width)));
function addWrappedWithPrefix(prefix: string, text: string) {
const prefixWidth = visibleWidth(prefix);
if (prefixWidth >= renderWidth) {
addWrapped(prefix + text);
return;
}
const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth);
const continuationPrefix = " ".repeat(prefixWidth);
for (let i = 0; i < wrapped.length; i++) {
lines.push(`${i === 0 ? prefix : continuationPrefix}${wrapped[i]}`);
}
}
lines.push(theme.fg("accent", "─".repeat(renderWidth)));
// Tab bar (multi-question only)
if (isMulti) {
@@ -287,7 +310,7 @@ export default function questionnaire(pi: ExtensionAPI) {
? theme.bg("selectedBg", theme.fg("text", submitText))
: theme.fg(canSubmit ? "success" : "dim", submitText);
tabs.push(`${submitStyled}`);
add(` ${tabs.join("")}`);
addWrappedWithPrefix(" ", tabs.join(""));
lines.push("");
}
@@ -298,54 +321,52 @@ export default function questionnaire(pi: ExtensionAPI) {
const selected = i === optionIndex;
const isOther = opt.isOther === true;
const prefix = selected ? theme.fg("accent", "> ") : " ";
const color = selected ? "accent" : "text";
// Mark "Type something" differently when in input mode
if (isOther && inputMode) {
add(prefix + theme.fg("accent", `${i + 1}. ${opt.label}`));
} else {
add(prefix + theme.fg(color, `${i + 1}. ${opt.label}`));
}
const label = `${i + 1}. ${opt.label}${isOther && inputMode ? "" : ""}`;
const color = selected || (isOther && inputMode) ? "accent" : "text";
addWrappedWithPrefix(prefix, theme.fg(color, label));
if (opt.description) {
add(` ${theme.fg("muted", opt.description)}`);
addWrappedWithPrefix(" ", theme.fg("muted", opt.description));
}
}
}
// Content
if (inputMode && q) {
add(theme.fg("text", ` ${q.prompt}`));
addWrappedWithPrefix(" ", theme.fg("text", q.prompt));
lines.push("");
// Show options for reference
renderOptions();
lines.push("");
add(theme.fg("muted", " Your answer:"));
for (const line of editor.render(width - 2)) {
add(` ${line}`);
addWrappedWithPrefix(" ", theme.fg("muted", "Your answer:"));
for (const line of editor.render(Math.max(1, renderWidth - 2))) {
lines.push(` ${line}`);
}
lines.push("");
add(theme.fg("dim", " Enter to submit • Esc to cancel"));
addWrappedWithPrefix(" ", theme.fg("dim", "Enter to submit • Esc to cancel"));
} else if (currentTab === questions.length) {
add(theme.fg("accent", theme.bold(" Ready to submit")));
addWrappedWithPrefix(" ", theme.fg("accent", theme.bold("Ready to submit")));
lines.push("");
for (const question of questions) {
const answer = answers.get(question.id);
if (answer) {
const prefix = answer.wasCustom ? "(wrote) " : "";
add(`${theme.fg("muted", ` ${question.label}: `)}${theme.fg("text", prefix + answer.label)}`);
const summary = `${theme.fg("muted", `${question.label}: `)}${theme.fg("text", prefix + answer.label)}`;
addWrappedWithPrefix(" ", summary);
}
}
lines.push("");
if (allAnswered()) {
add(theme.fg("success", " Press Enter to submit"));
addWrappedWithPrefix(" ", theme.fg("success", "Press Enter to submit"));
} else {
const missing = questions
.filter((q) => !answers.has(q.id))
.map((q) => q.label)
.join(", ");
add(theme.fg("warning", ` Unanswered: ${missing}`));
addWrappedWithPrefix(" ", theme.fg("warning", `Unanswered: ${missing}`));
}
} else if (q) {
add(theme.fg("text", ` ${q.prompt}`));
addWrappedWithPrefix(" ", theme.fg("text", q.prompt));
lines.push("");
renderOptions();
}
@@ -353,11 +374,11 @@ export default function questionnaire(pi: ExtensionAPI) {
lines.push("");
if (!inputMode) {
const help = isMulti
? " Tab/←→ navigate • ↑↓ select • Enter confirm • Esc cancel"
: " ↑↓ navigate • Enter select • Esc cancel";
add(theme.fg("dim", help));
? "Tab/←→ navigate • ↑↓ select • Enter confirm • Esc cancel"
: "↑↓ navigate • Enter select • Esc cancel";
addWrappedWithPrefix(" ", theme.fg("dim", help));
}
add(theme.fg("accent", "─".repeat(width)));
lines.push(theme.fg("accent", "─".repeat(renderWidth)));
cachedLines = lines;
return lines;
@@ -400,7 +421,7 @@ export default function questionnaire(pi: ExtensionAPI) {
let text = theme.fg("toolTitle", theme.bold("questionnaire "));
text += theme.fg("muted", `${count} question${count !== 1 ? "s" : ""}`);
if (labels) {
text += theme.fg("dim", ` (${truncateToWidth(labels, 40)})`);
text += theme.fg("dim", ` (${labels})`);
}
return new Text(text, 0, 0);
},
@@ -46,7 +46,7 @@ import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { SandboxManager, type SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { type BashOperations, createBashTool, getAgentDir } from "@earendil-works/pi-coding-agent";
import { type BashOperations, CONFIG_DIR_NAME, createBashTool, getAgentDir } from "@earendil-works/pi-coding-agent";
interface SandboxConfig extends SandboxRuntimeConfig {
enabled?: boolean;
@@ -77,7 +77,7 @@ const DEFAULT_CONFIG: SandboxConfig = {
};
function loadConfig(cwd: string): SandboxConfig {
const projectConfigPath = join(cwd, ".pi", "sandbox.json");
const projectConfigPath = join(cwd, CONFIG_DIR_NAME, "sandbox.json");
const globalConfigPath = join(getAgentDir(), "extensions", "sandbox.json");
let globalConfig: Partial<SandboxConfig> = {};
@@ -1,12 +1,12 @@
{
"name": "pi-extension-sandbox",
"version": "1.9.1",
"version": "1.9.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-sandbox",
"version": "1.9.1",
"version": "1.9.10",
"dependencies": {
"@anthropic-ai/sandbox-runtime": "^0.0.26"
}
@@ -1,7 +1,7 @@
{
"name": "pi-extension-sandbox",
"private": true,
"version": "1.9.1",
"version": "1.9.10",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
@@ -4,7 +4,7 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
import { CONFIG_DIR_NAME, getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
export type AgentScope = "user" | "project" | "both";
@@ -85,7 +85,7 @@ function isDirectory(p: string): boolean {
function findNearestProjectAgentsDir(cwd: string): string | null {
let currentDir = cwd;
while (true) {
const candidate = path.join(currentDir, ".pi", "agents");
const candidate = path.join(currentDir, CONFIG_DIR_NAME, "agents");
if (isDirectory(candidate)) return candidate;
const parentDir = path.dirname(currentDir);
@@ -19,7 +19,13 @@ import * as path from "node:path";
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
import type { Message } from "@earendil-works/pi-ai";
import { StringEnum } from "@earendil-works/pi-ai";
import { type ExtensionAPI, getMarkdownTheme, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
import {
CONFIG_DIR_NAME,
type ExtensionAPI,
getAgentDir,
getMarkdownTheme,
withFileMutationQueue,
} from "@earendil-works/pi-coding-agent";
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
import { Type } from "typebox";
import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.ts";
@@ -458,8 +464,8 @@ export default function (pi: ExtensionAPI) {
description: [
"Delegate tasks to specialized subagents with isolated context.",
"Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).",
'Default agent scope is "user" (from ~/.pi/agent/agents).',
'To enable project-local agents in .pi/agents, set agentScope: "both" (or "project").',
`Default agent scope is "user" (from ${path.join(getAgentDir(), "agents")}).`,
`To enable project-local agents in ${CONFIG_DIR_NAME}/agents, set agentScope: "both" (or "project").`,
].join(" "),
parameters: SubagentParams,
@@ -1,12 +1,12 @@
{
"name": "pi-extension-with-deps",
"version": "0.79.1",
"version": "0.79.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-with-deps",
"version": "0.79.1",
"version": "0.79.10",
"dependencies": {
"ms": "^2.1.3"
},
@@ -1,7 +1,7 @@
{
"name": "pi-extension-with-deps",
"private": true,
"version": "0.79.1",
"version": "0.79.10",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
+78 -44
View File
@@ -1,17 +1,17 @@
{
"name": "@earendil-works/pi-coding-agent",
"version": "0.79.1",
"version": "0.79.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@earendil-works/pi-coding-agent",
"version": "0.79.1",
"version": "0.79.10",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-agent-core": "^0.79.1",
"@earendil-works/pi-ai": "^0.79.1",
"@earendil-works/pi-tui": "^0.79.1",
"@earendil-works/pi-agent-core": "^0.79.10",
"@earendil-works/pi-ai": "^0.79.10",
"@earendil-works/pi-tui": "^0.79.10",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
"cross-spawn": "7.0.6",
@@ -23,8 +23,9 @@
"jiti": "2.7.0",
"minimatch": "10.2.5",
"proper-lockfile": "4.1.2",
"semver": "7.8.0",
"typebox": "1.1.38",
"undici": "8.3.0",
"undici": "8.5.0",
"yaml": "2.9.0"
},
"optionalDependencies": {
@@ -473,11 +474,11 @@
}
},
"node_modules/@earendil-works/pi-agent-core": {
"version": "0.79.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.1.tgz",
"version": "0.79.10",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.10.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-ai": "^0.79.1",
"@earendil-works/pi-ai": "^0.79.10",
"ignore": "7.0.5",
"typebox": "1.1.38",
"yaml": "2.9.0"
@@ -487,15 +488,16 @@
}
},
"node_modules/@earendil-works/pi-ai": {
"version": "0.79.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.1.tgz",
"version": "0.79.10",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.10.tgz",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "0.91.1",
"@aws-sdk/client-bedrock-runtime": "3.1048.0",
"@smithy/node-http-handler": "4.7.3",
"@google/genai": "1.52.0",
"@mistralai/mistralai": "2.2.1",
"@mistralai/mistralai": "2.2.6",
"@opentelemetry/api": "1.9.0",
"@smithy/node-http-handler": "4.7.3",
"http-proxy-agent": "7.0.2",
"https-proxy-agent": "7.0.6",
"openai": "6.26.0",
@@ -510,12 +512,12 @@
}
},
"node_modules/@earendil-works/pi-tui": {
"version": "0.79.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.1.tgz",
"version": "0.79.10",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.10.tgz",
"license": "MIT",
"dependencies": {
"get-east-asian-width": "1.6.0",
"marked": "15.0.12"
"marked": "18.0.5"
},
"engines": {
"node": ">=22.19.0"
@@ -740,14 +742,23 @@
"optional": true
},
"node_modules/@mistralai/mistralai": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz",
"integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==",
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz",
"integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.40.0",
"ws": "^8.18.0",
"zod": "^3.25.0 || ^4.0.0",
"zod-to-json-schema": "^3.25.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.9.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
}
}
},
"node_modules/@nodable/entities": {
@@ -762,6 +773,24 @@
}
]
},
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@opentelemetry/semantic-conventions": {
"version": "1.41.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz",
"integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
@@ -781,9 +810,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
"integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
@@ -801,12 +830,6 @@
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz",
"integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
@@ -1398,15 +1421,15 @@
}
},
"node_modules/marked": {
"version": "15.0.12",
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
"integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
"version": "18.0.5",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz",
"integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
"node": ">= 20"
}
},
"node_modules/minimatch": {
@@ -1584,23 +1607,22 @@
}
},
"node_modules/protobufjs": {
"version": "7.5.9",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.9.tgz",
"integrity": "sha512-Od4muIm3HW1AouyHF5lONOf1FWo3hY1NbFDoy191X9GzhpgW1clCoaFjfVs2rKJNFYpTNJbje4cbAIDBZJ63ZA==",
"version": "7.6.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz",
"integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.5",
"@protobufjs/eventemitter": "^1.1.0",
"@protobufjs/eventemitter": "^1.1.1",
"@protobufjs/fetch": "^1.1.1",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.2",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.1",
"@types/node": ">=13.7.0",
"long": "^5.0.0"
"long": "^5.3.2"
},
"engines": {
"node": ">=12.0.0"
@@ -1636,6 +1658,18 @@
}
]
},
"node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -1694,9 +1728,9 @@
"license": "MIT"
},
"node_modules/undici": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz",
"integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==",
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz",
"integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
@@ -1733,9 +1767,9 @@
}
},
"node_modules/ws": {
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"peerDependencies": {
"bufferutil": "^4.0.1",
+8 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-coding-agent",
"version": "0.79.1",
"version": "0.79.10",
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
"type": "module",
"piConfig": {
@@ -36,9 +36,9 @@
"prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap"
},
"dependencies": {
"@earendil-works/pi-agent-core": "^0.79.1",
"@earendil-works/pi-ai": "^0.79.1",
"@earendil-works/pi-tui": "^0.79.1",
"@earendil-works/pi-agent-core": "^0.79.10",
"@earendil-works/pi-ai": "^0.79.10",
"@earendil-works/pi-tui": "^0.79.10",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
"cross-spawn": "7.0.6",
@@ -50,8 +50,9 @@
"jiti": "2.7.0",
"minimatch": "10.2.5",
"proper-lockfile": "4.1.2",
"semver": "7.8.0",
"typebox": "1.1.38",
"undici": "8.3.0",
"undici": "8.5.0",
"yaml": "2.9.0"
},
"overrides": {
@@ -70,9 +71,10 @@
"@types/ms": "2.1.0",
"@types/node": "24.12.4",
"@types/proper-lockfile": "4.1.4",
"@types/semver": "7.7.1",
"shx": "0.4.0",
"typescript": "5.9.3",
"vitest": "3.2.4"
"vitest": "4.1.9"
},
"keywords": [
"coding-agent",
@@ -4,6 +4,10 @@
* Bun compiled binaries have an empty `process.env` when running inside
* sandbox environments (e.g. nono on Linux/macOS). On Linux we can recover
* the environment from `/proc/self/environ`.
*
* Keep this in sync with getBunSandboxEnvValue() in
* packages/ai/src/utils/provider-env.ts. The ai package duplicates the lookup
* for direct consumers that do not go through this coding-agent entrypoint.
*/
import { readFileSync } from "node:fs";
+1 -1
View File
@@ -229,7 +229,7 @@ ${chalk.bold("Commands:")}
${APP_NAME} install <source> [-l] Install extension source and add to settings
${APP_NAME} remove <source> [-l] Remove extension source from settings
${APP_NAME} uninstall <source> [-l] Alias for remove
${APP_NAME} update [source|self|pi] Update pi and installed extensions
${APP_NAME} update [source|self|pi] Update pi (use --all for pi and extensions)
${APP_NAME} list List installed extensions from settings
${APP_NAME} config Open TUI to enable/disable package resources
${APP_NAME} <command> --help Show help for install/remove/uninstall/update/list
+49 -15
View File
@@ -1,6 +1,6 @@
import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui";
import { existsSync } from "fs";
import { ENV_AGENT_DIR, getSettingsPath } from "../config.ts";
import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR, getSettingsPath, PACKAGE_NAME } from "../config.ts";
import { areExperimentalFeaturesEnabled } from "../core/experimental.ts";
import { KeybindingsManager } from "../core/keybindings.ts";
import type { SettingsManager } from "../core/settings-manager.ts";
@@ -10,7 +10,25 @@ import {
FirstTimeSetupComponent,
type FirstTimeSetupResult,
} from "../modes/interactive/components/first-time-setup.ts";
import { detectTerminalBackground, initTheme, setTheme } from "../modes/interactive/theme/theme.ts";
import { detectTerminalBackgroundTheme, initTheme, setTheme } from "../modes/interactive/theme/theme.ts";
const OFFICIAL_PACKAGE_NAME = "@earendil-works/pi-coding-agent";
const OFFICIAL_APP_NAME = "pi";
const OFFICIAL_CONFIG_DIR_NAME = ".pi";
interface DistributionMetadata {
packageName: string;
appName: string;
configDirName: string;
}
function isOfficialDistribution({ packageName, appName, configDirName }: DistributionMetadata): boolean {
return (
packageName === OFFICIAL_PACKAGE_NAME &&
appName === OFFICIAL_APP_NAME &&
configDirName === OFFICIAL_CONFIG_DIR_NAME
);
}
function createStartupTui(settingsManager: SettingsManager): TUI {
initTheme(settingsManager.getTheme());
@@ -28,11 +46,21 @@ async function clearStartupTui(ui: TUI): Promise<void> {
/**
* First-time setup runs when all of these hold:
* - this is the official Pi distribution (not a fork/rebrand)
* - experimental features are enabled (PI_EXPERIMENTAL=1)
* - the default agent directory is used (no custom agent dir override)
* - setup was not completed before (settings.json does not exist)
*/
export function shouldRunFirstTimeSetup(settingsPath: string = getSettingsPath()): boolean {
if (
!isOfficialDistribution({
packageName: PACKAGE_NAME,
appName: APP_NAME,
configDirName: CONFIG_DIR_NAME,
})
) {
return false;
}
if (!areExperimentalFeaturesEnabled()) {
return false;
}
@@ -95,19 +123,25 @@ export async function showFirstTimeSetup(settingsManager: SettingsManager): Prom
resolve();
};
const component = new FirstTimeSetupComponent({
detectedTheme: detectTerminalBackground().theme,
onThemePreview: (themeName) => {
setTheme(themeName);
ui.invalidate();
ui.requestRender();
},
onSubmit: (result) => void finish(result),
onCancel: () => void finish(undefined),
});
ui.addChild(component);
ui.setFocus(component);
ui.start();
const showSetup = async () => {
ui.start();
const detection = await detectTerminalBackgroundTheme({ ui, timeoutMs: 100 });
setTheme(detection.theme);
const component = new FirstTimeSetupComponent({
detectedTheme: detection.theme,
onThemePreview: (themeName) => {
setTheme(themeName);
ui.requestRender();
},
onSubmit: (result) => void finish(result),
onCancel: () => void finish(undefined),
});
ui.addChild(component);
ui.setFocus(component);
ui.requestRender();
};
void showSetup();
});
}
+41 -17
View File
@@ -38,6 +38,18 @@ export interface SelfUpdateCommand extends SelfUpdateCommandStep {
steps?: SelfUpdateCommandStep[];
}
export type SelfUpdatePackageTarget = string | { packageName: string; installSpec?: string };
function normalizeSelfUpdatePackageTarget(target: SelfUpdatePackageTarget): {
packageName: string;
installSpec: string;
} {
if (typeof target === "string") {
return { packageName: target, installSpec: target };
}
return { packageName: target.packageName, installSpec: target.installSpec ?? target.packageName };
}
function makeSelfUpdateCommand(
installStep: SelfUpdateCommandStep,
uninstallStep?: SelfUpdateCommandStep,
@@ -103,29 +115,38 @@ function getInferredNpmInstall(): { root: string; prefix: string } | undefined {
function getSelfUpdateCommandForMethod(
method: InstallMethod,
installedPackageName: string,
updatePackageName = installedPackageName,
updatePackageTarget: SelfUpdatePackageTarget = installedPackageName,
npmCommand?: string[],
): SelfUpdateCommand | undefined {
const target = normalizeSelfUpdatePackageTarget(updatePackageTarget);
switch (method) {
case "bun-binary":
return undefined;
case "pnpm":
case "pnpm": {
const match = readCommandOutput("pnpm", ["root", "-g"])
? undefined
: /^(.*[\\/]global[\\/][^\\/]+)[\\/]\.pnpm[\\/]/.exec(getPackageDir());
const binDirArgs = match
? [`--config.global-bin-dir=${process.env.PNPM_HOME || dirname(dirname(match[1]))}`]
: [];
return makeSelfUpdateCommand(
makeSelfUpdateCommandStep("pnpm", [
"install",
"-g",
"--ignore-scripts",
"--config.minimumReleaseAge=0",
updatePackageName,
...binDirArgs,
target.installSpec,
]),
updatePackageName === installedPackageName
target.packageName === installedPackageName
? undefined
: makeSelfUpdateCommandStep("pnpm", ["remove", "-g", installedPackageName]),
: makeSelfUpdateCommandStep("pnpm", ["remove", "-g", ...binDirArgs, installedPackageName]),
);
}
case "yarn":
return makeSelfUpdateCommand(
makeSelfUpdateCommandStep("yarn", ["global", "add", "--ignore-scripts", updatePackageName]),
updatePackageName === installedPackageName
makeSelfUpdateCommandStep("yarn", ["global", "add", "--ignore-scripts", target.installSpec]),
target.packageName === installedPackageName
? undefined
: makeSelfUpdateCommandStep("yarn", ["global", "remove", installedPackageName]),
);
@@ -136,9 +157,9 @@ function getSelfUpdateCommandForMethod(
"-g",
"--ignore-scripts",
"--minimum-release-age=0",
updatePackageName,
target.installSpec,
]),
updatePackageName === installedPackageName
target.packageName === installedPackageName
? undefined
: makeSelfUpdateCommandStep("bun", ["uninstall", "-g", installedPackageName]),
);
@@ -152,10 +173,10 @@ function getSelfUpdateCommandForMethod(
"-g",
"--ignore-scripts",
"--min-release-age=0",
updatePackageName,
target.installSpec,
]);
const uninstallStep =
updatePackageName === installedPackageName
target.packageName === installedPackageName
? undefined
: makeSelfUpdateCommandStep(command, [...prefixArgs, "uninstall", "-g", installedPackageName]);
return makeSelfUpdateCommand(installStep, uninstallStep);
@@ -205,7 +226,9 @@ function getGlobalPackageRoots(method: InstallMethod, _packageName: string, npmC
}
case "pnpm": {
const root = readCommandOutput("pnpm", ["root", "-g"]);
return root ? [root, dirname(root)] : [];
if (root) return [root, dirname(root)];
const match = /^(.*[\\/]global[\\/][^\\/]+)[\\/]\.pnpm[\\/]/.exec(getPackageDir());
return match ? [match[1]] : [];
}
case "yarn": {
const dir = readCommandOutput("yarn", ["global", "dir"]);
@@ -292,10 +315,10 @@ function isManagedByGlobalPackageManager(method: InstallMethod, packageName: str
export function getSelfUpdateCommand(
packageName: string,
npmCommand?: string[],
updatePackageName = packageName,
updatePackageTarget: SelfUpdatePackageTarget = packageName,
): SelfUpdateCommand | undefined {
const method = detectInstallMethod();
const command = getSelfUpdateCommandForMethod(method, packageName, updatePackageName, npmCommand);
const command = getSelfUpdateCommandForMethod(method, packageName, updatePackageTarget, npmCommand);
if (!command || !isManagedByGlobalPackageManager(method, packageName, npmCommand) || !isSelfUpdatePathWritable()) {
return undefined;
}
@@ -305,20 +328,21 @@ export function getSelfUpdateCommand(
export function getSelfUpdateUnavailableInstruction(
packageName: string,
npmCommand?: string[],
updatePackageName = packageName,
updatePackageTarget: SelfUpdatePackageTarget = packageName,
): string {
const method = detectInstallMethod();
const target = normalizeSelfUpdatePackageTarget(updatePackageTarget);
if (method === "bun-binary") {
return `Download from: https://github.com/earendil-works/pi-mono/releases/latest`;
}
const command = getSelfUpdateCommandForMethod(method, packageName, updatePackageName, npmCommand);
const command = getSelfUpdateCommandForMethod(method, packageName, target, npmCommand);
if (command) {
if (isManagedByGlobalPackageManager(method, packageName, npmCommand) && !isSelfUpdatePathWritable()) {
return `This installation is managed by a global ${method} install, but the install path is not writable. Update it yourself with: ${command.display}`;
}
return `This installation is not managed by a global ${method} install. Update it with the package manager, wrapper, or source checkout that provides it.`;
}
return `Update ${updatePackageName} using the package manager, wrapper, or source checkout that provides this installation.`;
return `Update ${target.installSpec} using the package manager, wrapper, or source checkout that provides this installation.`;
}
export function getUpdateInstruction(packageName: string): string {
+68 -46
View File
@@ -33,7 +33,7 @@ import {
resetApiProviders,
streamSimple,
} from "@earendil-works/pi-ai/compat";
import { theme } from "../modes/interactive/theme/theme.ts";
import { getThemeByName, theme } from "../modes/interactive/theme/theme.ts";
import { stripFrontmatter } from "../utils/frontmatter.ts";
import { resolvePath } from "../utils/paths.ts";
import { sleep } from "../utils/sleep.ts";
@@ -45,6 +45,7 @@ import {
collectEntriesForBranchSummary,
compact,
estimateContextTokens,
estimateTokens,
generateBranchSummary,
prepareCompaction,
shouldCompact,
@@ -242,6 +243,14 @@ interface ToolDefinitionEntry {
sourceInfo: SourceInfo;
}
function estimateMessagesTokens(messages: AgentMessage[]): number {
let tokens = 0;
for (const message of messages) {
tokens += estimateTokens(message);
}
return tokens;
}
// ============================================================================
// Constants
// ============================================================================
@@ -357,6 +366,7 @@ export class AgentSession {
private async _getRequiredRequestAuth(model: Model<any>): Promise<{
apiKey: string;
headers?: Record<string, string>;
env?: Record<string, string>;
}> {
const result = await this._modelRegistry.getApiKeyAndHeaders(model);
if (!result.ok) {
@@ -366,7 +376,7 @@ export class AgentSession {
throw new Error(result.error);
}
if (result.apiKey) {
return { apiKey: result.apiKey, headers: result.headers };
return { apiKey: result.apiKey, headers: result.headers, env: result.env };
}
const isOAuth = this._modelRegistry.isUsingOAuth(model);
@@ -383,13 +393,14 @@ export class AgentSession {
private async _getCompactionRequestAuth(model: Model<any>): Promise<{
apiKey?: string;
headers?: Record<string, string>;
env?: Record<string, string>;
}> {
if (this.agent.streamFn === streamSimple) {
return this._getRequiredRequestAuth(model);
}
const result = await this._modelRegistry.getApiKeyAndHeaders(model);
return result.ok ? { apiKey: result.apiKey, headers: result.headers } : {};
return result.ok ? { apiKey: result.apiKey, headers: result.headers, env: result.env } : {};
}
/**
@@ -1649,7 +1660,7 @@ export class AgentSession {
throw new Error(formatNoModelSelectedMessage());
}
const { apiKey, headers } = await this._getCompactionRequestAuth(this.model);
const { apiKey, headers, env } = await this._getCompactionRequestAuth(this.model);
const pathEntries = this.sessionManager.getBranch();
const settings = this.settingsManager.getCompactionSettings();
@@ -1673,6 +1684,8 @@ export class AgentSession {
preparation,
branchEntries: pathEntries,
customInstructions,
reason: "manual",
willRetry: false,
signal: this._compactionAbortController.signal,
})) as SessionBeforeCompactResult | undefined;
@@ -1708,6 +1721,7 @@ export class AgentSession {
this._compactionAbortController.signal,
this.thinkingLevel,
this.agent.streamFn,
env,
);
summary = result.summary;
firstKeptEntryId = result.firstKeptEntryId;
@@ -1723,6 +1737,7 @@ export class AgentSession {
const newEntries = this.sessionManager.getEntries();
const sessionContext = this.sessionManager.buildSessionContext();
this.agent.state.messages = sessionContext.messages;
const estimatedTokensAfter = estimateMessagesTokens(sessionContext.messages);
// Get the saved compaction entry for the extension event
const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as
@@ -1734,13 +1749,16 @@ export class AgentSession {
type: "session_compact",
compactionEntry: savedCompactionEntry,
fromExtension,
reason: "manual",
willRetry: false,
});
}
const compactionResult = {
const compactionResult: CompactionResult = {
summary,
firstKeptEntryId,
tokensBefore,
estimatedTokensAfter,
details,
};
this._emit({
@@ -1821,8 +1839,17 @@ export class AgentSession {
return false;
}
// Case 1: Overflow - LLM returned context overflow error
// Case 1: Overflow - LLM returned context overflow error, or reported usage exceeded
// the configured window. A successful response over the configured window should compact
// but must not retry: the assistant answer already completed and agent.continue() cannot
// continue from an assistant message.
if (sameModel && isContextOverflow(assistantMessage, contextWindow)) {
const willRetry = assistantMessage.stopReason !== "stop";
if (!willRetry) {
return await this._runAutoCompaction("overflow", false);
}
if (this._overflowRecoveryAttempted) {
this._emit({
type: "compaction_end",
@@ -1843,7 +1870,7 @@ export class AgentSession {
if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
this.agent.state.messages = messages.slice(0, -1);
}
return await this._runAutoCompaction("overflow", true);
return await this._runAutoCompaction("overflow", willRetry);
}
// Case 2: Threshold - context is getting large
@@ -1880,56 +1907,39 @@ export class AgentSession {
*/
private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise<boolean> {
const settings = this.settingsManager.getCompactionSettings();
this._emit({ type: "compaction_start", reason });
this._autoCompactionAbortController = new AbortController();
let started = false;
try {
if (!this.model) {
this._emit({
type: "compaction_end",
reason,
result: undefined,
aborted: false,
willRetry: false,
});
return false;
}
let apiKey: string | undefined;
let headers: Record<string, string> | undefined;
let env: Record<string, string> | undefined;
if (this.agent.streamFn === streamSimple) {
const authResult = await this._modelRegistry.getApiKeyAndHeaders(this.model);
if (!authResult.ok || !authResult.apiKey) {
this._emit({
type: "compaction_end",
reason,
result: undefined,
aborted: false,
willRetry: false,
});
return false;
}
apiKey = authResult.apiKey;
headers = authResult.headers;
env = authResult.env;
} else {
({ apiKey, headers } = await this._getCompactionRequestAuth(this.model));
({ apiKey, headers, env } = await this._getCompactionRequestAuth(this.model));
}
const pathEntries = this.sessionManager.getBranch();
const preparation = prepareCompaction(pathEntries, settings);
if (!preparation) {
this._emit({
type: "compaction_end",
reason,
result: undefined,
aborted: false,
willRetry: false,
});
return false;
}
this._emit({ type: "compaction_start", reason });
this._autoCompactionAbortController = new AbortController();
started = true;
let extensionCompaction: CompactionResult | undefined;
let fromExtension = false;
@@ -1939,6 +1949,8 @@ export class AgentSession {
preparation,
branchEntries: pathEntries,
customInstructions: undefined,
reason,
willRetry,
signal: this._autoCompactionAbortController.signal,
})) as SessionBeforeCompactResult | undefined;
@@ -1981,6 +1993,7 @@ export class AgentSession {
this._autoCompactionAbortController.signal,
this.thinkingLevel,
this.agent.streamFn,
env,
);
summary = compactResult.summary;
firstKeptEntryId = compactResult.firstKeptEntryId;
@@ -2003,6 +2016,7 @@ export class AgentSession {
const newEntries = this.sessionManager.getEntries();
const sessionContext = this.sessionManager.buildSessionContext();
this.agent.state.messages = sessionContext.messages;
const estimatedTokensAfter = estimateMessagesTokens(sessionContext.messages);
// Get the saved compaction entry for the extension event
const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as
@@ -2014,6 +2028,8 @@ export class AgentSession {
type: "session_compact",
compactionEntry: savedCompactionEntry,
fromExtension,
reason,
willRetry,
});
}
@@ -2021,6 +2037,7 @@ export class AgentSession {
summary,
firstKeptEntryId,
tokensBefore,
estimatedTokensAfter,
details,
};
this._emit({ type: "compaction_end", reason, result, aborted: false, willRetry });
@@ -2039,17 +2056,19 @@ export class AgentSession {
return this.agent.hasQueuedMessages();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "compaction failed";
this._emit({
type: "compaction_end",
reason,
result: undefined,
aborted: false,
willRetry: false,
errorMessage:
reason === "overflow"
? `Context overflow recovery failed: ${errorMessage}`
: `Auto-compaction failed: ${errorMessage}`,
});
if (started) {
this._emit({
type: "compaction_end",
reason,
result: undefined,
aborted: false,
willRetry: false,
errorMessage:
reason === "overflow"
? `Context overflow recovery failed: ${errorMessage}`
: `Auto-compaction failed: ${errorMessage}`,
});
}
return false;
} finally {
this._autoCompactionAbortController = undefined;
@@ -2432,7 +2451,7 @@ export class AgentSession {
});
}
async reload(): Promise<void> {
async reload(options?: { beforeSessionStart?: () => void | Promise<void> }): Promise<void> {
const previousFlagValues = this._extensionRunner.getFlagValues();
await emitSessionShutdownEvent(this._extensionRunner, { type: "session_shutdown", reason: "reload" });
await this.settingsManager.reload();
@@ -2451,6 +2470,7 @@ export class AgentSession {
this._extensionShutdownHandler ||
this._extensionErrorListener;
if (hasBindings) {
await options?.beforeSessionStart?.();
await this._extensionRunner.emit({ type: "session_start", reason: "reload" });
await this.extendResourcesFromExtensions("reload");
}
@@ -2784,12 +2804,13 @@ export class AgentSession {
let summaryDetails: unknown;
if (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) {
const model = this.model!;
const { apiKey, headers } = await this._getRequiredRequestAuth(model);
const { apiKey, headers, env } = await this._getRequiredRequestAuth(model);
const branchSummarySettings = this.settingsManager.getBranchSummarySettings();
const result = await generateBranchSummary(entriesToSummarize, {
model,
apiKey,
headers,
env,
signal: this._branchSummaryAbortController.signal,
customInstructions,
replaceInstructions,
@@ -3017,7 +3038,8 @@ export class AgentSession {
* @returns Path to exported file
*/
async exportToHtml(outputPath?: string): Promise<string> {
const themeName = this.settingsManager.getTheme();
const configuredThemeName = this.settingsManager.getTheme();
const themeName = configuredThemeName && getThemeByName(configuredThemeName) ? configuredThemeName : undefined;
// Create tool renderer if we have an extension runner (for custom tool HTML rendering)
const toolRenderer: ToolHtmlRenderer = createToolHtmlRenderer({
+17 -2
View File
@@ -24,6 +24,7 @@ import { resolveConfigValue } from "./resolve-config-value.ts";
export type ApiKeyCredential = {
type: "api_key";
key: string;
env?: Record<string, string>;
};
export type OAuthCredential = {
@@ -40,6 +41,10 @@ export type AuthStatus = {
label?: string;
};
export interface GetApiKeyOptions {
includeFallback?: boolean;
}
type LockResult<T> = {
result: T;
next?: string;
@@ -294,6 +299,14 @@ export class AuthStorage {
return this.data[provider] ?? undefined;
}
/**
* Get provider-scoped environment values for an API key credential.
*/
getProviderEnv(provider: string): Record<string, string> | undefined {
const cred = this.data[provider];
return cred?.type === "api_key" && cred.env ? { ...cred.env } : undefined;
}
/**
* Set credential for a provider.
*/
@@ -446,7 +459,7 @@ export class AuthStorage {
* 3. OAuth token from auth.json (auto-refreshed with locking)
* 4. Environment variable
*/
async getApiKey(providerId: string): Promise<string | undefined> {
async getApiKey(providerId: string, options: GetApiKeyOptions = {}): Promise<string | undefined> {
// Runtime override takes highest priority
const runtimeKey = this.runtimeOverrides.get(providerId);
if (runtimeKey) {
@@ -456,7 +469,7 @@ export class AuthStorage {
const cred = this.data[providerId];
if (cred?.type === "api_key") {
return resolveConfigValue(cred.key);
return resolveConfigValue(cred.key, cred.env);
}
if (cred?.type === "oauth") {
@@ -497,6 +510,8 @@ export class AuthStorage {
}
}
if (options.includeFallback === false) return undefined;
// Fall back to environment variable
const envKey = getEnvApiKey(providerId);
if (envKey) return envKey;
@@ -69,6 +69,8 @@ export interface GenerateBranchSummaryOptions {
apiKey: string;
/** Request headers for the model */
headers?: Record<string, string>;
/** Provider-scoped environment values for the model */
env?: Record<string, string>;
/** Abort signal for cancellation */
signal: AbortSignal;
/** Optional custom instructions for summarization */
@@ -290,6 +292,7 @@ export async function generateBranchSummary(
model,
apiKey,
headers,
env,
signal,
customInstructions,
replaceInstructions,
@@ -335,7 +338,7 @@ export async function generateBranchSummary(
// request behavior (timeouts, retries, attribution headers) stays consistent
// without running through agent state/events.
const context = { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages };
const requestOptions: SimpleStreamOptions = { apiKey, headers, signal, maxTokens: 2048 };
const requestOptions: SimpleStreamOptions = { apiKey, headers, env, signal, maxTokens: 2048 };
const response = streamFn
? await (await streamFn(model, context, requestOptions)).result()
: await completeSimple(model, context, requestOptions);
@@ -104,6 +104,7 @@ export interface CompactionResult<T = unknown> {
summary: string;
firstKeptEntryId: string;
tokensBefore: number;
estimatedTokensAfter?: number;
/** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
details?: T;
}
@@ -528,10 +529,11 @@ function createSummarizationOptions(
maxTokens: number,
apiKey: string | undefined,
headers: Record<string, string> | undefined,
env: Record<string, string> | undefined,
signal: AbortSignal | undefined,
thinkingLevel: ThinkingLevel | undefined,
): SimpleStreamOptions {
const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers };
const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers, env };
if (model.reasoning && thinkingLevel && thinkingLevel !== "off") {
options.reasoning = thinkingLevel;
}
@@ -566,6 +568,7 @@ export async function generateSummary(
previousSummary?: string,
thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn,
env?: Record<string, string>,
): Promise<string> {
const maxTokens = Math.min(
Math.floor(0.8 * reserveTokens),
@@ -598,7 +601,7 @@ export async function generateSummary(
},
];
const completionOptions = createSummarizationOptions(model, maxTokens, apiKey, headers, signal, thinkingLevel);
const completionOptions = createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel);
const response = await completeSummarization(
model,
@@ -696,6 +699,10 @@ export function prepareCompaction(
}
}
if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0) {
return undefined;
}
// Extract file operations from messages and previous compaction
const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);
@@ -753,6 +760,7 @@ export async function compact(
signal?: AbortSignal,
thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn,
env?: Record<string, string>,
): Promise<CompactionResult> {
const {
firstKeptEntryId,
@@ -783,6 +791,7 @@ export async function compact(
previousSummary,
thinkingLevel,
streamFn,
env,
)
: Promise.resolve("No prior history."),
generateTurnPrefixSummary(
@@ -791,6 +800,7 @@ export async function compact(
settings.reserveTokens,
apiKey,
headers,
env,
signal,
thinkingLevel,
streamFn,
@@ -811,6 +821,7 @@ export async function compact(
previousSummary,
thinkingLevel,
streamFn,
env,
);
}
@@ -839,6 +850,7 @@ async function generateTurnPrefixSummary(
reserveTokens: number,
apiKey: string | undefined,
headers?: Record<string, string>,
env?: Record<string, string>,
signal?: AbortSignal,
thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn,
@@ -861,7 +873,7 @@ async function generateTurnPrefixSummary(
const response = await completeSummarization(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
createSummarizationOptions(model, maxTokens, apiKey, headers, signal, thinkingLevel),
createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel),
streamFn,
);
File diff suppressed because one or more lines are too long
@@ -127,6 +127,30 @@ function getAliases(): Record<string, string> {
type HandlerFn = (...args: unknown[]) => Promise<unknown>;
let extensionCacheCwd: string | undefined;
let extensionCacheGeneration = 0;
const extensionCache = new Map<string, ExtensionFactory>();
interface ExtensionCacheToken {
cwd: string;
generation: number;
}
export function clearExtensionCache(): void {
extensionCache.clear();
extensionCacheCwd = undefined;
extensionCacheGeneration++;
}
function useExtensionCacheCwd(cwd: string): ExtensionCacheToken {
const resolvedCwd = resolvePath(cwd);
if (extensionCacheCwd !== undefined && extensionCacheCwd !== resolvedCwd) {
clearExtensionCache();
}
extensionCacheCwd = resolvedCwd;
return { cwd: resolvedCwd, generation: extensionCacheGeneration };
}
/**
* Create a runtime with throwing stubs for action methods.
* Runner.bindCore() replaces these with real implementations.
@@ -338,7 +362,22 @@ function createExtensionAPI(
return api;
}
async function loadExtensionModule(extensionPath: string) {
function isCurrentCacheToken(cacheToken: ExtensionCacheToken | undefined): cacheToken is ExtensionCacheToken {
return (
cacheToken !== undefined &&
extensionCacheCwd === cacheToken.cwd &&
extensionCacheGeneration === cacheToken.generation
);
}
async function loadExtensionModule(extensionPath: string, cacheToken?: ExtensionCacheToken) {
if (isCurrentCacheToken(cacheToken)) {
const cachedFactory = extensionCache.get(extensionPath);
if (cachedFactory) {
return cachedFactory;
}
}
const jiti = createJiti(import.meta.url, {
moduleCache: false,
// In Bun binary: use virtualModules for bundled packages (no filesystem resolution)
@@ -349,7 +388,13 @@ async function loadExtensionModule(extensionPath: string) {
const module = await jiti.import(extensionPath, { default: true });
const factory = module as ExtensionFactory;
return typeof factory !== "function" ? undefined : factory;
if (typeof factory !== "function") {
return undefined;
}
if (isCurrentCacheToken(cacheToken)) {
extensionCache.set(extensionPath, factory);
}
return factory;
}
/**
@@ -380,11 +425,12 @@ async function loadExtension(
cwd: string,
eventBus: EventBus,
runtime: ExtensionRuntime,
cacheToken?: ExtensionCacheToken,
): Promise<{ extension: Extension | null; error: string | null }> {
const resolvedPath = resolvePath(extensionPath, cwd, { normalizeUnicodeSpaces: true });
try {
const factory = await loadExtensionModule(resolvedPath);
const factory = await loadExtensionModule(resolvedPath, cacheToken);
if (!factory) {
return { extension: null, error: `Extension does not export a valid factory function: ${extensionPath}` };
}
@@ -420,20 +466,28 @@ export async function loadExtensionFromFactory(
/**
* Load extensions from paths.
*/
export async function loadExtensions(
async function loadExtensionsInternal(
paths: string[],
cwd: string,
eventBus?: EventBus,
runtime?: ExtensionRuntime,
useCache = false,
): Promise<LoadExtensionsResult> {
const extensions: Extension[] = [];
const errors: Array<{ path: string; error: string }> = [];
const resolvedCwd = resolvePath(cwd);
const cacheToken = useCache ? useExtensionCacheCwd(cwd) : undefined;
const resolvedCwd = cacheToken?.cwd ?? resolvePath(cwd);
const resolvedEventBus = eventBus ?? createEventBus();
const resolvedRuntime = runtime ?? createExtensionRuntime();
for (const extPath of paths) {
const { extension, error } = await loadExtension(extPath, resolvedCwd, resolvedEventBus, resolvedRuntime);
const { extension, error } = await loadExtension(
extPath,
resolvedCwd,
resolvedEventBus,
resolvedRuntime,
cacheToken,
);
if (error) {
errors.push({ path: extPath, error });
@@ -452,6 +506,24 @@ export async function loadExtensions(
};
}
export async function loadExtensions(
paths: string[],
cwd: string,
eventBus?: EventBus,
runtime?: ExtensionRuntime,
): Promise<LoadExtensionsResult> {
return loadExtensionsInternal(paths, cwd, eventBus, runtime);
}
export async function loadExtensionsCached(
paths: string[],
cwd: string,
eventBus?: EventBus,
runtime?: ExtensionRuntime,
): Promise<LoadExtensionsResult> {
return loadExtensionsInternal(paths, cwd, eventBus, runtime, true);
}
interface PiManifest {
extensions?: string[];
themes?: string[];
@@ -571,6 +571,10 @@ export interface SessionBeforeCompactEvent {
preparation: CompactionPreparation;
branchEntries: SessionEntry[];
customInstructions?: string;
/** What triggered the compaction: manual /compact, the context threshold, or context overflow recovery */
reason: "manual" | "threshold" | "overflow";
/** True when the aborted turn is retried after this compaction (overflow recovery) */
willRetry: boolean;
signal: AbortSignal;
}
@@ -579,6 +583,10 @@ export interface SessionCompactEvent {
type: "session_compact";
compactionEntry: CompactionEntry;
fromExtension: boolean;
/** What triggered the compaction: manual /compact, the context threshold, or context overflow recovery */
reason: "manual" | "threshold" | "overflow";
/** True when the aborted turn is retried after this compaction (overflow recovery) */
willRetry: boolean;
}
/** Fired before an extension runtime is torn down due to quit, reload, or session replacement. */
@@ -10,6 +10,9 @@ export const HTTP_IDLE_TIMEOUT_CHOICES = [
{ label: "disabled", timeoutMs: 0 },
] as const;
const originalGlobalFetch = globalThis.fetch;
let installedGlobalFetch: typeof globalThis.fetch | undefined;
export function parseHttpIdleTimeoutMs(value: unknown): number | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
@@ -36,6 +39,13 @@ export function formatHttpIdleTimeoutMs(timeoutMs: number): string {
return `${timeoutMs / 1000} sec`;
}
export function applyHttpProxySettings(httpProxy: string | undefined): void {
const proxy = httpProxy?.trim();
if (!proxy) return;
process.env.HTTP_PROXY ??= proxy;
process.env.HTTPS_PROXY ??= proxy;
}
export function configureHttpDispatcher(timeoutMs: number = DEFAULT_HTTP_IDLE_TIMEOUT_MS): void {
const normalizedTimeoutMs = parseHttpIdleTimeoutMs(timeoutMs);
if (normalizedTimeoutMs === undefined) {
@@ -51,5 +61,13 @@ export function configureHttpDispatcher(timeoutMs: number = DEFAULT_HTTP_IDLE_TI
// Keep fetch and the dispatcher on the same undici implementation. Node 26.0's
// bundled fetch can otherwise consume compressed responses through npm undici's
// dispatcher without decompressing them, causing response.json() failures.
undici.install?.();
// If a caller replaced fetch after module load, preserve that deliberate override.
const shouldInstallGlobals =
installedGlobalFetch === undefined
? globalThis.fetch === originalGlobalFetch
: globalThis.fetch === installedGlobalFetch;
if (shouldInstallGlobals) {
undici.install?.();
installedGlobalFetch = globalThis.fetch;
}
}
@@ -25,7 +25,6 @@ import { type Static, Type } from "typebox";
import { Compile } from "typebox/compile";
import type { TLocalizedValidationError } from "typebox/error";
import { getAgentDir } from "../config.ts";
import { warnDeprecation } from "../utils/deprecation.ts";
import { stripJsonComments } from "../utils/json.ts";
import { normalizePath } from "../utils/paths.ts";
import type { AuthStatus, AuthStorage } from "./auth-storage.ts";
@@ -35,7 +34,6 @@ import {
getConfigValueEnvVarNames,
isCommandConfigValue,
isConfigValueConfigured,
isLegacyEnvVarNameConfigValue,
resolveConfigValueOrThrow,
resolveConfigValueUncached,
resolveHeadersOrThrow,
@@ -98,6 +96,13 @@ const ThinkingLevelMapSchema = Type.Object({
xhigh: Type.Optional(ThinkingLevelMapValueSchema),
});
const ChatTemplateKwargScalarSchema = Type.Union([Type.String(), Type.Number(), Type.Boolean(), Type.Null()]);
const ChatTemplateKwargVariableSchema = Type.Object({
$var: Type.Union([Type.Literal("thinking.enabled"), Type.Literal("thinking.effort")]),
omitWhenOff: Type.Optional(Type.Boolean()),
});
const ChatTemplateKwargSchema = Type.Union([ChatTemplateKwargScalarSchema, ChatTemplateKwargVariableSchema]);
const OpenAICompletionsCompatSchema = Type.Object({
supportsStore: Type.Optional(Type.Boolean()),
supportsDeveloperRole: Type.Optional(Type.Boolean()),
@@ -116,9 +121,13 @@ const OpenAICompletionsCompatSchema = Type.Object({
Type.Literal("deepseek"),
Type.Literal("zai"),
Type.Literal("qwen"),
Type.Literal("chat-template"),
Type.Literal("qwen-chat-template"),
Type.Literal("string-thinking"),
Type.Literal("ant-ling"),
]),
),
chatTemplateKwargs: Type.Optional(Type.Record(Type.String(), ChatTemplateKwargSchema)),
cacheControlFormat: Type.Optional(Type.Literal("anthropic")),
openRouterRouting: Type.Optional(OpenRouterRoutingSchema),
vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema),
@@ -237,82 +246,12 @@ interface ProviderRequestConfig {
authHeader?: boolean;
}
function migrateLegacyRegisterProviderConfigValue(providerName: string, field: string, value: string): string {
if (!isLegacyEnvVarNameConfigValue(value)) return value;
warnDeprecation(
`registerProvider("${providerName}") ${field} value "${value}" is treated as a legacy environment variable reference. This will no longer be detected as an environment variable reference in a future release. Pass "$${value}" instead.`,
);
return `$${value}`;
}
function migrateLegacyRegisterProviderHeaders(
providerName: string,
field: string,
headers: Record<string, string> | undefined,
): Record<string, string> | undefined {
if (!headers) return undefined;
let migratedHeaders: Record<string, string> | undefined;
for (const [key, value] of Object.entries(headers)) {
const migratedValue = migrateLegacyRegisterProviderConfigValue(providerName, `${field} header "${key}"`, value);
if (migratedValue === value) continue;
migratedHeaders ??= { ...headers };
migratedHeaders[key] = migratedValue;
}
return migratedHeaders ?? headers;
}
function migrateLegacyRegisterProviderConfigValues(
providerName: string,
config: ProviderConfigInput,
): ProviderConfigInput {
let migratedConfig: ProviderConfigInput | undefined;
const setMigratedConfigValue = <TKey extends keyof ProviderConfigInput>(
key: TKey,
value: ProviderConfigInput[TKey],
) => {
migratedConfig ??= { ...config };
migratedConfig[key] = value;
};
if (config.apiKey) {
const apiKey = migrateLegacyRegisterProviderConfigValue(providerName, "apiKey", config.apiKey);
if (apiKey !== config.apiKey) {
setMigratedConfigValue("apiKey", apiKey);
}
}
const headers = migrateLegacyRegisterProviderHeaders(providerName, "headers", config.headers);
if (headers !== config.headers) {
setMigratedConfigValue("headers", headers);
}
if (config.models) {
let models: ProviderConfigInput["models"] | undefined;
for (let index = 0; index < config.models.length; index++) {
const model = config.models[index];
const modelHeaders = migrateLegacyRegisterProviderHeaders(
providerName,
`model "${model.id}" headers`,
model.headers,
);
if (modelHeaders === model.headers) continue;
models ??= [...config.models];
models[index] = { ...model, headers: modelHeaders };
}
if (models) {
setMigratedConfigValue("models", models);
}
}
return migratedConfig ?? config;
}
export type ResolvedRequestAuth =
| {
ok: true;
apiKey?: string;
headers?: Record<string, string>;
env?: Record<string, string>;
}
| {
ok: false;
@@ -361,6 +300,13 @@ function mergeCompat(
};
}
if (baseCompletions?.chatTemplateKwargs || overrideCompletions.chatTemplateKwargs) {
mergedCompletions.chatTemplateKwargs = {
...baseCompletions?.chatTemplateKwargs,
...overrideCompletions.chatTemplateKwargs,
};
}
return merged as Model<Api>["compat"];
}
@@ -757,17 +703,27 @@ export class ModelRegistry {
async getApiKeyAndHeaders(model: Model<Api>): Promise<ResolvedRequestAuth> {
try {
const providerConfig = this.providerRequestConfigs.get(model.provider);
const apiKeyFromAuthStorage = await this.authStorage.getApiKey(model.provider);
const providerEnv = this.authStorage.getProviderEnv(model.provider);
const apiKeyFromAuthStorage = await this.authStorage.getApiKey(model.provider, { includeFallback: false });
const apiKey =
apiKeyFromAuthStorage ??
(providerConfig?.apiKey
? resolveConfigValueOrThrow(providerConfig.apiKey, `API key for provider "${model.provider}"`)
? resolveConfigValueOrThrow(
providerConfig.apiKey,
`API key for provider "${model.provider}"`,
providerEnv,
)
: undefined);
const providerHeaders = resolveHeadersOrThrow(providerConfig?.headers, `provider "${model.provider}"`);
const providerHeaders = resolveHeadersOrThrow(
providerConfig?.headers,
`provider "${model.provider}"`,
providerEnv,
);
const modelHeaders = resolveHeadersOrThrow(
this.modelRequestHeaders.get(this.getModelRequestKey(model.provider, model.id)),
`model "${model.provider}/${model.id}"`,
providerEnv,
);
let headers =
@@ -786,6 +742,7 @@ export class ModelRegistry {
ok: true,
apiKey,
headers: headers && Object.keys(headers).length > 0 ? headers : undefined,
env: providerEnv && Object.keys(providerEnv).length > 0 ? providerEnv : undefined,
};
} catch (error) {
return {
@@ -850,7 +807,9 @@ export class ModelRegistry {
}
const providerApiKey = this.providerRequestConfigs.get(provider)?.apiKey;
return providerApiKey ? resolveConfigValueUncached(providerApiKey) : undefined;
return providerApiKey
? resolveConfigValueUncached(providerApiKey, this.authStorage.getProviderEnv(provider))
: undefined;
}
/**
@@ -869,10 +828,9 @@ export class ModelRegistry {
* If provider has oauth: registers OAuth provider for /login support.
*/
registerProvider(providerName: string, config: ProviderConfigInput): void {
const migratedConfig = migrateLegacyRegisterProviderConfigValues(providerName, config);
this.validateProviderConfig(providerName, migratedConfig);
this.applyProviderConfig(providerName, migratedConfig);
this.upsertRegisteredProvider(providerName, migratedConfig);
this.validateProviderConfig(providerName, config);
this.applyProviderConfig(providerName, config);
this.upsertRegisteredProvider(providerName, config);
}
/**
@@ -340,7 +340,7 @@ export interface ResolveCliModelResult {
export function resolveCliModel(options: {
cliProvider?: string;
cliModel?: string;
cliThinking?: string;
cliThinking?: ThinkingLevel;
modelRegistry: ModelRegistry;
}): ResolveCliModelResult {
const { cliProvider, cliModel, cliThinking, modelRegistry } = options;
@@ -422,6 +422,27 @@ export function resolveCliModel(options: {
});
if (model) {
// If provider inference matched an unauthenticated provider/model pair, prefer
// one exact raw model-id match that is authenticated. This keeps
// "provider/model" syntax preferred when usable, but handles models whose
// literal id starts with a known provider name (for example
// commandcode model id "xiaomi/mimo-v2.5-pro").
if (inferredProvider) {
const rawExactMatches = availableModels.filter(
(m) => m.id.toLowerCase() === cliModel.toLowerCase() && !modelsAreEqual(m, model),
);
if (rawExactMatches.length > 0 && !modelRegistry.hasConfiguredAuth(model)) {
const authenticatedRawMatches = rawExactMatches.filter((m) => modelRegistry.hasConfiguredAuth(m));
if (authenticatedRawMatches.length === 1) {
return {
model: authenticatedRawMatches[0],
thinkingLevel: undefined,
warning: undefined,
error: undefined,
};
}
}
}
return { model, thinkingLevel, warning, error: undefined };
}
@@ -470,10 +491,13 @@ export function resolveCliModel(options: {
const fallbackModel = buildFallbackModel(provider, fallbackPattern, availableModels);
if (fallbackModel) {
const requestedThinking = cliThinking ?? fallbackThinking;
const model =
requestedThinking && requestedThinking !== "off" ? { ...fallbackModel, reasoning: true } : fallbackModel;
const fallbackWarning = warning
? `${warning} Model "${fallbackPattern}" not found for provider "${provider}". Using custom model id.`
: `Model "${fallbackPattern}" not found for provider "${provider}". Using custom model id.`;
return { model: fallbackModel, thinkingLevel: fallbackThinking, warning: fallbackWarning, error: undefined };
return { model, thinkingLevel: fallbackThinking, warning: fallbackWarning, error: undefined };
}
}
@@ -27,6 +27,7 @@ import type { Readable } from "node:stream";
import { globSync } from "glob";
import ignore from "ignore";
import { minimatch } from "minimatch";
import { maxSatisfying, rcompare, satisfies, valid, validRange } from "semver";
import { CONFIG_DIR_NAME } from "../config.ts";
import { spawnProcess, spawnProcessSync } from "../utils/child-process.ts";
import { type GitSource, parseGitUrl } from "../utils/git.ts";
@@ -44,6 +45,14 @@ function isOfflineModeEnabled(): boolean {
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
}
function isExactNpmVersion(version: string | undefined): boolean {
return valid(version ?? "") !== null;
}
function getNpmVersionRange(version: string | undefined): string | undefined {
return version ? (validRange(version) ?? undefined) : undefined;
}
export interface PathMetadata {
source: string;
scope: SourceScope;
@@ -119,6 +128,8 @@ type NpmSource = {
type: "npm";
spec: string;
name: string;
version?: string;
range?: string;
pinned: boolean;
};
@@ -1113,8 +1124,8 @@ export class DefaultPackageManager implements PackageManager {
}
try {
const latestVersion = await this.getLatestNpmVersion(source.name);
return latestVersion !== installedVersion;
const targetVersion = await this.getLatestNpmVersion(source.version ? source.spec : source.name, source.range);
return targetVersion !== installedVersion;
} catch {
// Preserve existing update behavior when version lookup fails.
return true;
@@ -1128,7 +1139,7 @@ export class DefaultPackageManager implements PackageManager {
const sourceLabel = sources.length === 1 ? sources[0].source : `${scope} npm packages`;
const message = sources.length === 1 ? `Updating ${sources[0].source}...` : `Updating ${scope} npm packages...`;
const specs = sources.map((entry) => `${entry.parsed.name}@latest`);
const specs = sources.map((entry) => (entry.parsed.version ? entry.parsed.spec : `${entry.parsed.name}@latest`));
await this.withProgress("update", sourceLabel, message, async () => {
await this.installNpmBatch(specs, scope);
@@ -1241,8 +1252,7 @@ export class DefaultPackageManager implements PackageManager {
if (parsed.type === "npm") {
let installedPath = this.getNpmInstallPath(parsed, scope);
const needsInstall =
!existsSync(installedPath) ||
(parsed.pinned && !(await this.installedNpmMatchesPinnedVersion(parsed, installedPath)));
!existsSync(installedPath) || !(await this.installedNpmMatchesConfiguredVersion(parsed, installedPath));
if (needsInstall) {
const installed = await installMissing();
if (!installed) continue;
@@ -1394,7 +1404,9 @@ export class DefaultPackageManager implements PackageManager {
type: "npm",
spec,
name,
pinned: Boolean(version),
version,
range: getNpmVersionRange(version),
pinned: isExactNpmVersion(version),
};
}
@@ -1411,18 +1423,12 @@ export class DefaultPackageManager implements PackageManager {
return { type: "local", path: source };
}
private async installedNpmMatchesPinnedVersion(source: NpmSource, installedPath: string): Promise<boolean> {
private async installedNpmMatchesConfiguredVersion(source: NpmSource, installedPath: string): Promise<boolean> {
const installedVersion = this.getInstalledNpmVersion(installedPath);
if (!installedVersion) {
return false;
}
const { version: pinnedVersion } = this.parseNpmSpec(source.spec);
if (!pinnedVersion) {
return true;
}
return installedVersion === pinnedVersion;
return source.range ? satisfies(installedVersion, source.range) : true;
}
private async npmHasAvailableUpdate(source: NpmSource, installedPath: string): Promise<boolean> {
@@ -1436,8 +1442,8 @@ export class DefaultPackageManager implements PackageManager {
}
try {
const latestVersion = await this.getLatestNpmVersion(source.name);
return latestVersion !== installedVersion;
const targetVersion = await this.getLatestNpmVersion(source.version ? source.spec : source.name, source.range);
return targetVersion !== installedVersion;
} catch {
return false;
}
@@ -1455,16 +1461,25 @@ export class DefaultPackageManager implements PackageManager {
}
}
private async getLatestNpmVersion(packageName: string): Promise<string> {
private async getLatestNpmVersion(packageSpec: string, range?: string): Promise<string> {
const npmCommand = this.getNpmCommand();
const stdout = await this.runCommandCapture(
npmCommand.command,
[...npmCommand.args, "view", packageName, "version", "--json"],
[...npmCommand.args, "view", packageSpec, "version", "--json"],
{ cwd: this.cwd, timeoutMs: NETWORK_TIMEOUT_MS },
);
const raw = stdout.trim();
if (!raw) throw new Error("Empty response from npm view");
return JSON.parse(raw);
const parsed = JSON.parse(raw) as unknown;
if (typeof parsed === "string") {
return parsed;
}
if (Array.isArray(parsed)) {
const versions = parsed.filter((value): value is string => typeof value === "string" && value.length > 0);
const latest = range ? maxSatisfying(versions, range) : [...versions].sort(rcompare)[0];
if (latest) return latest;
}
throw new Error("Unexpected response from npm view");
}
private async gitHasAvailableUpdate(installedPath: string): Promise<boolean> {
@@ -1,9 +1,10 @@
import { CONFIG_DIR_NAME } from "../config.ts";
import { emitProjectTrustEvent } from "./extensions/runner.ts";
import type { LoadExtensionsResult, ProjectTrustContext } from "./extensions/types.ts";
import type { DefaultProjectTrust } from "./settings-manager.ts";
import {
getProjectTrustOptions,
hasProjectTrustInputs,
hasTrustRequiringProjectResources,
type ProjectTrustOption,
type ProjectTrustStore,
} from "./trust-manager.ts";
@@ -21,7 +22,7 @@ export interface ResolveProjectTrustedOptions {
}
function formatProjectTrustPrompt(cwd: string): string {
return `Trust project folder?\n${cwd}\n\nThis allows pi to load .pi settings and resources, install missing project packages, and execute project extensions.`;
return `Trust project folder?\n${cwd}\n\nThis allows pi to load ${CONFIG_DIR_NAME} settings and resources, install missing project packages, and execute project extensions.`;
}
async function selectProjectTrustOption(
@@ -46,7 +47,7 @@ export async function resolveProjectTrusted(options: ResolveProjectTrustedOption
if (options.trustOverride !== undefined) {
return options.trustOverride;
}
if (!hasProjectTrustInputs(options.cwd)) {
if (!hasTrustRequiringProjectResources(options.cwd)) {
return true;
}
@@ -7,6 +7,7 @@ const NVIDIA_NIM_HOST = "integrate.api.nvidia.com";
const CLOUDFLARE_API_HOST = "api.cloudflare.com";
const CLOUDFLARE_AI_GATEWAY_HOST = "gateway.ai.cloudflare.com";
const OPENCODE_HOST = "opencode.ai";
const VERCEL_GATEWAY_HOST = "ai-gateway.vercel.sh";
function matchesHost(baseUrl: string, expectedHost: string): boolean {
try {
@@ -33,6 +34,10 @@ function isCloudflareModel(model: Model<Api>): boolean {
);
}
function isVercelGatewayModel(model: Model<Api>): boolean {
return model.provider === "vercel-ai-gateway" || matchesHost(model.baseUrl, VERCEL_GATEWAY_HOST);
}
function getDefaultAttributionHeaders(
model: Model<Api>,
settingsManager: SettingsManager,
@@ -61,6 +66,13 @@ function getDefaultAttributionHeaders(
};
}
if (isVercelGatewayModel(model)) {
return {
"http-referer": "https://pi.dev",
"x-title": "pi",
};
}
return undefined;
}
@@ -10,7 +10,6 @@ import { getShellConfig } from "../utils/shell.ts";
const commandResultCache = new Map<string, string | undefined>();
const ENV_VAR_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
const ENV_VAR_NAME_PREFIX_RE = /^[A-Za-z_][A-Za-z0-9_]*/;
const LEGACY_ENV_VAR_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
type TemplatePart = { type: "literal"; value: string } | { type: "env"; name: string };
@@ -86,8 +85,8 @@ function parseConfigValueReference(config: string): ConfigValueReference {
return { type: "template", parts: parseConfigValueTemplate(config) };
}
function resolveEnvConfigValue(name: string): string | undefined {
return process.env[name] || undefined;
function resolveEnvConfigValue(name: string, env?: Record<string, string>): string | undefined {
return env?.[name] || process.env[name] || undefined;
}
function getTemplateEnvVarNames(parts: TemplatePart[]): string[] {
@@ -99,14 +98,14 @@ function getTemplateEnvVarNames(parts: TemplatePart[]): string[] {
return names;
}
function resolveTemplate(parts: TemplatePart[]): string | undefined {
function resolveTemplate(parts: TemplatePart[], env?: Record<string, string>): string | undefined {
let resolved = "";
for (const part of parts) {
if (part.type === "literal") {
resolved += part.value;
continue;
}
const envValue = resolveEnvConfigValue(part.name);
const envValue = resolveEnvConfigValue(part.name, env);
if (envValue === undefined) return undefined;
resolved += envValue;
}
@@ -124,20 +123,16 @@ export function getConfigValueEnvVarNames(config: string): string[] {
return reference.type === "template" ? getTemplateEnvVarNames(reference.parts) : [];
}
export function getMissingConfigValueEnvVarNames(config: string): string[] {
return getConfigValueEnvVarNames(config).filter((name) => resolveEnvConfigValue(name) === undefined);
export function getMissingConfigValueEnvVarNames(config: string, env?: Record<string, string>): string[] {
return getConfigValueEnvVarNames(config).filter((name) => resolveEnvConfigValue(name, env) === undefined);
}
export function isCommandConfigValue(config: string): boolean {
return parseConfigValueReference(config).type === "command";
}
export function isConfigValueConfigured(config: string): boolean {
return getMissingConfigValueEnvVarNames(config).length === 0;
}
export function isLegacyEnvVarNameConfigValue(config: string): boolean {
return LEGACY_ENV_VAR_NAME_RE.test(config);
export function isConfigValueConfigured(config: string, env?: Record<string, string>): boolean {
return getMissingConfigValueEnvVarNames(config, env).length === 0;
}
/**
@@ -147,21 +142,23 @@ export function isLegacyEnvVarNameConfigValue(config: string): boolean {
* - In non-command values, "$$" escapes a literal "$" and "$!" escapes a literal "!"
* - Otherwise treats the value as a literal
*/
export function resolveConfigValue(config: string): string | undefined {
export function resolveConfigValue(config: string, env?: Record<string, string>): string | undefined {
const reference = parseConfigValueReference(config);
if (reference.type === "command") {
return executeCommand(reference.config);
}
return resolveTemplate(reference.parts);
return resolveTemplate(reference.parts, env);
}
function executeWithConfiguredShell(command: string): { executed: boolean; value: string | undefined } {
try {
const { shell, args } = getShellConfig();
const result = spawnSync(shell, [...args, command], {
const { shell, args, commandTransport } = getShellConfig();
const commandFromStdin = commandTransport === "stdin";
const result = spawnSync(shell, commandFromStdin ? args : [...args, command], {
encoding: "utf-8",
input: commandFromStdin ? command : undefined,
timeout: 10000,
stdio: ["ignore", "pipe", "ignore"],
stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "ignore"],
shell: false,
windowsHide: true,
});
@@ -221,16 +218,16 @@ function executeCommand(commandConfig: string): string | undefined {
/**
* Resolve all header values using the same resolution logic as API keys.
*/
export function resolveConfigValueUncached(config: string): string | undefined {
export function resolveConfigValueUncached(config: string, env?: Record<string, string>): string | undefined {
const reference = parseConfigValueReference(config);
if (reference.type === "command") {
return executeCommandUncached(reference.config);
}
return resolveTemplate(reference.parts);
return resolveTemplate(reference.parts, env);
}
export function resolveConfigValueOrThrow(config: string, description: string): string {
const resolvedValue = resolveConfigValueUncached(config);
export function resolveConfigValueOrThrow(config: string, description: string, env?: Record<string, string>): string {
const resolvedValue = resolveConfigValueUncached(config, env);
if (resolvedValue !== undefined) {
return resolvedValue;
}
@@ -241,7 +238,7 @@ export function resolveConfigValueOrThrow(config: string, description: string):
}
if (reference.type === "template") {
const missingEnvVars = getMissingConfigValueEnvVarNames(config);
const missingEnvVars = getMissingConfigValueEnvVarNames(config, env);
if (missingEnvVars.length === 1) {
throw new Error(`Failed to resolve ${description} from environment variable: ${missingEnvVars[0]}`);
}
@@ -256,11 +253,14 @@ export function resolveConfigValueOrThrow(config: string, description: string):
/**
* Resolve all header values using the same resolution logic as API keys.
*/
export function resolveHeaders(headers: Record<string, string> | undefined): Record<string, string> | undefined {
export function resolveHeaders(
headers: Record<string, string> | undefined,
env?: Record<string, string>,
): Record<string, string> | undefined {
if (!headers) return undefined;
const resolved: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
const resolvedValue = resolveConfigValue(value);
const resolvedValue = resolveConfigValue(value, env);
if (resolvedValue) {
resolved[key] = resolvedValue;
}
@@ -271,11 +271,12 @@ export function resolveHeaders(headers: Record<string, string> | undefined): Rec
export function resolveHeadersOrThrow(
headers: Record<string, string> | undefined,
description: string,
env?: Record<string, string>,
): Record<string, string> | undefined {
if (!headers) return undefined;
const resolved: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
resolved[key] = resolveConfigValueOrThrow(value, `${description} header "${key}"`);
resolved[key] = resolveConfigValueOrThrow(value, `${description} header "${key}"`, env);
}
return Object.keys(resolved).length > 0 ? resolved : undefined;
}
@@ -9,7 +9,12 @@ export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.ts";
import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts";
import { createEventBus, type EventBus } from "./event-bus.ts";
import { createExtensionRuntime, loadExtensionFromFactory, loadExtensions } from "./extensions/loader.ts";
import {
clearExtensionCache,
createExtensionRuntime,
loadExtensionFromFactory,
loadExtensionsCached,
} from "./extensions/loader.ts";
import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.ts";
import { DefaultPackageManager, type PathMetadata, type ResolvedResource } from "./package-manager.ts";
import type { PromptTemplate } from "./prompt-templates.ts";
@@ -206,6 +211,7 @@ export class DefaultResourceLoader implements ResourceLoader {
private extensionThemeSourceInfos: Map<string, SourceInfo>;
private lastPromptPaths: string[];
private lastThemePaths: string[];
private loaded: boolean;
constructor(options: DefaultResourceLoaderOptions) {
this.cwd = resolvePath(options.cwd);
@@ -252,6 +258,7 @@ export class DefaultResourceLoader implements ResourceLoader {
this.extensionThemeSourceInfos = new Map();
this.lastPromptPaths = [];
this.lastThemePaths = [];
this.loaded = false;
}
getExtensions(): LoadExtensionsResult {
@@ -331,6 +338,10 @@ export class DefaultResourceLoader implements ResourceLoader {
}
async reload(options?: ResourceLoaderReloadOptions): Promise<void> {
if (this.loaded) {
clearExtensionCache();
}
let preTrustExtensions: LoadExtensionsResult | undefined;
if (options?.resolveProjectTrust) {
preTrustExtensions = await this.loadProjectTrustExtensions();
@@ -475,6 +486,7 @@ export class DefaultResourceLoader implements ResourceLoader {
this.appendSystemPrompt = this.appendSystemPromptOverride
? this.appendSystemPromptOverride(baseAppend)
: baseAppend;
this.loaded = true;
}
private async loadCurrentExtensionSet(options: { includeInlineFactories: boolean }): Promise<LoadExtensionsResult> {
@@ -487,7 +499,7 @@ export class DefaultResourceLoader implements ResourceLoader {
const extensionPaths = this.noExtensions
? cliEnabledExtensions
: this.mergePaths(cliEnabledExtensions, enabledExtensions);
const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus);
const extensionsResult = await loadExtensionsCached(extensionPaths, this.cwd, this.eventBus);
if (!options.includeInlineFactories) {
return extensionsResult;
}
@@ -507,7 +519,7 @@ export class DefaultResourceLoader implements ResourceLoader {
preTrustExtensions: LoadExtensionsResult | undefined,
): Promise<LoadExtensionsResult> {
if (!preTrustExtensions) {
const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus);
const extensionsResult = await loadExtensionsCached(extensionPaths, this.cwd, this.eventBus);
const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime);
extensionsResult.extensions.push(...inlineExtensions.extensions);
extensionsResult.errors.push(...inlineExtensions.errors);
@@ -527,7 +539,7 @@ export class DefaultResourceLoader implements ResourceLoader {
const resolvedPath = this.resolveExtensionLoadPath(path);
return !preloadedByPath.has(resolvedPath) && !failedPreloadPaths.has(resolvedPath);
});
const remainingExtensions = await loadExtensions(
const remainingExtensions = await loadExtensionsCached(
remainingPaths,
this.cwd,
this.eventBus,
+2
View File
@@ -303,6 +303,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
if (!auth.ok) {
throw new Error(auth.error);
}
const env = auth.env || options?.env ? { ...(auth.env ?? {}), ...(options?.env ?? {}) } : undefined;
const providerRetrySettings = settingsManager.getProviderRetrySettings();
const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs();
// SDKs treat timeout=0 as 0ms (immediate timeout), not "no timeout".
@@ -314,6 +315,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
return streamSimple(model, context, {
...options,
apiKey: auth.apiKey,
env,
timeoutMs,
websocketConnectTimeoutMs,
maxRetries: options?.maxRetries ?? providerRetrySettings.maxRetries,
@@ -357,9 +357,10 @@ export function buildSessionContext(
const path: SessionEntry[] = [];
let current: SessionEntry | undefined = leaf;
while (current) {
path.unshift(current);
path.push(current);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
path.reverse();
// Extract settings and find compaction
let thinkingLevel = "off";
@@ -1152,9 +1153,10 @@ export class SessionManager {
const startId = fromId ?? this.leafId;
let current = startId ? this.byId.get(startId) : undefined;
while (current) {
path.unshift(current);
path.push(current);
current = current.parentId ? this.byId.get(current.parentId) : undefined;
}
path.reverse();
return path;
}
@@ -1290,8 +1292,16 @@ export class SessionManager {
throw new Error(`Entry ${leafId} not found`);
}
// Filter out LabelEntry from path - we'll recreate them from the resolved map
const pathWithoutLabels = path.filter((e) => e.type !== "label");
// Filter out LabelEntry from path - we'll recreate them from the resolved map.
// Because labels are real tree entries, later entries can be children of labels;
// removing labels requires re-chaining the retained path to avoid orphaned subtrees.
const pathWithoutLabels: SessionEntry[] = [];
let pathParentId: string | null = null;
for (const entry of path) {
if (entry.type === "label") continue;
pathWithoutLabels.push({ ...entry, parentId: pathParentId });
pathParentId = entry.id;
}
const newSessionId = createSessionId();
const timestamp = new Date().toISOString();
@@ -117,6 +117,7 @@ export interface Settings {
markdown?: MarkdownSettings;
warnings?: WarningSettings;
sessionDir?: string; // Custom session storage directory (same format as --session-dir CLI flag)
httpProxy?: string; // Proxy URL applied as HTTP_PROXY and HTTPS_PROXY for Pi-managed HTTP clients
httpIdleTimeoutMs?: number; // HTTP header/body idle timeout in milliseconds; 0 disables it
websocketConnectTimeoutMs?: number; // WebSocket connect/open handshake timeout in milliseconds; 0 disables it
}
@@ -713,8 +714,15 @@ export class SettingsManager {
this.save();
}
getThemeSetting(): string | undefined {
const value = this.settings.theme;
if (typeof value === "string") return value;
return undefined;
}
getTheme(): string | undefined {
return this.settings.theme;
const theme = this.getThemeSetting();
return theme?.includes("/") ? undefined : theme;
}
setTheme(theme: string): void {
+11 -3
View File
@@ -66,7 +66,7 @@ export interface BashOperations {
export function createLocalBashOperations(options?: { shellPath?: string }): BashOperations {
return {
exec: async (command, cwd, { onData, signal, timeout, env }) => {
const { shell, args } = getShellConfig(options?.shellPath);
const shellConfig = getShellConfig(options?.shellPath);
try {
await fsAccess(cwd, constants.F_OK);
} catch {
@@ -76,13 +76,18 @@ export function createLocalBashOperations(options?: { shellPath?: string }): Bas
throw new Error("aborted");
}
const child = spawn(shell, [...args, command], {
const commandFromStdin = shellConfig.commandTransport === "stdin";
const child = spawn(shellConfig.shell, commandFromStdin ? shellConfig.args : [...shellConfig.args, command], {
cwd,
detached: process.platform !== "win32",
env: env ?? getShellEnv(),
stdio: ["ignore", "pipe", "pipe"],
stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "pipe"],
windowsHide: true,
});
if (commandFromStdin) {
child.stdin?.on("error", () => {});
child.stdin?.end(command);
}
if (child.pid) trackDetachedChildPid(child.pid);
let timedOut = false;
let timeoutHandle: NodeJS.Timeout | undefined;
@@ -289,6 +294,7 @@ export function createBashToolDefinition(
const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command;
const spawnContext = resolveSpawnContext(resolvedCommand, cwd, spawnHook);
const output = new OutputAccumulator({ tempFilePrefix: "pi-bash" });
let acceptingOutput = true;
let updateTimer: NodeJS.Timeout | undefined;
let updateDirty = false;
let lastUpdateAt = 0;
@@ -334,11 +340,13 @@ export function createBashToolDefinition(
}
const handleData = (data: Buffer) => {
if (!acceptingOutput) return;
output.append(data);
scheduleOutputUpdate();
};
const finishOutput = async () => {
acceptingOutput = false;
output.finish();
clearUpdateTimer();
emitOutputUpdate();
+133 -27
View File
@@ -1,6 +1,5 @@
/**
* Shared diff computation utilities for the edit tool.
* Used by both edit.ts (for execution) and tool-execution.ts (for preview rendering).
* Shared diff computation utilities for the edit and similar tools.
*/
import * as Diff from "diff";
@@ -54,6 +53,124 @@ export function normalizeForFuzzyMatch(text: string): string {
);
}
function splitLinesWithEndings(content: string): string[] {
return content.match(/[^\n]*\n|[^\n]+/g) ?? [];
}
interface LineSpan {
start: number;
end: number;
}
interface MatchedEdit {
editIndex: number;
matchIndex: number;
matchLength: number;
newText: string;
}
type TextReplacement = Pick<MatchedEdit, "matchIndex" | "matchLength" | "newText">;
function getLineSpans(content: string): LineSpan[] {
let offset = 0;
return splitLinesWithEndings(content).map((line) => {
const span = { start: offset, end: offset + line.length };
offset = span.end;
return span;
});
}
function getReplacementLineRange(lines: LineSpan[], replacement: TextReplacement) {
const replacementStart = replacement.matchIndex;
const replacementEnd = replacement.matchIndex + replacement.matchLength;
let startLine = -1;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (replacementStart >= line.start && replacementStart < line.end) {
startLine = i;
break;
}
}
if (startLine === -1) {
throw new Error("Replacement range is outside the base content.");
}
let endLine = startLine;
while (endLine < lines.length && lines[endLine].end < replacementEnd) {
endLine++;
}
if (endLine >= lines.length) {
throw new Error("Replacement range is outside the base content.");
}
return { startLine, endLine: endLine + 1 };
}
function applyReplacements(content: string, replacements: TextReplacement[], offset = 0): string {
let result = content;
for (let i = replacements.length - 1; i >= 0; i--) {
const replacement = replacements[i];
const matchIndex = replacement.matchIndex - offset;
result =
result.substring(0, matchIndex) + replacement.newText + result.substring(matchIndex + replacement.matchLength);
}
return result;
}
/**
* Apply replacements matched against `baseContent` to `originalContent` while
* preserving unchanged line blocks from the original.
*
* This is useful when `baseContent` is a normalized view of the original. Each
* replacement is widened to the lines it actually touches, those touched lines
* are rewritten from the normalized base, and all other lines are copied back
* from `originalContent`. The actual replacement ranges drive preservation so
* duplicate normalized lines cannot be aligned to the wrong occurrence.
*/
export function applyReplacementsPreservingUnchangedLines(
originalContent: string,
baseContent: string,
replacements: TextReplacement[],
): string {
const originalLines = splitLinesWithEndings(originalContent);
const baseLines = getLineSpans(baseContent);
if (originalLines.length !== baseLines.length) {
throw new Error("Cannot preserve unchanged lines because the base content has a different line count.");
}
const groups: Array<{ startLine: number; endLine: number; replacements: TextReplacement[] }> = [];
const sortedReplacements = [...replacements].sort((a, b) => a.matchIndex - b.matchIndex);
for (const replacement of sortedReplacements) {
const range = getReplacementLineRange(baseLines, replacement);
const current = groups[groups.length - 1];
if (current && range.startLine < current.endLine) {
current.endLine = Math.max(current.endLine, range.endLine);
current.replacements.push(replacement);
continue;
}
groups.push({ ...range, replacements: [replacement] });
}
let originalLineIndex = 0;
let result = "";
for (const group of groups) {
result += originalLines.slice(originalLineIndex, group.startLine).join("");
const groupStartOffset = baseLines[group.startLine].start;
const groupEndOffset = baseLines[group.endLine - 1].end;
result += applyReplacements(
baseContent.slice(groupStartOffset, groupEndOffset),
group.replacements,
groupStartOffset,
);
originalLineIndex = group.endLine;
}
result += originalLines.slice(originalLineIndex).join("");
return result;
}
export interface FuzzyMatchResult {
/** Whether a match was found */
found: boolean;
@@ -75,13 +192,6 @@ export interface Edit {
newText: string;
}
interface MatchedEdit {
editIndex: number;
matchIndex: number;
matchLength: number;
newText: string;
}
export interface AppliedEditsResult {
baseContent: string;
newContent: string;
@@ -121,9 +231,9 @@ export function fuzzyFindText(content: string, oldText: string): FuzzyMatchResul
};
}
// When fuzzy matching, we work in the normalized space for replacement.
// This means the output will have normalized whitespace/quotes/dashes,
// which is acceptable since we're fixing minor formatting differences anyway.
// When fuzzy matching, return offsets in normalized space. Callers can use
// the normalized content to compute replacements, then decide how much of
// that normalized output should be written back.
return {
found: true,
index: fuzzyIndex,
@@ -187,8 +297,9 @@ function getNoChangeError(path: string, totalEdits: number): Error {
*
* All edits are matched against the same original content. Replacements are
* then applied in reverse order so offsets remain stable. If any edit needs
* fuzzy matching, the operation runs in fuzzy-normalized content space to
* preserve current single-edit behavior.
* fuzzy matching, the operation runs in fuzzy-normalized content space and then
* overlays those line-level changes onto the original content so unchanged line
* blocks keep their original bytes.
*/
export function applyEditsToNormalizedContent(
normalizedContent: string,
@@ -207,19 +318,18 @@ export function applyEditsToNormalizedContent(
}
const initialMatches = normalizedEdits.map((edit) => fuzzyFindText(normalizedContent, edit.oldText));
const baseContent = initialMatches.some((match) => match.usedFuzzyMatch)
? normalizeForFuzzyMatch(normalizedContent)
: normalizedContent;
const usedFuzzyMatch = initialMatches.some((match) => match.usedFuzzyMatch);
const replacementBaseContent = usedFuzzyMatch ? normalizeForFuzzyMatch(normalizedContent) : normalizedContent;
const matchedEdits: MatchedEdit[] = [];
for (let i = 0; i < normalizedEdits.length; i++) {
const edit = normalizedEdits[i];
const matchResult = fuzzyFindText(baseContent, edit.oldText);
const matchResult = fuzzyFindText(replacementBaseContent, edit.oldText);
if (!matchResult.found) {
throw getNotFoundError(path, i, normalizedEdits.length);
}
const occurrences = countOccurrences(baseContent, edit.oldText);
const occurrences = countOccurrences(replacementBaseContent, edit.oldText);
if (occurrences > 1) {
throw getDuplicateError(path, i, normalizedEdits.length, occurrences);
}
@@ -243,14 +353,10 @@ export function applyEditsToNormalizedContent(
}
}
let newContent = baseContent;
for (let i = matchedEdits.length - 1; i >= 0; i--) {
const edit = matchedEdits[i];
newContent =
newContent.substring(0, edit.matchIndex) +
edit.newText +
newContent.substring(edit.matchIndex + edit.matchLength);
}
const baseContent = normalizedContent;
const newContent = usedFuzzyMatch
? applyReplacementsPreservingUnchangedLines(normalizedContent, replacementBaseContent, matchedEdits)
: applyReplacements(replacementBaseContent, matchedEdits);
if (baseContent === newContent) {
throw getNoChangeError(path, normalizedEdits.length);
+18 -11
View File
@@ -221,17 +221,24 @@ export function createFindToolDefinition(
return;
}
// Build fd arguments. --no-require-git makes fd apply hierarchical .gitignore
// semantics whether or not the search path is inside a git repository, without
// leaking sibling-directory rules the way --ignore-file (a global source) would.
const args: string[] = [
"--glob",
"--color=never",
"--hidden",
"--no-require-git",
"--max-results",
String(effectiveLimit),
];
const args: string[] = ["--glob", "--color=never", "--hidden"];
// fd normally ignores .gitignore outside git repos, so keep --no-require-git
// there. Inside repos, use fd's default git-aware behavior so parent
// .gitignore rules stop at nested repo boundaries:
// https://github.com/earendil-works/pi/issues/5960
let insideGitRepo = false;
for (let current = searchPath; ; ) {
if (await pathExists(path.join(current, ".git"))) {
insideGitRepo = true;
break;
}
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
if (!insideGitRepo) args.push("--no-require-git");
args.push("--max-results", String(effectiveLimit));
// fd --glob matches against the basename unless --full-path is set; in --full-path
// mode it matches against the absolute candidate path, so a path-containing
+28 -13
View File
@@ -1,4 +1,5 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import lockfile from "proper-lockfile";
import { CONFIG_DIR_NAME } from "../config.ts";
@@ -25,6 +26,16 @@ export interface ProjectTrustOption {
type TrustFile = Record<string, boolean | null | undefined>;
const TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES = [
"settings.json",
"extensions",
"skills",
"prompts",
"themes",
"SYSTEM.md",
"APPEND_SYSTEM.md",
] as const;
function normalizeCwd(cwd: string): string {
return canonicalizePath(resolvePath(cwd));
}
@@ -45,18 +56,14 @@ function findNearestTrustEntry(data: TrustFile, cwd: string): ProjectTrustStoreE
}
}
export function getProjectTrustPath(cwd: string): string {
return normalizeCwd(cwd);
}
export function getProjectTrustParentPath(cwd: string): string | undefined {
const trustPath = getProjectTrustPath(cwd);
const trustPath = normalizeCwd(cwd);
const parentDir = dirname(trustPath);
return parentDir === trustPath ? undefined : parentDir;
}
export function getProjectTrustOptions(cwd: string, options?: { includeSessionOnly?: boolean }): ProjectTrustOption[] {
const trustPath = getProjectTrustPath(cwd);
const trustPath = normalizeCwd(cwd);
const trustOptions: ProjectTrustOption[] = [
{ label: "Trust", trusted: true, updates: [{ path: trustPath, decision: true }], savedPath: trustPath },
];
@@ -167,18 +174,26 @@ function withTrustFileLock<T>(path: string, fn: () => T): T {
}
}
export function hasProjectConfigDir(cwd: string): boolean {
return existsSync(join(canonicalizePath(resolvePath(cwd)), CONFIG_DIR_NAME));
}
export function hasProjectTrustInputs(cwd: string): boolean {
/**
* Returns true when cwd has project-local resources that must be gated by
* project trust: trust-requiring entries under cwd/.pi, or .agents/skills in
* cwd or one of its ancestors. Returns false when no such project resources
* exist. The user/global ~/.agents/skills directory is always treated as a
* trusted user resource and is ignored here, even when cwd is $HOME.
*/
export function hasTrustRequiringProjectResources(cwd: string): boolean {
const homeDir = canonicalizePath(resolvePath(process.env.HOME || homedir()));
const userAgentsSkillsDir = join(homeDir, ".agents", "skills");
let currentDir = canonicalizePath(resolvePath(cwd));
if (hasProjectConfigDir(currentDir)) {
const configDir = join(currentDir, CONFIG_DIR_NAME);
if (TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES.some((entry) => existsSync(join(configDir, entry)))) {
return true;
}
while (true) {
if (existsSync(join(currentDir, ".agents", "skills"))) {
const agentsSkillsDir = join(currentDir, ".agents", "skills");
if (agentsSkillsDir !== userAgentsSkillsDir && existsSync(agentsSkillsDir)) {
return true;
}
+11 -2
View File
@@ -3,7 +3,15 @@
export { type Args, parseArgs } from "./cli/args.ts";
// Config paths
export { getAgentDir, getDocsPath, getExamplesPath, getPackageDir, getReadmePath, VERSION } from "./config.ts";
export {
CONFIG_DIR_NAME,
getAgentDir,
getDocsPath,
getExamplesPath,
getPackageDir,
getReadmePath,
VERSION,
} from "./config.ts";
export {
AgentSession,
type AgentSessionConfig,
@@ -238,6 +246,7 @@ export {
type SkillFrontmatter,
} from "./core/skills.ts";
export { createSyntheticSourceInfo } from "./core/source-info.ts";
export { type EditDiffResult, generateDiffString, generateUnifiedPatch } from "./core/tools/edit-diff.ts";
// Tools
export {
type BashOperations,
@@ -289,7 +298,7 @@ export {
withFileMutationQueue,
} from "./core/tools/index.ts";
export {
hasProjectTrustInputs,
hasTrustRequiringProjectResources,
type ProjectTrustDecision,
ProjectTrustStore,
type ProjectTrustStoreEntry,
+27 -9
View File
@@ -26,7 +26,7 @@ import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts";
import { AuthStorage } from "./core/auth-storage.ts";
import { exportFromFile } from "./core/export-html/index.ts";
import type { ExtensionFactory } from "./core/extensions/types.ts";
import { configureHttpDispatcher } from "./core/http-dispatcher.ts";
import { applyHttpProxySettings, configureHttpDispatcher } from "./core/http-dispatcher.ts";
import type { ModelRegistry } from "./core/model-registry.ts";
import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.ts";
import { restoreStdout, takeOverStdout } from "./core/output-guard.ts";
@@ -41,7 +41,7 @@ import {
import { assertValidSessionId, SessionManager } from "./core/session-manager.ts";
import { SettingsManager } from "./core/settings-manager.ts";
import { printTimings, resetTimings, time } from "./core/timings.ts";
import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts";
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts";
import { runMigrations, showDeprecationWarnings } from "./migrations.ts";
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts";
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts";
@@ -466,7 +466,22 @@ export async function main(args: string[], options?: MainOptions) {
cleanupWindowsSelfUpdateQuarantine(getPackageDir());
}
const cwd = process.cwd();
const agentDir = getAgentDir();
const bootstrapSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false });
applyHttpProxySettings(bootstrapSettingsManager.getGlobalSettings().httpProxy);
configureHttpDispatcher();
if (await handlePackageCommand(args, { extensionFactories: options?.extensionFactories })) {
const exitCode = process.exitCode ?? 0;
if (process.platform === "win32" && exitCode === 0 && args[0] === "update") {
// We normally prefer process.exit(0) for package commands so bad extensions cannot keep
// one-shot commands alive. On Windows, Node can assert after fetch() if process.exit(0)
// runs during teardown; let successful `pi update` drain naturally instead.
// https://github.com/nodejs/node/issues/56645
return;
}
process.exit(exitCode);
return;
}
@@ -520,11 +535,9 @@ export async function main(args: string[], options?: MainOptions) {
validateSessionIdFlags(parsed);
// Run migrations (pass cwd for project-local migrations)
const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(process.cwd());
const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(cwd);
time("runMigrations");
const cwd = process.cwd();
const agentDir = getAgentDir();
const startupSettingsManager = SettingsManager.create(cwd, agentDir);
reportDiagnostics(collectSettingsDiagnostics(startupSettingsManager, "startup session lookup"));
@@ -572,7 +585,9 @@ export async function main(args: string[], options?: MainOptions) {
const trustStore = new ProjectTrustStore(agentDir);
const sessionCwd = sessionManager.getCwd();
const autoTrustOnReloadCwd =
parsed.projectTrustOverride === undefined && !hasProjectTrustInputs(sessionCwd) ? sessionCwd : undefined;
parsed.projectTrustOverride === undefined && !hasTrustRequiringProjectResources(sessionCwd)
? sessionCwd
: undefined;
const trustPromptMode: AppMode = parsed.help || parsed.listModels !== undefined ? "print" : appMode;
const projectTrustByCwd = new Map<string, boolean>();
@@ -591,12 +606,14 @@ export async function main(args: string[], options?: MainOptions) {
const isInitialRuntime = sessionStartEvent === undefined;
const projectTrustDiagnostics: AgentSessionRuntimeDiagnostic[] = [];
const cachedProjectTrust = projectTrustByCwd.get(cwd);
const hasTrustInputs = hasProjectTrustInputs(cwd);
const hasTrustRequiringResources = hasTrustRequiringProjectResources(cwd);
const shouldResolveProjectTrust =
parsed.projectTrustOverride === undefined && cachedProjectTrust === undefined && hasTrustInputs;
parsed.projectTrustOverride === undefined && cachedProjectTrust === undefined && hasTrustRequiringResources;
const projectTrusted = shouldResolveProjectTrust
? false
: (cachedProjectTrust ?? parsed.projectTrustOverride ?? (!hasTrustInputs || trustStore.get(cwd) === true));
: (cachedProjectTrust ??
parsed.projectTrustOverride ??
(!hasTrustRequiringResources || trustStore.get(cwd) === true));
const runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted });
const services = await createAgentSessionServices({
cwd,
@@ -713,6 +730,7 @@ export async function main(args: string[], options?: MainOptions) {
time("createAgentSessionRuntime");
const { services, session, modelFallbackMessage } = runtime;
const { settingsManager, modelRegistry, resourceLoader } = services;
applyHttpProxySettings(settingsManager.getGlobalSettings().httpProxy);
configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs());
if (parsed.help) {
+1 -138
View File
@@ -3,12 +3,10 @@
*/
import chalk from "chalk";
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs";
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs";
import { dirname, join } from "path";
import { CONFIG_DIR_NAME, getAgentDir, getBinDir } from "./config.ts";
import { migrateKeybindingsConfig } from "./core/keybindings.ts";
import { isLegacyEnvVarNameConfigValue } from "./core/resolve-config-value.ts";
import { stripJsonComments } from "./utils/json.ts";
const MIGRATION_GUIDE_URL =
"https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/CHANGELOG.md#extensions-migration";
@@ -74,140 +72,6 @@ export function migrateAuthToAuthJson(): string[] {
return providers;
}
interface ConfigValueMigration {
location: string;
from: string;
to: string;
}
function migrateLegacyEnvVarString(value: string): string | undefined {
return isLegacyEnvVarNameConfigValue(value) ? `$${value}` : undefined;
}
function migrateStringProperty(
record: Record<string, unknown>,
key: string,
location: string,
migrations: ConfigValueMigration[],
): boolean {
const value = record[key];
if (typeof value !== "string") return false;
const migrated = migrateLegacyEnvVarString(value);
if (migrated === undefined) return false;
record[key] = migrated;
migrations.push({ location, from: value, to: migrated });
return true;
}
function migrateHeadersConfig(headers: unknown, location: string, migrations: ConfigValueMigration[]): boolean {
if (typeof headers !== "object" || headers === null || Array.isArray(headers)) return false;
const headerRecord = headers as Record<string, unknown>;
let migrated = false;
for (const [key, value] of Object.entries(headerRecord)) {
if (typeof value !== "string") continue;
const migratedValue = migrateLegacyEnvVarString(value);
if (migratedValue === undefined) continue;
headerRecord[key] = migratedValue;
migrations.push({ location: `${location}[${JSON.stringify(key)}]`, from: value, to: migratedValue });
migrated = true;
}
return migrated;
}
function migrateAuthJsonConfigValues(agentDir: string): ConfigValueMigration[] {
const authPath = join(agentDir, "auth.json");
if (!existsSync(authPath)) return [];
try {
const parsed = JSON.parse(readFileSync(authPath, "utf-8")) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return [];
const authData = parsed as Record<string, unknown>;
const migrations: ConfigValueMigration[] = [];
for (const [provider, credential] of Object.entries(authData)) {
if (typeof credential !== "object" || credential === null || Array.isArray(credential)) continue;
const credentialRecord = credential as Record<string, unknown>;
if (credentialRecord.type !== "api_key") continue;
migrateStringProperty(credentialRecord, "key", `auth.json[${JSON.stringify(provider)}].key`, migrations);
}
if (migrations.length === 0) return [];
writeFileSync(authPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf-8");
chmodSync(authPath, 0o600);
return migrations;
} catch {
return [];
}
}
function migrateModelsJsonConfigValues(agentDir: string): ConfigValueMigration[] {
const modelsPath = join(agentDir, "models.json");
if (!existsSync(modelsPath)) return [];
try {
const parsed = JSON.parse(stripJsonComments(readFileSync(modelsPath, "utf-8"))) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return [];
const modelsData = parsed as Record<string, unknown>;
const providers = modelsData.providers;
if (typeof providers !== "object" || providers === null || Array.isArray(providers)) return [];
const migrations: ConfigValueMigration[] = [];
for (const [provider, providerConfig] of Object.entries(providers)) {
if (typeof providerConfig !== "object" || providerConfig === null || Array.isArray(providerConfig)) continue;
const providerRecord = providerConfig as Record<string, unknown>;
const providerLocation = `models.json.providers[${JSON.stringify(provider)}]`;
migrateStringProperty(providerRecord, "apiKey", `${providerLocation}.apiKey`, migrations);
migrateHeadersConfig(providerRecord.headers, `${providerLocation}.headers`, migrations);
if (Array.isArray(providerRecord.models)) {
for (let index = 0; index < providerRecord.models.length; index++) {
const modelConfig = providerRecord.models[index];
if (typeof modelConfig !== "object" || modelConfig === null || Array.isArray(modelConfig)) continue;
const modelRecord = modelConfig as Record<string, unknown>;
const modelKey = typeof modelRecord.id === "string" ? JSON.stringify(modelRecord.id) : String(index);
migrateHeadersConfig(modelRecord.headers, `${providerLocation}.models[${modelKey}].headers`, migrations);
}
}
const modelOverrides = providerRecord.modelOverrides;
if (typeof modelOverrides === "object" && modelOverrides !== null && !Array.isArray(modelOverrides)) {
for (const [modelId, modelOverride] of Object.entries(modelOverrides)) {
if (typeof modelOverride !== "object" || modelOverride === null || Array.isArray(modelOverride))
continue;
const modelOverrideRecord = modelOverride as Record<string, unknown>;
migrateHeadersConfig(
modelOverrideRecord.headers,
`${providerLocation}.modelOverrides[${JSON.stringify(modelId)}].headers`,
migrations,
);
}
}
}
if (migrations.length === 0) return [];
writeFileSync(modelsPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf-8");
return migrations;
} catch {
return [];
}
}
function migrateExplicitEnvVarConfigValues(): void {
const agentDir = getAgentDir();
const migrations = [...migrateAuthJsonConfigValues(agentDir), ...migrateModelsJsonConfigValues(agentDir)];
if (migrations.length === 0) return;
const details = migrations.map((migration) => ` - ${migration.location}: ${migration.from} -> ${migration.to}`);
console.log(
chalk.yellow(
[
"Warning: Migrated API key/header environment references to explicit $ENV_VAR syntax. Plain strings will be treated as literals.",
...details,
].join("\n"),
),
);
}
/**
* Migrate sessions from ~/.pi/agent/*.jsonl to proper session directories.
*
@@ -443,7 +307,6 @@ export function runMigrations(cwd: string): {
deprecationWarnings: string[];
} {
const migratedAuthProviders = migrateAuthToAuthJson();
migrateExplicitEnvVarConfigValues();
migrateSessionsFromAgentRoot();
migrateToolsToBin();
migrateKeybindingsConfigFile();
@@ -73,7 +73,7 @@ function formatBaseDir(baseDir: string): string {
return displayPath.endsWith("/") ? displayPath : `${displayPath}/`;
}
function getGroupLabel(metadata: PathMetadata): string {
function getGroupLabel(metadata: PathMetadata, agentDir: string): string {
if (metadata.origin === "package") {
return `${metadata.source} (${metadata.scope})`;
}
@@ -84,12 +84,12 @@ function getGroupLabel(metadata: PathMetadata): string {
? `User (${formatBaseDir(metadata.baseDir)})`
: `Project (${formatBaseDir(metadata.baseDir)})`;
}
return metadata.scope === "user" ? "User (~/.pi/agent/)" : "Project (.pi/)";
return metadata.scope === "user" ? `User (${formatBaseDir(agentDir)})` : `Project (${CONFIG_DIR_NAME}/)`;
}
return metadata.scope === "user" ? "User settings" : "Project settings";
}
function buildGroups(resolved: ResolvedPaths): ResourceGroup[] {
function buildGroups(resolved: ResolvedPaths, agentDir: string): ResourceGroup[] {
const groupMap = new Map<string, ResourceGroup>();
const addToGroup = (resources: ResolvedResource[], resourceType: ResourceType) => {
@@ -100,7 +100,7 @@ function buildGroups(resolved: ResolvedPaths): ResourceGroup[] {
if (!groupMap.has(groupKey)) {
groupMap.set(groupKey, {
key: groupKey,
label: getGroupLabel(metadata),
label: getGroupLabel(metadata, agentDir),
scope: metadata.scope,
origin: metadata.origin,
source: metadata.source,
@@ -601,7 +601,7 @@ export class ConfigSelectorComponent extends Container implements Focusable {
) {
super();
const groups = buildGroups(resolvedPaths);
const groups = buildGroups(resolvedPaths, agentDir);
// Add header
this.addChild(new Spacer(1));
@@ -1,6 +1,7 @@
import { isAbsolute, relative, resolve, sep } from "node:path";
import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
import type { AgentSession } from "../../../core/agent-session.ts";
import { areExperimentalFeaturesEnabled } from "../../../core/experimental.ts";
import type { ReadonlyFooterDataProvider } from "../../../core/footer-data-provider.ts";
import { theme } from "../theme/theme.ts";
@@ -159,6 +160,9 @@ export class FooterComponent implements Component {
contextPercentStr = contextPercentDisplay;
}
statsParts.push(contextPercentStr);
if (areExperimentalFeaturesEnabled()) {
statsParts.push(`${theme.fg("dim", "•")} ${theme.bold(theme.fg("warning", "xp"))}`);
}
let statsLeft = statsParts.join(" ");
@@ -128,7 +128,6 @@ export class LoginDialogComponent extends Container implements Focusable {
this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(theme.fg("warning", `Enter code: ${info.userCode}`), 1, 0));
openBrowser(info.verificationUri);
this.tui.requestRender();
}
@@ -11,6 +11,7 @@ import {
} from "@earendil-works/pi-tui";
import type { ModelRegistry } from "../../../core/model-registry.ts";
import type { SettingsManager } from "../../../core/settings-manager.ts";
import { getModelSelectorSearchText } from "../model-search.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint } from "./keybinding-hints.ts";
@@ -217,10 +218,8 @@ export class ModelSelectorComponent extends Container implements Focusable {
private filterModels(query: string): void {
this.filteredModels = query
? fuzzyFilter(
this.activeModels,
query,
({ id, provider }) => `${id} ${provider} ${provider}/${id} ${provider} ${id}`,
? fuzzyFilter(this.activeModels, query, ({ id, provider, model }) =>
getModelSelectorSearchText({ id, provider, name: model.name }),
)
: this.activeModels;
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
@@ -10,6 +10,7 @@ import {
Spacer,
Text,
} from "@earendil-works/pi-tui";
import { getModelSearchText } from "../model-search.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyText } from "./keybinding-hints.ts";
@@ -182,7 +183,11 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl
private refresh(): void {
const query = this.searchInput.getValue();
const items = this.buildItems();
this.filteredItems = query ? fuzzyFilter(items, query, (i) => `${i.model.id} ${i.model.provider}`) : items;
this.filteredItems = query
? fuzzyFilter(items, query, (i) =>
getModelSearchText({ id: i.model.id, provider: i.model.provider, name: i.model.name }),
)
: items;
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1));
this.updateList();
this.footerText.setText(this.getFooterText());
@@ -694,7 +694,6 @@ export class SessionSelectorComponent extends Container implements Focusable {
private allSessions: SessionInfo[] | null = null;
private currentSessionsLoader: SessionsLoader;
private allSessionsLoader: SessionsLoader;
private onCancel: () => void;
private requestRender: () => void;
private renameSession?: (sessionPath: string, currentName: string | undefined) => Promise<void>;
private currentLoading = false;
@@ -751,7 +750,6 @@ export class SessionSelectorComponent extends Container implements Focusable {
this.keybindings = options?.keybindings ?? KeybindingsManager.create();
this.currentSessionsLoader = currentSessionsLoader;
this.allSessionsLoader = allSessionsLoader;
this.onCancel = onCancel;
this.requestRender = requestRender;
this.header = new SessionSelectorHeader(this.scope, this.sortMode, this.nameFilter, this.requestRender);
const renameSession = options?.renameSession;
@@ -948,10 +946,6 @@ export class SessionSelectorComponent extends Container implements Focusable {
this.header.setLoading(false);
this.sessionList.setSessions(sessions, showCwd);
this.requestRender();
if (scope === "all" && sessions.length === 0 && (this.currentSessions?.length ?? 0) === 0) {
this.onCancel();
}
} catch (err) {
if (scope === "current") {
this.currentLoading = false;
@@ -1,6 +1,7 @@
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { Transport } from "@earendil-works/pi-ai";
import {
type Component,
Container,
getCapabilities,
type SelectItem,
@@ -13,7 +14,13 @@ import {
} from "@earendil-works/pi-tui";
import { formatHttpIdleTimeoutMs, HTTP_IDLE_TIMEOUT_CHOICES } from "../../../core/http-dispatcher.ts";
import type { DefaultProjectTrust, WarningSettings } from "../../../core/settings-manager.ts";
import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.ts";
import {
getSelectListTheme,
getSettingsListTheme,
parseAutoThemeSetting,
type TerminalTheme,
theme,
} from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyDisplayText } from "./keybinding-hints.ts";
@@ -55,6 +62,7 @@ export interface SettingsConfig {
thinkingLevel: ThinkingLevel;
availableThinkingLevels: ThinkingLevel[];
currentTheme: string;
terminalTheme: TerminalTheme;
availableThemes: string[];
hideThinkingBlock: boolean;
collapseChangelog: boolean;
@@ -210,6 +218,249 @@ class SelectSubmenu extends Container {
}
}
function themeItems(availableThemes: string[]): SelectItem[] {
return availableThemes.map((name) => ({ value: name, label: name }));
}
const AUTOMATIC_THEME_VALUE = "/";
function singleModeThemeItems(availableThemes: string[]): SelectItem[] {
return [
{
value: AUTOMATIC_THEME_VALUE,
label: "Automatic",
description: "Use separate themes for light and dark terminal appearance",
},
...themeItems(availableThemes),
];
}
function preferredTheme(availableThemes: string[], preferred: string | undefined, fallback: string): string {
if (preferred && availableThemes.includes(preferred)) return preferred;
if (availableThemes.includes(fallback)) return fallback;
return availableThemes[0] ?? fallback;
}
function defaultAutomaticThemes(
currentThemeSetting: string,
availableThemes: string[],
): { lightTheme: string; darkTheme: string } {
const autoTheme = parseAutoThemeSetting(currentThemeSetting);
if (autoTheme) return autoTheme;
const currentFixedTheme = currentThemeSetting.includes("/") ? undefined : currentThemeSetting;
const themeName = preferredTheme(availableThemes, currentFixedTheme, "dark");
return { lightTheme: themeName, darkTheme: themeName };
}
class ThemeSubmenu extends Container {
private inputComponent: Component | undefined;
private readonly callbacks: SettingsCallbacks;
private readonly availableThemes: string[];
private readonly terminalTheme: TerminalTheme;
private readonly onDone: (selectedValue?: string) => void;
private readonly originalThemeSetting: string;
private mode: "single" | "automatic";
private singleTheme: string;
private lightTheme: string;
private darkTheme: string;
constructor(
currentThemeSetting: string,
terminalTheme: TerminalTheme,
availableThemes: string[],
callbacks: SettingsCallbacks,
onDone: (selectedValue?: string) => void,
) {
super();
this.callbacks = callbacks;
this.availableThemes = availableThemes;
this.terminalTheme = terminalTheme;
this.onDone = onDone;
this.originalThemeSetting = currentThemeSetting;
const autoTheme = parseAutoThemeSetting(currentThemeSetting);
const automaticThemes = defaultAutomaticThemes(currentThemeSetting, availableThemes);
const fixedTheme = autoTheme || currentThemeSetting.includes("/") ? undefined : currentThemeSetting;
this.mode = autoTheme ? "automatic" : "single";
this.lightTheme = automaticThemes.lightTheme;
this.darkTheme = automaticThemes.darkTheme;
this.singleTheme = preferredTheme(
availableThemes,
fixedTheme ?? (autoTheme ? this.getActiveAutomaticTheme() : undefined),
"dark",
);
if (this.mode === "automatic") {
this.showAutomaticMenu();
} else {
this.showSingleMenu();
}
}
handleInput(data: string): void {
this.inputComponent?.handleInput?.(data);
}
private setContent(renderComponent: Component, inputComponent: Component = renderComponent): void {
this.clear();
this.addChild(renderComponent);
this.inputComponent = inputComponent;
}
private showSingleMenu(): void {
this.mode = "single";
const menu = new SelectSubmenu(
"Theme",
"Select a theme, or choose Automatic to follow terminal appearance.",
singleModeThemeItems(this.availableThemes),
this.singleTheme,
(value) => {
if (value === AUTOMATIC_THEME_VALUE) {
this.mode = "automatic";
this.callbacks.onThemePreview?.(this.getThemeSetting());
this.showAutomaticMenu();
return;
}
this.singleTheme = value;
this.apply(value);
},
() => this.cancel(),
(value) => {
this.callbacks.onThemePreview?.(value === AUTOMATIC_THEME_VALUE ? this.getAutomaticThemeSetting() : value);
},
);
this.setContent(menu);
}
private showAutomaticMenu(): void {
this.mode = "automatic";
const content = new Container();
content.addChild(new Text(theme.bold(theme.fg("accent", "Automatic Theme")), 0, 0));
content.addChild(new Spacer(1));
content.addChild(new Text(theme.fg("muted", "Choose themes for terminal light and dark appearance."), 0, 0));
content.addChild(new Text(theme.fg("muted", "Light/dark detection requires terminal support."), 0, 0));
content.addChild(new Spacer(1));
const items: SettingItem[] = [
{
id: "light-theme",
label: "Light theme",
description: "Theme to use in automatic mode when the terminal is light",
currentValue: this.lightTheme,
submenu: (currentValue, done) =>
this.createThemeSelect(
"Light Theme",
"Select the theme to use for light terminal appearance",
currentValue,
done,
(value) => {
this.lightTheme = value;
this.callbacks.onThemePreview?.(this.getThemeSetting());
done(value);
},
),
},
{
id: "dark-theme",
label: "Dark theme",
description: "Theme to use in automatic mode when the terminal is dark",
currentValue: this.darkTheme,
submenu: (currentValue, done) =>
this.createThemeSelect(
"Dark Theme",
"Select the theme to use for dark terminal appearance",
currentValue,
done,
(value) => {
this.darkTheme = value;
this.callbacks.onThemePreview?.(this.getThemeSetting());
done(value);
},
),
},
{
id: "apply",
label: "Apply",
description: "Save and go back",
currentValue: "save and go back",
values: ["save and go back"],
},
{
id: "single-mode",
label: "Change mode",
description: "Switch to one theme for light and dark",
currentValue: "switch to single theme",
values: ["switch to single theme"],
},
];
const settingsList = new SettingsList(
items,
Math.min(items.length, 10),
getSettingsListTheme(),
(id) => {
switch (id) {
case "single-mode":
this.mode = "single";
this.singleTheme = this.getActiveAutomaticTheme();
this.callbacks.onThemePreview?.(this.singleTheme);
this.showSingleMenu();
break;
case "apply":
this.apply(this.getAutomaticThemeSetting());
break;
}
},
() => this.cancel(),
);
content.addChild(settingsList);
this.setContent(content, settingsList);
}
private createThemeSelect(
title: string,
description: string,
currentValue: string,
done: (selectedValue?: string) => void,
onSelect: (value: string) => void,
): SelectSubmenu {
return new SelectSubmenu(
title,
description,
themeItems(this.availableThemes),
currentValue,
onSelect,
() => {
this.callbacks.onThemePreview?.(this.getThemeSetting());
done();
},
(value) => this.callbacks.onThemePreview?.(value),
);
}
private getThemeSetting(): string {
return this.mode === "automatic" ? this.getAutomaticThemeSetting() : this.singleTheme;
}
private getActiveAutomaticTheme(): string {
return this.terminalTheme === "light" ? this.lightTheme : this.darkTheme;
}
private getAutomaticThemeSetting(): string {
return `${this.lightTheme}/${this.darkTheme}`;
}
private apply(themeSetting: string): void {
this.onDone(themeSetting);
}
private cancel(): void {
this.callbacks.onThemePreview?.(this.originalThemeSetting);
this.onDone();
}
}
/**
* Main settings selector component.
*/
@@ -353,28 +604,7 @@ export class SettingsSelectorComponent extends Container {
description: "Color theme for the interface",
currentValue: config.currentTheme,
submenu: (currentValue, done) =>
new SelectSubmenu(
"Theme",
"Select color theme",
config.availableThemes.map((t) => ({
value: t,
label: t,
})),
currentValue,
(value) => {
callbacks.onThemeChange(value);
done(value);
},
() => {
// Restore original theme on cancel
callbacks.onThemePreview?.(currentValue);
done();
},
(value) => {
// Preview theme on selection change
callbacks.onThemePreview?.(value);
},
),
new ThemeSubmenu(currentValue, config.terminalTheme, config.availableThemes, callbacks, done),
},
];
@@ -561,6 +791,9 @@ export class SettingsSelectorComponent extends Container {
case "terminal-progress":
callbacks.onShowTerminalProgressChange(newValue === "true");
break;
case "theme":
callbacks.onThemeChange(newValue);
break;
}
},
callbacks.onCancel,
@@ -4,15 +4,18 @@ import {
type Focusable,
getKeybindings,
Input,
type Keybinding,
Spacer,
sliceByColumn,
Text,
TruncatedText,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
} from "@earendil-works/pi-tui";
import type { SessionTreeNode } from "../../../core/session-manager.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint, keyText } from "./keybinding-hints.ts";
import { formatKeyText, keyHint } from "./keybinding-hints.ts";
/** Gutter info: position (displayIndent where connector was) and whether to show │ */
interface GutterInfo {
@@ -35,6 +38,59 @@ interface FlatNode {
isVirtualRootChild: boolean;
}
interface HorizontalViewportRow {
gutter: string;
body: string;
anchorCol: number;
bodyWidth: number;
isSelected: boolean;
}
const TREE_GUTTER_WIDTH = 2;
const MIN_VISIBLE_ANCHOR_CONTENT_WIDTH = 4;
const MAX_VISIBLE_ANCHOR_CONTENT_WIDTH = 20;
const MIN_ANCHOR_CONTEXT_WIDTH = 2;
const MAX_ANCHOR_CONTEXT_WIDTH = 12;
/**
* Render tree rows into a horizontally clipped viewport.
*
* The tree gutter is always kept visible. The row bodies are shifted left only
* when the selected row's anchor (the start of its entry text after tree
* indentation/markers) would otherwise be too far right to see useful content.
*/
function renderHorizontalViewport(rows: HorizontalViewportRow[], width: number): string[] {
const viewportWidth = Math.max(0, width - TREE_GUTTER_WIDTH);
const maxBodyWidth = rows.reduce((max, row) => Math.max(max, row.bodyWidth), 0);
const maxHorizontalScroll = Math.max(0, maxBodyWidth - viewportWidth);
const selectedRow = rows.find((row) => row.isSelected);
// Only pan horizontally when needed to keep enough selected-row content visible after its anchor.
let horizontalScroll = 0;
if (selectedRow && maxHorizontalScroll > 0) {
const minVisibleAnchorContentWidth = Math.min(
MAX_VISIBLE_ANCHOR_CONTENT_WIDTH,
Math.max(MIN_VISIBLE_ANCHOR_CONTENT_WIDTH, Math.floor(viewportWidth / 3)),
);
if (selectedRow.anchorCol > viewportWidth - minVisibleAnchorContentWidth) {
const anchorContextWidth = Math.min(
MAX_ANCHOR_CONTEXT_WIDTH,
Math.max(MIN_ANCHOR_CONTEXT_WIDTH, Math.floor(viewportWidth / 4)),
);
horizontalScroll = Math.min(maxHorizontalScroll, selectedRow.anchorCol - anchorContextWidth);
}
}
// Clip only the body; the fixed-width gutter remains visible as navigation context.
return rows.map((row) => {
const line =
horizontalScroll > 0
? `${row.gutter}${sliceByColumn(row.body, horizontalScroll, viewportWidth, true)}\x1b[0m`
: row.gutter + row.body;
return truncateToWidth(line, width, "");
});
}
/** Filter mode for tree display */
export type FilterMode = "default" | "no-tools" | "user-only" | "labeled-only" | "all";
@@ -617,6 +673,7 @@ class TreeList implements Component {
);
const endIndex = Math.min(startIndex + this.maxVisibleLines, this.filteredNodes.length);
const renderedRows: HorizontalViewportRow[] = [];
for (let i = startIndex; i < endIndex; i++) {
const flatNode = this.filteredNodes[i];
const entry = flatNode.node.entry;
@@ -680,14 +737,18 @@ class TreeList implements Component {
? theme.fg("muted", `${this.formatLabelTimestamp(flatNode.node.labelTimestamp)} `)
: "";
const content = this.getEntryDisplayText(flatNode.node, isSelected);
let line = cursor + theme.fg("dim", prefix) + foldMarker + pathMarker + label + labelTimestamp + content;
const prefixPart = theme.fg("dim", prefix) + foldMarker + pathMarker;
const anchorCol = visibleWidth(prefixPart);
let gutter = cursor;
let body = prefixPart + label + labelTimestamp + content;
if (isSelected) {
line = theme.bg("selectedBg", line);
gutter = theme.bg("selectedBg", gutter);
body = theme.bg("selectedBg", body);
}
lines.push(truncateToWidth(line, width));
renderedRows.push({ gutter, body, anchorCol, bodyWidth: visibleWidth(body), isSelected });
}
lines.push(...renderHorizontalViewport(renderedRows, width));
lines.push(
truncateToWidth(
theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredNodes.length})${this.getStatusLabels()}`),
@@ -1075,6 +1136,98 @@ class SearchLine implements Component {
handleInput(_keyData: string): void {}
}
/** Component that renders tree help as semantic rows with chunk-aware wrapping */
class TreeHelp implements Component {
invalidate(): void {}
render(width: number): string[] {
const items = TREE_HELP_ITEMS.map(({ keys, label, labelFirst }) => {
const text = formatHelpKeys(keys);
if (!text) return label;
return labelFirst ? `${label} ${text}` : `${text} ${label}`;
});
const availableWidth = Math.max(1, width);
const indent = " ";
const separator = " · ";
const lines: string[] = [];
let currentLine = "";
for (const item of items) {
const candidate = currentLine
? `${currentLine}${separator}${item}`
: visibleWidth(`${indent}${item}`) <= availableWidth
? `${indent}${item}`
: item;
if (!currentLine || visibleWidth(candidate) <= availableWidth) {
currentLine = candidate;
continue;
}
lines.push(...wrapTextWithAnsi(currentLine.trimEnd(), availableWidth));
currentLine = visibleWidth(`${indent}${item}`) <= availableWidth ? `${indent}${item}` : item;
}
if (currentLine) {
lines.push(...wrapTextWithAnsi(currentLine.trimEnd(), availableWidth));
}
return lines.map((line) => theme.fg("muted", line));
}
}
const TREE_HELP_ITEMS: Array<{ keys: Keybinding[]; label: string; labelFirst?: boolean }> = [
{ keys: ["tui.select.up", "tui.select.down"], label: "move" },
{ keys: ["tui.editor.cursorLeft", "tui.editor.cursorRight"], label: "page" },
{ keys: ["app.tree.foldOrUp", "app.tree.unfoldOrDown"], label: "branch" },
{ keys: ["app.tree.editLabel"], label: "label" },
{ keys: ["app.tree.toggleLabelTimestamp"], label: "label time" },
{
keys: [
"app.tree.filter.default",
"app.tree.filter.noTools",
"app.tree.filter.userOnly",
"app.tree.filter.labeledOnly",
"app.tree.filter.all",
],
label: "filters",
labelFirst: true,
},
{ keys: ["app.tree.filter.cycleForward", "app.tree.filter.cycleBackward"], label: "cycle", labelFirst: true },
];
function formatHelpKeys(keybindings: Keybinding[]): string {
const keys: string[] = [];
for (const keybinding of keybindings) {
const key = getKeybindings().getKeys(keybinding)[0];
if (key !== undefined) keys.push(key);
}
if (keys.length === 0) return "";
return formatKeyText(compactRawKeys(keys))
.replace(/\bpageUp\b/g, "pgup")
.replace(/\bpageDown\b/g, "pgdn")
.replace(/\bup\b/g, "↑")
.replace(/\bdown\b/g, "↓")
.replace(/\bleft\b/g, "←")
.replace(/\bright\b/g, "→");
}
function compactRawKeys(keys: string[]): string {
if (keys.length === 1) return keys[0]!;
const parts = keys.map((key) => {
const separatorIndex = key.lastIndexOf("+");
return separatorIndex === -1
? { prefix: "", suffix: key }
: { prefix: key.slice(0, separatorIndex + 1), suffix: key.slice(separatorIndex + 1) };
});
const prefix = parts[0]!.prefix;
return prefix && parts.every((part) => part.prefix === prefix)
? `${prefix}${parts.map((part) => part.suffix).join("/")}`
: keys.join("/");
}
/** Label input component shown when editing a label */
class LabelInput implements Component, Focusable {
private input: Input;
@@ -1181,25 +1334,7 @@ export class TreeSelectorComponent extends Container implements Focusable {
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
this.addChild(new Text(theme.bold(" Session Tree"), 1, 0));
const filterKeys = [
keyText("app.tree.filter.default"),
keyText("app.tree.filter.noTools"),
keyText("app.tree.filter.userOnly"),
keyText("app.tree.filter.labeledOnly"),
keyText("app.tree.filter.all"),
].join("/");
const cycleKeys = `${keyText("app.tree.filter.cycleForward")}/${keyText("app.tree.filter.cycleBackward")}`;
const branchKeys = `${keyText("app.tree.foldOrUp")}/${keyText("app.tree.unfoldOrDown")}`;
this.addChild(
new TruncatedText(
theme.fg(
"muted",
` ↑/↓: move. ←/→: page. ${branchKeys}: fold/branch. ${keyText("app.tree.editLabel")}: label. ${filterKeys}: filters (${cycleKeys} cycle). ${keyText("app.tree.toggleLabelTimestamp")}: label time`,
),
0,
0,
),
);
this.addChild(new TreeHelp());
this.addChild(new SearchLine(this.treeList));
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
@@ -1,7 +1,6 @@
import { Container, getKeybindings, Spacer, Text } from "@earendil-works/pi-tui";
import {
getProjectTrustOptions,
getProjectTrustPath,
type ProjectTrustOption,
type ProjectTrustStoreEntry,
} from "../../../core/trust-manager.ts";
@@ -19,12 +18,12 @@ export interface TrustSelectorOptions {
onCancel: () => void;
}
function formatDecision(cwd: string, decision: ProjectTrustStoreEntry | null): string {
function formatDecision(trustPath: string | undefined, decision: ProjectTrustStoreEntry | null): string {
if (decision === null) {
return "none";
}
const label = decision.decision ? "trusted" : "untrusted";
if (decision.path !== getProjectTrustPath(cwd)) {
if (trustPath !== undefined && decision.path !== trustPath) {
return `${label} (inherited from ${decision.path})`;
}
return `${label} (${decision.path})`;
@@ -56,7 +55,14 @@ export class TrustSelectorComponent extends Container {
this.addChild(new Text(theme.fg("muted", options.cwd), 1, 0));
this.addChild(new Spacer(1));
this.addChild(
new Text(theme.fg("muted", `Saved decision: ${formatDecision(options.cwd, options.savedDecision)}`), 1, 0),
new Text(
theme.fg(
"muted",
`Saved decision: ${formatDecision(this.trustOptions[0]?.savedPath, options.savedDecision)}`,
),
1,
0,
),
);
this.addChild(
new Text(theme.fg("muted", `Current session: ${options.projectTrusted ? "trusted" : "untrusted"}`), 1, 0),
@@ -52,6 +52,7 @@ import { spawn, spawnSync } from "child_process";
import {
APP_NAME,
APP_TITLE,
CONFIG_DIR_NAME,
getAgentDir,
getAuthPath,
getDebugLogPath,
@@ -86,7 +87,7 @@ import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts";
import type { SourceInfo } from "../../core/source-info.ts";
import { isInstallTelemetryEnabled } from "../../core/telemetry.ts";
import type { TruncationResult } from "../../core/tools/truncate.ts";
import { hasProjectConfigDir, hasProjectTrustInputs, ProjectTrustStore } from "../../core/trust-manager.ts";
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts";
import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts";
import { copyToClipboard } from "../../utils/clipboard.ts";
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
@@ -125,22 +126,21 @@ import { TreeSelectorComponent } from "./components/tree-selector.ts";
import { TrustSelectorComponent } from "./components/trust-selector.ts";
import { UserMessageComponent } from "./components/user-message.ts";
import { UserMessageSelectorComponent } from "./components/user-message-selector.ts";
import { getModelSearchText } from "./model-search.ts";
import {
getAvailableThemes,
getAvailableThemesWithPaths,
getEditorTheme,
getMarkdownTheme,
getThemeByName,
initTheme,
onThemeChange,
setRegisteredThemes,
setTheme,
setThemeInstance,
stopThemeWatcher,
Theme,
type ThemeColor,
theme,
} from "./theme/theme.ts";
import { InteractiveThemeController } from "./theme/theme-controller.ts";
/** Interface for components that can be expanded/collapsed */
interface Expandable {
@@ -371,6 +371,7 @@ export class InteractiveMode {
private options: InteractiveModeOptions;
private autoTrustOnReloadCwd: string | undefined;
private themeController: InteractiveThemeController;
// Convenience accessors
private get session(): AgentSession {
@@ -394,7 +395,7 @@ export class InteractiveMode {
this.resetExtensionUI();
});
this.runtimeHost.setRebindSession(async () => {
await this.rebindCurrentSession();
await this.rebindCurrentSession({ renderBeforeBind: true });
});
this.version = VERSION;
this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor());
@@ -425,7 +426,12 @@ export class InteractiveMode {
// Register themes from resource loader and initialize
setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
initTheme(this.settingsManager.getTheme(), true);
this.themeController = new InteractiveThemeController(
this.ui,
this.settingsManager,
(message) => this.showError(message),
() => this.updateEditorBorderColor(),
);
}
private getAutocompleteSourceTag(sourceInfo?: SourceInfo): string | undefined {
@@ -498,11 +504,12 @@ export class InteractiveMode {
const items = models.map((m) => ({
id: m.id,
provider: m.provider,
name: m.name,
label: `${m.provider}/${m.id}`,
}));
// Fuzzy filter by model ID + provider (allows "opus anthropic" to match)
const filtered = fuzzyFilter(items, prefix, (item) => `${item.id} ${item.provider}`);
// Fuzzy filter by model ID + provider in either order.
const filtered = fuzzyFilter(items, prefix, getModelSearchText);
if (filtered.length === 0) return null;
@@ -629,9 +636,28 @@ export class InteractiveMode {
console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`));
}
// Add header container as first child
// Add header container as first child. Populate it after detectThemeIfUnset.
this.ui.addChild(this.headerContainer);
this.ui.addChild(this.chatContainer);
this.ui.addChild(this.pendingMessagesContainer);
this.ui.addChild(this.statusContainer);
this.renderWidgets(); // Initialize with default spacer
this.ui.addChild(this.widgetContainerAbove);
this.ui.addChild(this.editorContainer);
this.ui.addChild(this.widgetContainerBelow);
this.ui.addChild(this.footer);
this.ui.setFocus(this.editor);
this.setupKeyHandlers();
this.setupEditorSubmitHandler();
// Start the UI before initializing extensions so session_start handlers can use interactive dialogs
this.ui.start();
this.isInitialized = true;
await this.themeController.applyFromSettings();
// Add header with keybindings from config (unless silenced)
if (this.options.verbose || !this.settingsManager.getQuietStartup()) {
const logo = theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`);
@@ -692,23 +718,7 @@ export class InteractiveMode {
this.builtInHeader = new Text("", 0, 0);
this.headerContainer.addChild(this.builtInHeader);
}
this.ui.addChild(this.chatContainer);
this.ui.addChild(this.pendingMessagesContainer);
this.ui.addChild(this.statusContainer);
this.renderWidgets(); // Initialize with default spacer
this.ui.addChild(this.widgetContainerAbove);
this.ui.addChild(this.editorContainer);
this.ui.addChild(this.widgetContainerBelow);
this.ui.addChild(this.footer);
this.ui.setFocus(this.editor);
this.setupKeyHandlers();
this.setupEditorSubmitHandler();
// Start the UI before initializing extensions so session_start handlers can use interactive dialogs
this.ui.start();
this.isInitialized = true;
this.ui.requestRender();
// Initialize extensions first so resources are shown before messages
await this.rebindCurrentSession();
@@ -1533,12 +1543,7 @@ export class InteractiveMode {
}
this.statusContainer.clear();
try {
const result = await this.runtimeHost.newSession(options);
if (!result.cancelled) {
this.renderCurrentSessionState();
this.ui.requestRender();
}
return result;
return await this.runtimeHost.newSession(options);
} catch (error: unknown) {
return this.handleFatalRuntimeError("Failed to create session", error);
}
@@ -1547,7 +1552,6 @@ export class InteractiveMode {
try {
const result = await this.runtimeHost.fork(entryId, options);
if (!result.cancelled) {
this.renderCurrentSessionState();
this.editor.setText(result.selectedText ?? "");
this.showStatus("Forked to new session");
}
@@ -1621,12 +1625,18 @@ export class InteractiveMode {
}
}
private async rebindCurrentSession(): Promise<void> {
private async rebindCurrentSession(options: { renderBeforeBind?: boolean } = {}): Promise<void> {
this.unsubscribe?.();
this.unsubscribe = undefined;
this.applyRuntimeSettings();
await this.bindCurrentSessionExtensions();
this.subscribeToAgent();
if (options.renderBeforeBind) {
this.renderCurrentSessionState();
this.subscribeToAgent();
await this.bindCurrentSessionExtensions();
} else {
await this.bindCurrentSessionExtensions();
this.subscribeToAgent();
}
await this.updateAvailableProviderCount();
this.updateEditorBorderColor();
this.updateTerminalTitle();
@@ -2054,16 +2064,13 @@ export class InteractiveMode {
getTheme: (name) => getThemeByName(name),
setTheme: (themeOrName) => {
if (themeOrName instanceof Theme) {
setThemeInstance(themeOrName);
this.ui.requestRender();
return { success: true };
return this.themeController.setThemeInstance(themeOrName);
}
const result = setTheme(themeOrName, true);
const result = this.themeController.setThemeName(themeOrName);
if (result.success) {
if (this.settingsManager.getTheme() !== themeOrName) {
this.settingsManager.setTheme(themeOrName);
}
this.ui.requestRender();
}
return result;
},
@@ -3271,7 +3278,7 @@ export class InteractiveMode {
}
private renderProjectTrustWarningIfNeeded(): void {
if (this.settingsManager.isProjectTrusted() || !hasProjectTrustInputs(this.sessionManager.getCwd())) {
if (this.settingsManager.isProjectTrusted() || !hasTrustRequiringProjectResources(this.sessionManager.getCwd())) {
return;
}
@@ -3282,7 +3289,7 @@ export class InteractiveMode {
new Text(
theme.fg(
"warning",
"This project is not trusted. Project .pi resources and packages are ignored. Use /trust to save a trust decision, then restart pi.",
`This project is not trusted. Project ${CONFIG_DIR_NAME} resources and packages are ignored. Use /trust to save a trust decision, then restart pi.`,
),
1,
0,
@@ -3339,7 +3346,9 @@ export class InteractiveMode {
private async shutdown(options?: { fromSignal?: boolean }): Promise<void> {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
this.unregisterSignalHandlers();
// Keep signal handlers registered until terminal cleanup has completed.
// `signal-exit` checks the listener list during the same SIGTERM/SIGHUP
// dispatch and re-sends the signal if only its own listeners remain.
if (options?.fromSignal) {
// Signal-triggered shutdown (SIGTERM/SIGHUP). Emit extension cleanup
@@ -3350,6 +3359,7 @@ export class InteractiveMode {
// which the stdout/stderr error handler turns into emergencyTerminalExit;
// the render loop is already idle, so this cannot hot-spin (see #4144).
await this.runtimeHost.dispose();
this.themeController.disableAutoSync();
await this.ui.terminal.drainInput(1000);
this.stop();
process.exit(0);
@@ -3360,6 +3370,7 @@ export class InteractiveMode {
// the final frame while the process is exiting.
// Drain any in-flight Kitty key release events before stopping.
// This prevents escape sequences from leaking to the parent shell over slow SSH.
this.themeController.disableAutoSync();
await this.ui.terminal.drainInput(1000);
this.stop();
@@ -3689,7 +3700,6 @@ export class InteractiveMode {
showError(errorMessage: string): void {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0));
this.chatContainer.addChild(new Spacer(1));
this.ui.requestRender();
}
@@ -3704,7 +3714,7 @@ export class InteractiveMode {
const updateInstruction = theme.fg("muted", `New version ${release.version} is available. Run `) + action;
const changelogUrl = "https://pi.dev/changelog";
const changelogLink = getCapabilities().hyperlinks
? hyperlink(theme.fg("accent", "open changelog"), changelogUrl)
? hyperlink(theme.fg("accent", changelogUrl), changelogUrl)
: theme.fg("accent", changelogUrl);
const changelogLine = theme.fg("muted", "Changelog: ") + changelogLink;
const note = release.note?.trim();
@@ -3729,7 +3739,7 @@ export class InteractiveMode {
}
showPackageUpdateNotification(packages: string[]): void {
const action = theme.fg("accent", `${APP_NAME} update`);
const action = theme.fg("accent", `${APP_NAME} update --extensions`);
const updateInstruction = theme.fg("muted", "Package updates are available. Run ") + action;
const packageLines = packages.map((pkg) => `- ${pkg}`).join("\n");
@@ -3963,7 +3973,8 @@ export class InteractiveMode {
httpIdleTimeoutMs: this.settingsManager.getHttpIdleTimeoutMs(),
thinkingLevel: this.session.thinkingLevel,
availableThinkingLevels: this.session.getAvailableThinkingLevels(),
currentTheme: this.settingsManager.getTheme() || "dark",
currentTheme: this.settingsManager.getThemeSetting() || "dark",
terminalTheme: this.themeController.getTerminalTheme(),
availableThemes: getAvailableThemes(),
hideThinkingBlock: this.hideThinkingBlock,
collapseChangelog: this.settingsManager.getCollapseChangelog(),
@@ -4030,21 +4041,11 @@ export class InteractiveMode {
this.footer.invalidate();
this.updateEditorBorderColor();
},
onThemeChange: (themeName) => {
const result = setTheme(themeName, true);
this.settingsManager.setTheme(themeName);
this.ui.invalidate();
if (!result.success) {
this.showError(`Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`);
}
},
onThemePreview: (themeName) => {
const result = setTheme(themeName, true);
if (result.success) {
this.ui.invalidate();
this.ui.requestRender();
}
onThemeChange: (themeSetting) => {
this.settingsManager.setTheme(themeSetting);
void this.themeController.applyFromSettings();
},
onThemePreview: (themeName) => this.themeController.preview(themeName),
onHideThinkingBlockChange: (hidden) => {
this.hideThinkingBlock = hidden;
this.settingsManager.setHideThinkingBlock(hidden);
@@ -4198,7 +4199,7 @@ export class InteractiveMode {
if (this.autoTrustOnReloadCwd !== cwd) {
return false;
}
if (!this.settingsManager.isProjectTrusted() || !hasProjectConfigDir(cwd)) {
if (!this.settingsManager.isProjectTrusted() || !hasTrustRequiringProjectResources(cwd)) {
return false;
}
@@ -4375,7 +4376,6 @@ export class InteractiveMode {
return;
}
this.renderCurrentSessionState();
this.editor.setText(result.selectedText ?? "");
done();
this.showStatus("Forked to new session");
@@ -4408,7 +4408,6 @@ export class InteractiveMode {
return;
}
this.renderCurrentSessionState();
this.editor.setText("");
this.showStatus("Cloned to new session");
} catch (error: unknown) {
@@ -4600,7 +4599,6 @@ export class InteractiveMode {
if (result.cancelled) {
return result;
}
this.renderCurrentSessionState();
this.showStatus("Resumed session");
return result;
} catch (error: unknown) {
@@ -4618,7 +4616,6 @@ export class InteractiveMode {
if (result.cancelled) {
return result;
}
this.renderCurrentSessionState();
this.showStatus("Resumed session in current cwd");
return result;
}
@@ -5071,8 +5068,20 @@ export class InteractiveMode {
this.ui.requestRender();
};
let chatRestoredBeforeSessionStart = false;
let reloadBoxDismissed = false;
const restoreChatBeforeSessionStart = () => {
if (chatRestoredBeforeSessionStart) {
return;
}
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
this.rebuildChatFromMessages();
chatRestoredBeforeSessionStart = true;
};
try {
await this.session.reload();
await this.session.reload({ beforeSessionStart: restoreChatBeforeSessionStart });
restoreChatBeforeSessionStart();
configureHttpDispatcher(this.settingsManager.getHttpIdleTimeoutMs());
this.keybindings.reload();
const activeHeader = this.customHeader ?? this.builtInHeader;
@@ -5080,12 +5089,7 @@ export class InteractiveMode {
activeHeader.setExpanded(this.toolOutputExpanded);
}
setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
const themeName = this.settingsManager.getTheme();
const themeResult = themeName ? setTheme(themeName, true) : { success: true };
if (!themeResult.success) {
this.showError(`Failed to load theme "${themeName}": ${themeResult.error}\nFell back to dark theme.`);
}
await this.themeController.applyFromSettings();
const editorPaddingX = this.settingsManager.getEditorPaddingX();
const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible();
this.defaultEditor.setPaddingX(editorPaddingX);
@@ -5099,8 +5103,6 @@ export class InteractiveMode {
this.setupAutocompleteProvider();
const runner = this.session.extensionRunner;
this.setupExtensionShortcuts(runner);
this.rebuildChatFromMessages();
dismissReloadBox(this.editor as Component);
this.showLoadedResources({
force: false,
showDiagnosticsWhenQuiet: true,
@@ -5115,8 +5117,12 @@ export class InteractiveMode {
? "Reloaded keybindings, extensions, skills, prompts, themes; saved project trust"
: "Reloaded keybindings, extensions, skills, prompts, themes",
);
dismissReloadBox(this.editor as Component);
reloadBoxDismissed = true;
} catch (error) {
dismissReloadBox(previousEditor as Component);
if (!reloadBoxDismissed) {
dismissReloadBox(previousEditor as Component);
}
this.showError(`Reload failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
@@ -5190,7 +5196,6 @@ export class InteractiveMode {
this.showStatus("Import cancelled");
return;
}
this.renderCurrentSessionState();
this.showStatus(`Session imported from: ${inputPath}`);
} catch (error: unknown) {
if (error instanceof MissingSessionCwdError) {
@@ -5204,7 +5209,6 @@ export class InteractiveMode {
this.showStatus("Import cancelled");
return;
}
this.renderCurrentSessionState();
this.showStatus(`Session imported from: ${inputPath}`);
return;
}
@@ -5543,7 +5547,6 @@ export class InteractiveMode {
if (result.cancelled) {
return;
}
this.renderCurrentSessionState();
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1));
this.ui.requestRender();
@@ -5697,14 +5700,6 @@ export class InteractiveMode {
}
private async handleCompactCommand(customInstructions?: string): Promise<void> {
const entries = this.sessionManager.getEntries();
const messageCount = entries.filter((e) => e.type === "message").length;
if (messageCount < 2) {
this.showWarning("Nothing to compact (no messages yet)");
return;
}
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
@@ -5719,7 +5714,6 @@ export class InteractiveMode {
}
stop(): void {
this.unregisterSignalHandlers();
if (this.settingsManager.getShowTerminalProgress()) {
this.ui.terminal.setProgress(false);
}
@@ -5727,6 +5721,7 @@ export class InteractiveMode {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.themeController.disableAutoSync();
this.clearExtensionTerminalInputListeners();
this.footer.dispose();
this.footerDataProvider.dispose();
@@ -5737,5 +5732,6 @@ export class InteractiveMode {
this.ui.stop();
this.isInitialized = false;
}
this.unregisterSignalHandlers();
}
}
@@ -0,0 +1,21 @@
export interface ModelSearchItem {
id: string;
provider: string;
name?: string;
}
export function getModelSearchText(item: ModelSearchItem): string {
const { id, provider } = item;
const name = item.name ? ` ${item.name}` : "";
return `${id} ${provider} ${provider}/${id} ${provider} ${id}${name}`;
}
/**
* The /model selector search should rank exact provider-prefixed queries before proxy-provider IDs
* like openrouter/openai/gpt-5, so keep the bare model ID out of the leading position.
*/
export function getModelSelectorSearchText(item: ModelSearchItem): string {
const { id, provider } = item;
const name = item.name ? ` ${item.name}` : "";
return `${provider} ${provider}/${id} ${provider} ${id}${name}`;
}
@@ -0,0 +1,135 @@
import type { TUI } from "@earendil-works/pi-tui";
import type { SettingsManager } from "../../../core/settings-manager.ts";
import {
detectTerminalBackgroundFromEnv,
detectTerminalBackgroundTheme,
initTheme,
parseAutoThemeSetting,
resolveThemeSetting,
setTheme,
setThemeInstance,
type TerminalTheme,
type Theme,
} from "./theme.ts";
type ThemeResult = { success: boolean; error?: string };
export class InteractiveThemeController {
private readonly ui: TUI;
private readonly settingsManager: SettingsManager;
private readonly showError: (message: string) => void;
private readonly onChanged: () => void;
private terminalTheme: TerminalTheme = detectTerminalBackgroundFromEnv().theme;
private activeThemeName: string | undefined;
private autoSyncEnabled = false;
constructor(ui: TUI, settingsManager: SettingsManager, showError: (message: string) => void, onChanged: () => void) {
this.ui = ui;
this.settingsManager = settingsManager;
this.showError = showError;
this.onChanged = onChanged;
this.activeThemeName = resolveThemeSetting(this.settingsManager.getThemeSetting(), this.terminalTheme);
initTheme(this.activeThemeName, true);
this.ui.onTerminalColorSchemeChange((terminalTheme) => this.applyTerminalTheme(terminalTheme));
}
async applyFromSettings(): Promise<void> {
const themeSetting = this.settingsManager.getThemeSetting();
const autoTheme = parseAutoThemeSetting(themeSetting);
if (autoTheme) {
this.terminalTheme = await this.detectTerminalThemeForAuto();
this.setAutoSync(true);
this.applyThemeName(this.terminalTheme === "light" ? autoTheme.lightTheme : autoTheme.darkTheme, true);
return;
}
this.setAutoSync(false);
if (themeSetting !== undefined) {
this.applyThemeName(themeSetting, true);
return;
}
const detection = await detectTerminalBackgroundTheme({ ui: this.ui, timeoutMs: 100 });
this.terminalTheme = detection.theme;
if (!this.applyThemeName(detection.theme).success) return;
if (detection.confidence === "high") {
this.settingsManager.setTheme(detection.theme);
await this.settingsManager.flush();
}
}
setThemeName(themeName: string, showError = false): ThemeResult {
this.setAutoSync(false);
return this.applyThemeName(themeName, showError);
}
setThemeInstance(themeInstance: Theme): ThemeResult {
this.setAutoSync(false);
setThemeInstance(themeInstance);
this.activeThemeName = "<in-memory>";
this.notifyChanged();
return { success: true };
}
preview(themeSettingOrName: string): void {
const themeName = resolveThemeSetting(themeSettingOrName, this.terminalTheme) ?? this.activeThemeName;
if (!themeName) return;
if (setTheme(themeName, true).success) {
this.ui.invalidate();
this.ui.requestRender();
}
}
disableAutoSync(): void {
this.setAutoSync(false);
}
getTerminalTheme(): TerminalTheme {
return this.terminalTheme;
}
private applyThemeName(themeName: string, showError = false): ThemeResult {
const result = setTheme(themeName, true);
this.activeThemeName = result.success ? themeName : "dark";
this.notifyChanged();
if (!result.success && showError) {
this.showError(`Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`);
}
return result;
}
private notifyChanged(): void {
this.ui.invalidate();
this.onChanged();
}
private setAutoSync(enabled: boolean): void {
if (this.autoSyncEnabled === enabled) return;
this.autoSyncEnabled = enabled;
this.ui.setTerminalColorSchemeNotifications(enabled);
}
private async detectTerminalThemeForAuto(): Promise<TerminalTheme> {
try {
const colorScheme = await this.ui.queryTerminalColorScheme({ timeoutMs: 100 });
if (colorScheme) return colorScheme;
} catch {
// Fall back to OSC 11 / COLORFGBG detection when color-scheme DSR is unsupported.
}
return (await detectTerminalBackgroundTheme({ ui: this.ui, timeoutMs: 100 })).theme;
}
private applyTerminalTheme(terminalTheme: TerminalTheme): void {
if (!this.autoSyncEnabled) return;
this.terminalTheme = terminalTheme;
const autoTheme = parseAutoThemeSetting(this.settingsManager.getThemeSetting());
if (!autoTheme) {
this.setAutoSync(false);
return;
}
const themeName = terminalTheme === "light" ? autoTheme.lightTheme : autoTheme.darkTheme;
if (themeName !== this.activeThemeName) {
this.applyThemeName(themeName);
}
}
}
@@ -11,7 +11,8 @@
},
"name": {
"type": "string",
"description": "Theme name"
"pattern": "^[^/]+$",
"description": "Theme name. Must not contain '/' because it is reserved for automatic light/dark theme settings."
},
"vars": {
"type": "object",
@@ -4,6 +4,7 @@ import {
type EditorTheme,
getCapabilities,
type MarkdownTheme,
type RgbColor,
type SelectListTheme,
type SettingsListTheme,
} from "@earendil-works/pi-tui";
@@ -502,6 +503,14 @@ function getCustomThemeInfos(): ThemeInfo[] {
return result;
}
function assertThemeNameIsValid(name: string): void {
if (name.includes("/")) {
throw new Error(
`Invalid theme name "${name}": theme names cannot contain "/" because it is reserved for automatic light/dark theme settings.`,
);
}
}
function parseThemeJson(label: string, json: unknown): ThemeJson {
if (!validateThemeJson.Check(json)) {
const errors = Array.from(validateThemeJson.Errors(json));
@@ -538,7 +547,9 @@ function parseThemeJson(label: string, json: unknown): ThemeJson {
throw new Error(errorMessage);
}
return json as ThemeJson;
const themeJson = json as ThemeJson;
assertThemeNameIsValid(themeJson.name);
return themeJson;
}
function parseThemeJsonContent(label: string, content: string): ThemeJson {
@@ -624,10 +635,34 @@ export function getThemeByName(name: string): Theme | undefined {
export type TerminalTheme = "dark" | "light";
export interface RgbColor {
r: number;
g: number;
b: number;
export function parseAutoThemeSetting(
themeSetting: string | undefined,
): { lightTheme: string; darkTheme: string } | undefined {
if (!themeSetting) return undefined;
const slashIndex = themeSetting.indexOf("/");
if (slashIndex === -1 || themeSetting.indexOf("/", slashIndex + 1) !== -1) {
return undefined;
}
const lightTheme = themeSetting.slice(0, slashIndex).trim();
const darkTheme = themeSetting.slice(slashIndex + 1).trim();
if (!lightTheme || !darkTheme) {
return undefined;
}
return { lightTheme, darkTheme };
}
export function resolveThemeSetting(
themeSetting: string | undefined,
terminalTheme: TerminalTheme,
): string | undefined {
const autoTheme = parseAutoThemeSetting(themeSetting);
if (autoTheme) {
return terminalTheme === "light" ? autoTheme.lightTheme : autoTheme.darkTheme;
}
if (themeSetting?.includes("/")) return undefined;
if (typeof themeSetting === "string") return themeSetting;
return undefined;
}
export interface TerminalThemeDetection {
@@ -641,6 +676,15 @@ export interface TerminalThemeDetectionOptions {
env?: NodeJS.ProcessEnv;
}
export interface TerminalBackgroundThemeDetector {
queryTerminalBackgroundColor({ timeoutMs }: { timeoutMs: number }): Promise<RgbColor | undefined>;
}
export interface TerminalBackgroundThemeDetectionOptions extends TerminalThemeDetectionOptions {
ui: TerminalBackgroundThemeDetector;
timeoutMs: number;
}
function getColorFgBgBackgroundIndex(colorfgbg: string): number | undefined {
const parts = colorfgbg.split(";");
for (let i = parts.length - 1; i >= 0; i--) {
@@ -668,50 +712,7 @@ export function getThemeForRgbColor(rgb: RgbColor): TerminalTheme {
return getRgbColorLuminance(rgb) >= 0.5 ? "light" : "dark";
}
function parseOscHexChannel(channel: string): number | undefined {
if (!/^[0-9a-f]+$/i.test(channel)) {
return undefined;
}
const max = 16 ** channel.length - 1;
if (max <= 0) {
return undefined;
}
return Math.round((parseInt(channel, 16) / max) * 255);
}
export function parseOsc11BackgroundColor(data: string): RgbColor | undefined {
const match = data.match(/^\x1b\]11;([^\x07\x1b]*)(?:\x07|\x1b\\)$/i);
if (!match) {
return undefined;
}
const value = match[1].trim();
if (value.startsWith("#")) {
const hex = value.slice(1);
if (/^[0-9a-f]{6}$/i.test(hex)) {
return hexToRgb(value);
}
if (/^[0-9a-f]{12}$/i.test(hex)) {
const r = parseOscHexChannel(hex.slice(0, 4));
const g = parseOscHexChannel(hex.slice(4, 8));
const b = parseOscHexChannel(hex.slice(8, 12));
return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined;
}
return undefined;
}
const rgbValue = value.replace(/^rgba?:/i, "");
const [red, green, blue] = rgbValue.split("/");
if (red === undefined || green === undefined || blue === undefined) {
return undefined;
}
const r = parseOscHexChannel(red);
const g = parseOscHexChannel(green);
const b = parseOscHexChannel(blue);
return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined;
}
export function detectTerminalBackground(options: TerminalThemeDetectionOptions = {}): TerminalThemeDetection {
export function detectTerminalBackgroundFromEnv(options: TerminalThemeDetectionOptions = {}): TerminalThemeDetection {
const env = options.env ?? process.env;
const colorfgbg = env.COLORFGBG || "";
const bg = getColorFgBgBackgroundIndex(colorfgbg);
@@ -732,8 +733,30 @@ export function detectTerminalBackground(options: TerminalThemeDetectionOptions
};
}
export async function detectTerminalBackgroundTheme({
ui,
timeoutMs,
env,
}: TerminalBackgroundThemeDetectionOptions): Promise<TerminalThemeDetection> {
try {
const rgb = await ui.queryTerminalBackgroundColor({ timeoutMs });
if (rgb) {
return {
theme: getThemeForRgbColor(rgb),
source: "terminal background",
detail: `OSC 11 background rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`,
confidence: "high",
};
}
} catch {
// Fall back to environment-based detection when the terminal query fails.
}
return detectTerminalBackgroundFromEnv({ env });
}
export function getDefaultTheme(): string {
return detectTerminalBackground().theme;
return detectTerminalBackgroundFromEnv().theme;
}
// ============================================================================
@@ -769,6 +792,7 @@ export function setRegisteredThemes(themes: Theme[]): void {
registeredThemes.clear();
for (const theme of themes) {
if (theme.name) {
assertThemeNameIsValid(theme.name);
registeredThemes.set(theme.name, theme);
}
}
@@ -667,7 +667,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
default: {
const unknownCommand = command as { type: string };
return error(undefined, unknownCommand.type, `Unknown command: ${unknownCommand.type}`);
return error(id, unknownCommand.type, `Unknown command: ${unknownCommand.type}`);
}
}
};
@@ -4,6 +4,7 @@ import { selectConfig } from "./cli/config-selector.ts";
import { createProjectTrustContext } from "./cli/project-trust.ts";
import {
APP_NAME,
CONFIG_DIR_NAME,
detectInstallMethod,
getAgentDir,
getPackageDir,
@@ -11,6 +12,7 @@ import {
getSelfUpdateUnavailableInstruction,
PACKAGE_NAME,
type SelfUpdateCommand,
type SelfUpdatePackageTarget,
VERSION,
} from "./config.ts";
import type { ExtensionFactory } from "./core/extensions/types.ts";
@@ -18,7 +20,7 @@ import { DefaultPackageManager } from "./core/package-manager.ts";
import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts";
import { DefaultResourceLoader } from "./core/resource-loader.ts";
import { SettingsManager } from "./core/settings-manager.ts";
import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts";
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts";
import { spawnProcess } from "./utils/child-process.ts";
import { getLatestPiRelease, isNewerPackageVersion } from "./utils/version-check.ts";
import {
@@ -51,6 +53,7 @@ interface PackageCommandOptions {
command: PackageCommand;
source?: string;
updateTarget?: UpdateTarget;
showExtensionsSkippedNote: boolean;
local: boolean;
force: boolean;
projectTrustOverride?: boolean;
@@ -78,7 +81,7 @@ function getPackageCommandUsage(command: PackageCommand): string {
case "remove":
return `${APP_NAME} remove <source> [-l] [--approve|--no-approve]`;
case "update":
return `${APP_NAME} update [source|self|pi] [--self] [--extensions] [--extension <source>] [--approve|--no-approve] [--force]`;
return `${APP_NAME} update [source|self|pi] [--self|--extensions|--all] [--extension <source>] [--approve|--no-approve] [--force]`;
case "list":
return `${APP_NAME} list [--approve|--no-approve]`;
}
@@ -93,7 +96,7 @@ function printPackageCommandHelp(command: PackageCommand): void {
Install a package and add it to settings.
Options:
-l, --local Install project-locally (.pi/settings.json)
-l, --local Install project-locally (${CONFIG_DIR_NAME}/settings.json)
-a, --approve Trust project-local files for this command
-na, --no-approve Ignore project-local files for this command
@@ -115,7 +118,7 @@ Remove a package and its source from settings.
Alias: ${APP_NAME} uninstall <source> [-l]
Options:
-l, --local Remove from project settings (.pi/settings.json)
-l, --local Remove from project settings (${CONFIG_DIR_NAME}/settings.json)
-a, --approve Trust project-local files for this command
-na, --no-approve Ignore project-local files for this command
@@ -132,15 +135,17 @@ Examples:
Update pi and installed packages.
Options:
--self Update pi only
--self Update pi only (default when no target is given)
--extensions Update installed packages only
--all Update pi and installed packages
--extension <source> Update one package only
-a, --approve Trust project-local files for this command
-na, --no-approve Ignore project-local files for this command
--force Reinstall pi even if the current version is latest
Short forms:
${APP_NAME} update Update pi and all extensions
${APP_NAME} update Update pi only
${APP_NAME} update --all Update pi and all extensions
${APP_NAME} update <source> Update one package
${APP_NAME} update pi Update pi only (self works as alias to pi)
`);
@@ -183,6 +188,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
let source: string | undefined;
let selfFlag = false;
let extensionsFlag = false;
let allFlag = false;
let extensionFlagSource: string | undefined;
for (let index = 0; index < rest.length; index++) {
@@ -219,6 +225,15 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
continue;
}
if (arg === "--all") {
if (command === "update") {
allFlag = true;
} else {
invalidOption = invalidOption ?? arg;
}
continue;
}
if (arg === "--approve" || arg === "-a") {
projectTrustOverride = true;
continue;
@@ -270,10 +285,20 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
}
let updateTarget: UpdateTarget | undefined;
let showExtensionsSkippedNote = false;
if (command === "update") {
if (allFlag && (selfFlag || extensionsFlag || extensionFlagSource)) {
conflictingOptions =
conflictingOptions ?? "--all cannot be combined with --self, --extensions, or --extension";
}
if (allFlag && source) {
conflictingOptions = conflictingOptions ?? "--all cannot be combined with a positional source";
}
if (extensionFlagSource) {
if (selfFlag || extensionsFlag) {
conflictingOptions = conflictingOptions ?? "--extension cannot be combined with --self or --extensions";
if (selfFlag || extensionsFlag || allFlag) {
conflictingOptions =
conflictingOptions ?? "--extension cannot be combined with --self, --extensions, or --all";
}
if (source) {
conflictingOptions = conflictingOptions ?? "--extension cannot be combined with a positional source";
@@ -284,12 +309,15 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
if (sourceIsSelf) {
updateTarget = extensionsFlag ? { type: "all" } : { type: "self" };
} else {
if (extensionsFlag || selfFlag) {
if (extensionsFlag || selfFlag || allFlag) {
conflictingOptions =
conflictingOptions ?? "positional update targets cannot be combined with --self or --extensions";
conflictingOptions ??
"positional update targets cannot be combined with --self, --extensions, or --all";
}
updateTarget = { type: "extensions", source };
}
} else if (allFlag) {
updateTarget = { type: "all" };
} else if (selfFlag && extensionsFlag) {
updateTarget = { type: "all" };
} else if (selfFlag) {
@@ -297,7 +325,8 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
} else if (extensionsFlag) {
updateTarget = { type: "extensions" };
} else {
updateTarget = { type: "all" };
updateTarget = { type: "self" };
showExtensionsSkippedNote = true;
}
}
@@ -305,6 +334,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
command,
source,
updateTarget,
showExtensionsSkippedNote,
local,
force,
projectTrustOverride,
@@ -324,9 +354,12 @@ function updateTargetIncludesExtensions(target: UpdateTarget): boolean {
return target.type === "all" || target.type === "extensions";
}
function printSelfUpdateUnavailable(npmCommand?: string[], updatePackageName = PACKAGE_NAME): void {
function printSelfUpdateUnavailable(
npmCommand?: string[],
updatePackageTarget: SelfUpdatePackageTarget = PACKAGE_NAME,
): void {
console.error(`error: ${APP_NAME} cannot self-update this installation.`);
console.error(getSelfUpdateUnavailableInstruction(PACKAGE_NAME, npmCommand, updatePackageName));
console.error(getSelfUpdateUnavailableInstruction(PACKAGE_NAME, npmCommand, updatePackageTarget));
const entrypoint = process.argv[1];
if (entrypoint) {
@@ -361,27 +394,38 @@ function printSelfUpdateNote(note: string): void {
interface SelfUpdatePlan {
packageName: string;
installSpec: string;
version: string;
shouldRun: boolean;
note?: string;
}
async function getSelfUpdatePlan(force: boolean): Promise<SelfUpdatePlan> {
if (force) {
return { packageName: PACKAGE_NAME, shouldRun: true };
let latestRelease: Awaited<ReturnType<typeof getLatestPiRelease>>;
try {
latestRelease = await getLatestPiRelease(VERSION);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Could not determine latest ${APP_NAME} version: ${message}`);
}
if (!latestRelease) {
throw new Error(`Could not determine latest ${APP_NAME} version.`);
}
try {
const latestRelease = await getLatestPiRelease(VERSION);
const packageName = latestRelease?.packageName ?? PACKAGE_NAME;
if (!latestRelease || packageName !== PACKAGE_NAME || isNewerPackageVersion(latestRelease.version, VERSION)) {
return { packageName, shouldRun: true, ...(latestRelease?.note ? { note: latestRelease.note } : {}) };
}
} catch {
return { packageName: PACKAGE_NAME, shouldRun: true };
const packageName = latestRelease.packageName ?? PACKAGE_NAME;
const installSpec = `${packageName}@${latestRelease.version}`;
if (force || packageName !== PACKAGE_NAME || isNewerPackageVersion(latestRelease.version, VERSION)) {
return {
packageName,
installSpec,
version: latestRelease.version,
...(latestRelease.note ? { note: latestRelease.note } : {}),
shouldRun: true,
};
}
console.log(chalk.green(`${APP_NAME} is already up to date (v${VERSION})`));
return { packageName: PACKAGE_NAME, shouldRun: false };
return { packageName, installSpec, version: latestRelease.version, shouldRun: false };
}
async function runSelfUpdate(command: SelfUpdateCommand): Promise<void> {
@@ -452,13 +496,21 @@ async function createCommandSettingsManager(options: {
cwd: string;
agentDir: string;
projectTrustOverride?: boolean;
useSavedProjectTrustOnly?: boolean;
extensionFactories?: ExtensionFactory[];
}): Promise<CommandSettingsResult> {
const settingsManager = SettingsManager.create(options.cwd, options.agentDir, { projectTrusted: false });
const projectTrustWarnings: string[] = [];
const trustStore = new ProjectTrustStore(options.agentDir);
if (options.useSavedProjectTrustOnly) {
const savedProjectTrusted = trustStore.get(options.cwd) === true;
settingsManager.setProjectTrusted(options.projectTrustOverride ?? savedProjectTrusted);
return { settingsManager, projectTrustWarnings };
}
const appMode = getCommandAppMode();
const extensionsResult =
options.projectTrustOverride === undefined && hasProjectTrustInputs(options.cwd)
options.projectTrustOverride === undefined && hasTrustRequiringProjectResources(options.cwd)
? await new DefaultResourceLoader({
cwd: options.cwd,
agentDir: options.agentDir,
@@ -472,7 +524,7 @@ async function createCommandSettingsManager(options: {
const projectTrusted = await resolveProjectTrusted({
cwd: options.cwd,
trustStore: new ProjectTrustStore(options.agentDir),
trustStore,
trustOverride: options.projectTrustOverride,
defaultProjectTrust: settingsManager.getDefaultProjectTrust(),
extensionsResult,
@@ -576,6 +628,7 @@ export async function handlePackageCommand(
cwd,
agentDir,
projectTrustOverride: options.projectTrustOverride,
useSavedProjectTrustOnly: options.command === "update",
extensionFactories: runtimeOptions.extensionFactories,
});
reportProjectTrustWarnings(projectTrustWarnings);
@@ -650,7 +703,12 @@ export async function handlePackageCommand(
}
case "update": {
const target = options.updateTarget ?? { type: "all" };
const target = options.updateTarget ?? { type: "self" };
if (options.showExtensionsSkippedNote) {
console.log(
chalk.dim(`Extensions are skipped. Run ${APP_NAME} update --extensions to update extensions.`),
);
}
if (updateTargetIncludesExtensions(target)) {
const updateSource = target.type === "extensions" ? target.source : undefined;
await packageManager.update(updateSource);
@@ -674,13 +732,13 @@ export async function handlePackageCommand(
process.exitCode = 1;
return true;
}
const selfUpdateCommand = getSelfUpdateCommand(
PACKAGE_NAME,
selfUpdateNpmCommand,
selfUpdatePlan.packageName,
);
const selfUpdateTarget = {
packageName: selfUpdatePlan.packageName,
installSpec: selfUpdatePlan.installSpec,
};
const selfUpdateCommand = getSelfUpdateCommand(PACKAGE_NAME, selfUpdateNpmCommand, selfUpdateTarget);
if (!selfUpdateCommand) {
printSelfUpdateUnavailable(selfUpdateNpmCommand, selfUpdatePlan.packageName);
printSelfUpdateUnavailable(selfUpdateNpmCommand, selfUpdateTarget);
process.exitCode = 1;
return true;
}
@@ -699,7 +757,7 @@ export async function handlePackageCommand(
process.exitCode = 1;
return true;
}
console.log(chalk.green(`Updated ${APP_NAME}`));
console.log(chalk.green(`Updated ${APP_NAME} from ${VERSION} to ${selfUpdatePlan.version}`));
}
return true;
}
@@ -38,10 +38,13 @@ export function spawnProcessSync(
/**
* Wait for a child process to terminate without hanging on inherited stdio handles.
*
* On Windows, daemonized descendants can inherit the child's stdout/stderr pipe
* handles. In that case the child emits `exit`, but `close` can hang forever even
* though the original process is already gone. We wait briefly for stdio to end,
* then forcibly stop tracking the inherited handles.
* A short-lived child can `exit` while a detached descendant keeps its stdout/stderr
* pipe open. We must not resolve and destroy the streams on a fixed deadline measured
* from `exit`, or output still being written past that deadline is silently lost
* (earendil-works/pi#5303). Instead, after `exit` we wait for the pipes to fall idle:
* the grace timer is re-armed on every chunk, so an actively writing descendant keeps
* us reading, while a quiet inherited handle (e.g. a Windows daemonized descendant
* that never lets `close` fire) still releases us after the grace elapses.
*/
export function waitForChildProcess(child: ChildProcess): Promise<number | null> {
return new Promise((resolve, reject) => {
@@ -62,6 +65,8 @@ export function waitForChildProcess(child: ChildProcess): Promise<number | null>
child.removeListener("close", onClose);
child.stdout?.removeListener("end", onStdoutEnd);
child.stderr?.removeListener("end", onStderrEnd);
child.stdout?.removeListener("data", onData);
child.stderr?.removeListener("data", onData);
};
const finalize = (code: number | null) => {
@@ -80,6 +85,17 @@ export function waitForChildProcess(child: ChildProcess): Promise<number | null>
}
};
const armIdleTimer = () => {
if (postExitTimer) clearTimeout(postExitTimer);
postExitTimer = setTimeout(() => finalize(exitCode), EXIT_STDIO_GRACE_MS);
};
const onData = () => {
// Output is still arriving after exit; defer finalizing so we don't
// destroy the stream mid-write and truncate the tail.
if (exited && !settled) armIdleTimer();
};
const onStdoutEnd = () => {
stdoutEnded = true;
maybeFinalizeAfterExit();
@@ -102,7 +118,7 @@ export function waitForChildProcess(child: ChildProcess): Promise<number | null>
exitCode = code;
maybeFinalizeAfterExit();
if (!settled) {
postExitTimer = setTimeout(() => finalize(code), EXIT_STDIO_GRACE_MS);
armIdleTimer();
}
};
@@ -112,6 +128,8 @@ export function waitForChildProcess(child: ChildProcess): Promise<number | null>
child.stdout?.once("end", onStdoutEnd);
child.stderr?.once("end", onStderrEnd);
child.stdout?.on("data", onData);
child.stderr?.on("data", onData);
child.once("error", onError);
child.once("exit", onExit);
child.once("close", onClose);
+15 -5
View File
@@ -6,11 +6,21 @@ import { getBinDir } from "../config.ts";
export interface ShellConfig {
shell: string;
args: string[];
commandTransport?: "argv" | "stdin";
}
/**
* Find bash executable on PATH (cross-platform)
*/
function isLegacyWslBashPath(path: string): boolean {
const normalized = path.replace(/\//g, "\\").toLowerCase();
return /^[a-z]:\\windows\\(?:system32|sysnative)\\bash\.exe$/.test(normalized);
}
function getBashShellConfig(shell: string): ShellConfig {
return isLegacyWslBashPath(shell) ? { shell, args: ["-s"], commandTransport: "stdin" } : { shell, args: ["-c"] };
}
function findBashOnPath(): string | null {
if (process.platform === "win32") {
// Windows: Use 'where' and verify file exists (where can return non-existent paths)
@@ -58,7 +68,7 @@ export function getShellConfig(customShellPath?: string): ShellConfig {
// 1. Check user-specified shell path
if (customShellPath) {
if (existsSync(customShellPath)) {
return { shell: customShellPath, args: ["-c"] };
return getBashShellConfig(customShellPath);
}
throw new Error(`Custom shell path not found: ${customShellPath}`);
}
@@ -77,14 +87,14 @@ export function getShellConfig(customShellPath?: string): ShellConfig {
for (const path of paths) {
if (existsSync(path)) {
return { shell: path, args: ["-c"] };
return getBashShellConfig(path);
}
}
// 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
const bashOnPath = findBashOnPath();
if (bashOnPath) {
return { shell: bashOnPath, args: ["-c"] };
return getBashShellConfig(bashOnPath);
}
throw new Error(
@@ -98,12 +108,12 @@ export function getShellConfig(customShellPath?: string): ShellConfig {
// Unix: try /bin/bash, then bash on PATH, then fallback to sh
if (existsSync("/bin/bash")) {
return { shell: "/bin/bash", args: ["-c"] };
return getBashShellConfig("/bin/bash");
}
const bashOnPath = findBashOnPath();
if (bashOnPath) {
return { shell: bashOnPath, args: ["-c"] };
return getBashShellConfig(bashOnPath);
}
return { shell: "sh", args: ["-c"] };
@@ -1,3 +1,4 @@
import { compare, valid } from "semver";
import { getPiUserAgent } from "./pi-user-agent.ts";
const LATEST_VERSION_URL = "https://pi.dev/api/latest-version";
@@ -9,40 +10,13 @@ export interface LatestPiRelease {
note?: string;
}
interface ParsedVersion {
major: number;
minor: number;
patch: number;
prerelease?: string;
}
function parsePackageVersion(version: string): ParsedVersion | undefined {
const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/);
if (!match) {
return undefined;
}
return {
major: Number.parseInt(match[1], 10),
minor: Number.parseInt(match[2], 10),
patch: Number.parseInt(match[3], 10),
prerelease: match[4],
};
}
export function comparePackageVersions(leftVersion: string, rightVersion: string): number | undefined {
const left = parsePackageVersion(leftVersion);
const right = parsePackageVersion(rightVersion);
const left = valid(leftVersion.trim());
const right = valid(rightVersion.trim());
if (!left || !right) {
return undefined;
}
if (left.major !== right.major) return left.major - right.major;
if (left.minor !== right.minor) return left.minor - right.minor;
if (left.patch !== right.patch) return left.patch - right.patch;
if (left.prerelease === right.prerelease) return 0;
if (!left.prerelease) return 1;
if (!right.prerelease) return -1;
return left.prerelease.localeCompare(right.prerelease);
return compare(left, right);
}
export function isNewerPackageVersion(candidateVersion: string, currentVersion: string): boolean {
@@ -2,7 +2,8 @@ import { existsSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Agent } from "@earendil-works/pi-agent-core";
import { type AssistantMessage, getModel } from "@earendil-works/pi-ai/compat";
import { type AssistantMessage, createAssistantMessageEventStream, fauxAssistantMessage } from "@earendil-works/pi-ai";
import { getModel } from "@earendil-works/pi-ai/compat";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AgentSession } from "../src/core/agent-session.ts";
import { AuthStorage } from "../src/core/auth-storage.ts";
@@ -11,51 +12,10 @@ import { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
import { createTestResourceLoader } from "./utilities.ts";
vi.mock("../src/core/compaction/index.js", () => ({
calculateContextTokens: (usage: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
totalTokens?: number;
}) => usage.totalTokens ?? usage.input + usage.output + usage.cacheRead + usage.cacheWrite,
collectEntriesForBranchSummary: () => ({ entries: [], commonAncestorId: null }),
compact: async () => ({
summary: "compacted",
firstKeptEntryId: "entry-1",
tokensBefore: 100,
details: {},
}),
estimateContextTokens: (
messages: Array<{
role: string;
usage?: { input: number; output: number; cacheRead: number; cacheWrite: number; totalTokens?: number };
stopReason?: string;
}>,
) => {
// Walk backwards to find last non-error, non-aborted assistant with usage
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role === "assistant" && msg.stopReason !== "error" && msg.stopReason !== "aborted" && msg.usage) {
const tokens =
msg.usage.totalTokens ?? msg.usage.input + msg.usage.output + msg.usage.cacheRead + msg.usage.cacheWrite;
return { tokens, usageTokens: tokens, trailingTokens: 0, lastUsageIndex: i };
}
}
return { tokens: 0, usageTokens: 0, trailingTokens: 0, lastUsageIndex: null };
},
generateBranchSummary: async () => ({ summary: "", aborted: false, readFiles: [], modifiedFiles: [] }),
prepareCompaction: () => ({ dummy: true }),
shouldCompact: (
contextTokens: number,
contextWindow: number,
settings: { enabled: boolean; reserveTokens: number },
) => settings.enabled && contextTokens > contextWindow - settings.reserveTokens,
}));
describe("AgentSession auto-compaction queue resume", () => {
let session: AgentSession;
let sessionManager: SessionManager;
let settingsManager: SettingsManager;
let tempDir: string;
beforeEach(() => {
@@ -73,7 +33,7 @@ describe("AgentSession auto-compaction queue resume", () => {
});
sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.create(tempDir, tempDir);
settingsManager = SettingsManager.create(tempDir, tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
@@ -98,6 +58,57 @@ describe("AgentSession auto-compaction queue resume", () => {
});
it("should resume after threshold compaction when only agent-level queued messages exist", async () => {
settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } });
const model = session.model!;
const now = Date.now();
sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: "message to compact" }],
timestamp: now - 1000,
});
sessionManager.appendMessage({
role: "assistant",
content: [{ type: "text", text: "assistant response to compact" }],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 100,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 100,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: now - 500,
});
session.agent.state.messages = sessionManager.buildSessionContext().messages;
session.agent.streamFn = (summaryModel) => {
const stream = createAssistantMessageEventStream();
queueMicrotask(() => {
stream.push({
type: "done",
reason: "stop",
message: {
...fauxAssistantMessage("compacted"),
api: summaryModel.api,
provider: summaryModel.provider,
model: summaryModel.id,
usage: {
input: 10,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 10,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
},
});
});
return stream;
};
session.agent.followUp({
role: "custom",
customType: "test",
@@ -5,7 +5,8 @@ import { registerOAuthProvider } from "@earendil-works/pi-ai/oauth";
import lockfile from "proper-lockfile";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { clearConfigValueCache } from "../src/core/resolve-config-value.ts";
import { clearConfigValueCache, resolveConfigValueUncached } from "../src/core/resolve-config-value.ts";
import * as shellModule from "../src/utils/shell.ts";
describe("AuthStorage", () => {
let tempDir: string;
@@ -134,6 +135,34 @@ describe("AuthStorage", () => {
}
});
test("apiKey env bag takes precedence over process.env", async () => {
const originalEnv = process.env.TEST_AUTH_SCOPED_API_KEY_12345;
process.env.TEST_AUTH_SCOPED_API_KEY_12345 = "process-env-value";
try {
writeAuthJson({
anthropic: {
type: "api_key",
key: "$TEST_AUTH_SCOPED_API_KEY_12345",
env: { TEST_AUTH_SCOPED_API_KEY_12345: "credential-env-value" },
},
});
authStorage = AuthStorage.create(authJsonPath);
expect(await authStorage.getApiKey("anthropic")).toBe("credential-env-value");
expect(authStorage.getProviderEnv("anthropic")).toEqual({
TEST_AUTH_SCOPED_API_KEY_12345: "credential-env-value",
});
} finally {
if (originalEnv === undefined) {
delete process.env.TEST_AUTH_SCOPED_API_KEY_12345;
} else {
process.env.TEST_AUTH_SCOPED_API_KEY_12345 = originalEnv;
}
}
});
test("apiKey with braced env syntax resolves to env value", async () => {
const originalEnv = process.env.TEST_AUTH_BRACED_API_KEY_12345;
process.env.TEST_AUTH_BRACED_API_KEY_12345 = "braced-env-api-key-value";
@@ -293,6 +322,30 @@ describe("AuthStorage", () => {
expect(apiKey).toBe("hello-world");
});
test("command config uses stdin when configured shell requires it", () => {
if (process.platform === "win32") return;
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
vi.spyOn(shellModule, "getShellConfig").mockReturnValue({
shell: "/bin/bash",
args: ["-s"],
commandTransport: "stdin",
});
try {
Object.defineProperty(process, "platform", {
configurable: true,
value: "win32",
});
const nameExpansion = "$" + "{name}";
expect(resolveConfigValueUncached(`!name='World'; echo "Hello, ${nameExpansion}!"`)).toBe("Hello, World!");
} finally {
if (platformDescriptor) {
Object.defineProperty(process, "platform", platformDescriptor);
}
}
});
describe("caching", () => {
test("command is only executed once per process", async () => {
// Use a command that writes to a file to count invocations
@@ -98,6 +98,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
const sessionManager = SessionManager.create(tempDir);
const settingsManager = SettingsManager.create(tempDir, tempDir);
settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } });
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
const modelRegistry = ModelRegistry.create(authStorage);
+2 -23
View File
@@ -9,7 +9,6 @@ import {
calculateContextTokens,
compact,
DEFAULT_COMPACTION_SETTINGS,
estimateContextTokens,
findCutPoint,
getLastAssistantUsage,
prepareCompaction,
@@ -396,7 +395,7 @@ describe("buildSessionContext", () => {
});
describe("prepareCompaction with previous compaction", () => {
it("should preserve kept messages across repeated compactions when they still fit", () => {
it("should skip repeated compactions when kept messages still fit", () => {
const u1 = createMessageEntry(createUserMessage("user msg 1 (summarized by compaction1)"));
const a1 = createMessageEntry(createAssistantMessage("assistant msg 1"));
const u2 = createMessageEntry(createUserMessage("user msg 2 - kept by compaction1"));
@@ -408,29 +407,9 @@ describe("prepareCompaction with previous compaction", () => {
const a4 = createMessageEntry(createAssistantMessage("assistant msg 4", createMockUsage(8000, 2000)));
const pathEntries = [u1, a1, u2, a2, u3, a3, compaction1, u4, a4];
const contextBefore = buildSessionContext(pathEntries);
const preparation = prepareCompaction(pathEntries, DEFAULT_COMPACTION_SETTINGS);
expect(preparation).toBeDefined();
expect(preparation!.firstKeptEntryId).toBe(u2.id);
expect(preparation!.previousSummary).toBe("First summary");
expect(extractText(preparation!.messagesToSummarize)).not.toContain("First summary");
expect(preparation!.tokensBefore).toBe(estimateContextTokens(contextBefore.messages).tokens);
const compaction2: CompactionEntry = {
type: "compaction",
id: "compaction2-id",
parentId: a4.id,
timestamp: new Date().toISOString(),
summary: "Second summary",
firstKeptEntryId: preparation!.firstKeptEntryId,
tokensBefore: preparation!.tokensBefore,
};
const contextAfter = buildSessionContext([...pathEntries, compaction2]);
const contextAfterText = extractText(contextAfter.messages);
expect(contextAfterText).toContain("user msg 2 - kept by compaction1");
expect(contextAfterText).toContain("user msg 3 - kept by compaction1");
expect(preparation).toBeUndefined();
});
it("should re-summarize previously kept messages when the recent window moves past them", () => {
@@ -37,7 +37,7 @@ describe("config value env var syntax migration", () => {
}
}
it("rewrites legacy uppercase auth.json API key values to explicit env references", () => {
it("leaves uppercase auth.json API key values unchanged", () => {
const agentDir = createAgentDir();
fs.writeFileSync(
path.join(agentDir, "auth.json"),
@@ -61,19 +61,17 @@ describe("config value env var syntax migration", () => {
string,
Record<string, unknown>
>;
expect(migrated.anthropic.key).toBe("$ANTHROPIC_API_KEY");
expect(migrated.anthropic.key).toBe("ANTHROPIC_API_KEY");
expect(migrated.openai.key).toBe("$OPENAI_API_KEY");
expect(migrated.opencode.key).toBe("public");
expect(migrated.github.access).toBe("ACCESS_TOKEN");
const logMessage = String(logSpy.mock.calls[0]?.[0] ?? "");
expect(logMessage).toContain("explicit $ENV_VAR syntax");
expect(logMessage).toContain('auth.json["anthropic"].key: ANTHROPIC_API_KEY -> $ANTHROPIC_API_KEY');
expect(logSpy).not.toHaveBeenCalled();
});
it.each([
["malformed", '{\n "providers": {\n'],
["blank", ""],
])("does not throw on %s models.json during config migration", (_name, content) => {
])("does not throw on %s models.json during migrations", (_name, content) => {
const agentDir = createAgentDir();
const modelsPath = path.join(agentDir, "models.json");
fs.writeFileSync(modelsPath, content, "utf-8");
@@ -87,71 +85,93 @@ describe("config value env var syntax migration", () => {
expect(loadError).toContain(`File: ${modelsPath}`);
});
it("rewrites legacy uppercase models.json API key and header values", () => {
it("leaves uppercase models.json API key and header values unchanged", async () => {
const agentDir = createAgentDir();
fs.writeFileSync(
path.join(agentDir, "models.json"),
`${JSON.stringify(
{
providers: {
"custom-provider": {
baseUrl: "https://example.com/v1",
apiKey: "CUSTOM_API_KEY",
api: "openai-completions",
headers: {
"x-api-key": "HEADER_API_KEY",
"x-literal": "literal",
},
models: [
{
id: "model-a",
headers: { "x-model-key": "MODEL_API_KEY" },
const envKeys = ["CUSTOM_API_KEY", "HEADER_API_KEY", "MODEL_API_KEY", "OVERRIDE_API_KEY"];
const savedEnv: Record<string, string | undefined> = {};
for (const key of envKeys) {
savedEnv[key] = process.env[key];
process.env[key] = `env-${key}`;
}
try {
fs.writeFileSync(
path.join(agentDir, "models.json"),
`${JSON.stringify(
{
providers: {
"custom-provider": {
baseUrl: "https://example.com/v1",
apiKey: "CUSTOM_API_KEY",
api: "openai-completions",
headers: {
"x-api-key": "HEADER_API_KEY",
"x-literal": "literal",
},
models: [
{
id: "model-a",
headers: { "x-model-key": "MODEL_API_KEY" },
},
],
modelOverrides: {
"model-b": { headers: { "x-override-key": "OVERRIDE_API_KEY" } },
},
],
modelOverrides: {
"model-b": { headers: { "x-override-key": "OVERRIDE_API_KEY" } },
},
},
},
null,
2,
)}\n`,
"utf-8",
);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
withAgentDir(agentDir, () => runMigrations(agentDir));
const migrated = JSON.parse(fs.readFileSync(path.join(agentDir, "models.json"), "utf-8")) as {
providers: Record<
string,
{
apiKey?: string;
headers?: Record<string, string>;
models?: Array<{ headers?: Record<string, string> }>;
modelOverrides?: Record<string, { headers?: Record<string, string> }>;
}
>;
};
const provider = migrated.providers["custom-provider"]!;
expect(provider.apiKey).toBe("CUSTOM_API_KEY");
expect(provider.headers?.["x-api-key"]).toBe("HEADER_API_KEY");
expect(provider.headers?.["x-literal"]).toBe("literal");
expect(provider.models?.[0]?.headers?.["x-model-key"]).toBe("MODEL_API_KEY");
expect(provider.modelOverrides?.["model-b"]?.headers?.["x-override-key"]).toBe("OVERRIDE_API_KEY");
expect(logSpy).not.toHaveBeenCalled();
const registry = ModelRegistry.create(
AuthStorage.create(path.join(agentDir, "auth.json")),
path.join(agentDir, "models.json"),
);
const model = registry.find("custom-provider", "model-a");
expect(model).toBeDefined();
expect(await registry.getApiKeyForProvider("custom-provider")).toBe("CUSTOM_API_KEY");
expect(await registry.getApiKeyAndHeaders(model!)).toMatchObject({
ok: true,
apiKey: "CUSTOM_API_KEY",
headers: {
"x-api-key": "HEADER_API_KEY",
"x-literal": "literal",
"x-model-key": "MODEL_API_KEY",
},
null,
2,
)}\n`,
"utf-8",
);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
withAgentDir(agentDir, () => runMigrations(agentDir));
const migrated = JSON.parse(fs.readFileSync(path.join(agentDir, "models.json"), "utf-8")) as {
providers: Record<
string,
{
apiKey?: string;
headers?: Record<string, string>;
models?: Array<{ headers?: Record<string, string> }>;
modelOverrides?: Record<string, { headers?: Record<string, string> }>;
});
} finally {
for (const key of envKeys) {
if (savedEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = savedEnv[key];
}
>;
};
const provider = migrated.providers["custom-provider"]!;
expect(provider.apiKey).toBe("$CUSTOM_API_KEY");
expect(provider.headers?.["x-api-key"]).toBe("$HEADER_API_KEY");
expect(provider.headers?.["x-literal"]).toBe("literal");
expect(provider.models?.[0]?.headers?.["x-model-key"]).toBe("$MODEL_API_KEY");
expect(provider.modelOverrides?.["model-b"]?.headers?.["x-override-key"]).toBe("$OVERRIDE_API_KEY");
const logMessage = String(logSpy.mock.calls[0]?.[0] ?? "");
expect(logMessage).toContain(
'models.json.providers["custom-provider"].apiKey: CUSTOM_API_KEY -> $CUSTOM_API_KEY',
);
expect(logMessage).toContain(
'models.json.providers["custom-provider"].headers["x-api-key"]: HEADER_API_KEY -> $HEADER_API_KEY',
);
expect(logMessage).toContain(
'models.json.providers["custom-provider"].models["model-a"].headers["x-model-key"]: MODEL_API_KEY -> $MODEL_API_KEY',
);
expect(logMessage).toContain(
'models.json.providers["custom-provider"].modelOverrides["model-b"].headers["x-override-key"]: OVERRIDE_API_KEY -> $OVERRIDE_API_KEY',
);
}
}
});
});
+23
View File
@@ -188,6 +188,29 @@ describe("detectInstallMethod", () => {
});
});
test("self-updates exact npm versions without uninstalling the current package", () => {
const { prefix } = createNpmPrefixInstall();
const command = getSelfUpdateCommand("@earendil-works/pi-coding-agent", undefined, {
packageName: "@earendil-works/pi-coding-agent",
installSpec: "@earendil-works/pi-coding-agent@1.2.3",
});
expect(command).toEqual({
command: "npm",
args: [
"--prefix",
prefix,
"install",
"-g",
"--ignore-scripts",
"--min-release-age=0",
"@earendil-works/pi-coding-agent@1.2.3",
],
display: `npm --prefix ${prefix} install -g --ignore-scripts --min-release-age=0 @earendil-works/pi-coding-agent@1.2.3`,
});
});
test("self-updates renamed packages from the current install prefix", () => {
const { prefix } = createNpmPrefixInstall();
@@ -0,0 +1,39 @@
import { mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../src/config.ts", async (importOriginal) => {
const actual = await importOriginal();
return {
...(actual as Record<string, unknown>),
PACKAGE_NAME: "@example/pi-coding-agent",
};
});
import { shouldRunFirstTimeSetup } from "../src/cli/startup-ui.ts";
describe("shouldRunFirstTimeSetup in forked distributions", () => {
const originalPiExperimental = process.env.PI_EXPERIMENTAL;
let tempDir: string;
let settingsPath: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "pi-first-time-setup-fork-"));
settingsPath = join(tempDir, "settings.json");
process.env.PI_EXPERIMENTAL = "1";
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
if (originalPiExperimental === undefined) {
delete process.env.PI_EXPERIMENTAL;
} else {
process.env.PI_EXPERIMENTAL = originalPiExperimental;
}
});
it("returns false for a forked package", () => {
expect(shouldRunFirstTimeSetup(settingsPath)).toBe(false);
});
});
@@ -0,0 +1,53 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { applyHttpProxySettings } from "../src/core/http-dispatcher.ts";
const PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY"] as const;
describe("http proxy settings", () => {
let savedEnv: Record<(typeof PROXY_ENV_KEYS)[number], string | undefined>;
beforeEach(() => {
savedEnv = Object.fromEntries(PROXY_ENV_KEYS.map((key) => [key, process.env[key]])) as Record<
(typeof PROXY_ENV_KEYS)[number],
string | undefined
>;
for (const key of PROXY_ENV_KEYS) {
delete process.env[key];
}
});
afterEach(() => {
for (const key of PROXY_ENV_KEYS) {
const value = savedEnv[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
it("applies httpProxy to HTTP_PROXY and HTTPS_PROXY", () => {
applyHttpProxySettings("http://127.0.0.1:7890");
expect(process.env.HTTP_PROXY).toBe("http://127.0.0.1:7890");
expect(process.env.HTTPS_PROXY).toBe("http://127.0.0.1:7890");
});
it("does not override existing proxy env vars", () => {
process.env.HTTP_PROXY = "http://env-http:8080";
process.env.HTTPS_PROXY = "http://env-https:8080";
applyHttpProxySettings("http://settings:7890");
expect(process.env.HTTP_PROXY).toBe("http://env-http:8080");
expect(process.env.HTTPS_PROXY).toBe("http://env-https:8080");
});
it("ignores empty values", () => {
applyHttpProxySettings(" ");
expect(process.env.HTTP_PROXY).toBeUndefined();
expect(process.env.HTTPS_PROXY).toBeUndefined();
});
});
@@ -41,7 +41,7 @@ describe("InteractiveMode /clone", () => {
await interactiveModePrototype.handleCloneCommand.call(context);
expect(fork).toHaveBeenCalledWith("leaf-123", { position: "at" });
expect(renderCurrentSessionState).toHaveBeenCalled();
expect(renderCurrentSessionState).not.toHaveBeenCalled();
expect(setText).toHaveBeenCalledWith("");
expect(showStatus).toHaveBeenCalledWith("Cloned to new session");
expect(showError).not.toHaveBeenCalled();
@@ -151,6 +151,13 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => {
const fakeThis: any = {
session: { settingsManager },
settingsManager,
themeController: {
setThemeInstance: vi.fn(() => ({ success: true })),
setThemeName: vi.fn(() => {
fakeThis.ui.requestRender();
return { success: true };
}),
},
ui: { requestRender: vi.fn() },
};
@@ -158,6 +165,7 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => {
const result = uiContext.setTheme("light");
expect(result.success).toBe(true);
expect(fakeThis.themeController.setThemeName).toHaveBeenCalledWith("light");
expect(settingsManager.setTheme).toHaveBeenCalledWith("light");
expect(currentTheme).toBe("light");
expect(fakeThis.ui.requestRender).toHaveBeenCalledTimes(1);
@@ -173,6 +181,10 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => {
const fakeThis: any = {
session: { settingsManager },
settingsManager,
themeController: {
setThemeInstance: vi.fn(() => ({ success: true })),
setThemeName: vi.fn(() => ({ success: false, error: "Theme not found" })),
},
ui: { requestRender: vi.fn() },
};
@@ -180,6 +192,7 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => {
const result = uiContext.setTheme("__missing_theme__");
expect(result.success).toBe(false);
expect(fakeThis.themeController.setThemeName).toHaveBeenCalledWith("__missing_theme__");
expect(settingsManager.setTheme).not.toHaveBeenCalled();
expect(fakeThis.ui.requestRender).not.toHaveBeenCalled();
});
@@ -356,6 +369,59 @@ describe("InteractiveMode.setupAutocompleteProvider", () => {
});
});
describe("InteractiveMode.createBaseAutocompleteProvider", () => {
test("matches model command arguments across provider/model order", async () => {
type TestModel = { id: string; provider: string; name: string };
type FakeInteractiveMode = {
session: {
scopedModels: Array<{ model: TestModel }>;
modelRegistry: { getAvailable: () => TestModel[] };
promptTemplates: [];
extensionRunner: { getRegisteredCommands: () => [] };
resourceLoader: { getSkills: () => { skills: [] } };
};
settingsManager: { getEnableSkillCommands: () => boolean };
skillCommands: Map<string, string>;
sessionManager: { getCwd: () => string };
fdPath: null;
};
const createBaseAutocompleteProvider = (
InteractiveMode as unknown as {
prototype: { createBaseAutocompleteProvider(this: FakeInteractiveMode): AutocompleteProvider };
}
).prototype.createBaseAutocompleteProvider;
const models = [
{ id: "gpt-5.2-codex", provider: "github-copilot", name: "GPT-5.2 Codex" },
{ id: "gpt-5.5", provider: "openai-codex", name: "GPT-5.5" },
];
const fakeThis: FakeInteractiveMode = {
session: {
scopedModels: [],
modelRegistry: { getAvailable: () => models },
promptTemplates: [],
extensionRunner: { getRegisteredCommands: () => [] },
resourceLoader: { getSkills: () => ({ skills: [] }) },
},
settingsManager: { getEnableSkillCommands: () => false },
skillCommands: new Map(),
sessionManager: { getCwd: () => "/tmp" },
fdPath: null,
};
const provider = createBaseAutocompleteProvider.call(fakeThis);
const line = "/model codexgpt";
const suggestions = await provider.getSuggestions([line], 0, line.length, {
signal: new AbortController().signal,
});
expect(suggestions?.items.map((item) => item.value)).toEqual([
"openai-codex/gpt-5.5",
"github-copilot/gpt-5.2-codex",
]);
});
});
describe("InteractiveMode.showLoadedResources", () => {
beforeAll(() => {
initTheme("dark");
+127 -13
View File
@@ -13,7 +13,6 @@ import { getOAuthProvider } from "@earendil-works/pi-ai/oauth";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { clearApiKeyCache, ModelRegistry, type ProviderConfigInput } from "../src/core/model-registry.ts";
import { clearDeprecationWarningsForTests } from "../src/utils/deprecation.ts";
describe("ModelRegistry", () => {
let tempDir: string;
@@ -25,7 +24,6 @@ describe("ModelRegistry", () => {
mkdirSync(tempDir, { recursive: true });
modelsJsonPath = join(tempDir, "models.json");
authStorage = AuthStorage.create(join(tempDir, "auth.json"));
clearDeprecationWarningsForTests();
});
afterEach(() => {
@@ -33,7 +31,6 @@ describe("ModelRegistry", () => {
rmSync(tempDir, { recursive: true });
}
clearApiKeyCache();
clearDeprecationWarningsForTests();
vi.restoreAllMocks();
});
@@ -443,6 +440,43 @@ describe("ModelRegistry", () => {
expect(compat?.cacheControlFormat).toBe("anthropic");
});
test("compat schema accepts chat template thinking configuration", () => {
writeRawModelsJson({
demo: {
baseUrl: "https://example.com/v1",
apiKey: "DEMO_KEY",
api: "openai-completions",
models: [
{
id: "demo-model",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
compat: {
thinkingFormat: "chat-template",
chatTemplateKwargs: {
preserve_thinking: true,
thinking: { $var: "thinking.enabled" },
},
},
},
],
},
});
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
const compat = registry.find("demo", "demo-model")?.compat as OpenAICompletionsCompat | undefined;
expect(registry.getError()).toBeUndefined();
expect(compat?.thinkingFormat).toBe("chat-template");
expect(compat?.chatTemplateKwargs).toEqual({
preserve_thinking: true,
thinking: { $var: "thinking.enabled" },
});
});
test("compat schema accepts Anthropic eager tool input streaming flag", () => {
writeRawModelsJson({
demo: {
@@ -902,26 +936,87 @@ describe("ModelRegistry", () => {
expect(registry.getProviderDisplayName("oauth-provider")).toBe("OAuth Provider");
});
test("registerProvider warns and temporarily treats uppercase apiKey as an env reference", async () => {
const originalEnv = process.env.CUSTOM_NAME;
process.env.CUSTOM_NAME = "legacy-env-key";
test("stored API key env propagates to request auth and resolves headers", async () => {
authStorage.set("cloudflare-ai-gateway", {
type: "api_key",
key: "$CLOUDFLARE_API_KEY",
env: {
CLOUDFLARE_API_KEY: "stored-cf-token",
CLOUDFLARE_ACCOUNT_ID: "stored-account",
},
});
writeRawModelsJson({
"cloudflare-ai-gateway": {
headers: { "x-account": "$CLOUDFLARE_ACCOUNT_ID" },
},
});
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
const model = registry.getAll().find((m) => m.provider === "cloudflare-ai-gateway");
expect(model).toBeDefined();
const auth = await registry.getApiKeyAndHeaders(model!);
expect(auth).toEqual({
ok: true,
apiKey: "stored-cf-token",
headers: { "x-account": "stored-account" },
env: {
CLOUDFLARE_API_KEY: "stored-cf-token",
CLOUDFLARE_ACCOUNT_ID: "stored-account",
},
});
});
test("registerProvider treats uppercase apiKey and headers as literals", async () => {
const envKeys = ["CUSTOM_NAME", "BEARER", "MODEL_TOKEN"];
const savedEnv: Record<string, string | undefined> = {};
for (const key of envKeys) {
savedEnv[key] = process.env[key];
process.env[key] = `env-${key}`;
}
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
registry.registerProvider("legacy-provider", {
registry.registerProvider("literal-provider", {
...providerConfig("https://provider.test/v1", [{ id: "demo-model" }], "openai-completions"),
apiKey: "CUSTOM_NAME",
headers: { Authorization: "BEARER" },
models: [
{
id: "demo-model",
name: "demo-model",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 100000,
maxTokens: 8000,
headers: { "x-model-token": "MODEL_TOKEN" },
},
],
});
expect(await registry.getApiKeyForProvider("legacy-provider")).toBe("legacy-env-key");
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Pass "$CUSTOM_NAME" instead'));
expect(await registry.getApiKeyForProvider("literal-provider")).toBe("CUSTOM_NAME");
const model = registry.find("literal-provider", "demo-model");
expect(model).toBeDefined();
expect(await registry.getApiKeyAndHeaders(model!)).toMatchObject({
ok: true,
apiKey: "CUSTOM_NAME",
headers: {
Authorization: "BEARER",
"x-model-token": "MODEL_TOKEN",
},
});
expect(warnSpy).not.toHaveBeenCalled();
} finally {
if (originalEnv === undefined) {
delete process.env.CUSTOM_NAME;
} else {
process.env.CUSTOM_NAME = originalEnv;
for (const key of envKeys) {
if (savedEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = savedEnv[key];
}
}
}
});
@@ -1617,6 +1712,25 @@ describe("ModelRegistry", () => {
expect(count).toBe(0);
});
test("getAvailable filters GitHub Copilot OAuth models to account picker availability", () => {
authStorage.set("github-copilot", {
type: "oauth",
refresh: "github-access-token",
access: "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;",
expires: Date.now() + 60_000,
availableModelIds: ["gpt-4.1"],
});
const registry = ModelRegistry.create(authStorage, modelsJsonPath);
expect(
registry
.getAvailable()
.filter((m) => m.provider === "github-copilot")
.map((m) => m.id),
).toEqual(["gpt-4.1"]);
});
test("getApiKeyAndHeaders resolves authHeader on every request", async () => {
const tokenFile = join(tempDir, "token");
writeFileSync(tokenFile, "token-1");
@@ -344,6 +344,7 @@ describe("resolveCliModel", () => {
};
const registry = {
getAll: () => [...allModels, zaiModel, gatewayModel],
hasConfiguredAuth: () => true,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
const result = resolveCliModel({
@@ -356,6 +357,46 @@ describe("resolveCliModel", () => {
expect(result.model?.id).toBe("glm-5");
});
test("prefers an authenticated exact raw model id over an unauthenticated inferred provider", () => {
const commandcodeModel: Model<"anthropic-messages"> = {
id: "xiaomi/mimo-v2.5-pro",
name: "Xiaomi MiMo via Commandcode",
api: "anthropic-messages",
provider: "commandcode",
baseUrl: "https://example.invalid",
reasoning: false,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 },
contextWindow: 128000,
maxTokens: 8192,
};
const xiaomiModel: Model<"anthropic-messages"> = {
id: "mimo-v2.5-pro",
name: "Xiaomi MiMo",
api: "anthropic-messages",
provider: "xiaomi",
baseUrl: "https://api.xiaomimimo.com",
reasoning: false,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 },
contextWindow: 128000,
maxTokens: 8192,
};
const registry = {
getAll: () => [...allModels, commandcodeModel, xiaomiModel],
hasConfiguredAuth: (model: Model<"anthropic-messages">) => model.provider === "commandcode",
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
const result = resolveCliModel({
cliModel: "xiaomi/mimo-v2.5-pro",
modelRegistry: registry,
});
expect(result.error).toBeUndefined();
expect(result.model?.provider).toBe("commandcode");
expect(result.model?.id).toBe("xiaomi/mimo-v2.5-pro");
});
test("resolves provider-prefixed fuzzy patterns (openrouter/qwen -> openrouter model)", () => {
const registry = {
getAll: () => allModels,
@@ -403,6 +444,7 @@ describe("resolveCliModel", () => {
expect(result.model?.provider).toBe("neuralwatt");
// The :high suffix must NOT leak into the model id sent to the API
expect(result.model?.id).toBe("zai-org/GLM-5.1-FP8");
expect(result.model?.reasoning).toBe(true);
expect(result.thinkingLevel).toBe("high");
});
@@ -1,10 +1,11 @@
import { mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ENV_AGENT_DIR, PACKAGE_NAME, VERSION } from "../src/config.ts";
import { ProjectTrustStore } from "../src/core/trust-manager.ts";
import { main } from "../src/main.ts";
import { handlePackageCommand } from "../src/package-manager-cli.ts";
describe("package commands", () => {
let tempDir: string;
@@ -22,6 +23,10 @@ describe("package commands", () => {
return `${major}.${minor}.${Number.parseInt(patch, 10) + 1}`;
}
async function runPackageCommandDirectly(args: string[]): Promise<void> {
expect(await handlePackageCommand(args)).toBe(true);
}
beforeEach(() => {
tempDir = join(tmpdir(), `pi-package-commands-${Date.now()}-${Math.random().toString(36).slice(2)}`);
agentDir = join(tempDir, "agent");
@@ -37,12 +42,21 @@ describe("package commands", () => {
originalExitCode = process.exitCode;
originalExecPath = process.execPath;
process.exitCode = undefined;
vi.spyOn(process, "exit").mockImplementation(((code?: string | number | null) => {
if (code === undefined || code === null || Number(code) === 0) {
process.exitCode = undefined;
} else {
process.exitCode = code;
}
return undefined as never;
}) as typeof process.exit);
process.env[ENV_AGENT_DIR] = agentDir;
process.chdir(projectDir);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
process.chdir(originalCwd);
process.exitCode = originalExitCode;
if (originalAgentDir === undefined) {
@@ -202,6 +216,69 @@ describe("package commands", () => {
}
});
it("does not prompt or ask extensions for project trust during update", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" }));
const fakeNpmPath = join(tempDir, "fake-project-npm.cjs");
const recordPath = join(tempDir, "project-update.json");
writeFileSync(
fakeNpmPath,
`const fs=require("node:fs");fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(process.argv.slice(2)));`,
);
writeFileSync(
join(projectDir, ".pi", "settings.json"),
JSON.stringify({ packages: ["npm:fake-package"], npmCommand: [originalExecPath, fakeNpmPath] }),
);
let projectTrustCalled = false;
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
await expect(
main(["update", "--extensions"], {
extensionFactories: [
(pi) => {
pi.on("project_trust", () => {
projectTrustCalled = true;
return { trusted: "yes" };
});
},
],
}),
).resolves.toBeUndefined();
expect(projectTrustCalled).toBe(false);
expect(existsSync(recordPath)).toBe(false);
expect(process.exitCode).toBeUndefined();
} finally {
logSpy.mockRestore();
}
});
it("uses saved project trust during update", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
const fakeNpmPath = join(tempDir, "fake-trusted-project-npm.cjs");
const recordPath = join(tempDir, "trusted-project-update.json");
writeFileSync(
fakeNpmPath,
`const fs=require("node:fs");fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(process.argv.slice(2)));`,
);
writeFileSync(
join(projectDir, ".pi", "settings.json"),
JSON.stringify({ packages: ["npm:fake-package"], npmCommand: [originalExecPath, fakeNpmPath] }),
);
new ProjectTrustStore(agentDir).set(projectDir, true);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
await expect(main(["update", "--extensions"])).resolves.toBeUndefined();
expect(existsSync(recordPath)).toBe(true);
expect(process.exitCode).toBeUndefined();
} finally {
logSpy.mockRestore();
}
});
it("lets trust.json override default project trust", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" }));
@@ -223,6 +300,7 @@ describe("package commands", () => {
it("blocks local package changes when project is untrusted", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(join(projectDir, ".pi", "settings.json"), "{}");
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
@@ -296,7 +374,7 @@ describe("package commands", () => {
}
});
it("uses global npmCommand and current package name for forced self updates without checking the api", async () => {
it("uses the update check version for forced self updates even when current", async () => {
const globalPrefix = join(tempDir, "global-prefix");
const projectPrefix = join(tempDir, "project-prefix");
const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@earendil-works", "pi-coding-agent");
@@ -324,22 +402,25 @@ else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args));
value: join(selfPackageDir, "dist", "cli.js"),
configurable: true,
});
const fetchMock = vi.fn();
const fetchMock = vi.fn(async () => Response.json({ version: VERSION }));
vi.stubGlobal("fetch", fetchMock);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await expect(main(["update", "--self", "--force"])).resolves.toBeUndefined();
await expect(runPackageCommandDirectly(["update", "--self", "--force"])).resolves.toBeUndefined();
expect(process.exitCode).toBeUndefined();
expect(errorSpy).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledOnce();
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
const recordedArgs = JSON.parse(readFileSync(recordPath, "utf-8")) as string[];
expect(recordedArgs).toContain(globalPrefix);
expect(recordedArgs).toContain(PACKAGE_NAME);
expect(recordedArgs).toContain(`${PACKAGE_NAME}@${VERSION}`);
expect(recordedArgs).not.toContain(PACKAGE_NAME);
expect(recordedArgs).not.toContain(projectPrefix);
expect(stdout).toContain(`Updated pi from ${VERSION} to ${VERSION}`);
} finally {
logSpy.mockRestore();
errorSpy.mockRestore();
@@ -368,20 +449,24 @@ else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args));
value: join(selfPackageDir, "dist", "cli.js"),
configurable: true,
});
const fetchMock = vi.fn(async () => Response.json({ version: getNewerPatchVersion() }));
const targetVersion = getNewerPatchVersion();
const fetchMock = vi.fn(async () => Response.json({ version: targetVersion }));
vi.stubGlobal("fetch", fetchMock);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await expect(main(["update", "--self"])).resolves.toBeUndefined();
await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined();
expect(process.exitCode).toBeUndefined();
expect(errorSpy).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledOnce();
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
const recordedArgs = JSON.parse(readFileSync(recordPath, "utf-8")) as string[];
expect(recordedArgs).toContain(PACKAGE_NAME);
expect(recordedArgs).toContain(`${PACKAGE_NAME}@${targetVersion}`);
expect(recordedArgs).not.toContain(PACKAGE_NAME);
expect(stdout).toContain(`Updated pi from ${VERSION} to ${targetVersion}`);
} finally {
logSpy.mockRestore();
errorSpy.mockRestore();
@@ -424,14 +509,14 @@ else {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await expect(main(["update", "--self"])).resolves.toBeUndefined();
await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined();
expect(process.exitCode).toBeUndefined();
expect(errorSpy).not.toHaveBeenCalled();
const recordedCalls = JSON.parse(readFileSync(recordPath, "utf-8")) as string[][];
expect(recordedCalls).toEqual([
expect.arrayContaining(["uninstall", "-g", PACKAGE_NAME]),
expect.arrayContaining(["install", "-g", activePackageName]),
expect.arrayContaining(["install", "-g", `${activePackageName}@0.73.0`]),
]);
} finally {
logSpy.mockRestore();
@@ -477,7 +562,7 @@ if(args.includes("install")) process.exit(23);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await expect(main(["update", "--self"])).resolves.toBeUndefined();
await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
@@ -487,7 +572,7 @@ if(args.includes("install")) process.exit(23);
const recordedCalls = JSON.parse(readFileSync(recordPath, "utf-8")) as string[][];
expect(recordedCalls).toEqual([
expect.arrayContaining(["uninstall", "-g", PACKAGE_NAME]),
expect.arrayContaining(["install", "-g", activePackageName]),
expect.arrayContaining(["install", "-g", `${activePackageName}@0.73.0`]),
]);
} finally {
logSpy.mockRestore();
@@ -1128,8 +1128,17 @@ Content`,
});
it("should parse package source types from docs examples", () => {
expect((packageManager as any).parseSource("npm:@scope/pkg@1.2.3").type).toBe("npm");
expect((packageManager as any).parseSource("npm:pkg").type).toBe("npm");
const parseNpm = (source: string) => {
const parsed = (packageManager as any).parseSource(source);
if (parsed.type !== "npm") {
throw new Error(`Expected npm source: ${source}`);
}
return parsed;
};
expect(parseNpm("npm:@scope/pkg@1.2.3").pinned).toBe(true);
expect(parseNpm("npm:@scope/pkg@^1.2.3").pinned).toBe(false);
expect(parseNpm("npm:pkg").pinned).toBe(false);
expect((packageManager as any).parseSource("git:github.com/user/repo@v1").type).toBe("git");
expect((packageManager as any).parseSource("https://github.com/user/repo@v1").type).toBe("git");
@@ -2052,25 +2061,27 @@ export default function(api) { api.registerTool({ name: "test", description: "te
});
describe("offline mode and network timeouts", () => {
it("should update project npm packages using @latest when newer version is available", async () => {
it("should update npm range packages using the configured spec", async () => {
const installedPath = join(tempDir, ".pi", "npm", "node_modules", "example");
mkdirSync(installedPath, { recursive: true });
writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "1.0.0" }));
settingsManager.setProjectPackages(["npm:example"]);
settingsManager.setProjectPackages(["npm:example@^1.0.0"]);
const runCommandCaptureSpy = vi.spyOn(packageManager as any, "runCommandCapture").mockResolvedValue('"1.2.3"');
const runCommandCaptureSpy = vi
.spyOn(packageManager as any, "runCommandCapture")
.mockResolvedValue('["1.0.0","1.2.0"]');
const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockResolvedValue(undefined);
await packageManager.update("npm:example");
expect(runCommandCaptureSpy).toHaveBeenCalledWith(
"npm",
["view", "example", "version", "--json"],
["view", "example@^1.0.0", "version", "--json"],
expect.objectContaining({ cwd: tempDir, timeoutMs: expect.any(Number) }),
);
expect(runCommandSpy).toHaveBeenCalledWith(
"npm",
["install", "example@latest", "--prefix", join(tempDir, ".pi", "npm"), "--legacy-peer-deps"],
["install", "example@^1.0.0", "--prefix", join(tempDir, ".pi", "npm"), "--legacy-peer-deps"],
undefined,
);
});
@@ -2078,17 +2089,19 @@ export default function(api) { api.registerTool({ name: "test", description: "te
it("should skip project npm update when installed version matches latest", async () => {
const installedPath = join(tempDir, ".pi", "npm", "node_modules", "example");
mkdirSync(installedPath, { recursive: true });
writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "1.2.3" }));
settingsManager.setProjectPackages(["npm:example"]);
writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "1.3.1" }));
settingsManager.setProjectPackages(["npm:example@^1.0.0"]);
const runCommandCaptureSpy = vi.spyOn(packageManager as any, "runCommandCapture").mockResolvedValue('"1.2.3"');
const runCommandCaptureSpy = vi
.spyOn(packageManager as any, "runCommandCapture")
.mockResolvedValue('["1.0.0","1.3.1","1.0.2"]');
const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockResolvedValue(undefined);
await packageManager.update("npm:example");
expect(runCommandCaptureSpy).toHaveBeenCalledWith(
"npm",
["view", "example", "version", "--json"],
["view", "example@^1.0.0", "version", "--json"],
expect.objectContaining({ cwd: tempDir, timeoutMs: expect.any(Number) }),
);
expect(runCommandSpy).not.toHaveBeenCalled();
@@ -2298,11 +2311,12 @@ export default function(api) { api.registerTool({ name: "test", description: "te
});
it("should not run npm view during resolve for installed unpinned packages", async () => {
process.env.PI_OFFLINE = "1";
const installedPath = join(tempDir, ".pi", "npm", "node_modules", "example");
mkdirSync(join(installedPath, "extensions"), { recursive: true });
writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "1.0.0" }));
writeFileSync(join(installedPath, "extensions", "index.ts"), "export default function() {};");
settingsManager.setProjectPackages(["npm:example"]);
settingsManager.setProjectPackages(["npm:example@^1.0.0"]);
const runCommandCaptureSpy = vi.spyOn(packageManager as any, "runCommandCapture");
@@ -0,0 +1,167 @@
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { AssistantMessage } from "@earendil-works/pi-ai";
import { describe, expect, it, vi } from "vitest";
import planModeExtension from "../examples/extensions/plan-mode/index.ts";
import type { ExtensionAPI, ExtensionContext } from "../src/core/extensions/index.ts";
type CommandHandler = (args: string, ctx: ExtensionContext) => Promise<void> | void;
type AgentEndHandler = (
event: { type: "agent_end"; messages: AgentMessage[] },
ctx: ExtensionContext,
) => Promise<void> | void;
function createAssistantMessage(text: string): AssistantMessage {
return {
role: "assistant",
content: [{ type: "text", text }],
api: "anthropic-messages",
provider: "anthropic",
model: "mock",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
}
function setup(options: { activeTools?: string[]; selectChoice?: string; editorText?: string } = {}) {
let activeTools = options.activeTools ?? ["read", "bash", "edit", "write"];
const commands = new Map<string, CommandHandler>();
let agentEndHandler: AgentEndHandler | undefined;
const sendMessage = vi.fn<ExtensionAPI["sendMessage"]>();
const sendUserMessage = vi.fn<ExtensionAPI["sendUserMessage"]>();
const setActiveTools = vi.fn<ExtensionAPI["setActiveTools"]>((toolNames) => {
activeTools = [...toolNames];
});
const appendEntry = vi.fn<ExtensionAPI["appendEntry"]>();
const api = {
registerFlag: vi.fn(),
registerCommand(name: string, command: { handler: CommandHandler }) {
commands.set(name, command.handler);
},
registerShortcut: vi.fn(),
on(event: string, handler: unknown) {
if (event === "agent_end") agentEndHandler = handler as AgentEndHandler;
},
getFlag: vi.fn(() => false),
getActiveTools: vi.fn(() => [...activeTools]),
setActiveTools,
sendMessage,
sendUserMessage,
appendEntry,
} as unknown as ExtensionAPI;
planModeExtension(api);
const ctx = {
hasUI: true,
ui: {
notify: vi.fn(),
select: vi.fn(async () => options.selectChoice),
editor: vi.fn(async () => options.editorText),
setStatus: vi.fn(),
setWidget: vi.fn(),
theme: {
fg: (_name: string, text: string) => text,
strikethrough: (text: string) => text,
},
},
sessionManager: { getEntries: () => [] },
isIdle: () => false,
hasPendingMessages: () => false,
} as unknown as ExtensionContext;
async function runCommand(name: string): Promise<void> {
const command = commands.get(name);
if (!command) throw new Error(`Missing command: ${name}`);
await command("", ctx);
}
async function triggerAgentEnd(text: string): Promise<void> {
if (!agentEndHandler) throw new Error("Missing agent_end handler");
await agentEndHandler({ type: "agent_end", messages: [createAssistantMessage(text)] }, ctx);
}
return {
activeTools: () => activeTools,
appendEntry,
ctx,
runCommand,
sendMessage,
sendUserMessage,
setActiveTools,
triggerAgentEnd,
};
}
describe("plan-mode example extension", () => {
it("preserves custom active tools while toggling plan mode", async () => {
const { activeTools, runCommand, setActiveTools } = setup({
activeTools: ["read", "bash", "edit", "write", "echo_tool"],
});
await runCommand("plan");
expect(activeTools()).toEqual(["read", "bash", "echo_tool", "grep", "find", "ls", "questionnaire"]);
expect(setActiveTools).toHaveBeenLastCalledWith([
"read",
"bash",
"echo_tool",
"grep",
"find",
"ls",
"questionnaire",
]);
await runCommand("plan");
expect(activeTools()).toEqual(["read", "bash", "edit", "write", "echo_tool"]);
expect(setActiveTools).toHaveBeenLastCalledWith(["read", "bash", "edit", "write", "echo_tool"]);
});
it("does not prompt when the assistant response contains no plan", async () => {
const { ctx, runCommand, sendMessage, triggerAgentEnd } = setup();
await runCommand("plan");
await triggerAgentEnd("This file defines the command-line argument parser.");
expect(ctx.ui.select).not.toHaveBeenCalled();
expect(sendMessage).not.toHaveBeenCalled();
});
it("queues plan refinement as a follow-up user message", async () => {
const { runCommand, sendUserMessage, triggerAgentEnd } = setup({
selectChoice: "Refine the plan",
editorText: "Add a regression test.",
});
await runCommand("plan");
await triggerAgentEnd("Plan:\n1. Inspect the current implementation\n2. Add a regression test");
expect(sendUserMessage).toHaveBeenCalledWith("Add a regression test.", { deliverAs: "followUp" });
});
it("queues plan execution as a follow-up custom message", async () => {
const { activeTools, runCommand, sendMessage, triggerAgentEnd } = setup({
activeTools: ["read", "bash", "edit", "write", "echo_tool"],
selectChoice: "Execute the plan (track progress)",
});
await runCommand("plan");
await triggerAgentEnd("Plan:\n1. Inspect the current implementation\n2. Add a regression test");
expect(activeTools()).toEqual(["read", "bash", "edit", "write", "echo_tool"]);
expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ customType: "plan-mode-execute" }), {
triggerTurn: true,
deliverAs: "followUp",
});
});
});

Some files were not shown because too many files have changed in this diff Show More