Merge main into model-registry
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1110,7 +1110,8 @@ DefaultResourceLoader
|
||||
type ResourceLoader
|
||||
createEventBus
|
||||
|
||||
// Helpers
|
||||
// Constants and helpers
|
||||
CONFIG_DIR_NAME
|
||||
defineTool
|
||||
getAgentDir
|
||||
getPackageDir
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user