feat: sync upstream pi-mono (386 commits)
从上游 badlogic/pi-mono 同步 386 个提交,手动解决所有冲突: 保留 SproutClaw 独有功能: - RPC: reload 命令、bash 流式输出、get_extensions 命令 - settings-manager: showChangelogOnStartup 设置 - agent-session: turnIndex getter - 移除 pi 版本检查通知(interactive-mode) - skills 系统、启动脚本、自定义工具脚本 直接采用上游版本: - packages/ai 模型列表及测试文件 - packages/coding-agent 文档 - 其余未冲突的全部上游代码 升级 @mistralai/mistralai 至 2.2.6(修复 promptCacheKey 类型错误) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,20 +2,427 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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 a standard unified patch to edit tool result details for SDK consumers ([#4821](https://github.com/earendil-works/pi/issues/4821)).
|
||||
- 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
|
||||
|
||||
- **Claude Fable 5** - Claude Fable 5 is now available on the Anthropic and Amazon Bedrock providers, with adaptive thinking and `xhigh` effort support.
|
||||
- **Prompt template defaults** - Prompt templates can use default positional arguments such as `${1:-7}` for optional values. See [Prompt Template Arguments](docs/prompt-templates.md#arguments).
|
||||
- **Configurable project trust defaults** - `defaultProjectTrust` lets users choose whether unresolved project trust asks, always trusts, or never trusts by default, and extensions can inspect effective trust decisions. See [Project Trust](docs/security.md#project-trust) and [`ctx.isProjectTrusted()`](docs/extensions.md#ctxisprojecttrusted).
|
||||
- **Natural extension autocomplete triggers** - Extension autocomplete providers can declare trigger characters such as `#` or `$` so suggestions open without slash-command prefixes. See [Autocomplete Providers](docs/extensions.md#autocomplete-providers).
|
||||
|
||||
### Added
|
||||
|
||||
- Added default-value expansion for prompt template positional arguments, e.g. `${1:-7}` ([#5553](https://github.com/earendil-works/pi/pull/5553) by [@dannote](https://github.com/dannote)).
|
||||
- Added `areExperimentalFeaturesEnabled` feature guard to allow users to opt in to early features ([#5547](https://github.com/earendil-works/pi/pull/5547) by [@vegarsti](https://github.com/vegarsti)).
|
||||
- Added `ctx.isProjectTrusted()` for extensions to observe the effective project trust decision, including temporary trust decisions ([#5523](https://github.com/earendil-works/pi/issues/5523)).
|
||||
- Added a global `defaultProjectTrust` setting to choose whether unresolved project trust asks, always trusts, or never trusts by default.
|
||||
- Added extension autocomplete trigger character support for `ctx.ui.addAutocompleteProvider()` wrappers ([#4703](https://github.com/earendil-works/pi/issues/4703)).
|
||||
- Added Claude Fable 5 model support inherited from `@earendil-works/pi-ai` for the Anthropic and Amazon Bedrock providers, with adaptive thinking and `xhigh` effort support.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed inherited Amazon Bedrock inference profile ARN region resolution to prefer the ARN's embedded region over `AWS_REGION` ([#5527](https://github.com/earendil-works/pi/pull/5527) by [@AJM10565](https://github.com/AJM10565)).
|
||||
- Fixed inherited IME hardware cursor positioning while slash-command autocomplete is visible ([#5283](https://github.com/earendil-works/pi/pull/5283) by [@smoosex](https://github.com/smoosex)).
|
||||
- Fixed inherited z.ai thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5330](https://github.com/earendil-works/pi/issues/5330)).
|
||||
- Fixed inherited OpenCode completions model metadata to send explicit `maxTokens` as `max_tokens` ([#5331](https://github.com/earendil-works/pi/issues/5331)).
|
||||
- Fixed inherited Moonshot Kimi thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5531](https://github.com/earendil-works/pi/issues/5531)).
|
||||
- Fixed inherited Azure OpenAI Responses requests to disable server-side response storage ([#5530](https://github.com/earendil-works/pi/issues/5530)).
|
||||
- Fixed inherited Azure GPT-5.4 and GPT-5.5 context window metadata to 1,050,000 tokens, matching Azure Foundry deployments instead of OpenAI's 272k limit ([#5559](https://github.com/earendil-works/pi/issues/5559)).
|
||||
- Fixed inherited OpenAI and Azure GPT-5 Pro `maxTokens` metadata to 128,000, correcting an upstream value that duplicated the input sub-limit as the output limit ([#5559](https://github.com/earendil-works/pi/issues/5559)).
|
||||
- Fixed inherited prompt history navigation to restore the current draft when returning from history browsing ([#5494](https://github.com/earendil-works/pi/issues/5494)).
|
||||
- Fixed inherited wrapping for mixed Latin and CJK text so unspaced CJK runs can break at grapheme boundaries without leaving large trailing gaps ([#5495](https://github.com/earendil-works/pi/issues/5495)).
|
||||
- Fixed extension OAuth login prompts to keep previous submitted prompt rows stable instead of mirroring the active input value ([#5433](https://github.com/earendil-works/pi/issues/5433)).
|
||||
- Fixed `/reload` to apply updated `steeringMode` and `followUpMode` settings to the current session ([#5377](https://github.com/earendil-works/pi/issues/5377)).
|
||||
- Fixed invalid `models.json` syntax to skip startup config migrations and report the normal file-path-aware models error instead of a raw JSON parse stack trace ([#5418](https://github.com/earendil-works/pi/issues/5418)).
|
||||
- Fixed GitHub release notes and interactive changelog links to resolve package-relative documentation URLs correctly ([#5516](https://github.com/earendil-works/pi/issues/5516)).
|
||||
- Fixed CLI help and version output, including plain redirected `--help`/`--version` output and simplified `list`/`config` help text.
|
||||
- Fixed `/new` from ephemeral sessions to keep the new session ephemeral instead of persisting it by default ([#5045](https://github.com/earendil-works/pi/issues/5045)).
|
||||
- Clarified custom model docs that `name` and `modelOverrides.name` do not replace model IDs in the footer or primary model lists ([#4841](https://github.com/earendil-works/pi/issues/4841)).
|
||||
|
||||
## [0.79.0] - 2026-06-08
|
||||
|
||||
### New Features
|
||||
|
||||
- **Project trust for local inputs** - Pi now asks before loading project-local settings, resources, instructions, and packages, with saved decisions and `--approve` / `--no-approve` controls for non-interactive modes. See [Project Trust](README.md#project-trust).
|
||||
- **Extension-controlled trust decisions** - Global and CLI extensions can handle `project_trust`, decide, remember, or defer project trust before project-local resources load. See [`project_trust`](docs/extensions.md#project_trust).
|
||||
- **Cache-hit visibility in the footer** - The interactive footer now shows the latest prompt cache hit rate (`CH`). See [Interactive Mode](README.md#interactive-mode).
|
||||
- **Richer SDK and RPC extension surfaces** - Public exports now include RPC extension UI request/response types and package asset path helpers. See [Extension UI Protocol](docs/rpc.md#extension-ui-protocol) and [SDK Exports](docs/sdk.md#exports).
|
||||
|
||||
### Added
|
||||
|
||||
- Added a `project_trust` extension event so global and CLI extensions can decide or defer project trust during startup and runtime cwd switches.
|
||||
- Added project trust gating for project-local settings, resources, instructions, and packages ([#5332](https://github.com/earendil-works/pi/pull/5332)).
|
||||
- Added the latest prompt cache hit rate to the interactive footer.
|
||||
- Exported RPC extension UI request and response types from the public API ([#5455](https://github.com/earendil-works/pi/issues/5455)).
|
||||
- Exported coding-agent package asset path helpers from the public API ([#5415](https://github.com/earendil-works/pi/issues/5415)).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed package exports by removing the stale `./hooks` subpath that pointed at non-existent build output.
|
||||
- Fixed inherited TUI rendering to clear stale lines when content shrinks to zero.
|
||||
- Fixed inherited autocomplete suggestions to refresh after editor cursor movement ([#5499](https://github.com/earendil-works/pi/pull/5499) by [@Roman-Galeev](https://github.com/Roman-Galeev)).
|
||||
- Fixed `/reload` to persist project trust when an implicitly trusted session creates a project `.pi` directory.
|
||||
- Fixed project trust input discovery to traverse parent directories portably.
|
||||
- Fixed inherited intermittent Shift+Enter handling by making Kitty keyboard protocol fallback response-driven instead of timeout-driven ([#5188](https://github.com/earendil-works/pi/issues/5188)).
|
||||
- Fixed the compaction summarization system prompt to use neutral AI assistant wording for non-coding agents ([#5401](https://github.com/earendil-works/pi/issues/5401)).
|
||||
- Fixed `models.json` schema support and inherited OpenAI Responses custom-provider handling for `compat.supportsDeveloperRole: false` ([#5456](https://github.com/earendil-works/pi/issues/5456)).
|
||||
- Fixed inherited prompt history navigation to place the cursor at the start when browsing upward and at the end when browsing downward ([#5454](https://github.com/earendil-works/pi/issues/5454)).
|
||||
- Fixed tmux setup documentation to require tmux 3.5 for `extended-keys-format csi-u` and document the tmux 3.2-3.4 fallback ([#5432](https://github.com/earendil-works/pi/issues/5432)).
|
||||
- Fixed inherited OpenRouter routing preferences on OpenAI-compatible custom providers to work when the custom provider base URL does not point directly at OpenRouter ([#5347](https://github.com/earendil-works/pi/issues/5347)).
|
||||
- Fixed built-in tool expand hints to style closing parentheses consistently ([#5359](https://github.com/earendil-works/pi/issues/5359)).
|
||||
- Fixed skill-wrapped prompts to insert spacing between skill instructions and the user message ([#5371](https://github.com/earendil-works/pi/pull/5371) by [@Perlence](https://github.com/Perlence)).
|
||||
|
||||
## [0.78.1] - 2026-06-04
|
||||
|
||||
### New Features
|
||||
|
||||
- **More built-in provider coverage** - Added Ant Ling and NVIDIA NIM provider setup, plus MiniMax-M3 support for the direct MiniMax providers. See [Providers](docs/providers.md).
|
||||
- **Richer extension context** - Extensions can use `ctx.mode` and `ctx.getSystemPromptOptions()` to adapt behavior across TUI, RPC, JSON, and print modes and inspect base system prompt inputs. See [Extensions](docs/extensions.md).
|
||||
|
||||
### Added
|
||||
|
||||
- Added containerization documentation and a Gondolin extension example for routing built-in tools into a local micro-VM.
|
||||
- Added Ant Ling provider selection and setup documentation.
|
||||
- Added MiniMax-M3 model support inherited from `@earendil-works/pi-ai` for the `minimax` and `minimax-cn` direct providers ([#5313](https://github.com/earendil-works/pi/issues/5313)).
|
||||
- Added NVIDIA NIM provider selection, setup documentation, and direct NIM request attribution headers.
|
||||
- Added `ctx.mode` to extension contexts so extensions can distinguish TUI, RPC, JSON, and print mode.
|
||||
- Added `ctx.getSystemPromptOptions()` for extension commands to inspect the current base system prompt inputs ([#5306](https://github.com/earendil-works/pi/pull/5306) by [@xl0](https://github.com/xl0)).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed temporary extension package installs to use a private `~/.pi/agent/tmp/extensions` directory with `0700` permissions instead of `os.tmpdir()/pi-extensions`.
|
||||
- Fixed git package source handling to reject unsafe host/path components and keep managed clone paths inside install roots.
|
||||
- Fixed stored XSS in HTML session exports by sanitizing Markdown link and image URLs with a scheme allow-list after stripping control characters.
|
||||
- Fixed SDK embedding in bundled Node apps failing with `ENOENT` when `package.json` is not present next to the bundle entrypoint. The package metadata reader now gracefully handles missing `package.json` by using defaults, enabling `createAgentSession()` without requiring package-adjacent files at runtime ([#5226](https://github.com/earendil-works/pi/issues/5226)).
|
||||
- Fixed HTTP timeout setting not being respected for non-Codex providers (e.g., llama.cpp via OpenAI-compatible API). The `httpIdleTimeoutMs` setting (set via `/settings` HTTP timeout) now applies as the default SDK request timeout for all providers that support it, not just OpenAI Codex Responses. Disabling the timeout (HTTP timeout = false) now correctly disables SDK timeouts for all supported providers by sending a maximum int32 value (effectively infinite) instead of 0, since SDKs treat timeout=0 as an immediate timeout ([#5294](https://github.com/earendil-works/pi/issues/5294)).
|
||||
- Fixed inherited Amazon Bedrock requests to replace blank required user/tool-result text with a placeholder and skip blank replay text blocks ([#4975](https://github.com/earendil-works/pi/issues/4975)).
|
||||
- Fixed inherited Anthropic Claude Opus 4.7+ requests to suppress deprecated temperature parameters ([#5251](https://github.com/earendil-works/pi/pull/5251) by [@yzhg1983](https://github.com/yzhg1983)).
|
||||
- Fixed inherited OpenAI GPT-5.5 generated metadata to omit unsupported minimal thinking ([#5243](https://github.com/earendil-works/pi/issues/5243)).
|
||||
- Fixed inherited OpenRouter Kimi K2.6 thinking replay and developer-role instruction handling ([#5309](https://github.com/earendil-works/pi/issues/5309)).
|
||||
- Fixed inherited OpenRouter reasoning instruction requests to preserve the system role when required ([#5221](https://github.com/earendil-works/pi/pull/5221) by [@PriNova](https://github.com/PriNova)).
|
||||
- Fixed inherited overlay focus restoration so non-capturing overlays remain interactive after UI rerenders and explicit focus release ([#5235](https://github.com/earendil-works/pi/pull/5235) by [@nicobailon](https://github.com/nicobailon)).
|
||||
- Fixed inherited tab width accounting in column slicing and overlay compositing so tab-containing output cannot exceed the terminal width ([#5218](https://github.com/earendil-works/pi/issues/5218)).
|
||||
- Fixed opening and listing very large JSONL session files by reading session entries line-by-line instead of materializing the full file as one string ([#5231](https://github.com/earendil-works/pi/issues/5231)).
|
||||
- Fixed the footer branch display in WSL `/mnt/...` repositories to refresh after branch changes ([#5264](https://github.com/earendil-works/pi/pull/5264) by [@psoukie](https://github.com/psoukie)).
|
||||
- Fixed `renderShell: "self"` tool renderers that emit no component lines leaving a blank chat row ([#5299](https://github.com/earendil-works/pi/issues/5299)).
|
||||
- Restored inherited NVIDIA Qwen 3.5 122B NIM model support.
|
||||
|
||||
## [0.78.0] - 2026-05-29
|
||||
|
||||
### New Features
|
||||
|
||||
- **Named startup sessions** - `--name` / `-n` sets the session display name before startup across interactive, print, JSON, and RPC modes. See [Naming Sessions](docs/sessions.md#naming-sessions) and [Session Options](docs/usage.md#session-options).
|
||||
- **Clickable file tool paths** - built-in file tool titles render OSC 8 `file://` hyperlinks when the terminal supports them, including supported tmux clients.
|
||||
|
||||
### Added
|
||||
|
||||
- Exported `convertToPng` for extension authors ([#5167](https://github.com/earendil-works/pi-mono/pull/5167) by [@xl0](https://github.com/xl0)).
|
||||
- Exported `parseArgs` and type `Args` for extension authors ([#5202](https://github.com/earendil-works/pi-mono/pull/5202) by [@xl0](https://github.com/xl0)).
|
||||
- Added `--name` / `-n` to set the session display name at startup ([#5153](https://github.com/earendil-works/pi-mono/issues/5153)).
|
||||
- Added a resume command hint when exiting interactive sessions ([#5176](https://github.com/earendil-works/pi-mono/pull/5176) by [@yzhg1983](https://github.com/yzhg1983)).
|
||||
- Added OSC 8 `file://` hyperlinks to file paths shown in built-in file tool titles ([#5189](https://github.com/earendil-works/pi-mono/pull/5189) by [@mpazik](https://github.com/mpazik)).
|
||||
- Added custom Amazon Bedrock request header support inherited from `@earendil-works/pi-ai` ([#5178](https://github.com/earendil-works/pi-mono/pull/5178) by [@stephanmck](https://github.com/stephanmck)).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Clarified the WezTerm/WSL IME hardware cursor docs to state that cursor visibility remains opt-in ([#5200](https://github.com/earendil-works/pi-mono/issues/5200)).
|
||||
- Fixed the GitLab Duo custom provider example to use adaptive thinking for Claude models, expose xhigh thinking, and include newer verified model IDs ([#5201](https://github.com/earendil-works/pi-mono/issues/5201)).
|
||||
- Fixed Bun release archive creation to install and copy the matching `@mariozechner/clipboard` base package and native sidecars ([#5184](https://github.com/earendil-works/pi-mono/issues/5184)).
|
||||
- Fixed early interactive input typed before the prompt loop starts so it is buffered instead of dropped ([#5195](https://github.com/earendil-works/pi-mono/pull/5195) by [@yzhg1983](https://github.com/yzhg1983)).
|
||||
- Fixed OpenRouter Moonshot Kimi K2.6 requests to use `system` instead of unsupported `developer` messages ([#5159](https://github.com/earendil-works/pi-mono/issues/5159)).
|
||||
- Fixed OpenCode Go Kimi K2.6 thinking requests to send `thinking` objects instead of invalid string values, and fixed OpenCode Zen Grok Build thinking requests to omit unsupported `reasoning_effort` ([#5169](https://github.com/earendil-works/pi-mono/issues/5169)).
|
||||
- Fixed OpenAI Codex Responses SSE streams to abort response body reads after terminal events.
|
||||
- Fixed OpenCode Kimi K2.6 generated metadata to use Anthropic-style thinking metadata instead of invalid reasoning-effort parameters.
|
||||
- Fixed OSC 8 hyperlinks to pass through tmux when the client supports them ([#5189](https://github.com/earendil-works/pi-mono/pull/5189) by [@mpazik](https://github.com/mpazik)).
|
||||
- Fixed ANSI text wrapping to avoid stack overflows on very long wrapped lines ([#5185](https://github.com/earendil-works/pi-mono/issues/5185)).
|
||||
|
||||
## [0.77.0] - 2026-05-28
|
||||
|
||||
### New Features
|
||||
|
||||
- **Claude Opus 4.8 support** - Adds Anthropic Claude Opus 4.8 metadata and updates Opus adaptive-thinking coverage.
|
||||
- **Selective tool disablement** - `--exclude-tools` / `-xt` disables specific built-in, extension, or custom tools while leaving the rest available. See [Tool Options](docs/usage.md#tool-options).
|
||||
- **Headless Codex subscription login** - `/login` can use device-code auth for ChatGPT Plus/Pro Codex subscriptions. See [Subscriptions](docs/providers.md#subscriptions) and [OpenAI Codex](docs/providers.md#openai-codex).
|
||||
- **Streaming-aware extension input** - extensions can distinguish idle prompts, mid-stream steers, and queued follow-ups with `InputEvent.streamingBehavior`. See [Input Events](docs/extensions.md#input-events).
|
||||
|
||||
### Added
|
||||
|
||||
- Added `--exclude-tools` / `-xt` to disable specific built-in, extension, or custom tools while leaving the rest available ([#5109](https://github.com/earendil-works/pi/issues/5109)).
|
||||
- Added OpenAI Codex subscription device-code login as a selectable headless alternative while keeping browser login as the default ([#4911](https://github.com/earendil-works/pi/pull/4911) by [@vegarsti](https://github.com/vegarsti)).
|
||||
- Added `streamingBehavior` to extension input events so extensions can distinguish idle prompts from mid-stream steers and queued follow-ups ([#5107](https://github.com/earendil-works/pi/pull/5107) by [@DanielThomas](https://github.com/DanielThomas)).
|
||||
- Added Claude Opus 4.8 model metadata for Anthropic and updated Opus adaptive-thinking coverage to use it.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed startup timing output so `readPipedStdin` no longer includes `createAgentSessionRuntime` work ([#4829](https://github.com/earendil-works/pi/issues/4829)).
|
||||
- Fixed OpenRouter DeepSeek V4 `xhigh` reasoning metadata to preserve OpenRouter's native effort instead of sending DeepSeek's `max` effort ([#4801](https://github.com/earendil-works/pi/issues/4801)).
|
||||
- Fixed custom session directories so current-folder resume/continue lookups stay scoped to the active cwd while all-session listings cover the custom directory.
|
||||
- Fixed SIGTERM/SIGHUP exits to run extension `session_shutdown` cleanup and restore the terminal: signal-triggered shutdown now emits `session_shutdown` before any terminal writes, and SIGHUP no longer hard-exits, so extension resources (e.g. sockets) are released even when the terminal is gone ([#5080](https://github.com/earendil-works/pi/issues/5080)).
|
||||
- Fixed keyboard protocol negotiation to ignore mismatched or delayed terminal responses, avoiding false Kitty keyboard protocol detection ([#5091](https://github.com/earendil-works/pi/pull/5091) by [@mitsuhiko](https://github.com/mitsuhiko)).
|
||||
- Fixed Windows startup crashes under MSYS2 ucrt64 Node.js by updating the native clipboard addon to napi-rs 3.x ([#5028](https://github.com/earendil-works/pi/issues/5028)).
|
||||
- Fixed API key and header config resolution to treat plain strings as literals, support `$ENV_VAR` / `${ENV_VAR}` interpolation and `$!` bang escaping, and require explicit env syntax for config files, avoiding Windows case-insensitive env matches corrupting literal keys ([#5095](https://github.com/earendil-works/pi/issues/5095)).
|
||||
- Fixed session disposal to abort in-flight agent, compaction, branch summary, retry, and bash work ([#5029](https://github.com/earendil-works/pi/pull/5029) by [@TerminallyChilI](https://github.com/TerminallyChilI)).
|
||||
- Fixed `pi.getAllTools()` to expose each tool's `promptGuidelines` for extensions that need per-tool guideline attribution ([#4879](https://github.com/earendil-works/pi/issues/4879)).
|
||||
- Fixed OpenAI Codex Responses replay after switching from Anthropic extended-thinking sessions by generating unique fallback message item IDs for converted thinking/text blocks ([#5148](https://github.com/earendil-works/pi/issues/5148)).
|
||||
- Fixed Anthropic-compatible replay for providers that return empty thinking signatures by adding an opt-in `allowEmptySignature` compatibility flag ([#4464](https://github.com/earendil-works/pi/issues/4464)).
|
||||
- Fixed OpenAI and OpenRouter GPT-5.5 Pro thinking level metadata to expose only supported medium, high, and xhigh efforts.
|
||||
- Fixed OpenCode Go Kimi K2.6 thinking-off requests to send `thinking: "none"` ([#5078](https://github.com/earendil-works/pi/issues/5078)).
|
||||
- Fixed Xiaomi Token Plan model metadata to omit unsupported `mimo-v2-flash` variants ([#5075](https://github.com/earendil-works/pi/issues/5075)).
|
||||
- Fixed follow-up messages queued by `agent_end` extension handlers to drain before the agent becomes idle ([#5115](https://github.com/earendil-works/pi/pull/5115) by [@DanielThomas](https://github.com/DanielThomas)).
|
||||
- Fixed extension input events to report `streamingBehavior` only for prompts actually queued during streaming ([#5107](https://github.com/earendil-works/pi/pull/5107) by [@DanielThomas](https://github.com/DanielThomas)).
|
||||
- Fixed system prompt tool-selection guidance to avoid preferring unavailable file exploration tools ([#5132](https://github.com/earendil-works/pi/issues/5132)).
|
||||
- Fixed fenced `diff` code blocks and other highlight.js scopes to keep theme-aware syntax colors after the `cli-highlight` replacement ([#5092](https://github.com/earendil-works/pi/issues/5092)).
|
||||
|
||||
## [0.76.0] - 2026-05-27
|
||||
|
||||
### New Features
|
||||
|
||||
- **Explicit session IDs for automation** - `--session-id <id>` lets scripts create or resume an exact project-local session. See [Sessions](docs/usage.md#sessions).
|
||||
- **RPC bash output can stay out of model context** - RPC clients can pass `excludeFromContext` to `bash` for commands whose output should not be sent with the next prompt. See [RPC mode](docs/rpc.md#bash).
|
||||
- **More predictable provider retries and timeouts** - Codex WebSocket/SSE waits are bounded, and `retry.provider.maxRetries` controls provider retries instead of hidden SDK defaults. See [Retry settings](docs/settings.md#retry).
|
||||
- **Better terminal editing across environments** - Apple Terminal Shift+Enter, Windows/JetBrains capability detection, and Unicode-aware word navigation improve interactive editing. See [Terminal setup](docs/terminal-setup.md) and [Keybindings](docs/keybindings.md).
|
||||
|
||||
### Added
|
||||
|
||||
- Added `--session-id` to let CLI callers use an exact project-local session ID, creating it if missing ([#4874](https://github.com/earendil-works/pi/issues/4874)).
|
||||
- Added `excludeFromContext` flag to the `bash` RPC command for parity with the internal `executeBash` API ([#5039](https://github.com/earendil-works/pi/issues/5039)).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed user message transcript rendering to preserve user-authored ordered-list markers ([#5013](https://github.com/earendil-works/pi/issues/5013)).
|
||||
- Fixed self-update commands to bypass npm, pnpm, and Bun minimum release age gates for explicit `pi update` runs ([#4929](https://github.com/earendil-works/pi/issues/4929)).
|
||||
- Fixed context token estimates to count user image attachments consistently with tool result images ([#4983](https://github.com/earendil-works/pi/issues/4983)).
|
||||
- Fixed `httpIdleTimeoutMs` to apply to OpenAI Codex Responses WebSocket idle waits, added `websocketConnectTimeoutMs` for bounded WebSocket connect waits, and added a 10s Codex SSE response-header timeout ([#4945](https://github.com/earendil-works/pi/issues/4945)).
|
||||
- Fixed `RpcClient` to reject pending requests and consume stdin pipe errors when the child process exits unexpectedly ([#4764](https://github.com/earendil-works/pi/issues/4764)).
|
||||
- Fixed managed npm extension updates to avoid package managers installing or resolving pi host packages as peer dependencies ([#4907](https://github.com/earendil-works/pi/issues/4907)).
|
||||
- Fixed RPC mode raw stdout writes to retry transient backpressure errors and flush queued protocol output during shutdown ([#4897](https://github.com/earendil-works/pi/issues/4897)).
|
||||
- Fixed OpenAI Codex Responses cache-affinity headers to send `session-id` instead of proxy-incompatible `session_id` ([#4967](https://github.com/earendil-works/pi/issues/4967)).
|
||||
- Fixed `openai-codex/gpt-5.3-codex-spark` model metadata to use its 128k context window ([#4969](https://github.com/earendil-works/pi/issues/4969)).
|
||||
- Fixed OpenRouter/Poolside context overflow detection for `maximum allowed input length` errors ([#4943](https://github.com/earendil-works/pi/issues/4943)).
|
||||
- Fixed provider retry controls so `retry.provider.maxRetries` is honored, SDK retries default to `0`, and quota/billing 429s are not retried behind Pi's retry handling ([#4991](https://github.com/earendil-works/pi-mono/pull/4991) by [@mitsuhiko](https://github.com/mitsuhiko)).
|
||||
- Fixed Apple Terminal `Shift+Enter` by detecting local macOS modifier state when Terminal.app sends plain Return.
|
||||
- Fixed Windows Terminal capability detection to enable OSC 8 hyperlinks, preserving clickable long URLs across wrapped lines ([#4923](https://github.com/earendil-works/pi/issues/4923)).
|
||||
- Fixed JetBrains terminal capability detection to enable truecolor while disabling unsupported OSC 8 hyperlinks ([#5037](https://github.com/earendil-works/pi-mono/pull/5037) by [@Perlence](https://github.com/Perlence)).
|
||||
- Fixed editor and input word navigation/deletion to use Unicode word boundaries while preserving ASCII punctuation boundaries ([#5022](https://github.com/earendil-works/pi-mono/pull/5022) by [@haoqixu](https://github.com/haoqixu), [#5067](https://github.com/earendil-works/pi-mono/pull/5067) by [@haoqixu](https://github.com/haoqixu), [#5068](https://github.com/earendil-works/pi-mono/pull/5068) by [@haoqixu](https://github.com/haoqixu)).
|
||||
- Fixed the development docs `AGENTS.md` link to point at the pi-mono guidelines ([#5041](https://github.com/earendil-works/pi/issues/5041)).
|
||||
|
||||
## [0.75.5] - 2026-05-23
|
||||
|
||||
### New Features
|
||||
|
||||
- **Cleaner read tool output** - Collapsed `read` tool cards now show only the read line by default, while `Ctrl+O` still expands the full file content.
|
||||
- **Faster file tools on Windows** - Built-in file tools now use async filesystem operations during streaming, and image resizes run off the main TUI thread in a worker.
|
||||
- **More reliable package updates** - `pi update` and git package installs now reconcile pinned git refs and keep package settings intact. See [Packages](docs/packages.md).
|
||||
- **Custom Anthropic-compatible adaptive thinking** - Custom provider model configs can opt into adaptive-thinking Claude behavior with `compat.forceAdaptiveThinking`. See [Custom providers](docs/custom-provider.md) and [Models](docs/models.md).
|
||||
|
||||
### Added
|
||||
|
||||
- Added `compat.forceAdaptiveThinking` support to custom Anthropic-compatible model configuration docs and validation ([#4797](https://github.com/earendil-works/pi-mono/pull/4797) by [@mbazso](https://github.com/mbazso)).
|
||||
- Added a standard unified patch to edit tool result details for SDK consumers ([#4821](https://github.com/earendil-works/pi/issues/4821)).
|
||||
- Added a Codex subscription login method selector with device-code auth for headless environments.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed collapsed read tool cards to show only the read line until expanded ([#4916](https://github.com/earendil-works/pi/issues/4916)).
|
||||
- Replaced the inherited optional `koffi` dependency for Windows VT input with a tiny vendored native helper, reducing install size while preserving Shift+Tab handling ([#4480](https://github.com/earendil-works/pi/issues/4480)).
|
||||
- Changed the root development install documentation to use `npm install --ignore-scripts` ([#4868](https://github.com/earendil-works/pi/issues/4868)).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `pi update` to reconcile git-pinned packages to their configured ref ([#4869](https://github.com/earendil-works/pi/issues/4869)).
|
||||
- Fixed package/resource path handling for Windows and glob/pattern resolution ([#4873](https://github.com/earendil-works/pi-mono/pull/4873) by [@mitsuhiko](https://github.com/mitsuhiko)).
|
||||
- Fixed config pattern matching to resolve patterns from the correct base directory ([#4898](https://github.com/earendil-works/pi-mono/pull/4898) by [@haoqixu](https://github.com/haoqixu)).
|
||||
- Fixed theme pickers to list themes by their content name instead of file stem ([#4830](https://github.com/earendil-works/pi-mono/pull/4830) by [@Perlence](https://github.com/Perlence)).
|
||||
- Fixed OpenCode Zen/Go requests to send per-session OpenCode routing headers ([#4847](https://github.com/earendil-works/pi/issues/4847)).
|
||||
- Fixed Amazon Bedrock provider loading under strict package managers by inheriting the declared `@smithy/node-http-handler` dependency from `@earendil-works/pi-ai` ([#4842](https://github.com/earendil-works/pi/issues/4842)).
|
||||
- Fixed inherited Amazon Bedrock Claude requests to send the model output token cap by default, avoiding Bedrock's 4096-token default truncation ([#4848](https://github.com/earendil-works/pi/issues/4848)).
|
||||
- Fixed exported session HTML to escape quote characters in attribute values ([#4832](https://github.com/earendil-works/pi/issues/4832)).
|
||||
- Fixed GitHub Copilot device-code login to keep opening the verification URL in browser-capable environments while ignoring browser launch failures for headless use ([#4788](https://github.com/earendil-works/pi-mono/pull/4788) by [@vegarsti](https://github.com/vegarsti)).
|
||||
- Fixed git package installs to reconcile existing checkouts to the requested ref and update package settings without losing filters ([#4870](https://github.com/earendil-works/pi/issues/4870)).
|
||||
- Published a 0.74.2 rescue release that tells Node 20 users to upgrade Node before updating to newer Pi versions ([#4876](https://github.com/earendil-works/pi/issues/4876)).
|
||||
- Fixed final bash tool cards to avoid rendering duplicate full-output truncation paths ([#4819](https://github.com/earendil-works/pi/issues/4819)).
|
||||
- Fixed bash tool truncation line counts to ignore the trailing newline as an extra output line ([#4818](https://github.com/earendil-works/pi/issues/4818)).
|
||||
- Fixed footer home-directory abbreviation to avoid shortening sibling paths that only share the same prefix ([#4878](https://github.com/earendil-works/pi/issues/4878)).
|
||||
- Fixed macOS Bun release binaries to resolve the native clipboard sidecar so Ctrl+V image paste can load `@mariozechner/clipboard` ([#4307](https://github.com/earendil-works/pi/issues/4307)).
|
||||
- Fixed coding-agent tools to avoid synchronous filesystem operations during streaming and moved image resizing off the main TUI thread ([#4756](https://github.com/earendil-works/pi-mono/pull/4756) by [@mitsuhiko](https://github.com/mitsuhiko)).
|
||||
|
||||
## [0.75.4] - 2026-05-20
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -110,9 +105,11 @@ For each built-in provider, pi maintains a list of tool-capable models, updated
|
||||
|
||||
**API keys:**
|
||||
- Anthropic
|
||||
- Ant Ling
|
||||
- OpenAI
|
||||
- Azure OpenAI
|
||||
- DeepSeek
|
||||
- NVIDIA NIM
|
||||
- Google Gemini
|
||||
- Google Vertex
|
||||
- Amazon Bedrock
|
||||
@@ -125,6 +122,7 @@ For each built-in provider, pi maintains a list of tool-capable models, updated
|
||||
- OpenRouter
|
||||
- Vercel AI Gateway
|
||||
- ZAI
|
||||
- ZAI Coding Plan (China)
|
||||
- OpenCode Zen
|
||||
- OpenCode Go
|
||||
- Hugging Face
|
||||
@@ -152,7 +150,7 @@ The interface from top to bottom:
|
||||
- **Startup header** - Shows shortcuts (`/hotkeys` for all), loaded AGENTS.md files, prompt templates, skills, and extensions
|
||||
- **Messages** - Your messages, assistant responses, tool calls and results, notifications, errors, and extension UI
|
||||
- **Editor** - Where you type; border color indicates thinking level
|
||||
- **Footer** - Working directory, session name, total token/cache usage, cost, context usage, current model
|
||||
- **Footer** - Working directory, session name, total token/cache usage (`↑` input, `↓` output, `R` cache read, `W` cache write, `CH` latest cache hit rate), cost, context usage, current model
|
||||
|
||||
The editor can be temporarily replaced by other UI, like built-in `/settings` or custom UI from extensions (e.g., a Q&A tool that lets the user answer model questions in a structured format). [Extensions](#extensions) can also replace the editor, add widgets above/below it, a status line, custom footer, or overlays.
|
||||
|
||||
@@ -183,6 +181,7 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist
|
||||
| `/name <name>` | Set session display name |
|
||||
| `/session` | Show session info (file, ID, messages, tokens, cost) |
|
||||
| `/tree` | Jump to any point in the session and continue from there |
|
||||
| `/trust` | Save project trust decision for future sessions (restart required) |
|
||||
| `/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, optional custom instructions |
|
||||
@@ -239,6 +238,7 @@ Sessions auto-save to `~/.pi/agent/sessions/` organized by working directory.
|
||||
pi -c # Continue most recent session
|
||||
pi -r # Browse and select from past sessions
|
||||
pi --no-session # Ephemeral mode (don't save)
|
||||
pi --name "my task" # Set session display name at startup
|
||||
pi --session <path|id> # Use specific session file or ID
|
||||
pi --fork <path|id> # Fork specific session file or ID into a new session
|
||||
```
|
||||
@@ -284,12 +284,26 @@ Use `/settings` to modify common options, or edit JSON files directly:
|
||||
|
||||
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 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 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, 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.
|
||||
|
||||
### Telemetry and update checks
|
||||
|
||||
Pi has two separate startup features:
|
||||
|
||||
- **Update check:** fetches `https://pi.dev/api/latest-version` to check whether a newer Pi version exists. Disable it with `PI_SKIP_VERSION_CHECK=1`. Disabling update checks only turns off this check.
|
||||
- **Install/update telemetry:** after first install or a changelog-detected update, sends an anonymous version ping to `https://pi.dev/api/report-install`. Opt out by setting `enableInstallTelemetry` to `false` in `settings.json`, or by setting `PI_TELEMETRY=0`. This does not disable update checks; Pi may still contact `pi.dev` for the latest version unless update checks are disabled or offline mode is enabled.
|
||||
- **Install/update telemetry:** after first install or a changelog-detected update, sends an anonymous version ping to `https://pi.dev/api/report-install`. This setting also controls optional provider attribution headers for OpenRouter, Cloudflare, and direct NVIDIA NIM requests. Opt out by setting `enableInstallTelemetry` to `false` in `settings.json`, or by setting `PI_TELEMETRY=0`. This does not disable update checks; Pi may still contact `pi.dev` for the latest version unless update checks are disabled or offline mode is enabled.
|
||||
|
||||
Use `--offline` or `PI_OFFLINE=1` to disable all startup network operations described here, including update checks, package update checks, and install/update telemetry.
|
||||
|
||||
@@ -302,7 +316,7 @@ Pi loads `AGENTS.md` (or `CLAUDE.md`) at startup from:
|
||||
- Parent directories (walking up from cwd)
|
||||
- Current directory
|
||||
|
||||
Use for project instructions, conventions, common commands. All matching files are concatenated.
|
||||
Use for project instructions (`AGENTS.md`/`CLAUDE.md`), conventions, common commands. All matching files are concatenated.
|
||||
|
||||
Disable context file loading with `--no-context-files` (or `-nc`).
|
||||
|
||||
@@ -400,7 +414,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
|
||||
@@ -408,7 +423,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`:
|
||||
|
||||
@@ -499,7 +514,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
|
||||
@@ -508,6 +524,8 @@ 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 update` never prompts for project trust.
|
||||
|
||||
### Modes
|
||||
|
||||
| Flag | Description |
|
||||
@@ -545,12 +563,14 @@ cat README.md | pi -p "Summarize this text"
|
||||
| `--fork <path\|id>` | Fork specific session file or partial UUID into a new session |
|
||||
| `--session-dir <dir>` | Custom session storage directory |
|
||||
| `--no-session` | Ephemeral mode (don't save) |
|
||||
| `--name <name>`, `-n <name>` | Set session display name at startup |
|
||||
|
||||
### Tool Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--tools <list>`, `-t <list>` | Allowlist specific tool names across built-in, extension, and custom tools |
|
||||
| `--exclude-tools <list>`, `-xt <list>` | Disable specific tool names across built-in, extension, and custom tools |
|
||||
| `--no-builtin-tools`, `-nbt` | Disable built-in tools by default but keep extension/custom tools enabled |
|
||||
| `--no-tools`, `-nt` | Disable all tools by default |
|
||||
|
||||
@@ -579,6 +599,8 @@ Combine `--no-*` with explicit flags to load exactly what you need, ignoring set
|
||||
| `--system-prompt <text>` | Replace default prompt (context files and skills still appended) |
|
||||
| `--append-system-prompt <text>` | Append to system prompt |
|
||||
| `--verbose` | Force verbose startup |
|
||||
| `-a`, `--approve` | Trust project-local files for this run |
|
||||
| `-na`, `--no-approve` | Ignore project-local files for this run |
|
||||
| `-h`, `--help` | Show help |
|
||||
| `-v`, `--version` | Show version |
|
||||
|
||||
@@ -604,6 +626,9 @@ pi -p "Summarize this codebase"
|
||||
# Non-interactive with piped stdin
|
||||
cat README.md | pi -p "Summarize this text"
|
||||
|
||||
# Named one-shot session
|
||||
pi --name "release audit" -p "Audit this repository"
|
||||
|
||||
# Different model
|
||||
pi --provider openai --model gpt-4o "Help me refactor"
|
||||
|
||||
@@ -619,6 +644,9 @@ pi --models "claude-*,gpt-4o"
|
||||
# Read-only mode
|
||||
pi --tools read,grep,find,ls -p "Review the code"
|
||||
|
||||
# Disable one extension or built-in tool while keeping the rest available
|
||||
pi --exclude-tools ask_question
|
||||
|
||||
# High thinking level
|
||||
pi --thinking high "Solve this complex problem"
|
||||
```
|
||||
@@ -632,7 +660,7 @@ pi --thinking high "Solve this complex problem"
|
||||
| `PI_PACKAGE_DIR` | Override package directory (useful for Nix/Guix where store paths tokenize poorly) |
|
||||
| `PI_OFFLINE` | Disable startup network operations, including update checks, package update checks, and install/update telemetry |
|
||||
| `PI_SKIP_VERSION_CHECK` | Skip the Pi version update check at startup. This prevents the `pi.dev` latest-version request |
|
||||
| `PI_TELEMETRY` | Override install/update telemetry. Use `1`/`true`/`yes` to enable or `0`/`false`/`no` to disable. This does not disable update checks |
|
||||
| `PI_TELEMETRY` | Override install/update telemetry and provider attribution headers. Use `1`/`true`/`yes` to enable or `0`/`false`/`no` to disable. This does not disable update checks |
|
||||
| `PI_CACHE_RETENTION` | Set to `long` for extended prompt cache (Anthropic: 1h, OpenAI: 24h) |
|
||||
| `VISUAL`, `EDITOR` | External editor for Ctrl+G |
|
||||
|
||||
@@ -642,8 +670,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
|
||||
@@ -653,3 +679,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>
|
||||
|
||||
111
packages/coding-agent/docs/containerization.md
Normal file
111
packages/coding-agent/docs/containerization.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Containerization
|
||||
|
||||
Pi runs with all permissions by default, but in some cases, you will want to have more control over what directories Pi can write to and which accesses it has.
|
||||
|
||||
There are two general options. You can either
|
||||
1. run the whole `pi` process inside an isolated environment, or
|
||||
2. run `pi` on the host and route tool execution into an isolated environment.
|
||||
|
||||
## Choose a pattern
|
||||
|
||||
| Pattern | What is isolated | Best for | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 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.
|
||||
|
||||
## Gondolin
|
||||
|
||||
[Gondolin](https://github.com/earendil-works/gondolin) is a local Linux micro-VM.
|
||||
Use the [example extension](../examples/extensions/gondolin) when you want `pi` on the host but all built-in tools routed into the VM.
|
||||
|
||||
Setup:
|
||||
|
||||
```bash
|
||||
cp -R packages/coding-agent/examples/extensions/gondolin ~/.pi/agent/extensions/gondolin
|
||||
cd ~/.pi/agent/extensions/gondolin
|
||||
npm install --ignore-scripts
|
||||
```
|
||||
|
||||
Run from the project you want mounted:
|
||||
|
||||
```bash
|
||||
cd /path/to/project
|
||||
pi -e ~/.pi/agent/extensions/gondolin
|
||||
```
|
||||
|
||||
The extension mounts the host cwd at `/workspace` in the VM and overrides `read`, `write`, `edit`, `bash`, `grep`, `find`, and `ls`.
|
||||
User `!` commands are routed into the VM, as well.
|
||||
File changes under `/workspace` write through to the host.
|
||||
|
||||
Requirements: Node.js >= 23.6.0 for `@earendil-works/gondolin`, plus QEMU (requires installation through your package manager).
|
||||
|
||||
## Plain Docker
|
||||
|
||||
Run the whole `pi` process in Docker when you want the simplest local container boundary.
|
||||
|
||||
`Dockerfile.pi`:
|
||||
|
||||
```dockerfile
|
||||
FROM node:24-bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends bash ca-certificates git ripgrep \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
||||
|
||||
WORKDIR /workspace
|
||||
ENTRYPOINT ["pi"]
|
||||
```
|
||||
|
||||
Build and run:
|
||||
|
||||
```bash
|
||||
docker build -t pi-sandbox -f Dockerfile.pi .
|
||||
|
||||
docker run --rm -it \
|
||||
-e ANTHROPIC_API_KEY \
|
||||
-v "$PWD:/workspace" \
|
||||
-v pi-agent-home:/root/.pi/agent \
|
||||
pi-sandbox
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -43,7 +43,7 @@ export default function (pi: ExtensionAPI) {
|
||||
pi.registerProvider("my-provider", {
|
||||
name: "My Provider",
|
||||
baseUrl: "https://api.example.com",
|
||||
apiKey: "MY_API_KEY",
|
||||
apiKey: "$MY_API_KEY",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
@@ -83,7 +83,7 @@ pi.registerProvider("openai", {
|
||||
pi.registerProvider("google", {
|
||||
baseUrl: "https://ai-gateway.corp.com/google",
|
||||
headers: {
|
||||
"X-Corp-Auth": "CORP_AUTH_TOKEN" // env var or literal
|
||||
"X-Corp-Auth": "$CORP_AUTH_TOKEN" // env var or literal
|
||||
}
|
||||
});
|
||||
```
|
||||
@@ -112,7 +112,7 @@ export default async function (pi: ExtensionAPI) {
|
||||
|
||||
pi.registerProvider("local-openai", {
|
||||
baseUrl: "http://localhost:1234/v1",
|
||||
apiKey: "LOCAL_OPENAI_API_KEY",
|
||||
apiKey: "$LOCAL_OPENAI_API_KEY",
|
||||
api: "openai-completions",
|
||||
models: payload.data.map((model) => ({
|
||||
id: model.id,
|
||||
@@ -132,7 +132,7 @@ This registers the fetched models before startup finishes.
|
||||
```typescript
|
||||
pi.registerProvider("my-llm", {
|
||||
baseUrl: "https://api.my-llm.com/v1",
|
||||
apiKey: "MY_LLM_API_KEY", // env var name or literal value
|
||||
apiKey: "$MY_LLM_API_KEY", // env var reference
|
||||
api: "openai-completions", // which streaming API to use
|
||||
models: [
|
||||
{
|
||||
@@ -155,6 +155,8 @@ pi.registerProvider("my-llm", {
|
||||
|
||||
When `models` is provided, it **replaces** all existing models for that provider.
|
||||
|
||||
`apiKey` and custom header values use the same config value syntax as `models.json`: `!command` at the start executes a command for the whole value, `$ENV_VAR` and `${ENV_VAR}` interpolate environment variables, `$$` emits a literal `$`, and `$!` emits a literal `!`.
|
||||
|
||||
## Unregister Provider
|
||||
|
||||
Use `pi.unregisterProvider(name)` to remove a provider that was previously registered via `pi.registerProvider(name, ...)`:
|
||||
@@ -163,7 +165,7 @@ Use `pi.unregisterProvider(name)` to remove a provider that was previously regis
|
||||
// Register
|
||||
pi.registerProvider("my-llm", {
|
||||
baseUrl: "https://api.my-llm.com/v1",
|
||||
apiKey: "MY_LLM_API_KEY",
|
||||
apiKey: "$MY_LLM_API_KEY",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
@@ -227,9 +229,11 @@ 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.
|
||||
|
||||
> Migration note: Mistral moved from `openai-completions` to `mistral-conversations`.
|
||||
> Use `mistral-conversations` for native Mistral models.
|
||||
> If you intentionally route Mistral-compatible/custom endpoints through `openai-completions`, set `compat` flags explicitly as needed.
|
||||
@@ -241,7 +245,7 @@ If your provider expects `Authorization: Bearer <key>` but doesn't use a standar
|
||||
```typescript
|
||||
pi.registerProvider("custom-api", {
|
||||
baseUrl: "https://api.example.com",
|
||||
apiKey: "MY_API_KEY",
|
||||
apiKey: "$MY_API_KEY",
|
||||
authHeader: true, // adds Authorization: Bearer header
|
||||
api: "openai-completions",
|
||||
models: [...]
|
||||
@@ -263,17 +267,28 @@ pi.registerProvider("corporate-ai", {
|
||||
name: "Corporate AI (SSO)",
|
||||
|
||||
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
||||
// Option 1: Browser-based OAuth
|
||||
callbacks.onAuth({ url: "https://sso.corp.com/authorize?..." });
|
||||
|
||||
// Option 2: Device code flow
|
||||
callbacks.onDeviceCode({
|
||||
userCode: "ABCD-1234",
|
||||
verificationUri: "https://sso.corp.com/device"
|
||||
const method = await callbacks.onSelect({
|
||||
message: "Select login method:",
|
||||
options: [
|
||||
{ id: "browser", label: "Browser OAuth" },
|
||||
{ id: "device", label: "Device code" }
|
||||
]
|
||||
});
|
||||
if (!method) throw new Error("Login cancelled");
|
||||
|
||||
// Option 3: Prompt for token/code
|
||||
const code = await callbacks.onPrompt({ message: "Enter SSO code:" });
|
||||
let code: string;
|
||||
if (method === "device") {
|
||||
callbacks.onDeviceCode({
|
||||
userCode: "ABCD-1234",
|
||||
verificationUri: "https://sso.corp.com/device",
|
||||
intervalSeconds: 5,
|
||||
expiresInSeconds: 900
|
||||
});
|
||||
code = await pollDeviceCodeUntilComplete();
|
||||
} else {
|
||||
callbacks.onAuth({ url: "https://sso.corp.com/authorize?..." });
|
||||
code = await callbacks.onPrompt({ message: "Enter SSO code:" });
|
||||
}
|
||||
|
||||
// Exchange for tokens (your implementation)
|
||||
const tokens = await exchangeCodeForTokens(code);
|
||||
@@ -322,10 +337,21 @@ interface OAuthLoginCallbacks {
|
||||
onAuth(params: { url: string }): void;
|
||||
|
||||
// Show device code (for device authorization flow)
|
||||
onDeviceCode(params: { userCode: string; verificationUri: string }): void;
|
||||
onDeviceCode(params: {
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
intervalSeconds?: number;
|
||||
expiresInSeconds?: number;
|
||||
}): void;
|
||||
|
||||
// Prompt user for input (for manual token entry)
|
||||
onPrompt(params: { message: string }): Promise<string>;
|
||||
|
||||
// Show an interactive selector, e.g. to choose browser OAuth vs device code
|
||||
onSelect(params: {
|
||||
message: string;
|
||||
options: { id: string; label: string }[];
|
||||
}): Promise<string | undefined>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -568,7 +594,7 @@ Register your stream function:
|
||||
```typescript
|
||||
pi.registerProvider("my-provider", {
|
||||
baseUrl: "https://api.example.com",
|
||||
apiKey: "MY_API_KEY",
|
||||
apiKey: "$MY_API_KEY",
|
||||
api: "my-custom-api",
|
||||
models: [...],
|
||||
streamSimple: streamMyProvider
|
||||
@@ -605,7 +631,7 @@ interface ProviderConfig {
|
||||
/** API endpoint URL. Required when defining models. */
|
||||
baseUrl?: string;
|
||||
|
||||
/** API key or environment variable name. Required when defining models (unless oauth). */
|
||||
/** API key literal, env interpolation ($ENV_VAR or ${ENV_VAR}), or !command. Required when defining models (unless oauth). */
|
||||
apiKey?: string;
|
||||
|
||||
/** API type for streaming. Required at provider or model level when defining models. */
|
||||
@@ -618,7 +644,7 @@ interface ProviderConfig {
|
||||
options?: SimpleStreamOptions
|
||||
) => AssistantMessageEventStream;
|
||||
|
||||
/** Custom headers to include in requests. Values can be env var names. */
|
||||
/** Custom headers to include in requests. Values use the same resolution syntax as apiKey. */
|
||||
headers?: Record<string, string>;
|
||||
|
||||
/** If true, adds Authorization: Bearer header with the resolved API key. */
|
||||
@@ -680,8 +706,9 @@ interface ProviderModelConfig {
|
||||
/** Custom headers for this specific model. */
|
||||
headers?: Record<string, string>;
|
||||
|
||||
/** OpenAI compatibility settings for openai-completions API. */
|
||||
/** Compatibility settings for the selected API. */
|
||||
compat?: {
|
||||
// openai-completions
|
||||
supportsStore?: boolean;
|
||||
supportsDeveloperRole?: boolean;
|
||||
supportsReasoningEffort?: boolean;
|
||||
@@ -691,11 +718,20 @@ 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
|
||||
supportsEagerToolInputStreaming?: boolean;
|
||||
supportsLongCacheRetention?: boolean;
|
||||
sendSessionAffinityHeaders?: boolean;
|
||||
supportsCacheControlOnTools?: boolean;
|
||||
forceAdaptiveThinking?: boolean;
|
||||
allowEmptySignature?: boolean;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
`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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Development
|
||||
|
||||
See [AGENTS.md](../../../AGENTS.md) for additional guidelines.
|
||||
See [AGENTS.md](https://github.com/earendil-works/pi-mono/blob/main/AGENTS.md) for additional guidelines.
|
||||
|
||||
## Setup
|
||||
|
||||
|
||||
@@ -19,6 +19,14 @@
|
||||
"title": "Providers",
|
||||
"path": "providers.md"
|
||||
},
|
||||
{
|
||||
"title": "Security",
|
||||
"path": "security.md"
|
||||
},
|
||||
{
|
||||
"title": "Containerization",
|
||||
"path": "containerization.md"
|
||||
},
|
||||
{
|
||||
"title": "Settings",
|
||||
"path": "settings.md"
|
||||
|
||||
@@ -109,7 +109,7 @@ pi -e ./my-extension.ts
|
||||
|
||||
> **Security:** Extensions run with your full system permissions and can execute arbitrary code. Only install from sources you trust.
|
||||
|
||||
Extensions are auto-discovered from:
|
||||
Extensions are auto-discovered from trusted locations. Project-local `.pi/extensions` entries load only after the project is trusted.
|
||||
|
||||
| Location | Scope |
|
||||
|----------|-------|
|
||||
@@ -199,7 +199,7 @@ export default async function (pi: ExtensionAPI) {
|
||||
|
||||
pi.registerProvider("local-openai", {
|
||||
baseUrl: "http://localhost:1234/v1",
|
||||
apiKey: "LOCAL_OPENAI_API_KEY",
|
||||
apiKey: "$LOCAL_OPENAI_API_KEY",
|
||||
api: "openai-completions",
|
||||
models: payload.data.map((model) => ({
|
||||
id: model.id,
|
||||
@@ -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:
|
||||
@@ -270,6 +276,7 @@ Run `npm install` in the extension directory, then imports from `node_modules/`
|
||||
```
|
||||
pi starts
|
||||
│
|
||||
├─► project_trust (user/global and CLI extensions only, before project resources load)
|
||||
├─► session_start { reason: "startup" }
|
||||
└─► resources_discover { reason: "startup" }
|
||||
│
|
||||
@@ -334,6 +341,25 @@ exit (Ctrl+C, Ctrl+D, SIGHUP, SIGTERM)
|
||||
└─► session_shutdown
|
||||
```
|
||||
|
||||
### Startup Events
|
||||
|
||||
#### project_trust
|
||||
|
||||
Fired before pi decides whether to trust a project with dynamic configs (`.pi` or `.agents/skills`). It runs during startup and when session replacement (for example `/resume`) enters a cwd whose trust has not been resolved in the current process. Only user/global extensions and CLI `-e` extensions participate; project-local extensions are not loaded until after trust is resolved.
|
||||
|
||||
```typescript
|
||||
pi.on("project_trust", async (event, ctx) => {
|
||||
// event.cwd - current working directory
|
||||
// ctx has a limited trust context: cwd, mode, hasUI, and select/confirm/input/notify UI helpers
|
||||
if (await ctx.ui.confirm("Trust project?", event.cwd)) {
|
||||
return { trusted: "yes", remember: true };
|
||||
}
|
||||
return { trusted: "undecided" };
|
||||
});
|
||||
```
|
||||
|
||||
A `project_trust` handler must return `{ trusted: "yes" | "no" | "undecided" }`. A user/global or CLI extension that returns `"yes"` or `"no"` owns the decision; the first yes/no decision wins and suppresses the built-in trust prompt. Use `remember: true` to persist a yes/no decision; otherwise it applies only to the current process. Return `"undecided"` to let later handlers or the built-in trust flow decide. Check `ctx.hasUI` before prompting. If no handler returns yes/no, normal trust resolution continues: saved `trust.json` decisions apply first, then `defaultProjectTrust` controls whether pi asks, trusts, or declines by default.
|
||||
|
||||
### Resource Events
|
||||
|
||||
#### resources_discover
|
||||
@@ -451,7 +477,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) => {
|
||||
@@ -819,6 +845,9 @@ pi.on("input", async (event, ctx) => {
|
||||
// event.text - raw input (before skill/template expansion)
|
||||
// event.images - attached images, if any
|
||||
// event.source - "interactive" (typed), "rpc" (API), or "extension" (via sendUserMessage)
|
||||
// event.streamingBehavior - "steer" | "followUp" | undefined
|
||||
// undefined when idle, "steer" for mid-stream interrupts,
|
||||
// "followUp" for messages queued until the agent finishes
|
||||
|
||||
// Transform: rewrite input before expansion
|
||||
if (event.text.startsWith("?quick "))
|
||||
@@ -847,7 +876,7 @@ pi.on("input", async (event, ctx) => {
|
||||
- `transform` - modify text/images, then continue to expansion
|
||||
- `handled` - skip agent entirely (first handler to return this wins)
|
||||
|
||||
Transforms chain across handlers. See [input-transform.ts](../examples/extensions/input-transform.ts).
|
||||
Transforms chain across handlers. See [input-transform.ts](../examples/extensions/input-transform.ts) and [input-transform-streaming.ts](../examples/extensions/input-transform-streaming.ts) for `streamingBehavior`-aware routing.
|
||||
|
||||
## ExtensionContext
|
||||
|
||||
@@ -857,14 +886,38 @@ All handlers receive `ctx: ExtensionContext`.
|
||||
|
||||
UI methods for user interaction. See [Custom UI](#custom-ui) for full details.
|
||||
|
||||
### ctx.mode
|
||||
|
||||
Current run mode: `"tui"`, `"rpc"`, `"json"`, or `"print"`. Use `ctx.mode === "tui"` to guard terminal-only features such as `custom()`, component factories, terminal input, and direct TUI rendering.
|
||||
|
||||
### ctx.hasUI
|
||||
|
||||
`false` in print mode (`-p`) and JSON mode. `true` in interactive and RPC mode. In RPC mode, dialog methods (`select`, `confirm`, `input`, `editor`) work via the extension UI sub-protocol, and fire-and-forget methods (`notify`, `setStatus`, `setWidget`, `setTitle`, `setEditorText`) emit requests to the client. Some TUI-specific methods are no-ops or return defaults (see [rpc.md](rpc.md#extension-ui-protocol)).
|
||||
`true` in TUI and RPC modes. `false` in print mode (`-p`) and JSON mode. Use this to guard dialog methods (`select`, `confirm`, `input`, `editor`) and fire-and-forget methods (`notify`, `setStatus`, `setWidget`, `setTitle`, `setEditorText`) that work in both TUI and RPC modes. In RPC mode, some TUI-specific methods are no-ops or return defaults (see [rpc.md](rpc.md#extension-ui-protocol)).
|
||||
|
||||
### ctx.cwd
|
||||
|
||||
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.
|
||||
|
||||
Use this before reading project-local extension configuration that should only be honored for trusted projects.
|
||||
|
||||
### ctx.sessionManager
|
||||
|
||||
Read-only access to session state. See [Session Format](session-format.md) for the full SessionManager API and entry types.
|
||||
@@ -975,6 +1028,19 @@ pi.on("before_agent_start", (event, ctx) => {
|
||||
|
||||
Command handlers receive `ExtensionCommandContext`, which extends `ExtensionContext` with session control methods. These are only available in commands because they can deadlock if called from event handlers.
|
||||
|
||||
### ctx.getSystemPromptOptions()
|
||||
|
||||
Returns the base inputs Pi currently uses to build the system prompt.
|
||||
|
||||
```typescript
|
||||
const options = ctx.getSystemPromptOptions();
|
||||
const contextPaths = options.contextFiles?.map((file) => file.path) ?? [];
|
||||
```
|
||||
|
||||
This has the same shape and mutability as `before_agent_start` `event.systemPromptOptions`: custom prompt, active tools, tool snippets, prompt guidelines, appended system prompt text, cwd, loaded context files, and loaded skills. It may include full context file contents, so treat it as sensitive extension-local data and avoid exposing it through command lists, logs, or autocomplete metadata.
|
||||
|
||||
This reports the current base prompt inputs. It does not include per-turn `before_agent_start` chained system-prompt changes, later `context` event message mutations, or `before_provider_request` payload rewrites.
|
||||
|
||||
### ctx.waitForIdle()
|
||||
|
||||
Wait for the agent to finish streaming:
|
||||
@@ -1482,24 +1548,25 @@ 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: ...,
|
||||
// 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
|
||||
```
|
||||
|
||||
`pi.getAllTools()` returns `name`, `description`, `parameters`, and `sourceInfo`.
|
||||
`pi.getAllTools()` returns `name`, `description`, `parameters`, `promptGuidelines`, and `sourceInfo`.
|
||||
|
||||
Typical `sourceInfo.source` values:
|
||||
- `builtin` for built-in tools
|
||||
@@ -1551,7 +1618,7 @@ If you need to discover models from a remote endpoint, prefer an async extension
|
||||
pi.registerProvider("my-proxy", {
|
||||
name: "My Proxy",
|
||||
baseUrl: "https://proxy.example.com",
|
||||
apiKey: "PROXY_API_KEY", // env var name or literal
|
||||
apiKey: "$PROXY_API_KEY", // env var reference
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
@@ -1598,7 +1665,7 @@ pi.registerProvider("corporate-ai", {
|
||||
**Config options:**
|
||||
- `name` - Display name for the provider in UI such as `/login`.
|
||||
- `baseUrl` - API endpoint URL. Required when defining models.
|
||||
- `apiKey` - API key or environment variable name. Required when defining models (unless `oauth` provided).
|
||||
- `apiKey` - API key literal, environment interpolation (`$ENV_VAR` or `${ENV_VAR}`), or leading `!command`. Required when defining models (unless `oauth` provided). `$$` escapes `$`, and `$!` escapes a literal `!` without triggering command execution.
|
||||
- `api` - API type: `"anthropic-messages"`, `"openai-completions"`, `"openai-responses"`, etc.
|
||||
- `headers` - Custom headers to include in requests.
|
||||
- `authHeader` - If true, adds `Authorization: Bearer` header automatically.
|
||||
@@ -2234,6 +2301,7 @@ ctx.ui.pasteToEditor("pasted content");
|
||||
|
||||
// Stack custom autocomplete behavior on top of the built-in provider
|
||||
ctx.ui.addAutocompleteProvider((current) => ({
|
||||
triggerCharacters: ["#"],
|
||||
async getSuggestions(lines, line, col, options) {
|
||||
const beforeCursor = (lines[line] ?? "").slice(0, col);
|
||||
const match = beforeCursor.match(/(?:^|[ \t])#([^\s#]*)$/);
|
||||
@@ -2282,7 +2350,7 @@ Custom working-indicator frames are rendered verbatim. If you want colors, add t
|
||||
|
||||
### Autocomplete Providers
|
||||
|
||||
Use `ctx.ui.addAutocompleteProvider()` to stack custom autocomplete logic on top of the built-in slash-command and path provider.
|
||||
Use `ctx.ui.addAutocompleteProvider()` to stack custom autocomplete logic on top of the built-in slash-command and path provider. Set `triggerCharacters` for custom natural triggers such as `$`.
|
||||
|
||||
Typical pattern:
|
||||
|
||||
@@ -2294,6 +2362,7 @@ Typical pattern:
|
||||
```typescript
|
||||
pi.on("session_start", (_event, ctx) => {
|
||||
ctx.ui.addAutocompleteProvider((current) => ({
|
||||
triggerCharacters: ["#"],
|
||||
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
||||
const line = lines[cursorLine] ?? "";
|
||||
const beforeCursor = line.slice(0, cursorCol);
|
||||
@@ -2367,7 +2436,7 @@ const result = await ctx.ui.custom<string | null>(
|
||||
);
|
||||
```
|
||||
|
||||
For advanced positioning (anchors, margins, percentages, responsive visibility), pass `overlayOptions`. Use `onHandle` to control visibility programmatically:
|
||||
For advanced positioning (anchors, margins, percentages, responsive visibility), pass `overlayOptions`. Use `onHandle` to control focus or visibility programmatically:
|
||||
|
||||
```typescript
|
||||
const result = await ctx.ui.custom<string | null>(
|
||||
@@ -2375,12 +2444,19 @@ const result = await ctx.ui.custom<string | null>(
|
||||
{
|
||||
overlay: true,
|
||||
overlayOptions: { anchor: "top-right", width: "50%", margin: 2 },
|
||||
onHandle: (handle) => { /* handle.setHidden(true/false) */ }
|
||||
onHandle: (handle) => {
|
||||
handle.focus(); // focus this overlay and bring it to the visual front
|
||||
// handle.unfocus({ target: editorComponent }); // release input to a specific component
|
||||
// handle.setHidden(true/false); // toggle visibility
|
||||
// handle.hide(); // permanently remove
|
||||
}
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
See [tui.md](tui.md) for the full `OverlayOptions` API and [overlay-qa-tests.ts](../examples/extensions/overlay-qa-tests.ts) for examples.
|
||||
A focused visible overlay can reclaim input after temporary non-overlay custom UI closes. If you intentionally want another component to keep input while the overlay stays visible, call `handle.unfocus({ target })`. Passing `{ target: null }` releases the overlay without focusing another component.
|
||||
|
||||
See [tui.md](tui.md) for the full `OverlayOptions` and `OverlayHandle` API and [overlay-qa-tests.ts](../examples/extensions/overlay-qa-tests.ts) for examples.
|
||||
|
||||
### Custom Editor
|
||||
|
||||
@@ -2505,14 +2581,14 @@ const highlighted = highlightCode(code, lang, theme);
|
||||
|
||||
## Mode Behavior
|
||||
|
||||
| Mode | UI Methods | Notes |
|
||||
|------|-----------|-------|
|
||||
| Interactive | Full TUI | Normal operation |
|
||||
| RPC (`--mode rpc`) | JSON protocol | Host handles UI, see [rpc.md](rpc.md) |
|
||||
| JSON (`--mode json`) | No-op | Event stream to stdout, see [json.md](json.md) |
|
||||
| Print (`-p`) | No-op | Extensions run but can't prompt |
|
||||
| Mode | `ctx.mode` | `ctx.hasUI` | Notes |
|
||||
|------|------------|-------------|-------|
|
||||
| Interactive | `"tui"` | `true` | Full TUI with terminal rendering |
|
||||
| RPC (`--mode rpc`) | `"rpc"` | `true` | Dialogs and notifications via JSON protocol; `custom()` returns `undefined`. See [rpc.md](rpc.md) |
|
||||
| JSON (`--mode json`) | `"json"` | `false` | Event stream to stdout; UI methods are no-ops |
|
||||
| Print (`-p`) | `"print"` | `false` | Extensions run but can't prompt |
|
||||
|
||||
In non-interactive modes, check `ctx.hasUI` before using UI methods.
|
||||
Use `ctx.mode === "tui"` before TUI-specific features (`custom()`, component factories, terminal input). Use `ctx.hasUI` before dialog and notification methods that work in both TUI and RPC modes.
|
||||
|
||||
## Examples Reference
|
||||
|
||||
@@ -2539,10 +2615,12 @@ All examples in [examples/extensions/](../examples/extensions/).
|
||||
| `shutdown-command.ts` | Graceful shutdown command | `registerCommand`, `shutdown()` |
|
||||
| **Events & Gates** |||
|
||||
| `permission-gate.ts` | Block dangerous commands | `on("tool_call")`, `ui.confirm` |
|
||||
| `project-trust.ts` | Decide or defer project trust from a user/global or CLI extension | `on("project_trust")`, trust UI, required trust result |
|
||||
| `protected-paths.ts` | Block writes to specific paths | `on("tool_call")` |
|
||||
| `confirm-destructive.ts` | Confirm session changes | `on("session_before_switch")`, `on("session_before_fork")` |
|
||||
| `dirty-repo-guard.ts` | Warn on dirty git repo | `on("session_before_*")`, `exec` |
|
||||
| `input-transform.ts` | Transform user input | `on("input")` |
|
||||
| `input-transform-streaming.ts` | Streaming-aware input transform | `on("input")`, `streamingBehavior` |
|
||||
| `model-status.ts` | React to model changes | `on("model_select")`, `setStatus` |
|
||||
| `provider-payload.ts` | Inspect payloads and provider response headers | `on("before_provider_request")`, `on("after_provider_response")` |
|
||||
| `system-prompt-header.ts` | Display system prompt info | `on("agent_start")`, `getSystemPrompt` |
|
||||
@@ -2553,6 +2631,7 @@ All examples in [examples/extensions/](../examples/extensions/).
|
||||
| `custom-compaction.ts` | Custom compaction summary | `on("session_before_compact")` |
|
||||
| `trigger-compact.ts` | Trigger compaction manually | `compact()` |
|
||||
| `git-checkpoint.ts` | Git stash on turns | `on("turn_start")`, `on("session_before_fork")`, `exec` |
|
||||
| `git-merge-and-resolve.ts` | Fetch, merge, and resolve conflicts | `on("agent_end")`, `exec`, `sendUserMessage` |
|
||||
| `auto-commit-on-exit.ts` | Commit on shutdown | `on("session_shutdown")`, `exec` |
|
||||
| **UI Components** |||
|
||||
| `status-line.ts` | Footer status indicator | `setStatus`, session events |
|
||||
@@ -2576,6 +2655,7 @@ All examples in [examples/extensions/](../examples/extensions/).
|
||||
| `ssh.ts` | SSH remote execution | `registerFlag`, `on("user_bash")`, `on("before_agent_start")`, tool operations |
|
||||
| `interactive-shell.ts` | Persistent shell session | `on("user_bash")` |
|
||||
| `sandbox/` | Sandboxed tool execution | Tool operations |
|
||||
| `gondolin/` | Route built-in tools and `!` commands into a Gondolin micro-VM | Tool operations, built-in tool overrides, `on("user_bash")` |
|
||||
| `subagent/` | Spawn sub-agents | `registerTool`, `exec` |
|
||||
| **Games** |||
|
||||
| `snake.ts` | Snake game | `registerCommand`, `ui.custom`, keyboard handling |
|
||||
|
||||
@@ -41,6 +41,8 @@ For the full first-run flow, see [Quickstart](quickstart.md).
|
||||
- [Quickstart](quickstart.md) - install, authenticate, and run a first session.
|
||||
- [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 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.
|
||||
|
||||
@@ -101,7 +101,7 @@ Use `google-generative-ai` with a `baseUrl` to add models from Google AI Studio,
|
||||
"my-google": {
|
||||
"baseUrl": "https://generativelanguage.googleapis.com/v1beta",
|
||||
"api": "google-generative-ai",
|
||||
"apiKey": "GEMINI_API_KEY",
|
||||
"apiKey": "$GEMINI_API_KEY",
|
||||
"models": [
|
||||
{
|
||||
"id": "gemma-4-31b-it",
|
||||
@@ -143,18 +143,25 @@ Set `api` at provider level (default for all models) or model level (override pe
|
||||
|
||||
### Value Resolution
|
||||
|
||||
The `apiKey` and `headers` fields support three formats:
|
||||
The `apiKey` and `headers` fields support command execution, environment interpolation, and literals:
|
||||
|
||||
- **Shell command:** `"!command"` executes and uses stdout
|
||||
- **Shell command:** `"!command"` at the start executes the whole value as a command and uses stdout
|
||||
```json
|
||||
"apiKey": "!security find-generic-password -ws 'anthropic'"
|
||||
"apiKey": "!op read 'op://vault/item/credential'"
|
||||
```
|
||||
- **Environment variable:** Uses the value of the named variable
|
||||
- **Environment interpolation:** `"$ENV_VAR"` or `"${ENV_VAR}"` uses the value of the named variable. Interpolation works inside larger literals.
|
||||
```json
|
||||
"apiKey": "MY_API_KEY"
|
||||
"apiKey": "$MY_API_KEY"
|
||||
"apiKey": "${KEY_PREFIX}_${KEY_SUFFIX}"
|
||||
```
|
||||
- **Literal value:** Used directly
|
||||
`$FOO_BAR` is the variable `FOO_BAR`; use `${FOO}_BAR` when `BAR` is literal text. Missing environment variables make the value unresolved.
|
||||
- **Escapes:** `"$$"` emits a literal `"$"`; `"$!"` emits a literal `"!"` without triggering command execution.
|
||||
```json
|
||||
"apiKey": "$$literal-dollar-prefix"
|
||||
"apiKey": "$!literal-bang-prefix"
|
||||
```
|
||||
- **Literal value:** Used directly. Plain uppercase strings such as `MY_API_KEY` are literals; use `$MY_API_KEY` for environment variables.
|
||||
```json
|
||||
"apiKey": "sk-..."
|
||||
```
|
||||
@@ -172,10 +179,10 @@ If your command is slow, expensive, rate-limited, or should keep using a previou
|
||||
"providers": {
|
||||
"custom-proxy": {
|
||||
"baseUrl": "https://proxy.example.com/v1",
|
||||
"apiKey": "MY_API_KEY",
|
||||
"apiKey": "$MY_API_KEY",
|
||||
"api": "anthropic-messages",
|
||||
"headers": {
|
||||
"x-portkey-api-key": "PORTKEY_API_KEY",
|
||||
"x-portkey-api-key": "$PORTKEY_API_KEY",
|
||||
"x-secret": "!op read 'op://vault/item/secret'"
|
||||
},
|
||||
"models": [...]
|
||||
@@ -189,7 +196,7 @@ If your command is slow, expensive, rate-limited, or should keep using a previou
|
||||
| Field | Required | Default | Description |
|
||||
|-------|----------|---------|-------------|
|
||||
| `id` | Yes | — | Model identifier (passed to the API) |
|
||||
| `name` | No | `id` | Human-readable model label. Used for matching (`--model` patterns) and shown in model details/status text. |
|
||||
| `name` | No | `id` | Human-readable model label. Used for matching (`--model` patterns) and shown as secondary model detail text. |
|
||||
| `api` | No | provider's `api` | Override provider's API for this model |
|
||||
| `reasoning` | No | `false` | Supports extended thinking |
|
||||
| `thinkingLevelMap` | No | omitted | Maps pi thinking levels to provider values and marks unsupported levels (see below) |
|
||||
@@ -200,8 +207,8 @@ If your command is slow, expensive, rate-limited, or should keep using a previou
|
||||
| `compat` | No | provider `compat` | Provider compatibility overrides. Merged with provider-level `compat` when both are set. |
|
||||
|
||||
Current behavior:
|
||||
- `/model` and `--list-models` list entries by model `id`.
|
||||
- The configured `name` is used for model matching and detail/status text.
|
||||
- `/model`, `--list-models`, and the interactive footer display entries by model `id`.
|
||||
- The configured `name` is used for model matching and secondary model detail text. It does not replace the footer/status-bar model id.
|
||||
|
||||
### Thinking Level Map
|
||||
|
||||
@@ -268,7 +275,7 @@ To merge custom models into a built-in provider, include the `models` array:
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"baseUrl": "https://my-proxy.example.com/v1",
|
||||
"apiKey": "ANTHROPIC_API_KEY",
|
||||
"apiKey": "$ANTHROPIC_API_KEY",
|
||||
"api": "anthropic-messages",
|
||||
"models": [...]
|
||||
}
|
||||
@@ -311,24 +318,31 @@ Behavior notes:
|
||||
- `modelOverrides` are applied to built-in provider models.
|
||||
- Unknown model IDs are ignored.
|
||||
- You can combine provider-level `baseUrl`/`headers` with `modelOverrides`.
|
||||
- Overriding `name` changes model matching and secondary detail text only; the footer and primary model lists continue to show the model `id`.
|
||||
- If `models` is also defined for a provider, custom models are merged after built-in overrides. A custom model with the same `id` replaces the overridden built-in model entry.
|
||||
|
||||
## Anthropic Messages Compatibility
|
||||
|
||||
For providers or proxies using `api: "anthropic-messages"`, use `compat.supportsEagerToolInputStreaming` to control Anthropic fine-grained tool streaming compatibility.
|
||||
For providers or proxies using `api: "anthropic-messages"`, use `compat` to control Anthropic-specific request compatibility.
|
||||
|
||||
By default pi sends per-tool `eager_input_streaming: true`. If a proxy or Anthropic-compatible backend rejects that field, set `supportsEagerToolInputStreaming` to `false`. Pi will omit `tools[].eager_input_streaming` and send the legacy `fine-grained-tool-streaming-2025-05-14` beta header for tool-enabled requests instead.
|
||||
|
||||
Some Anthropic models require adaptive thinking (`thinking.type: "adaptive"` plus `output_config.effort`) instead of the legacy budget-based thinking payload. Built-in models set this automatically. For custom providers or aliases that route to those models, set `forceAdaptiveThinking` to `true`.
|
||||
|
||||
Some Anthropic-compatible providers emit thinking blocks with empty signatures and still expect them on replay. Set `allowEmptySignature` to `true` only for those providers; real Anthropic rejects empty thinking signatures.
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"anthropic-proxy": {
|
||||
"baseUrl": "https://proxy.example.com",
|
||||
"api": "anthropic-messages",
|
||||
"apiKey": "ANTHROPIC_PROXY_KEY",
|
||||
"apiKey": "$ANTHROPIC_PROXY_KEY",
|
||||
"compat": {
|
||||
"supportsEagerToolInputStreaming": false,
|
||||
"supportsLongCacheRetention": true
|
||||
"supportsLongCacheRetention": true,
|
||||
"forceAdaptiveThinking": true,
|
||||
"allowEmptySignature": true
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
@@ -346,6 +360,10 @@ By default pi sends per-tool `eager_input_streaming: true`. If a proxy or Anthro
|
||||
|-------|-------------|
|
||||
| `supportsEagerToolInputStreaming` | Whether the provider accepts per-tool `eager_input_streaming`. Default: `true`. Set to `false` to omit that field and use the legacy fine-grained tool streaming beta header on tool-enabled requests. |
|
||||
| `supportsLongCacheRetention` | Whether the provider accepts Anthropic long cache retention (`cache_control.ttl: "1h"`) when cache retention is `long`. Default: `true`. |
|
||||
| `sendSessionAffinityHeaders` | Whether to send `x-session-affinity` from the session id when caching is enabled. Default: auto-detected for known providers. |
|
||||
| `supportsCacheControlOnTools` | Whether the provider accepts Anthropic-style `cache_control` markers on tool definitions. Default: `true`. |
|
||||
| `forceAdaptiveThinking` | Whether to send adaptive thinking (`thinking.type: "adaptive"` plus `output_config.effort`) for this model. Built-in adaptive models set this automatically. Default: `false`. |
|
||||
| `allowEmptySignature` | Whether to replay empty thinking signatures as `signature: ""` instead of converting thinking to text. Default: `false`. |
|
||||
|
||||
## OpenAI Compatibility
|
||||
|
||||
@@ -381,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.
|
||||
|
||||
@@ -399,7 +418,7 @@ Example:
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"baseUrl": "https://openrouter.ai/api/v1",
|
||||
"apiKey": "OPENROUTER_API_KEY",
|
||||
"apiKey": "$OPENROUTER_API_KEY",
|
||||
"api": "openai-completions",
|
||||
"models": [
|
||||
{
|
||||
@@ -449,7 +468,7 @@ Vercel AI Gateway example:
|
||||
"providers": {
|
||||
"vercel-ai-gateway": {
|
||||
"baseUrl": "https://ai-gateway.vercel.sh/v1",
|
||||
"apiKey": "AI_GATEWAY_API_KEY",
|
||||
"apiKey": "$AI_GATEWAY_API_KEY",
|
||||
"api": "openai-completions",
|
||||
"models": [
|
||||
{
|
||||
|
||||
@@ -28,17 +28,18 @@ pi install ./relative/path/to/package
|
||||
|
||||
pi remove npm:@foo/bar
|
||||
pi list # show installed packages from settings
|
||||
pi update # update pi and all non-pinned packages
|
||||
pi update --extensions # update all non-pinned packages only
|
||||
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
|
||||
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.
|
||||
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.
|
||||
|
||||
To try a package without installing it, use `--extension` or `-e`. This installs to a temporary directory for the current run only:
|
||||
|
||||
@@ -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,9 +86,10 @@ 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 and skip package updates (`pi update`, `pi update --extensions`). Use `pi install git:host/user/repo@new-ref` to move an existing package to a new pinned 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).
|
||||
- Runs `npm install` after clone, pull, or pinned ref change if `package.json` exists.
|
||||
- When reconciliation changes the checkout, pi resets and cleans the clone, then runs `npm install` if `package.json` exists.
|
||||
|
||||
**SSH examples:**
|
||||
```bash
|
||||
|
||||
@@ -9,7 +9,7 @@ Prompt templates are Markdown snippets that expand into full prompts. Type `/nam
|
||||
Pi loads prompt templates from:
|
||||
|
||||
- Global: `~/.pi/agent/prompts/*.md`
|
||||
- Project: `.pi/prompts/*.md`
|
||||
- Project: `.pi/prompts/*.md` (only after the project is trusted)
|
||||
- Packages: `prompts/` directories or `pi.prompts` entries in `package.json`
|
||||
- Settings: `prompts` array with files or directories
|
||||
- CLI: `--prompt-template <path>` (repeatable)
|
||||
@@ -64,10 +64,11 @@ Type `/` followed by the template name in the editor. Autocomplete shows availab
|
||||
|
||||
## Arguments
|
||||
|
||||
Templates support positional arguments and simple slicing:
|
||||
Templates support positional arguments, defaults, and simple slicing:
|
||||
|
||||
- `$1`, `$2`, ... positional args
|
||||
- `$@` or `$ARGUMENTS` for all args joined
|
||||
- `${1:-default}` uses arg 1 when present/non-empty, otherwise `default`
|
||||
- `${@:N}` for args from the Nth position (1-indexed)
|
||||
- `${@:N:L}` for `L` args starting at N
|
||||
|
||||
@@ -80,6 +81,12 @@ description: Create a component
|
||||
Create a React component named $1 with features: $@
|
||||
```
|
||||
|
||||
Default values are useful for optional arguments:
|
||||
|
||||
```markdown
|
||||
Summarize the current state in ${1:-7} bullet points.
|
||||
```
|
||||
|
||||
Usage: `/component Button "onClick handler" "disabled support"`
|
||||
|
||||
## Loading Rules
|
||||
|
||||
@@ -49,9 +49,11 @@ pi
|
||||
| Provider | Environment Variable | `auth.json` key |
|
||||
|----------|----------------------|------------------|
|
||||
| Anthropic | `ANTHROPIC_API_KEY` | `anthropic` |
|
||||
| Ant Ling | `ANT_LING_API_KEY` | `ant-ling` |
|
||||
| Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` |
|
||||
| OpenAI | `OPENAI_API_KEY` | `openai` |
|
||||
| DeepSeek | `DEEPSEEK_API_KEY` | `deepseek` |
|
||||
| NVIDIA NIM | `NVIDIA_API_KEY` | `nvidia` |
|
||||
| Google Gemini | `GEMINI_API_KEY` | `google` |
|
||||
| Mistral | `MISTRAL_API_KEY` | `mistral` |
|
||||
| Groq | `GROQ_API_KEY` | `groq` |
|
||||
@@ -62,6 +64,7 @@ pi
|
||||
| OpenRouter | `OPENROUTER_API_KEY` | `openrouter` |
|
||||
| Vercel AI Gateway | `AI_GATEWAY_API_KEY` | `vercel-ai-gateway` |
|
||||
| ZAI | `ZAI_API_KEY` | `zai` |
|
||||
| ZAI Coding Plan (China) | `ZAI_CODING_CN_API_KEY` | `zai-coding-cn` |
|
||||
| OpenCode Zen | `OPENCODE_API_KEY` | `opencode` |
|
||||
| OpenCode Go | `OPENCODE_API_KEY` | `opencode-go` |
|
||||
| Hugging Face | `HF_TOKEN` | `huggingface` |
|
||||
@@ -84,8 +87,10 @@ Store credentials in `~/.pi/agent/auth.json`:
|
||||
```json
|
||||
{
|
||||
"anthropic": { "type": "api_key", "key": "sk-ant-..." },
|
||||
"ant-ling": { "type": "api_key", "key": "..." },
|
||||
"openai": { "type": "api_key", "key": "sk-..." },
|
||||
"deepseek": { "type": "api_key", "key": "sk-..." },
|
||||
"nvidia": { "type": "api_key", "key": "nvapi-..." },
|
||||
"google": { "type": "api_key", "key": "..." },
|
||||
"opencode": { "type": "api_key", "key": "..." },
|
||||
"opencode-go": { "type": "api_key", "key": "..." },
|
||||
@@ -99,22 +104,48 @@ 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 three formats:
|
||||
The `key` field supports command execution, environment interpolation, and literals:
|
||||
|
||||
- **Shell command:** `"!command"` executes and uses stdout (cached for process lifetime)
|
||||
- **Shell command:** `"!command"` at the start executes the whole value as a command and uses stdout (cached for process lifetime)
|
||||
```json
|
||||
{ "type": "api_key", "key": "!security find-generic-password -ws 'anthropic'" }
|
||||
{ "type": "api_key", "key": "!op read 'op://vault/item/credential'" }
|
||||
```
|
||||
- **Environment variable:** Uses the value of the named variable
|
||||
- **Environment interpolation:** `"$ENV_VAR"` or `"${ENV_VAR}"` uses the value of the named variable. Interpolation works inside larger literals.
|
||||
```json
|
||||
{ "type": "api_key", "key": "MY_ANTHROPIC_KEY" }
|
||||
{ "type": "api_key", "key": "$MY_ANTHROPIC_KEY" }
|
||||
{ "type": "api_key", "key": "${KEY_PREFIX}_${KEY_SUFFIX}" }
|
||||
```
|
||||
- **Literal value:** Used directly
|
||||
`$FOO_BAR` is the variable `FOO_BAR`; use `${FOO}_BAR` when `BAR` is literal text. Missing environment variables make the value unresolved.
|
||||
- **Escapes:** `"$$"` emits a literal `"$"`; `"$!"` emits a literal `"!"` without triggering command execution.
|
||||
```json
|
||||
{ "type": "api_key", "key": "$$literal-dollar-prefix" }
|
||||
{ "type": "api_key", "key": "$!literal-bang-prefix" }
|
||||
```
|
||||
- **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" }
|
||||
```
|
||||
|
||||
OAuth credentials are also stored here after `/login` and managed automatically.
|
||||
@@ -181,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
|
||||
@@ -205,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
|
||||
|
||||
@@ -136,6 +136,7 @@ Sessions are saved automatically:
|
||||
```bash
|
||||
pi -c # Continue most recent session
|
||||
pi -r # Browse previous sessions
|
||||
pi --name "my task" # Set session display name at startup
|
||||
pi --session <path|id> # Open a specific session
|
||||
```
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ pi --mode rpc [options]
|
||||
Common options:
|
||||
- `--provider <name>`: Set the LLM provider (anthropic, openai, google, etc.)
|
||||
- `--model <pattern>`: Model pattern or ID (supports `provider/id` and optional `:<thinking>`)
|
||||
- `--name <name>` / `-n <name>`: Set the session display name at startup
|
||||
- `--no-session`: Disable session persistence
|
||||
- `--session-dir <path>`: Custom session storage directory
|
||||
|
||||
@@ -373,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.
|
||||
@@ -702,7 +706,7 @@ Response:
|
||||
}
|
||||
```
|
||||
|
||||
The current session name is available via `get_state` in the `sessionName` field.
|
||||
The current session name is available via `get_state` in the `sessionName` field. To set the initial name when starting RPC mode, pass `--name <name>` or `-n <name>` to the `pi --mode rpc` process.
|
||||
|
||||
### Commands
|
||||
|
||||
@@ -931,6 +935,7 @@ The `reason` field is `"manual"`, `"threshold"`, or `"overflow"`.
|
||||
"summary": "Summary of conversation...",
|
||||
"firstKeptEntryId": "abc123",
|
||||
"tokensBefore": 150000,
|
||||
"estimatedTokensAfter": 32000,
|
||||
"details": {}
|
||||
},
|
||||
"aborted": false,
|
||||
@@ -1010,7 +1015,7 @@ Some `ExtensionUIContext` methods are not supported or degraded in RPC mode beca
|
||||
- `getTheme()` returns `undefined`
|
||||
- `setTheme()` returns `{ success: false, error: "..." }`
|
||||
|
||||
Note: `ctx.hasUI` is `true` in RPC mode because the dialog and fire-and-forget methods are functional via the extension UI sub-protocol.
|
||||
Note: `ctx.mode` is `"rpc"` and `ctx.hasUI` is `true` in RPC mode because the dialog and fire-and-forget methods are functional via the extension UI sub-protocol. Use `ctx.mode === "tui"` to guard TUI-specific features like `custom()` that require a real terminal.
|
||||
|
||||
### Extension UI Requests (stdout)
|
||||
|
||||
|
||||
@@ -472,6 +472,7 @@ Specify which built-in tools to enable:
|
||||
- Default built-ins: `read`, `bash`, `edit`, `write`
|
||||
- `noTools: "all"` disables all tools
|
||||
- `noTools: "builtin"` disables default built-ins while keeping extension and custom tools enabled
|
||||
- `excludeTools` disables specific built-in, extension, or custom tool names after any `tools` allowlist is applied
|
||||
|
||||
The `edit` tool returns `details.diff` for Pi's TUI display and `details.patch` as a standard unified patch for SDK consumers.
|
||||
|
||||
@@ -487,6 +488,11 @@ const { session } = await createAgentSession({
|
||||
const { session } = await createAgentSession({
|
||||
tools: ["read", "bash", "grep"],
|
||||
});
|
||||
|
||||
// Disable one tool while keeping the rest available
|
||||
const { session } = await createAgentSession({
|
||||
excludeTools: ["ask_question"],
|
||||
});
|
||||
```
|
||||
|
||||
#### Tools with Custom cwd
|
||||
@@ -1104,8 +1110,14 @@ DefaultResourceLoader
|
||||
type ResourceLoader
|
||||
createEventBus
|
||||
|
||||
// Helpers
|
||||
// Constants and helpers
|
||||
CONFIG_DIR_NAME
|
||||
defineTool
|
||||
getAgentDir
|
||||
getPackageDir
|
||||
getReadmePath
|
||||
getDocsPath
|
||||
getExamplesPath
|
||||
|
||||
// Session management
|
||||
SessionManager
|
||||
|
||||
59
packages/coding-agent/docs/security.md
Normal file
59
packages/coding-agent/docs/security.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Security
|
||||
|
||||
Pi is a local coding agent. It runs with the permissions of the user account that starts it, and it treats files writable by that user as inside the same local trust boundary.
|
||||
|
||||
## Project Trust
|
||||
|
||||
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 resources that require trust when it finds any of these from the current working 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
|
||||
|
||||
A bare `.pi` directory does not count as a project resource that requires trust.
|
||||
|
||||
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
|
||||
- missing project packages configured through project settings
|
||||
- project-local extensions and project package-managed extensions
|
||||
|
||||
Declining trust skips protected resources. `AGENTS.md` and `CLAUDE.md` context files are loaded regardless of project trust unless context loading is disabled. Before trust is resolved, pi only loads context files, user/global extensions, and CLI `-e` extensions. User/global and CLI extensions can handle the `project_trust` event; the first extension that returns a yes/no decision owns the decision.
|
||||
|
||||
Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, `defaultProjectTrust: "ask"` and `"never"` ignore such resources, while `"always"` trusts them. Use `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run.
|
||||
|
||||
## No Built-in Sandbox
|
||||
|
||||
Pi does not include a built-in sandbox. Built-in tools can read files, write files, edit files, and run shell commands with the permissions of the pi process. Extensions are TypeScript modules that run with the same permissions. Package installs, shell commands, language servers, test commands, and other developer tools behave as ordinary local processes.
|
||||
|
||||
This is intentional. Pi is designed to operate on local source trees, invoke project toolchains, and integrate with the user's existing development environment. A partial in-process sandbox would be easy to misunderstand as a security boundary while still depending on the host shell, filesystem, package managers, credentials, and extension code. Real isolation needs to come from the operating system or a virtualization/container boundary.
|
||||
|
||||
Project trust is only an input-loading guard. It prevents a repository from silently changing pi's settings or extensions before you approve it. It does not make untrusted code, untrusted prompts, or untrusted model output safe. Prompt injection from repository files, comments, documentation, context files, or build output is expected local-agent risk and cannot be reliably prevented by pi.
|
||||
|
||||
## Running Untrusted or Unmonitored Work
|
||||
|
||||
For untrusted repositories, generated code you do not intend to monitor closely, or unattended automation, run pi in a contained environment. Use a container, VM, micro-VM, remote sandbox, or policy-controlled sandbox with only the files and credentials required for the task.
|
||||
|
||||
Common patterns are documented in [Containerization](containerization.md):
|
||||
|
||||
- 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
|
||||
- pass the minimum required API keys or use short-lived credentials
|
||||
- restrict network access when the task does not need it
|
||||
- review diffs and outputs before copying results back to trusted systems
|
||||
|
||||
If you bind-mount a host workspace read/write, writes from inside the container or VM can still modify host files. Use read-only mounts or copy files into and out of the sandbox when you need stronger protection from unintended writes.
|
||||
|
||||
## Reporting Security Issues
|
||||
|
||||
To report a security issue, follow the repository [Security Policy](https://github.com/earendil-works/pi-mono/blob/main/SECURITY.md). Do not open a public issue for security-sensitive reports.
|
||||
|
||||
Expected local-agent behavior, lack of a built-in sandbox, prompt injection from untrusted content, and behavior of user-installed extensions or skills are generally outside the security boundary unless the report demonstrates a real privilege-boundary bypass or shows how pi grants access that the local user did not already have.
|
||||
@@ -282,7 +282,7 @@ Set `label` to `undefined` to clear a label.
|
||||
|
||||
### SessionInfoEntry
|
||||
|
||||
Session metadata (e.g., user-defined display name). Set via `/name` command or `pi.setSessionName()` in extensions.
|
||||
Session metadata (e.g., user-defined display name). Set via `/name`, `--name` / `-n`, or `pi.setSessionName()` in extensions.
|
||||
|
||||
```json
|
||||
{"type":"session_info","id":"k1l2m3n4","parentId":"j0k1l2m3","timestamp":"2024-12-03T14:35:00.000Z","name":"Refactor auth module"}
|
||||
|
||||
@@ -10,6 +10,7 @@ Sessions auto-save to `~/.pi/agent/sessions/`, organized by working directory. E
|
||||
pi -c # Continue most recent session
|
||||
pi -r # Browse and select from past sessions
|
||||
pi --no-session # Ephemeral mode; do not save
|
||||
pi --name "my task" # Set session display name at startup
|
||||
pi --session <path|id> # Use a specific session file or partial session ID
|
||||
pi --fork <path|id> # Fork a session file or partial session ID into a new session
|
||||
```
|
||||
@@ -56,6 +57,13 @@ Use `/name <name>` to set a human-readable session name:
|
||||
/name Refactor auth module
|
||||
```
|
||||
|
||||
Set the name at startup with `--name` or `-n`:
|
||||
|
||||
```bash
|
||||
pi --name "Refactor auth module"
|
||||
pi --name "CI audit" -p "Review this build failure"
|
||||
```
|
||||
|
||||
Named sessions are easier to find in `/resume` and `pi -r`.
|
||||
|
||||
## Branching with `/tree`
|
||||
|
||||
@@ -9,6 +9,18 @@ Pi uses JSON settings files with project settings overriding global settings.
|
||||
|
||||
Edit directly or use `/settings` for common options.
|
||||
|
||||
## Project Trust
|
||||
|
||||
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 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, 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.
|
||||
|
||||
## All Settings
|
||||
|
||||
### Model & Thinking
|
||||
@@ -40,13 +52,16 @@ Edit directly or use `/settings` for common options.
|
||||
|---------|------|---------|-------------|
|
||||
| `theme` | string | `"dark"` | Theme name (`"dark"`, `"light"`, or custom) |
|
||||
| `quietStartup` | boolean | `false` | Hide startup header |
|
||||
| `defaultProjectTrust` | string | `"ask"` | Fallback project trust behavior: `"ask"`, `"always"`, or `"never"`. Global setting only |
|
||||
| `collapseChangelog` | boolean | `false` | Show condensed changelog after updates |
|
||||
| `enableInstallTelemetry` | boolean | `true` | Send an anonymous install/update version ping after first install or changelog-detected updates. This does not control update checks |
|
||||
| `enableAnalytics` | boolean | `false` | Opt-in analytics data sharing. Currently only asked for during the experimental first-time setup (`PI_EXPERIMENTAL=1`) |
|
||||
| `trackingId` | string | - | Analytics tracking identifier, generated when `enableAnalytics` is turned on |
|
||||
| `doubleEscapeAction` | string | `"tree"` | Action for double-escape: `"tree"`, `"fork"`, or `"none"` |
|
||||
| `treeFilterMode` | string | `"default"` | Default filter for `/tree`: `"default"`, `"no-tools"`, `"user-only"`, `"labeled-only"`, `"all"` |
|
||||
| `editorPaddingX` | number | `0` | Horizontal padding for input editor (0-3) |
|
||||
| `autocompleteMaxVisible` | number | `5` | Max visible items in autocomplete dropdown (3-20) |
|
||||
| `showHardwareCursor` | boolean | `false` | Show terminal cursor |
|
||||
| `showHardwareCursor` | boolean | `false` | Show the terminal cursor while TUI positions it for IME support |
|
||||
|
||||
### Telemetry and update checks
|
||||
|
||||
@@ -54,6 +69,18 @@ Edit directly or use `/settings` for common options.
|
||||
|
||||
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 |
|
||||
@@ -101,11 +128,13 @@ Set `PI_SKIP_VERSION_CHECK=1` to disable the Pi version update check. Use `--off
|
||||
| `retry.maxRetries` | number | `3` | Maximum agent-level retry attempts |
|
||||
| `retry.baseDelayMs` | number | `2000` | Base delay for agent-level exponential backoff (2s, 4s, 8s) |
|
||||
| `retry.provider.timeoutMs` | number | SDK default | Provider/SDK request timeout in milliseconds |
|
||||
| `retry.provider.maxRetries` | number | SDK default | Provider/SDK retry attempts |
|
||||
| `retry.provider.maxRetries` | number | `0` | Provider/SDK retry attempts |
|
||||
| `retry.provider.maxRetryDelayMs` | number | `60000` | Max server-requested delay before failing (60s) |
|
||||
|
||||
When a provider requests a retry delay longer than `retry.provider.maxRetryDelayMs` (e.g., Google's "quota will reset after 5h"), the request fails immediately with an informative error instead of waiting silently. Set to `0` to disable the cap.
|
||||
|
||||
Keep `retry.provider.maxRetries` at `0` unless provider-level retries are explicitly needed. Setting it above `0` can make SDK/provider retries handle out-of-usage-limit errors before Pi sees them, which may block the agent until the provider quota resets in some circumstances.
|
||||
|
||||
```json
|
||||
{
|
||||
"retry": {
|
||||
@@ -127,7 +156,9 @@ When a provider requests a retry delay longer than `retry.provider.maxRetryDelay
|
||||
|---------|------|---------|-------------|
|
||||
| `steeringMode` | string | `"one-at-a-time"` | How steering messages are sent: `"all"` or `"one-at-a-time"` |
|
||||
| `followUpMode` | string | `"one-at-a-time"` | How follow-up messages are sent: `"all"` or `"one-at-a-time"` |
|
||||
| `transport` | string | `"sse"` | Preferred transport for providers that support multiple transports: `"sse"`, `"websocket"`, or `"auto"` |
|
||||
| `transport` | string | `"auto"` | Preferred transport for providers that support multiple transports: `"sse"`, `"websocket"`, `"websocket-cached"`, or `"auto"` |
|
||||
| `httpIdleTimeoutMs` | number | `300000` | HTTP header/body idle timeout in milliseconds, also used by providers with explicit stream idle timeouts. Set to `0` to disable. |
|
||||
| `websocketConnectTimeoutMs` | number | `15000` | WebSocket connect/open handshake timeout in milliseconds for providers that support WebSocket transports. Set to `0` to disable. |
|
||||
|
||||
### Terminal & Images
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ Pi loads skills from:
|
||||
- Global:
|
||||
- `~/.pi/agent/skills/`
|
||||
- `~/.agents/skills/`
|
||||
- Project:
|
||||
- Project (only after the project is trusted):
|
||||
- `.pi/skills/`
|
||||
- `.agents/skills/` in `cwd` and ancestor directories (up to git repo root, or filesystem root when not in a repo)
|
||||
- Packages: `skills/` directories or `pi.skills` entries in `package.json`
|
||||
|
||||
@@ -6,6 +6,12 @@ Pi uses the [Kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-p
|
||||
|
||||
Work out of the box.
|
||||
|
||||
## Apple Terminal
|
||||
|
||||
Pi enables enhanced key reporting when available. If Terminal.app still sends plain Return for `Shift+Enter`, pi uses a local macOS modifier fallback to treat that Return as `Shift+Enter`.
|
||||
|
||||
This fallback only works when pi runs on the same Mac as Terminal.app. It cannot detect the local keyboard over remote SSH.
|
||||
|
||||
## Ghostty
|
||||
|
||||
Add to your Ghostty config (`~/Library/Application Support/com.mitchellh.ghostty/config` on macOS, `~/.config/ghostty/config` on Linux):
|
||||
@@ -34,7 +40,7 @@ If you want `Shift+Enter` to keep working in tmux via that remap, add `ctrl+j` t
|
||||
|
||||
## WezTerm
|
||||
|
||||
Create `~/.wezterm.lua`:
|
||||
WezTerm usually works out of the box for `Shift+Enter` via xterm modifyOtherKeys. To use the Kitty keyboard protocol explicitly, create `~/.wezterm.lua`:
|
||||
|
||||
```lua
|
||||
local wezterm = require 'wezterm'
|
||||
@@ -43,14 +49,50 @@ config.enable_kitty_keyboard = true
|
||||
return config
|
||||
```
|
||||
|
||||
On macOS, WezTerm binds `Option+Enter` to fullscreen by default. To use `Option+Enter` for pi follow-up queueing, add this key override:
|
||||
|
||||
```lua
|
||||
local wezterm = require 'wezterm'
|
||||
local config = wezterm.config_builder()
|
||||
config.keys = {
|
||||
{
|
||||
key = 'Enter',
|
||||
mods = 'ALT',
|
||||
action = wezterm.action.SendString('\x1b[13;3u'),
|
||||
},
|
||||
}
|
||||
return config
|
||||
```
|
||||
|
||||
If you already have a `config.keys` table, add the entry to it.
|
||||
|
||||
On WSL, WezTerm may require a visible hardware cursor for IME candidate window positioning. If CJK IME candidates do not follow the text cursor, set `PI_HARDWARE_CURSOR=1` before running pi or set `showHardwareCursor` to `true` in settings.
|
||||
|
||||
## Alacritty
|
||||
|
||||
Alacritty usually works out of the box for `Shift+Enter`. On macOS, `Option+Enter` may arrive as plain `Enter`. To use `Option+Enter` for pi follow-up queueing, add to `~/.config/alacritty/alacritty.toml`:
|
||||
|
||||
```toml
|
||||
[[keyboard.bindings]]
|
||||
key = "Enter"
|
||||
mods = "Alt"
|
||||
chars = "\u001b[13;3u"
|
||||
```
|
||||
|
||||
Restart Alacritty after changing the config.
|
||||
|
||||
## VS Code (Integrated Terminal)
|
||||
|
||||
VS Code 1.109.5 and newer enable Kitty keyboard protocol in the integrated terminal by default, so `Shift+Enter` should work out of the box.
|
||||
|
||||
VS Code versions older than 1.109.5 need an explicit terminal keybinding for `Shift+Enter`.
|
||||
|
||||
`keybindings.json` locations:
|
||||
- macOS: `~/Library/Application Support/Code/User/keybindings.json`
|
||||
- Linux: `~/.config/Code/User/keybindings.json`
|
||||
- Windows: `%APPDATA%\\Code\\User\\keybindings.json`
|
||||
|
||||
Add to `keybindings.json` to enable `Shift+Enter` for multi-line input:
|
||||
Add to `keybindings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -20,7 +20,7 @@ Pi loads themes from:
|
||||
|
||||
- Built-in: `dark`, `light`
|
||||
- Global: `~/.pi/agent/themes/*.json`
|
||||
- Project: `.pi/themes/*.json`
|
||||
- Project: `.pi/themes/*.json` (only after the project is trusted)
|
||||
- Packages: `themes/` directories or `pi.themes` entries in `package.json`
|
||||
- Settings: `themes` array with files or directories
|
||||
- CLI: `--theme <path>` (repeatable)
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ tmux kill-server
|
||||
tmux
|
||||
```
|
||||
|
||||
Pi requests extended key reporting automatically when Kitty keyboard protocol is not available. With `extended-keys-format csi-u`, tmux forwards modified keys in CSI-u format, which is the most reliable configuration.
|
||||
Pi requests extended key reporting automatically when Kitty keyboard protocol is not available. With `extended-keys-format csi-u`, tmux forwards modified keys in CSI-u format, which is the most reliable configuration. The `extended-keys-format` option requires tmux 3.5 or later.
|
||||
|
||||
## Why `csi-u` Is Recommended
|
||||
|
||||
@@ -57,5 +57,7 @@ This affects the default keybindings (`Enter` to submit, `Shift+Enter` for newli
|
||||
|
||||
## Requirements
|
||||
|
||||
- tmux 3.2 or later (run `tmux -V` to check)
|
||||
- tmux 3.5 or later for `extended-keys-format csi-u` (run `tmux -V` to check)
|
||||
- A terminal emulator that supports extended keys (Ghostty, Kitty, iTerm2, WezTerm, Windows Terminal)
|
||||
|
||||
With tmux 3.2 through 3.4, omit `extended-keys-format csi-u`; Pi still supports tmux's default xterm `modifyOtherKeys` format.
|
||||
|
||||
@@ -50,9 +50,9 @@ When a `Focusable` component has focus, TUI:
|
||||
1. Sets `focused = true` on the component
|
||||
2. Scans rendered output for `CURSOR_MARKER` (a zero-width APC escape sequence)
|
||||
3. Positions the hardware terminal cursor at that location
|
||||
4. Shows the hardware cursor
|
||||
4. Shows the hardware cursor only when `showHardwareCursor` is enabled
|
||||
|
||||
This enables IME candidate windows to appear at the correct position for CJK input methods. The `Editor` and `Input` built-in components already implement this interface.
|
||||
The cursor remains hidden by default. This keeps the fake cursor rendering, while still positioning the hardware cursor for terminals that track IME candidate windows with hidden cursors. Some terminals require a visible hardware cursor for IME positioning; enable it with `showHardwareCursor`, `setShowHardwareCursor(true)`, or `PI_HARDWARE_CURSOR=1`. The `Editor` and `Input` built-in components already implement this interface.
|
||||
|
||||
### Container Components with Embedded Inputs
|
||||
|
||||
@@ -145,8 +145,11 @@ const result = await ctx.ui.custom<string | null>(
|
||||
// Responsive: hide on narrow terminals
|
||||
visible: (termWidth, termHeight) => termWidth >= 80,
|
||||
},
|
||||
// Get handle for programmatic visibility control
|
||||
// Get handle for programmatic focus and visibility control
|
||||
onHandle: (handle) => {
|
||||
// handle.focus() - focus this overlay and bring it to the visual front
|
||||
// handle.unfocus() - release input to normal fallback
|
||||
// handle.unfocus({ target }) - release input to a specific component or null
|
||||
// handle.setHidden(true/false) - toggle visibility
|
||||
// handle.hide() - permanently remove
|
||||
},
|
||||
@@ -154,6 +157,12 @@ const result = await ctx.ui.custom<string | null>(
|
||||
);
|
||||
```
|
||||
|
||||
### Overlay Focus
|
||||
|
||||
A focused visible overlay keeps input ownership across temporary non-overlay UI. If an overlay opens another `ctx.ui.custom()` component without `{ overlay: true }`, that replacement UI receives input while it is active; when it closes, the focused overlay can reclaim input.
|
||||
|
||||
Use `handle.unfocus()` when a visible overlay should stop owning input and let TUI fall back to another visible capturing overlay or the previous focus target. Use `handle.unfocus({ target })` when a specific component should receive input while the overlay stays visible. Passing `{ target: null }` intentionally leaves no focused component until focus is set again.
|
||||
|
||||
### Overlay Lifecycle
|
||||
|
||||
Overlay components are disposed when closed. Don't reuse references - create fresh instances:
|
||||
@@ -248,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(
|
||||
|
||||
@@ -76,6 +76,7 @@ Sessions are saved automatically to `~/.pi/agent/sessions/`, organized by workin
|
||||
pi -c # Continue most recent session
|
||||
pi -r # Browse and select a session
|
||||
pi --no-session # Ephemeral mode; do not save
|
||||
pi --name "my task" # Set session display name at startup
|
||||
pi --session <path|id> # Use a specific session file or session ID
|
||||
pi --fork <path|id> # Fork a session into a new session file
|
||||
```
|
||||
@@ -109,6 +110,21 @@ Replace the default system prompt with:
|
||||
|
||||
Append to the default prompt without replacing it with `APPEND_SYSTEM.md` in either location.
|
||||
|
||||
### Project Trust
|
||||
|
||||
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 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, 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.
|
||||
|
||||
|
||||
## Exporting and Sharing Sessions
|
||||
|
||||
Use `/export [file]` to write a session to HTML.
|
||||
@@ -129,15 +145,16 @@ 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 --extensions # Update packages only
|
||||
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
|
||||
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).
|
||||
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.
|
||||
|
||||
@@ -178,12 +195,14 @@ cat README.md | pi -p "Summarize this text"
|
||||
| `--fork <path\|id>` | Fork a session file or partial UUID into a new session |
|
||||
| `--session-dir <dir>` | Custom session storage directory |
|
||||
| `--no-session` | Ephemeral mode; do not save |
|
||||
| `--name <name>`, `-n <name>` | Set session display name at startup |
|
||||
|
||||
### Tool Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--tools <list>`, `-t <list>` | Allowlist specific built-in, extension, and custom tools |
|
||||
| `--exclude-tools <list>`, `-xt <list>` | Disable specific built-in, extension, and custom tools |
|
||||
| `--no-builtin-tools`, `-nbt` | Disable built-in tools but keep extension/custom tools enabled |
|
||||
| `--no-tools`, `-nt` | Disable all tools |
|
||||
|
||||
@@ -216,6 +235,8 @@ pi --no-extensions -e ./my-extension.ts
|
||||
| `--system-prompt <text>` | Replace default prompt; context files and skills are still appended |
|
||||
| `--append-system-prompt <text>` | Append to system prompt |
|
||||
| `--verbose` | Force verbose startup |
|
||||
| `-a`, `--approve` | Trust project-local files for this run |
|
||||
| `-na`, `--no-approve` | Ignore project-local files for this run |
|
||||
| `-h`, `--help` | Show help |
|
||||
| `-v`, `--version` | Show version |
|
||||
|
||||
@@ -241,6 +262,9 @@ pi -p "Summarize this codebase"
|
||||
# Non-interactive with piped stdin
|
||||
cat README.md | pi -p "Summarize this text"
|
||||
|
||||
# Named one-shot session
|
||||
pi --name "release audit" -p "Audit this repository"
|
||||
|
||||
# Different model
|
||||
pi --provider openai --model gpt-4o "Help me refactor"
|
||||
|
||||
@@ -255,6 +279,9 @@ pi --models "claude-*,gpt-4o"
|
||||
|
||||
# Read-only mode
|
||||
pi --tools read,grep,find,ls -p "Review the code"
|
||||
|
||||
# Disable one extension or built-in tool while keeping the rest available
|
||||
pi --exclude-tools ask_question
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
@@ -266,7 +293,7 @@ pi --tools read,grep,find,ls -p "Review the code"
|
||||
| `PI_PACKAGE_DIR` | Override package directory, useful for Nix/Guix store paths |
|
||||
| `PI_OFFLINE` | Disable startup network operations, including update checks, package update checks, and install/update telemetry |
|
||||
| `PI_SKIP_VERSION_CHECK` | Skip the Pi version update check at startup. This prevents the `pi.dev` latest-version request |
|
||||
| `PI_TELEMETRY` | Override install/update telemetry: `1`/`true`/`yes` or `0`/`false`/`no`. This does not disable update checks |
|
||||
| `PI_TELEMETRY` | Override install/update telemetry and provider attribution headers: `1`/`true`/`yes` or `0`/`false`/`no`. This does not disable update checks |
|
||||
| `PI_CACHE_RETENTION` | Set to `long` for extended prompt cache where supported |
|
||||
| `VISUAL`, `EDITOR` | External editor for Ctrl+G |
|
||||
|
||||
|
||||
@@ -19,10 +19,12 @@ cp permission-gate.ts ~/.pi/agent/extensions/
|
||||
| Extension | Description |
|
||||
|-----------|-------------|
|
||||
| `permission-gate.ts` | Prompts for confirmation before dangerous bash commands (rm -rf, sudo, etc.) |
|
||||
| `project-trust.ts` | Demonstrates the `project_trust` event for user/global and CLI extensions |
|
||||
| `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) |
|
||||
| `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, fork) |
|
||||
| `dirty-repo-guard.ts` | Prevents session changes with uncommitted git changes |
|
||||
| `sandbox/` | OS-level sandboxing using `@anthropic-ai/sandbox-runtime` with per-project config |
|
||||
| `gondolin/` | Route built-in tools and `!` commands into a Gondolin micro-VM |
|
||||
|
||||
### Custom Tools
|
||||
|
||||
@@ -75,6 +77,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
|
||||
| `reload-runtime.ts` | Adds `/reload-runtime` and `reload_runtime` tool showing safe reload flow |
|
||||
| `interactive-shell.ts` | Run interactive commands (vim, htop) with full terminal via `user_bash` hook |
|
||||
| `inline-bash.ts` | Expands `!{command}` patterns in prompts via `input` event transformation |
|
||||
| `input-transform-streaming.ts` | Skips expensive input preprocessing for mid-stream steering via `streamingBehavior` |
|
||||
|
||||
### Git Integration
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ function getPiMascot(theme: Theme): string[] {
|
||||
export default function (pi: ExtensionAPI) {
|
||||
// Set custom header immediately on load (if UI is available)
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
if (ctx.hasUI) {
|
||||
if (ctx.mode === "tui") {
|
||||
ctx.ui.setHeader((_tui, theme) => {
|
||||
return {
|
||||
render(_width: number): string[] {
|
||||
|
||||
@@ -568,7 +568,7 @@ function streamCustomAnthropic(
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerProvider("custom-anthropic", {
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
apiKey: "CUSTOM_ANTHROPIC_API_KEY",
|
||||
apiKey: "$CUSTOM_ANTHROPIC_API_KEY",
|
||||
api: "custom-anthropic-api",
|
||||
|
||||
models: [
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pi-extension-custom-provider",
|
||||
"version": "0.75.4",
|
||||
"version": "0.79.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-custom-provider",
|
||||
"version": "0.75.4",
|
||||
"version": "0.79.9",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.52.0"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pi-extension-custom-provider-anthropic",
|
||||
"private": true,
|
||||
"version": "0.75.4",
|
||||
"version": "0.79.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type SimpleStreamOptions,
|
||||
streamSimpleAnthropic,
|
||||
streamSimpleOpenAIResponses,
|
||||
type ThinkingLevelMap,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
@@ -49,6 +50,7 @@ interface GitLabModel {
|
||||
backend: Backend;
|
||||
baseUrl: string;
|
||||
reasoning: boolean;
|
||||
thinkingLevelMap?: ThinkingLevelMap;
|
||||
input: ("text" | "image")[];
|
||||
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
||||
contextWindow: number;
|
||||
@@ -57,12 +59,37 @@ interface GitLabModel {
|
||||
|
||||
export const MODELS: GitLabModel[] = [
|
||||
// Anthropic
|
||||
{
|
||||
id: "claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
backend: "anthropic",
|
||||
baseUrl: ANTHROPIC_PROXY_URL,
|
||||
reasoning: true,
|
||||
thinkingLevelMap: { xhigh: "max" },
|
||||
input: ["text", "image"],
|
||||
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 128000,
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
backend: "anthropic",
|
||||
baseUrl: ANTHROPIC_PROXY_URL,
|
||||
reasoning: true,
|
||||
thinkingLevelMap: { xhigh: "max" },
|
||||
input: ["text", "image"],
|
||||
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 64000,
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4-5-20251101",
|
||||
name: "Claude Opus 4.5",
|
||||
backend: "anthropic",
|
||||
baseUrl: ANTHROPIC_PROXY_URL,
|
||||
reasoning: true,
|
||||
thinkingLevelMap: { xhigh: "max" },
|
||||
input: ["text", "image"],
|
||||
cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
|
||||
contextWindow: 200000,
|
||||
@@ -74,6 +101,7 @@ export const MODELS: GitLabModel[] = [
|
||||
backend: "anthropic",
|
||||
baseUrl: ANTHROPIC_PROXY_URL,
|
||||
reasoning: true,
|
||||
thinkingLevelMap: { xhigh: "max" },
|
||||
input: ["text", "image"],
|
||||
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
||||
contextWindow: 200000,
|
||||
@@ -85,12 +113,24 @@ export const MODELS: GitLabModel[] = [
|
||||
backend: "anthropic",
|
||||
baseUrl: ANTHROPIC_PROXY_URL,
|
||||
reasoning: true,
|
||||
thinkingLevelMap: { xhigh: "max" },
|
||||
input: ["text", "image"],
|
||||
cost: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
// OpenAI (all use Responses API)
|
||||
{
|
||||
id: "gpt-5.5-2026-04-23",
|
||||
name: "GPT-5.5",
|
||||
backend: "openai",
|
||||
baseUrl: OPENAI_PROXY_URL,
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 },
|
||||
contextWindow: 272000,
|
||||
maxTokens: 128000,
|
||||
},
|
||||
{
|
||||
id: "gpt-5.1-2025-11-13",
|
||||
name: "GPT-5.1",
|
||||
@@ -285,7 +325,17 @@ export function streamGitLabDuo(
|
||||
|
||||
const innerStream =
|
||||
cfg.backend === "anthropic"
|
||||
? streamSimpleAnthropic(modelWithBaseUrl as Model<"anthropic-messages">, context, streamOptions)
|
||||
? streamSimpleAnthropic(
|
||||
{
|
||||
...(modelWithBaseUrl as Model<"anthropic-messages">),
|
||||
compat: {
|
||||
...(modelWithBaseUrl as Model<"anthropic-messages">).compat,
|
||||
forceAdaptiveThinking: true,
|
||||
},
|
||||
},
|
||||
context,
|
||||
streamOptions,
|
||||
)
|
||||
: streamSimpleOpenAIResponses(modelWithBaseUrl as Model<"openai-responses">, context, streamOptions);
|
||||
|
||||
for await (const event of innerStream) stream.push(event);
|
||||
@@ -327,12 +377,13 @@ export function streamGitLabDuo(
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerProvider("gitlab-duo", {
|
||||
baseUrl: AI_GATEWAY_URL,
|
||||
apiKey: "GITLAB_TOKEN",
|
||||
apiKey: "$GITLAB_TOKEN",
|
||||
api: "gitlab-duo-api",
|
||||
models: MODELS.map(({ id, name, reasoning, input, cost, contextWindow, maxTokens }) => ({
|
||||
models: MODELS.map(({ id, name, reasoning, thinkingLevelMap, input, cost, contextWindow, maxTokens }) => ({
|
||||
id,
|
||||
name,
|
||||
reasoning,
|
||||
thinkingLevelMap,
|
||||
input,
|
||||
cost,
|
||||
contextWindow,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pi-extension-custom-provider-gitlab-duo",
|
||||
"private": true,
|
||||
"version": "0.75.4",
|
||||
"version": "0.79.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function (pi: ExtensionAPI) {
|
||||
description: "Play DOOM as an overlay. Q to pause and exit.",
|
||||
|
||||
handler: async (args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
if (ctx.mode !== "tui") {
|
||||
ctx.ui.notify("DOOM requires interactive mode", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Merge and Resolve
|
||||
*
|
||||
* Keeps the working branch up to date with its upstream tracking ref.
|
||||
* After each agent turn, fetches and merges. Clean merges complete
|
||||
* silently. When conflicts arise, the working tree is left dirty and
|
||||
* the agent receives a follow-up message listing each conflict block
|
||||
* with file, line range, and ours/theirs sections so it can resolve them.
|
||||
* Also re-sends unresolved conflicts from a previous incomplete merge.
|
||||
*
|
||||
* Start pi with this extension:
|
||||
* pi -e ./examples/extensions/git-merge-and-resolve.ts
|
||||
*/
|
||||
import { createReadStream } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
interface ConflictBlock {
|
||||
file: string;
|
||||
startLine: number;
|
||||
separatorLine: number;
|
||||
endLine: number;
|
||||
}
|
||||
|
||||
/** Parse conflict markers from working tree files with unmerged paths. */
|
||||
async function findConflicts(pi: ExtensionAPI, cwd: string): Promise<ConflictBlock[]> {
|
||||
const { stdout, code } = await pi.exec("git", ["diff", "--name-only", "--diff-filter=U"]);
|
||||
if (code !== 0 || !stdout.trim()) return [];
|
||||
|
||||
const blocks: ConflictBlock[] = [];
|
||||
for (const file of stdout.trim().split("\n")) {
|
||||
try {
|
||||
const rl = createInterface({ input: createReadStream(join(cwd, file), "utf-8") });
|
||||
let lineNo = 0;
|
||||
let blockStart: number | undefined;
|
||||
let separatorLine: number | undefined;
|
||||
for await (const line of rl) {
|
||||
lineNo++;
|
||||
if (line.startsWith("<<<<<<<")) {
|
||||
blockStart = lineNo;
|
||||
separatorLine = undefined;
|
||||
} else if (line.startsWith("=======") && blockStart !== undefined) {
|
||||
separatorLine = lineNo;
|
||||
} else if (line.startsWith(">>>>>>>") && blockStart !== undefined && separatorLine !== undefined) {
|
||||
blocks.push({ file, startLine: blockStart, separatorLine, endLine: lineNo });
|
||||
blockStart = undefined;
|
||||
separatorLine = undefined;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function formatRange(start: number, end: number): string {
|
||||
if (start > end) return "empty";
|
||||
if (start === end) return `${start}`;
|
||||
return `${start}-${end}`;
|
||||
}
|
||||
|
||||
function formatConflicts(ref: string, blocks: ConflictBlock[]): string {
|
||||
const lines = [`Merged ${ref} with conflicts:`, ""];
|
||||
for (const b of blocks) {
|
||||
const ours = formatRange(b.startLine + 1, b.separatorLine - 1);
|
||||
const theirs = formatRange(b.separatorLine + 1, b.endLine - 1);
|
||||
lines.push(` ${b.file}:${b.startLine}-${b.endLine} (ours ${ours}, theirs ${theirs})`);
|
||||
}
|
||||
lines.push("", "Resolve these conflicts.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("agent_end", async (_event, ctx) => {
|
||||
const { code: revParseCode } = await pi.exec("git", ["rev-parse", "--git-dir"]);
|
||||
if (revParseCode !== 0) return;
|
||||
|
||||
let ref = "MERGE_HEAD";
|
||||
|
||||
// If not already in a merge, attempt one
|
||||
const { code: mergeHeadCode } = await pi.exec("git", ["rev-parse", "MERGE_HEAD"]);
|
||||
if (mergeHeadCode !== 0) {
|
||||
// Only attempt a new merge if the working tree is clean
|
||||
const { stdout: status } = await pi.exec("git", ["status", "--porcelain"]);
|
||||
if (status.trim()) return;
|
||||
|
||||
const { stdout: upstream, code: upstreamCode } = await pi.exec("git", [
|
||||
"rev-parse",
|
||||
"--abbrev-ref",
|
||||
"--symbolic-full-name",
|
||||
"@{u}",
|
||||
]);
|
||||
if (upstreamCode !== 0) return;
|
||||
|
||||
ref = upstream.trim();
|
||||
const remote = ref.split("/")[0];
|
||||
ctx.ui.notify(`git-merge-and-resolve: fetching ${remote}, merging ${ref}`, "info");
|
||||
|
||||
const { code: fetchCode, stderr: fetchErr } = await pi.exec("git", ["fetch", remote]);
|
||||
if (fetchCode !== 0) {
|
||||
ctx.ui.notify(`git-merge-and-resolve: fetch failed: ${fetchErr.trim()}`, "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
const { code: mergeCode } = await pi.exec("git", ["merge", "--no-ff", ref]);
|
||||
if (mergeCode === 0) return;
|
||||
}
|
||||
|
||||
// Either we just merged with conflicts, or we were already in an unfinished merge
|
||||
const conflicts = await findConflicts(pi, ctx.cwd);
|
||||
if (conflicts.length === 0) return;
|
||||
|
||||
pi.sendUserMessage(formatConflicts(ref, conflicts), { deliverAs: "followUp" });
|
||||
});
|
||||
}
|
||||
1
packages/coding-agent/examples/extensions/gondolin/.gitignore
vendored
Normal file
1
packages/coding-agent/examples/extensions/gondolin/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
531
packages/coding-agent/examples/extensions/gondolin/index.ts
Normal file
531
packages/coding-agent/examples/extensions/gondolin/index.ts
Normal file
@@ -0,0 +1,531 @@
|
||||
/**
|
||||
* Gondolin Tool Routing Example
|
||||
*
|
||||
* Runs pi's built-in tools inside a local Gondolin micro-VM. The host working
|
||||
* directory is mounted at /workspace in the guest. File changes under
|
||||
* /workspace write through to the host; other guest filesystem changes are
|
||||
* isolated to the VM.
|
||||
*
|
||||
* Setup:
|
||||
* cd packages/coding-agent/examples/extensions/gondolin
|
||||
* npm install --ignore-scripts
|
||||
*
|
||||
* Usage:
|
||||
* cd /path/to/project
|
||||
* pi -e /path/to/pi/packages/coding-agent/examples/extensions/gondolin
|
||||
*
|
||||
* Requirements:
|
||||
* - Node.js >= 23.6.0 for @earendil-works/gondolin
|
||||
* - QEMU installed (for example, `brew install qemu` on macOS)
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import { RealFSProvider, VM } from "@earendil-works/gondolin";
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
type BashOperations,
|
||||
createBashTool,
|
||||
createEditTool,
|
||||
createFindTool,
|
||||
createGrepTool,
|
||||
createLsTool,
|
||||
createReadTool,
|
||||
createWriteTool,
|
||||
DEFAULT_MAX_BYTES,
|
||||
type EditOperations,
|
||||
type FindOperations,
|
||||
formatSize,
|
||||
type GrepToolDetails,
|
||||
type GrepToolInput,
|
||||
type LsOperations,
|
||||
type ReadOperations,
|
||||
truncateHead,
|
||||
truncateLine,
|
||||
type WriteOperations,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const GUEST_WORKSPACE = "/workspace";
|
||||
const DEFAULT_GREP_LIMIT = 100;
|
||||
|
||||
type TextToolResult<TDetails> = {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
details: TDetails | undefined;
|
||||
};
|
||||
|
||||
function stripAtPrefix(value: string): string {
|
||||
return value.startsWith("@") ? value.slice(1) : value;
|
||||
}
|
||||
|
||||
function toPosix(value: string): string {
|
||||
return value.split(path.sep).join(path.posix.sep);
|
||||
}
|
||||
|
||||
function isInsideHostPath(root: string, value: string): boolean {
|
||||
const relativePath = path.relative(root, value);
|
||||
return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
|
||||
}
|
||||
|
||||
function hostPathToGuest(localCwd: string, hostPath: string): string {
|
||||
const relativePath = path.relative(localCwd, hostPath);
|
||||
if (!isInsideHostPath(localCwd, hostPath)) return toPosix(hostPath);
|
||||
return relativePath ? path.posix.join(GUEST_WORKSPACE, toPosix(relativePath)) : GUEST_WORKSPACE;
|
||||
}
|
||||
|
||||
function toGuestPath(localCwd: string, inputPath: string): string {
|
||||
const trimmed = stripAtPrefix(inputPath.trim());
|
||||
if (!trimmed) return GUEST_WORKSPACE;
|
||||
if (path.isAbsolute(trimmed)) {
|
||||
if (isInsideHostPath(localCwd, trimmed)) return hostPathToGuest(localCwd, trimmed);
|
||||
return path.posix.resolve("/", toPosix(trimmed));
|
||||
}
|
||||
return path.posix.resolve(GUEST_WORKSPACE, toPosix(trimmed));
|
||||
}
|
||||
|
||||
function createGondolinReadOps(vm: VM, localCwd: string): ReadOperations {
|
||||
return {
|
||||
readFile: async (filePath) => vm.fs.readFile(toGuestPath(localCwd, filePath)),
|
||||
access: async (filePath) => {
|
||||
await vm.fs.access(toGuestPath(localCwd, filePath));
|
||||
},
|
||||
detectImageMimeType: async (filePath) => {
|
||||
const ext = path.posix.extname(toGuestPath(localCwd, filePath)).toLowerCase();
|
||||
if (ext === ".png") return "image/png";
|
||||
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
||||
if (ext === ".gif") return "image/gif";
|
||||
if (ext === ".webp") return "image/webp";
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createGondolinWriteOps(vm: VM, localCwd: string): WriteOperations {
|
||||
return {
|
||||
writeFile: async (filePath, content) => {
|
||||
await vm.fs.writeFile(toGuestPath(localCwd, filePath), content, { encoding: "utf8" });
|
||||
},
|
||||
mkdir: async (dirPath) => {
|
||||
await vm.fs.mkdir(toGuestPath(localCwd, dirPath), { recursive: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createGondolinEditOps(vm: VM, localCwd: string): EditOperations {
|
||||
const readOps = createGondolinReadOps(vm, localCwd);
|
||||
const writeOps = createGondolinWriteOps(vm, localCwd);
|
||||
return {
|
||||
readFile: readOps.readFile,
|
||||
writeFile: writeOps.writeFile,
|
||||
access: readOps.access,
|
||||
};
|
||||
}
|
||||
|
||||
function createGondolinLsOps(vm: VM, localCwd: string): LsOperations {
|
||||
return {
|
||||
exists: async (filePath) => {
|
||||
try {
|
||||
await vm.fs.access(toGuestPath(localCwd, filePath));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
stat: async (filePath) => vm.fs.stat(toGuestPath(localCwd, filePath)),
|
||||
readdir: async (dirPath) => vm.fs.listDir(toGuestPath(localCwd, dirPath)),
|
||||
};
|
||||
}
|
||||
|
||||
async function walkGuestFiles(
|
||||
vm: VM,
|
||||
root: string,
|
||||
visit: (guestPath: string, relativePath: string) => Promise<boolean>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
if (signal?.aborted) throw new Error("Operation aborted");
|
||||
const stat = await vm.fs.stat(root, { signal });
|
||||
if (!stat.isDirectory()) return visit(root, path.posix.basename(root));
|
||||
|
||||
const walkDirectory = async (dir: string, relativeDir: string): Promise<boolean> => {
|
||||
if (signal?.aborted) throw new Error("Operation aborted");
|
||||
const entries = await vm.fs.listDir(dir, { signal });
|
||||
for (const entry of entries) {
|
||||
if (entry === ".git" || entry === "node_modules") continue;
|
||||
const guestPath = path.posix.join(dir, entry);
|
||||
const relativePath = relativeDir ? path.posix.join(relativeDir, entry) : entry;
|
||||
let entryStat: Awaited<ReturnType<VM["fs"]["stat"]>>;
|
||||
try {
|
||||
entryStat = await vm.fs.stat(guestPath, { signal });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (entryStat.isDirectory()) {
|
||||
if (!(await walkDirectory(guestPath, relativePath))) return false;
|
||||
} else if (!(await visit(guestPath, relativePath))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return walkDirectory(root, "");
|
||||
}
|
||||
|
||||
function matchesToolGlob(relativePath: string, pattern: string): boolean {
|
||||
const normalizedPattern = toPosix(pattern);
|
||||
if (normalizedPattern.includes("/")) {
|
||||
return (
|
||||
path.posix.matchesGlob(relativePath, normalizedPattern) ||
|
||||
path.posix.matchesGlob(relativePath, `**/${normalizedPattern}`)
|
||||
);
|
||||
}
|
||||
return path.posix.matchesGlob(path.posix.basename(relativePath), normalizedPattern);
|
||||
}
|
||||
|
||||
function createGondolinFindOps(vm: VM, localCwd: string): FindOperations {
|
||||
return {
|
||||
exists: async (filePath) => {
|
||||
try {
|
||||
await vm.fs.access(toGuestPath(localCwd, filePath));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
glob: async (pattern, cwd, options) => {
|
||||
const root = toGuestPath(localCwd, cwd);
|
||||
const results: string[] = [];
|
||||
await walkGuestFiles(vm, root, async (guestPath, relativePath) => {
|
||||
if (results.length >= options.limit) return false;
|
||||
if (matchesToolGlob(relativePath, pattern)) results.push(guestPath);
|
||||
return results.length < options.limit;
|
||||
});
|
||||
return results;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createLineMatcher(pattern: string, literal: boolean | undefined, ignoreCase: boolean | undefined) {
|
||||
if (literal) {
|
||||
const needle = ignoreCase ? pattern.toLowerCase() : pattern;
|
||||
return (line: string) => (ignoreCase ? line.toLowerCase() : line).includes(needle);
|
||||
}
|
||||
const regex = new RegExp(pattern, ignoreCase ? "i" : undefined);
|
||||
return (line: string) => regex.test(line);
|
||||
}
|
||||
|
||||
function appendGrepBlock(params: {
|
||||
outputLines: string[];
|
||||
lines: string[];
|
||||
relativePath: string;
|
||||
lineIndex: number;
|
||||
contextLines: number;
|
||||
}): boolean {
|
||||
let linesTruncated = false;
|
||||
const start = params.contextLines > 0 ? Math.max(0, params.lineIndex - params.contextLines) : params.lineIndex;
|
||||
const end =
|
||||
params.contextLines > 0
|
||||
? Math.min(params.lines.length - 1, params.lineIndex + params.contextLines)
|
||||
: params.lineIndex;
|
||||
|
||||
for (let index = start; index <= end; index++) {
|
||||
const rawLine = params.lines[index] ?? "";
|
||||
const { text, wasTruncated } = truncateLine(rawLine.replace(/\r/g, ""));
|
||||
if (wasTruncated) linesTruncated = true;
|
||||
const separator = index === params.lineIndex ? ":" : "-";
|
||||
params.outputLines.push(`${params.relativePath}${separator}${index + 1}${separator} ${text}`);
|
||||
}
|
||||
return linesTruncated;
|
||||
}
|
||||
|
||||
async function executeGondolinGrep(
|
||||
vm: VM,
|
||||
localCwd: string,
|
||||
params: GrepToolInput,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TextToolResult<GrepToolDetails>> {
|
||||
const root = toGuestPath(localCwd, params.path ?? ".");
|
||||
const rootStat = await vm.fs.stat(root, { signal });
|
||||
const rootIsDirectory = rootStat.isDirectory();
|
||||
const matcher = createLineMatcher(params.pattern, params.literal, params.ignoreCase);
|
||||
const contextLines = params.context && params.context > 0 ? params.context : 0;
|
||||
const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
|
||||
const outputLines: string[] = [];
|
||||
const details: GrepToolDetails = {};
|
||||
let matchCount = 0;
|
||||
let matchLimitReached = false;
|
||||
let linesTruncated = false;
|
||||
|
||||
await walkGuestFiles(
|
||||
vm,
|
||||
root,
|
||||
async (guestPath, relativePath) => {
|
||||
if (matchCount >= effectiveLimit) return false;
|
||||
if (params.glob && !matchesToolGlob(relativePath, params.glob)) return true;
|
||||
let content: string;
|
||||
try {
|
||||
content = await vm.fs.readFile(guestPath, { encoding: "utf8", signal });
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
const displayPath = rootIsDirectory ? relativePath : path.posix.basename(guestPath);
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
if (signal?.aborted) throw new Error("Operation aborted");
|
||||
if (!matcher(lines[index] ?? "")) continue;
|
||||
matchCount++;
|
||||
if (appendGrepBlock({ outputLines, lines, relativePath: displayPath, lineIndex: index, contextLines })) {
|
||||
linesTruncated = true;
|
||||
}
|
||||
if (matchCount >= effectiveLimit) {
|
||||
matchLimitReached = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
signal,
|
||||
);
|
||||
|
||||
if (matchCount === 0) return { content: [{ type: "text", text: "No matches found" }], details: undefined };
|
||||
|
||||
const rawOutput = outputLines.join("\n");
|
||||
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
|
||||
const notices: string[] = [];
|
||||
let output = truncation.content;
|
||||
|
||||
if (matchLimitReached) {
|
||||
details.matchLimitReached = effectiveLimit;
|
||||
notices.push(`${effectiveLimit} matches limit reached`);
|
||||
}
|
||||
if (linesTruncated) {
|
||||
details.linesTruncated = true;
|
||||
notices.push("long lines truncated");
|
||||
}
|
||||
if (truncation.truncated) {
|
||||
details.truncation = truncation;
|
||||
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
|
||||
}
|
||||
if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: output }],
|
||||
details: Object.keys(details).length > 0 ? details : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeEnv(env: NodeJS.ProcessEnv | undefined): Record<string, string> | undefined {
|
||||
if (!env) return undefined;
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (typeof value === "string") result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function createGondolinBashOps(vm: VM, localCwd: string, shellPath: string): BashOperations {
|
||||
return {
|
||||
exec: async (command, cwd, { onData, signal, timeout, env }) => {
|
||||
if (signal?.aborted) throw new Error("aborted");
|
||||
const guestCwd = toGuestPath(localCwd, cwd);
|
||||
const controller = new AbortController();
|
||||
const onAbort = () => controller.abort();
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
let timedOut = false;
|
||||
const timer =
|
||||
timeout && timeout > 0
|
||||
? setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, timeout * 1000)
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const proc = vm.exec([shellPath, "-lc", command], {
|
||||
cwd: guestCwd,
|
||||
env: sanitizeEnv(env),
|
||||
signal: controller.signal,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
for await (const chunk of proc.output()) onData(chunk.data);
|
||||
const result = await proc;
|
||||
return { exitCode: result.exitCode };
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw new Error("aborted");
|
||||
if (timedOut) throw new Error(`timeout:${timeout}`);
|
||||
throw error;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
const localCwd = process.cwd();
|
||||
const localRead = createReadTool(localCwd);
|
||||
const localWrite = createWriteTool(localCwd);
|
||||
const localEdit = createEditTool(localCwd);
|
||||
const localBash = createBashTool(localCwd);
|
||||
const localGrep = createGrepTool(localCwd);
|
||||
const localFind = createFindTool(localCwd);
|
||||
const localLs = createLsTool(localCwd);
|
||||
|
||||
let vm: VM | undefined;
|
||||
let vmStarting: Promise<VM> | undefined;
|
||||
let shellPath = "/bin/sh";
|
||||
|
||||
async function startVm(ctx?: ExtensionContext): Promise<VM> {
|
||||
ctx?.ui.setStatus("gondolin", ctx.ui.theme.fg("accent", `Gondolin: starting ${GUEST_WORKSPACE}`));
|
||||
const created = await VM.create({
|
||||
sessionLabel: `pi ${path.basename(localCwd)}`,
|
||||
vfs: {
|
||||
mounts: {
|
||||
[GUEST_WORKSPACE]: new RealFSProvider(localCwd),
|
||||
},
|
||||
},
|
||||
});
|
||||
const bashProbe = await created.exec(["/bin/sh", "-lc", "command -v bash || true"]);
|
||||
shellPath = bashProbe.stdout.trim() || "/bin/sh";
|
||||
vm = created;
|
||||
ctx?.ui.setStatus(
|
||||
"gondolin",
|
||||
ctx.ui.theme.fg("accent", `Gondolin: ${created.id.slice(0, 8)} (${GUEST_WORKSPACE})`),
|
||||
);
|
||||
ctx?.ui.notify(`Gondolin VM ready. ${localCwd} is mounted at ${GUEST_WORKSPACE}.`, "info");
|
||||
return created;
|
||||
}
|
||||
|
||||
async function ensureVm(ctx?: ExtensionContext): Promise<VM> {
|
||||
if (vm) return vm;
|
||||
if (!vmStarting) {
|
||||
vmStarting = startVm(ctx).finally(() => {
|
||||
vmStarting = undefined;
|
||||
});
|
||||
}
|
||||
return vmStarting;
|
||||
}
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
await ensureVm(ctx);
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async (_event, ctx) => {
|
||||
const activeVm = vm;
|
||||
vm = undefined;
|
||||
vmStarting = undefined;
|
||||
if (!activeVm) return;
|
||||
ctx.ui.setStatus("gondolin", ctx.ui.theme.fg("muted", "Gondolin: stopping"));
|
||||
try {
|
||||
await activeVm.close();
|
||||
} finally {
|
||||
ctx.ui.setStatus("gondolin", undefined);
|
||||
}
|
||||
});
|
||||
|
||||
pi.registerCommand("gondolin", {
|
||||
description: "Show Gondolin VM status",
|
||||
handler: async (_args, ctx) => {
|
||||
const activeVm = await ensureVm(ctx);
|
||||
ctx.ui.notify(
|
||||
[
|
||||
`Gondolin VM: ${activeVm.id}`,
|
||||
`Host workspace: ${localCwd}`,
|
||||
`Guest workspace: ${GUEST_WORKSPACE}`,
|
||||
`Shell: ${shellPath}`,
|
||||
].join("\n"),
|
||||
"info",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
...localRead,
|
||||
async execute(id, params, signal, onUpdate, ctx) {
|
||||
const activeVm = await ensureVm(ctx);
|
||||
const tool = createReadTool(GUEST_WORKSPACE, {
|
||||
operations: createGondolinReadOps(activeVm, localCwd),
|
||||
});
|
||||
return tool.execute(id, params, signal, onUpdate);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
...localWrite,
|
||||
async execute(id, params, signal, onUpdate, ctx) {
|
||||
const activeVm = await ensureVm(ctx);
|
||||
const tool = createWriteTool(GUEST_WORKSPACE, {
|
||||
operations: createGondolinWriteOps(activeVm, localCwd),
|
||||
});
|
||||
return tool.execute(id, params, signal, onUpdate);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
...localEdit,
|
||||
async execute(id, params, signal, onUpdate, ctx) {
|
||||
const activeVm = await ensureVm(ctx);
|
||||
const tool = createEditTool(GUEST_WORKSPACE, {
|
||||
operations: createGondolinEditOps(activeVm, localCwd),
|
||||
});
|
||||
return tool.execute(id, params, signal, onUpdate);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
...localBash,
|
||||
async execute(id, params, signal, onUpdate, ctx) {
|
||||
const activeVm = await ensureVm(ctx);
|
||||
const tool = createBashTool(GUEST_WORKSPACE, {
|
||||
operations: createGondolinBashOps(activeVm, localCwd, shellPath),
|
||||
});
|
||||
return tool.execute(id, params, signal, onUpdate);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
...localLs,
|
||||
async execute(id, params, signal, onUpdate, ctx) {
|
||||
const activeVm = await ensureVm(ctx);
|
||||
const tool = createLsTool(GUEST_WORKSPACE, {
|
||||
operations: createGondolinLsOps(activeVm, localCwd),
|
||||
});
|
||||
return tool.execute(id, params, signal, onUpdate);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
...localFind,
|
||||
async execute(id, params, signal, onUpdate, ctx) {
|
||||
const activeVm = await ensureVm(ctx);
|
||||
const tool = createFindTool(GUEST_WORKSPACE, {
|
||||
operations: createGondolinFindOps(activeVm, localCwd),
|
||||
});
|
||||
return tool.execute(id, params, signal, onUpdate);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
...localGrep,
|
||||
async execute(_id, params, signal, _onUpdate, ctx) {
|
||||
const activeVm = await ensureVm(ctx);
|
||||
return executeGondolinGrep(activeVm, localCwd, params, signal);
|
||||
},
|
||||
});
|
||||
|
||||
pi.on("user_bash", async (_event, ctx) => {
|
||||
const activeVm = await ensureVm(ctx);
|
||||
return { operations: createGondolinBashOps(activeVm, localCwd, shellPath) };
|
||||
});
|
||||
|
||||
pi.on("before_agent_start", async (event, ctx) => {
|
||||
await ensureVm(ctx);
|
||||
const localLine = `Current working directory: ${localCwd}`;
|
||||
const guestLine = `Current working directory: ${GUEST_WORKSPACE} (Gondolin VM; host workspace mounted from ${localCwd})`;
|
||||
const systemPrompt = event.systemPrompt.includes(localLine)
|
||||
? event.systemPrompt.replace(localLine, guestLine)
|
||||
: `${event.systemPrompt}\n\n${guestLine}`;
|
||||
return { systemPrompt };
|
||||
});
|
||||
}
|
||||
185
packages/coding-agent/examples/extensions/gondolin/package-lock.json
generated
Normal file
185
packages/coding-agent/examples/extensions/gondolin/package-lock.json
generated
Normal file
@@ -0,0 +1,185 @@
|
||||
{
|
||||
"name": "pi-extension-gondolin",
|
||||
"version": "0.79.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-gondolin",
|
||||
"version": "0.79.9",
|
||||
"dependencies": {
|
||||
"@earendil-works/gondolin": "0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cto.af/wtf8": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@cto.af/wtf8/-/wtf8-0.0.5.tgz",
|
||||
"integrity": "sha512-LfUFi+Vv4eDzj+XAtR89e3wwjXA/NZjUSwU5NhwbBrLecxPaBYFy3exCuc1j+D4UZeOVdqlsl8G7LmOt18V0tg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/gondolin": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/gondolin/-/gondolin-0.12.0.tgz",
|
||||
"integrity": "sha512-BXbvzQKb5QmxY5NtthRDONJTu7+IDKbzqWGrJyyNXMP7N681Tx0Q9TK8pK1ba8nUvYQTipNJyGZOsJfYiZll1A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"cbor2": "^2.3.0",
|
||||
"node-forge": "^1.3.3",
|
||||
"ssh2": "^1.17.0",
|
||||
"undici": "^6.21.0"
|
||||
},
|
||||
"bin": {
|
||||
"gondolin": "dist/bin/gondolin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=23.6.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@earendil-works/gondolin-krun-runner-darwin-arm64": "0.12.0",
|
||||
"@earendil-works/gondolin-krun-runner-linux-x64": "0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/gondolin-krun-runner-darwin-arm64": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-darwin-arm64/-/gondolin-krun-runner-darwin-arm64-0.12.0.tgz",
|
||||
"integrity": "sha512-ftDlusht4PcT7Y3TuPrZIKrCXy3isiBTVMvlXYK0pcud2uXY6uwFTGeunYgP+8ND/60ddb+MImqbfmkcK8B84A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"bin": {
|
||||
"gondolin-krun-runner": "bin/gondolin-krun-runner"
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/gondolin-krun-runner-linux-x64": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-linux-x64/-/gondolin-krun-runner-linux-x64-0.12.0.tgz",
|
||||
"integrity": "sha512-RRYsgwe2r5ApKmFNy469QgwnyjAHpAs9XANdWpTd9ol4iUYOY3sX7e0xIooAKxd+ktxGI4N/xRWicwGen3D/Ow==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"bin": {
|
||||
"gondolin-krun-runner": "bin/gondolin-krun-runner"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1": {
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
|
||||
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": "~2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bcrypt-pbkdf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tweetnacl": "^0.14.3"
|
||||
}
|
||||
},
|
||||
"node_modules/buildcheck": {
|
||||
"version": "0.0.7",
|
||||
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
|
||||
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cbor2": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/cbor2/-/cbor2-2.3.0.tgz",
|
||||
"integrity": "sha512-76WB3hq8BoaGkMkBVJ27fW5LJU+qqDLEpgRNCG/SYKhODWXpVPOTD4UcUto3IEzYLA52nsvbhb0wabhHDn3qXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cto.af/wtf8": "0.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/cpu-features": {
|
||||
"version": "0.0.10",
|
||||
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
|
||||
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"buildcheck": "~0.0.6",
|
||||
"nan": "^2.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.27.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz",
|
||||
"integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/node-forge": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
|
||||
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
|
||||
"license": "(BSD-3-Clause OR GPL-2.0)",
|
||||
"engines": {
|
||||
"node": ">= 6.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ssh2": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
|
||||
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"asn1": "^0.2.6",
|
||||
"bcrypt-pbkdf": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"cpu-features": "~0.0.10",
|
||||
"nan": "^2.23.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "0.14.5",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.26.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz",
|
||||
"integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "pi-extension-gondolin",
|
||||
"private": true,
|
||||
"version": "0.79.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
"build": "echo 'nothing to build'",
|
||||
"check": "echo 'nothing to check'"
|
||||
},
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/gondolin": "0.12.0"
|
||||
}
|
||||
}
|
||||
@@ -81,7 +81,7 @@ export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("handoff", {
|
||||
description: "Transfer context to a new focused session",
|
||||
handler: async (args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
if (ctx.mode !== "tui") {
|
||||
ctx.ui.notify("handoff requires interactive mode", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Streaming-Aware Input Gate
|
||||
*
|
||||
* Demonstrates `event.streamingBehavior` to skip expensive pre-processing
|
||||
* during mid-stream steering, where low latency matters.
|
||||
*
|
||||
* This extension prepends `git diff --stat` output when the user mentions
|
||||
* file changes, giving the model immediate context. During steering the
|
||||
* exec call is skipped so the correction reaches the model without delay.
|
||||
*
|
||||
* Start pi with this extension:
|
||||
* pi -e ./examples/extensions/input-transform-streaming.ts
|
||||
*/
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const TRIGGER = /\b(changes?|diff|modified)\b/i;
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("input", async (event) => {
|
||||
// During steering, skip the exec call — corrections should be fast
|
||||
if (event.streamingBehavior === "steer") {
|
||||
return { action: "continue" };
|
||||
}
|
||||
|
||||
if (!TRIGGER.test(event.text)) {
|
||||
return { action: "continue" };
|
||||
}
|
||||
|
||||
const { stdout, code } = await pi.exec("git", ["diff", "--stat"]);
|
||||
if (code !== 0 || !stdout.trim()) {
|
||||
return { action: "continue" };
|
||||
}
|
||||
|
||||
return {
|
||||
action: "transform",
|
||||
text: `${event.text}\n\nCurrent uncommitted changes:\n\`\`\`\n${stdout.trim()}\n\`\`\``,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -146,7 +146,7 @@ export default function (pi: ExtensionAPI) {
|
||||
}
|
||||
|
||||
// No UI available (print mode, RPC, etc.)
|
||||
if (!ctx.hasUI) {
|
||||
if (ctx.mode !== "tui") {
|
||||
return {
|
||||
result: { output: "(interactive commands require TUI)", exitCode: 1, cancelled: false, truncated: false },
|
||||
};
|
||||
|
||||
@@ -15,13 +15,13 @@
|
||||
* /overlay-sidepanel - Responsive sidepanel (hides when terminal < 100 cols)
|
||||
* /overlay-toggle - Toggle visibility demo (demonstrates OverlayHandle.setHidden)
|
||||
* /overlay-passive - Non-capturing overlay demo (passive info panel alongside active overlay)
|
||||
* /overlay-focus - Focus cycling and rendering order with non-capturing overlays
|
||||
* /overlay-focus - Focus cycling, input routing, dismissal, and rendering order with overlays
|
||||
* /overlay-streaming - Multiple input panels with simulated streaming (Tab to cycle focus)
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
||||
import type { Component, OverlayAnchor, OverlayHandle, OverlayOptions, TUI } from "@earendil-works/pi-tui";
|
||||
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
||||
import { Input, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
||||
import { spawn } from "child_process";
|
||||
|
||||
// Global handle for toggle demo (in real code, use a more elegant pattern)
|
||||
@@ -272,9 +272,9 @@ export default function (pi: ExtensionAPI) {
|
||||
},
|
||||
});
|
||||
|
||||
// Focus cycling demo - demonstrates focus(), unfocus(), isFocused() and rendering order
|
||||
// Focus cycling demo - demonstrates focus(), input routing, per-panel dismissal, and rendering order
|
||||
pi.registerCommand("overlay-focus", {
|
||||
description: "Test focus cycling and rendering order with non-capturing overlays",
|
||||
description: "Test focus cycling, input routing, dismissal, and rendering order with overlays",
|
||||
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
||||
ctx.ui.setEditorText("");
|
||||
await ctx.ui.custom<void>((tui, theme, _kb, done) => new FocusDemoController(tui, theme, done), {
|
||||
@@ -1031,57 +1031,66 @@ class TimerPanel extends BaseOverlay {
|
||||
|
||||
// === Focus cycling demo ===
|
||||
|
||||
type FocusPanelColor = "error" | "success" | "accent";
|
||||
type FocusPanelConfig = { label: string; color: FocusPanelColor; options: OverlayOptions };
|
||||
type FocusPanelEntry = { panel: FocusPanel; handle: OverlayHandle };
|
||||
|
||||
const FOCUS_PANEL_CONFIGS = [
|
||||
{ label: "Alpha", color: "error", options: { row: 2, col: 4, width: 34 } },
|
||||
{ label: "Beta", color: "success", options: { row: 5, col: 28, width: 34 } },
|
||||
{ label: "Gamma", color: "accent", options: { row: 8, col: 52, width: 34 } },
|
||||
] satisfies FocusPanelConfig[];
|
||||
|
||||
class FocusDemoController extends BaseOverlay {
|
||||
private tui: TUI;
|
||||
private panels: FocusPanel[] = [];
|
||||
private handles: OverlayHandle[] = [];
|
||||
private focusIndex = -1;
|
||||
private done: () => void;
|
||||
private readonly tui: TUI;
|
||||
private entries: FocusPanelEntry[] = [];
|
||||
private readonly done: () => void;
|
||||
private closed = false;
|
||||
|
||||
constructor(tui: TUI, theme: Theme, done: () => void) {
|
||||
super(theme);
|
||||
this.tui = tui;
|
||||
this.done = done;
|
||||
const colors = ["error", "success", "accent"] as const;
|
||||
const labels = ["Alpha", "Beta", "Gamma"];
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const panel = new FocusPanel(
|
||||
theme,
|
||||
labels[i]!,
|
||||
colors[i]!,
|
||||
() => this.cycleFocus(),
|
||||
() => this.close(),
|
||||
);
|
||||
const handle = this.tui.showOverlay(panel, {
|
||||
nonCapturing: true,
|
||||
row: 2,
|
||||
col: 5 + i * 6,
|
||||
width: 28,
|
||||
});
|
||||
panel.handle = handle;
|
||||
this.panels.push(panel);
|
||||
this.handles.push(handle);
|
||||
for (const config of FOCUS_PANEL_CONFIGS) {
|
||||
const panel = new FocusPanel({ theme, config, controller: this });
|
||||
const handle = this.tui.showOverlay(panel, { nonCapturing: true, ...config.options });
|
||||
this.entries.push({ panel, handle });
|
||||
}
|
||||
|
||||
this.focusFirstOpenPanel();
|
||||
}
|
||||
|
||||
private cycleFocus(): void {
|
||||
if (this.focusIndex >= 0 && this.focusIndex < this.handles.length) {
|
||||
this.handles[this.focusIndex]!.unfocus();
|
||||
}
|
||||
this.focusIndex++;
|
||||
if (this.focusIndex >= this.handles.length) {
|
||||
this.focusIndex = -1;
|
||||
} else {
|
||||
this.handles[this.focusIndex]!.focus();
|
||||
}
|
||||
this.tui.requestRender();
|
||||
focusNext(current: FocusPanel, direction: 1 | -1 = 1): void {
|
||||
const openEntries = this.openEntries();
|
||||
const currentOpenPosition = openEntries.findIndex((entry) => entry.panel === current);
|
||||
if (currentOpenPosition === -1) throw new Error(`Panel ${current.label} is not open`);
|
||||
const nextOpenPosition = (currentOpenPosition + direction + openEntries.length) % openEntries.length;
|
||||
this.focusEntryAt(openEntries, nextOpenPosition);
|
||||
}
|
||||
|
||||
private close(): void {
|
||||
for (const handle of this.handles) handle.hide();
|
||||
this.handles = [];
|
||||
this.panels = [];
|
||||
dismiss(panel: FocusPanel): void {
|
||||
const openEntries = this.openEntries();
|
||||
const currentOpenPosition = openEntries.findIndex((candidate) => candidate.panel === panel);
|
||||
if (currentOpenPosition === -1) return;
|
||||
const entry = openEntries[currentOpenPosition];
|
||||
if (!entry) throw new Error(`Invalid focus panel index ${currentOpenPosition}`);
|
||||
const remainingEntries = openEntries.filter((candidate) => candidate.panel !== panel);
|
||||
|
||||
entry.panel.closed = true;
|
||||
entry.handle.hide();
|
||||
if (remainingEntries.length === 0) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
|
||||
this.focusEntryAt(remainingEntries, currentOpenPosition % remainingEntries.length);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.hidePanels();
|
||||
this.done();
|
||||
}
|
||||
|
||||
@@ -1089,86 +1098,148 @@ class FocusDemoController extends BaseOverlay {
|
||||
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
||||
this.close();
|
||||
} else if (matchesKey(data, "tab")) {
|
||||
this.cycleFocus();
|
||||
this.focusFirstOpenPanel();
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const th = this.theme;
|
||||
const focused = this.focusIndex === -1 ? "Controller" : (this.panels[this.focusIndex]?.label ?? "?");
|
||||
const focused = this.entries.find((entry) => entry.handle.isFocused())?.panel.label ?? "Controller";
|
||||
return this.box(
|
||||
[
|
||||
"",
|
||||
` Current focus: ${th.fg("accent", focused)}`,
|
||||
"",
|
||||
" Three overlapping panels above are",
|
||||
` all ${th.fg("accent", "nonCapturing")}. Press Tab to`,
|
||||
" cycle focus() between them.",
|
||||
` ${th.fg("accent", "nonCapturing")} overlays controlled with`,
|
||||
" raw OverlayHandle.focus()/hide().",
|
||||
"",
|
||||
" Focused panel renders on top",
|
||||
" (focus-based rendering order).",
|
||||
" Type in the focused panel's input.",
|
||||
" Focused panel renders on top.",
|
||||
"",
|
||||
th.fg("dim", " Tab = cycle focus | Esc = close"),
|
||||
th.fg("dim", " Tab/Shift+Tab = cycle panels"),
|
||||
th.fg("dim", " Esc/Ctrl+D = dismiss panel"),
|
||||
th.fg("dim", " Ctrl+C = close all"),
|
||||
"",
|
||||
],
|
||||
width,
|
||||
"Focus Demo",
|
||||
"Focus + Input Demo",
|
||||
);
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
for (const handle of this.handles) handle.hide();
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.hidePanels();
|
||||
}
|
||||
|
||||
private focusFirstOpenPanel(): void {
|
||||
const firstOpen = this.openEntries()[0];
|
||||
if (firstOpen) {
|
||||
firstOpen.handle.focus();
|
||||
this.tui.requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
private focusEntryAt(entries: FocusPanelEntry[], index: number): void {
|
||||
const entry = entries[index];
|
||||
if (!entry) throw new Error(`Invalid focus panel index ${index}`);
|
||||
entry.handle.focus();
|
||||
this.tui.requestRender();
|
||||
}
|
||||
|
||||
private hidePanels(): void {
|
||||
for (const entry of this.entries) {
|
||||
if (!entry.panel.closed) {
|
||||
entry.panel.closed = true;
|
||||
entry.handle.hide();
|
||||
}
|
||||
}
|
||||
this.entries = [];
|
||||
}
|
||||
|
||||
private openEntries(): FocusPanelEntry[] {
|
||||
return this.entries.filter((entry) => !entry.panel.closed);
|
||||
}
|
||||
}
|
||||
|
||||
class FocusPanel extends BaseOverlay {
|
||||
handle: OverlayHandle | null = null;
|
||||
focused = false;
|
||||
closed = false;
|
||||
readonly label: string;
|
||||
private color: "error" | "success" | "accent";
|
||||
private onTab: () => void;
|
||||
private onClose: () => void;
|
||||
private readonly color: FocusPanelColor;
|
||||
private readonly controller: FocusDemoController;
|
||||
private readonly input = new Input();
|
||||
private inputs: string[] = [];
|
||||
|
||||
constructor(
|
||||
theme: Theme,
|
||||
label: string,
|
||||
color: "error" | "success" | "accent",
|
||||
onTab: () => void,
|
||||
onClose: () => void,
|
||||
) {
|
||||
constructor({
|
||||
theme,
|
||||
config,
|
||||
controller,
|
||||
}: {
|
||||
theme: Theme;
|
||||
config: FocusPanelConfig;
|
||||
controller: FocusDemoController;
|
||||
}) {
|
||||
super(theme);
|
||||
this.label = label;
|
||||
this.color = color;
|
||||
this.onTab = onTab;
|
||||
this.onClose = onClose;
|
||||
this.label = config.label;
|
||||
this.color = config.color;
|
||||
this.controller = controller;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesKey(data, "tab")) {
|
||||
this.onTab();
|
||||
} else if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
||||
this.onClose();
|
||||
this.controller.focusNext(this);
|
||||
} else if (matchesKey(data, "shift+tab")) {
|
||||
this.controller.focusNext(this, -1);
|
||||
} else if (matchesKey(data, "escape") || matchesKey(data, "ctrl+d")) {
|
||||
this.controller.dismiss(this);
|
||||
} else if (matchesKey(data, "ctrl+c")) {
|
||||
this.controller.close();
|
||||
} else if (matchesKey(data, "return")) {
|
||||
this.inputs.push("Enter");
|
||||
} else if (matchesKey(data, "up")) {
|
||||
this.inputs.push("↑");
|
||||
} else if (matchesKey(data, "down")) {
|
||||
this.inputs.push("↓");
|
||||
} else if (matchesKey(data, "left")) {
|
||||
this.input.handleInput(data);
|
||||
this.inputs.push("←");
|
||||
} else if (matchesKey(data, "right")) {
|
||||
this.input.handleInput(data);
|
||||
this.inputs.push("→");
|
||||
} else if (matchesKey(data, "backspace")) {
|
||||
this.input.handleInput(data);
|
||||
this.inputs.push("Backspace");
|
||||
} else {
|
||||
this.input.handleInput(data);
|
||||
this.inputs.push(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const th = this.theme;
|
||||
const focused = this.handle?.isFocused() ?? false;
|
||||
const innerW = Math.max(1, width - 2);
|
||||
const border = (c: string) => th.fg(this.color, c);
|
||||
const border = (c: string) => th.fg(this.focused ? this.color : "dim", c);
|
||||
const padLine = (s: string) => truncateToWidth(s, innerW, "...", true);
|
||||
const recent = this.inputs.length === 0 ? "(none)" : this.inputs.slice(-6).join(" ");
|
||||
const lines: string[] = [];
|
||||
|
||||
this.input.focused = this.focused;
|
||||
const [inputLine = ""] = this.input.render(Math.max(1, innerW - 8));
|
||||
lines.push(border(`╭${"─".repeat(innerW)}╮`));
|
||||
lines.push(border("│") + padLine(` ${th.fg("accent", this.label)}`) + border("│"));
|
||||
lines.push(border("│") + padLine("") + border("│"));
|
||||
if (focused) {
|
||||
lines.push(border("│") + padLine(th.fg("success", " ● FOCUSED")) + border("│"));
|
||||
lines.push(border("│") + padLine(th.fg("dim", " (receiving input)")) + border("│"));
|
||||
} else {
|
||||
lines.push(border("│") + padLine(th.fg("dim", " ○ unfocused")) + border("│"));
|
||||
lines.push(border("│") + padLine(th.fg("dim", " (passive)")) + border("│"));
|
||||
}
|
||||
lines.push(
|
||||
border("│") +
|
||||
padLine(
|
||||
` ${th.fg(this.color, this.label)} ${this.focused ? th.fg("success", "FOCUSED") : th.fg("dim", "visible")}`,
|
||||
) +
|
||||
border("│"),
|
||||
);
|
||||
lines.push(border("│") + padLine("") + border("│"));
|
||||
lines.push(border("│") + padLine(` Input: ${inputLine}`) + border("│"));
|
||||
lines.push(border("│") + padLine(` Keys: ${recent}`) + border("│"));
|
||||
lines.push(border("│") + padLine(th.fg("dim", " Tab/Shift+Tab focus")) + border("│"));
|
||||
lines.push(border("│") + padLine(th.fg("dim", " Esc/Ctrl+D dismiss")) + border("│"));
|
||||
lines.push(border(`╰${"─".repeat(innerW)}╯`));
|
||||
|
||||
return lines;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
64
packages/coding-agent/examples/extensions/project-trust.ts
Normal file
64
packages/coding-agent/examples/extensions/project-trust.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Project Trust Extension
|
||||
*
|
||||
* Demonstrates the project_trust event. Install globally or pass via -e:
|
||||
*
|
||||
* mkdir -p ~/.pi/agent/extensions
|
||||
* cp packages/coding-agent/examples/extensions/project-trust.ts ~/.pi/agent/extensions/
|
||||
*
|
||||
* Or:
|
||||
*
|
||||
* pi -e packages/coding-agent/examples/extensions/project-trust.ts
|
||||
*
|
||||
* Try it in a project containing .pi, AGENTS.md/CLAUDE.md, or .agents/skills.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI, ProjectTrustEventResult } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let loadCount = 0;
|
||||
loadCount++;
|
||||
|
||||
// Multiple handlers in one extension are allowed. The first handler that returns
|
||||
// { trusted: "yes" } or { trusted: "no" } wins and suppresses the built-in
|
||||
// trust prompt. Return { trusted: "undecided" } to let another handler or the
|
||||
// built-in flow decide.
|
||||
pi.on("project_trust", async (event, ctx): Promise<ProjectTrustEventResult> => {
|
||||
ctx.ui.notify(`project_trust fired for ${event.cwd} (mode: ${ctx.mode}, load: ${loadCount})`, "info");
|
||||
|
||||
if (!ctx.hasUI) {
|
||||
return { trusted: "undecided" };
|
||||
}
|
||||
|
||||
const choice = await ctx.ui.select(`Project trust for:\n${event.cwd}`, [
|
||||
"Trust and remember",
|
||||
"Trust with note and remember",
|
||||
"Trust this session",
|
||||
"Do not trust this session",
|
||||
"Let built-in prompt decide",
|
||||
]);
|
||||
|
||||
if (choice === "Trust with note and remember") {
|
||||
const note = await ctx.ui.input("Project trust note", "Optional note for this demo");
|
||||
ctx.ui.notify(note ? `Recorded demo note: ${note}` : "No demo note entered", "info");
|
||||
return { trusted: "yes", remember: true };
|
||||
}
|
||||
if (choice === "Trust and remember") {
|
||||
return { trusted: "yes", remember: true };
|
||||
}
|
||||
if (choice === "Trust this session") {
|
||||
return { trusted: "yes" };
|
||||
}
|
||||
if (choice === "Do not trust this session") {
|
||||
return { trusted: "no" };
|
||||
}
|
||||
if (choice === "Let built-in prompt decide") {
|
||||
return { trusted: "undecided" };
|
||||
}
|
||||
return { trusted: "undecided" };
|
||||
});
|
||||
|
||||
pi.on("session_start", (_event, ctx) => {
|
||||
ctx.ui.notify(`project-trust example loaded after trust resolution in ${ctx.cwd}`, "info");
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("qna", {
|
||||
description: "Extract questions from last assistant message into editor",
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
if (ctx.mode !== "tui") {
|
||||
ctx.ui.notify("qna requires interactive mode", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -41,7 +49,7 @@ export default function question(pi: ExtensionAPI) {
|
||||
parameters: QuestionParams,
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
if (!ctx.hasUI) {
|
||||
if (ctx.mode !== "tui") {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: UI not available (running in non-interactive mode)" }],
|
||||
details: {
|
||||
@@ -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
|
||||
@@ -82,7 +90,7 @@ export default function questionnaire(pi: ExtensionAPI) {
|
||||
parameters: QuestionnaireParams,
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
if (!ctx.hasUI) {
|
||||
if (ctx.mode !== "tui") {
|
||||
return errorResult("Error: UI not available (running in non-interactive mode)");
|
||||
}
|
||||
if (params.questions.length === 0) {
|
||||
@@ -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.5.4",
|
||||
"version": "1.9.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-sandbox",
|
||||
"version": "1.5.4",
|
||||
"version": "1.9.9",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sandbox-runtime": "^0.0.26"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pi-extension-sandbox",
|
||||
"private": true,
|
||||
"version": "1.5.4",
|
||||
"version": "1.9.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
||||
@@ -311,7 +311,7 @@ export default function (pi: ExtensionAPI) {
|
||||
description: "Play Snake!",
|
||||
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
if (ctx.mode !== "tui") {
|
||||
ctx.ui.notify("Snake requires interactive mode", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -529,7 +529,7 @@ export default function (pi: ExtensionAPI) {
|
||||
description: "Play Space Invaders!",
|
||||
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
if (ctx.mode !== "tui") {
|
||||
ctx.ui.notify("Space Invaders requires interactive mode", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ const buildSummaryPrompt = (conversationText: string): string =>
|
||||
].join("\n");
|
||||
|
||||
const showSummaryUi = async (summary: string, ctx: ExtensionCommandContext) => {
|
||||
if (!ctx.hasUI) {
|
||||
if (ctx.mode !== "tui") {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -779,7 +779,7 @@ Decide the target cell first, then dump every action for the turn in one go.
|
||||
description: "Play tic-tac-toe against the agent",
|
||||
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
if (ctx.mode !== "tui") {
|
||||
ctx.ui.notify("Tic-tac-toe requires interactive mode", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -284,7 +284,7 @@ export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("todos", {
|
||||
description: "Show all todos on the current branch",
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
if (ctx.mode !== "tui") {
|
||||
ctx.ui.notify("/todos requires interactive mode", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,11 @@ export default function toolsExtension(pi: ExtensionAPI) {
|
||||
pi.registerCommand("tools", {
|
||||
description: "Enable/disable tools",
|
||||
handler: async (_args, ctx) => {
|
||||
if (ctx.mode !== "tui") {
|
||||
ctx.ui.notify("/tools requires TUI mode", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh tool list
|
||||
allTools = pi.getAllTools();
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pi-extension-with-deps",
|
||||
"version": "0.75.4",
|
||||
"version": "0.79.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-with-deps",
|
||||
"version": "0.75.4",
|
||||
"version": "0.79.9",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pi-extension-with-deps",
|
||||
"private": true,
|
||||
"version": "0.75.4",
|
||||
"version": "0.79.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
||||
224
packages/coding-agent/npm-shrinkwrap.json
generated
224
packages/coding-agent/npm-shrinkwrap.json
generated
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "@earendil-works/pi-coding-agent",
|
||||
"version": "0.75.4",
|
||||
"version": "0.79.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@earendil-works/pi-coding-agent",
|
||||
"version": "0.75.4",
|
||||
"version": "0.79.9",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-agent-core": "^0.75.4",
|
||||
"@earendil-works/pi-ai": "^0.75.4",
|
||||
"@earendil-works/pi-tui": "^0.75.4",
|
||||
"@earendil-works/pi-agent-core": "^0.79.9",
|
||||
"@earendil-works/pi-ai": "^0.79.9",
|
||||
"@earendil-works/pi-tui": "^0.79.9",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"chalk": "5.6.2",
|
||||
"cross-spawn": "7.0.6",
|
||||
@@ -23,12 +23,13 @@
|
||||
"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": {
|
||||
"@mariozechner/clipboard": "0.3.6"
|
||||
"@mariozechner/clipboard": "0.3.9"
|
||||
},
|
||||
"bin": {
|
||||
"pi": "dist/cli.js"
|
||||
@@ -473,11 +474,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-agent-core": {
|
||||
"version": "0.75.4",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.75.4.tgz",
|
||||
"version": "0.79.9",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.9.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-ai": "^0.75.4",
|
||||
"@earendil-works/pi-ai": "^0.79.9",
|
||||
"ignore": "7.0.5",
|
||||
"typebox": "1.1.38",
|
||||
"yaml": "2.9.0"
|
||||
@@ -487,14 +488,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-ai": {
|
||||
"version": "0.75.4",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.75.4.tgz",
|
||||
"version": "0.79.9",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.9.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.91.1",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1048.0",
|
||||
"@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",
|
||||
@@ -509,12 +512,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-tui": {
|
||||
"version": "0.75.4",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.75.4.tgz",
|
||||
"version": "0.79.9",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.9.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "1.6.0",
|
||||
"marked": "15.0.12"
|
||||
"marked": "18.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
@@ -545,21 +548,21 @@
|
||||
"hasInstallScript": true
|
||||
},
|
||||
"node_modules/@mariozechner/clipboard": {
|
||||
"version": "0.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.6.tgz",
|
||||
"integrity": "sha512-MXdtr+6+ntlIVHdrZYuZNQydu6o8yZswFJ2Ln81j2O/Y9B/LDHvEaIm95xWNPkjGTWriSOeLnQJRFs6dYb60bg==",
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz",
|
||||
"integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"@mariozechner/clipboard-darwin-arm64": "0.3.6",
|
||||
"@mariozechner/clipboard-darwin-universal": "0.3.6",
|
||||
"@mariozechner/clipboard-darwin-x64": "0.3.6",
|
||||
"@mariozechner/clipboard-linux-arm64-gnu": "0.3.6",
|
||||
"@mariozechner/clipboard-linux-arm64-musl": "0.3.6",
|
||||
"@mariozechner/clipboard-linux-riscv64-gnu": "0.3.6",
|
||||
"@mariozechner/clipboard-linux-x64-gnu": "0.3.6",
|
||||
"@mariozechner/clipboard-linux-x64-musl": "0.3.6",
|
||||
"@mariozechner/clipboard-win32-arm64-msvc": "0.3.6",
|
||||
"@mariozechner/clipboard-win32-x64-msvc": "0.3.6"
|
||||
"@mariozechner/clipboard-darwin-arm64": "0.3.9",
|
||||
"@mariozechner/clipboard-darwin-universal": "0.3.9",
|
||||
"@mariozechner/clipboard-darwin-x64": "0.3.9",
|
||||
"@mariozechner/clipboard-linux-arm64-gnu": "0.3.9",
|
||||
"@mariozechner/clipboard-linux-arm64-musl": "0.3.9",
|
||||
"@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9",
|
||||
"@mariozechner/clipboard-linux-x64-gnu": "0.3.9",
|
||||
"@mariozechner/clipboard-linux-x64-musl": "0.3.9",
|
||||
"@mariozechner/clipboard-win32-arm64-msvc": "0.3.9",
|
||||
"@mariozechner/clipboard-win32-x64-msvc": "0.3.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
@@ -567,9 +570,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@mariozechner/clipboard-darwin-arm64": {
|
||||
"version": "0.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.6.tgz",
|
||||
"integrity": "sha512-HjaisYCAbHi/1+N1yDAQHc8ZXGffufIUT5NSOSVR3f3AuMDusxTtnbK8tZ7JFDkShua1oNGZoNwQHsc8MPtE0Q==",
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz",
|
||||
"integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
@@ -583,9 +586,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@mariozechner/clipboard-darwin-universal": {
|
||||
"version": "0.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.6.tgz",
|
||||
"integrity": "sha512-8BWtPjOtJOJoykml3w0fx0zRrfWP31mXrJwfoA7xzNprkZw1uolCNfgmjDiVBseoKjp16EGITz7bN+61qn8dWA==",
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz",
|
||||
"integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
@@ -596,9 +599,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@mariozechner/clipboard-darwin-x64": {
|
||||
"version": "0.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.6.tgz",
|
||||
"integrity": "sha512-p9syiZD1kU4I+1ya7f7g+zD1GiUvR8fdlRlNmgsZNWlyjtc8rlV2EjTLd/35x1LsdBq020GVvtzp0ZmPgBI09Q==",
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz",
|
||||
"integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
@@ -612,9 +615,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@mariozechner/clipboard-linux-arm64-gnu": {
|
||||
"version": "0.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.6.tgz",
|
||||
"integrity": "sha512-5JFf5rGofrm+V29HNF+wLthXphHdQpMbKDUYJ5tML6/Z5DLlLOV/9Ak4kDPtYyZ+Dzf+kAusE0VsFg4+tfP1IA==",
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz",
|
||||
"integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
@@ -625,12 +628,15 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@mariozechner/clipboard-linux-arm64-musl": {
|
||||
"version": "0.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.6.tgz",
|
||||
"integrity": "sha512-JlVjxxw0GbGC0djXYWRIqyteO3J1KZ/QG3udlEFaOD5TLOM1FnmXXAPDQBqr+aBVr720ef9K00dirYnJ0LDCtw==",
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz",
|
||||
"integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
@@ -641,12 +647,15 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@mariozechner/clipboard-linux-riscv64-gnu": {
|
||||
"version": "0.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.6.tgz",
|
||||
"integrity": "sha512-4t8BUi5zZ+L77otFQVnVSlaTyAX4TVk9EqQm4syMrEQp96trFEHEwwNHcNEBGzYv5+K7mxay50TthYkz47OWzQ==",
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz",
|
||||
"integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
@@ -657,12 +666,15 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@mariozechner/clipboard-linux-x64-gnu": {
|
||||
"version": "0.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.6.tgz",
|
||||
"integrity": "sha512-trtPwcNLW37irwQCJLtCxLw757jjJZk3TSnY/MU9bhtWtA3K9b/eLW0e4RGhUXDoFRds9opNWWaUDuFLa8dm0w==",
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz",
|
||||
"integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
@@ -673,12 +685,15 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@mariozechner/clipboard-linux-x64-musl": {
|
||||
"version": "0.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.6.tgz",
|
||||
"integrity": "sha512-WfnzIvOCCWQiN0MmltCEo6cLceUDbYe+I7xyFZjaps5A+2Op/M2CY7Rey+C4ucQhrvmpoHmTSFgY9ODWk7snoA==",
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz",
|
||||
"integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
@@ -689,12 +704,15 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@mariozechner/clipboard-win32-arm64-msvc": {
|
||||
"version": "0.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.6.tgz",
|
||||
"integrity": "sha512-+8+1aHYsBPUjmW3otmWlg+Hijt0iJvoBBs5e0mxFeUd4gDaKMB8Bn6x7c6KVtscg7E5j5NFXnwQqNSIAO4p8zQ==",
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz",
|
||||
"integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
@@ -708,9 +726,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@mariozechner/clipboard-win32-x64-msvc": {
|
||||
"version": "0.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.6.tgz",
|
||||
"integrity": "sha512-S4xfPmERC8ZkiLHe3vekZCjdDwNEETCuvCgQK2kP6/TnvmUkq1y2Pk+DjM4t8uh9KMX9bH4zs5ePcKa8GTXmfg==",
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz",
|
||||
"integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
@@ -724,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": {
|
||||
@@ -746,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",
|
||||
@@ -765,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": {
|
||||
@@ -785,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",
|
||||
@@ -1382,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": {
|
||||
@@ -1568,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"
|
||||
@@ -1620,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",
|
||||
@@ -1678,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"
|
||||
@@ -1717,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.75.4",
|
||||
"version": "0.79.9",
|
||||
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
||||
"type": "module",
|
||||
"piConfig": {
|
||||
@@ -15,23 +15,20 @@
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./hooks": {
|
||||
"types": "./dist/core/hooks/index.d.ts",
|
||||
"import": "./dist/core/hooks/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"docs",
|
||||
"examples",
|
||||
"containerization.md",
|
||||
"CHANGELOG.md",
|
||||
"npm-shrinkwrap.json"
|
||||
],
|
||||
"scripts": {
|
||||
"clean": "shx rm -rf dist",
|
||||
"build": "tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js && npm run copy-assets",
|
||||
"build:binary": "npm --prefix ../tui run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm run build && bun build --compile ./dist/bun/cli.js --outfile dist/pi && npm run copy-binary-assets",
|
||||
"build:binary": "npm --prefix ../tui run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm run build && bun build --compile ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile dist/pi && npm run copy-binary-assets",
|
||||
"copy-assets": "shx mkdir -p dist/modes/interactive/theme && shx cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && shx mkdir -p dist/modes/interactive/assets && shx cp src/modes/interactive/assets/*.png dist/modes/interactive/assets/ && shx mkdir -p dist/core/export-html/vendor && shx cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && shx cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/",
|
||||
"copy-binary-assets": "shx cp package.json dist/ && shx cp README.md dist/ && shx cp CHANGELOG.md dist/ && shx mkdir -p dist/theme && shx cp src/modes/interactive/theme/*.json dist/theme/ && shx mkdir -p dist/assets && shx cp src/modes/interactive/assets/*.png dist/assets/ && shx mkdir -p dist/export-html/vendor && shx cp src/core/export-html/template.html dist/export-html/ && shx cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && shx cp -r docs dist/ && shx cp -r examples dist/ && shx cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist/",
|
||||
"test": "vitest --run",
|
||||
@@ -39,9 +36,9 @@
|
||||
"prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-agent-core": "^0.75.4",
|
||||
"@earendil-works/pi-ai": "^0.75.4",
|
||||
"@earendil-works/pi-tui": "^0.75.4",
|
||||
"@earendil-works/pi-agent-core": "^0.79.9",
|
||||
"@earendil-works/pi-ai": "^0.79.9",
|
||||
"@earendil-works/pi-tui": "^0.79.9",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"chalk": "5.6.2",
|
||||
"cross-spawn": "7.0.6",
|
||||
@@ -53,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": {
|
||||
@@ -64,7 +62,7 @@
|
||||
}
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@mariozechner/clipboard": "0.3.6"
|
||||
"@mariozechner/clipboard": "0.3.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
@@ -73,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",
|
||||
@@ -89,7 +88,7 @@
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/earendil-works/pi-mono.git",
|
||||
"url": "git+https://github.com/earendil-works/pi.git",
|
||||
"directory": "packages/coding-agent"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -21,12 +21,15 @@ export interface Args {
|
||||
help?: boolean;
|
||||
version?: boolean;
|
||||
mode?: Mode;
|
||||
name?: string;
|
||||
noSession?: boolean;
|
||||
session?: string;
|
||||
sessionId?: string;
|
||||
fork?: string;
|
||||
sessionDir?: string;
|
||||
models?: string[];
|
||||
tools?: string[];
|
||||
excludeTools?: string[];
|
||||
noTools?: boolean;
|
||||
noBuiltinTools?: boolean;
|
||||
extensions?: string[];
|
||||
@@ -43,6 +46,7 @@ export interface Args {
|
||||
listModels?: string | true;
|
||||
offline?: boolean;
|
||||
verbose?: boolean;
|
||||
projectTrustOverride?: boolean;
|
||||
messages: string[];
|
||||
fileArgs: string[];
|
||||
/** Unknown flags (potentially extension flags) - map of flag name to value */
|
||||
@@ -91,10 +95,18 @@ export function parseArgs(args: string[]): Args {
|
||||
} else if (arg === "--append-system-prompt" && i + 1 < args.length) {
|
||||
result.appendSystemPrompt = result.appendSystemPrompt ?? [];
|
||||
result.appendSystemPrompt.push(args[++i]);
|
||||
} else if (arg === "--name" || arg === "-n") {
|
||||
if (i + 1 < args.length) {
|
||||
result.name = args[++i];
|
||||
} else {
|
||||
result.diagnostics.push({ type: "error", message: "--name requires a value" });
|
||||
}
|
||||
} else if (arg === "--no-session") {
|
||||
result.noSession = true;
|
||||
} else if (arg === "--session" && i + 1 < args.length) {
|
||||
result.session = args[++i];
|
||||
} else if (arg === "--session-id" && i + 1 < args.length) {
|
||||
result.sessionId = args[++i];
|
||||
} else if (arg === "--fork" && i + 1 < args.length) {
|
||||
result.fork = args[++i];
|
||||
} else if (arg === "--session-dir" && i + 1 < args.length) {
|
||||
@@ -110,6 +122,11 @@ export function parseArgs(args: string[]): Args {
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((name) => name.length > 0);
|
||||
} else if ((arg === "--exclude-tools" || arg === "-xt") && i + 1 < args.length) {
|
||||
result.excludeTools = args[++i]
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((name) => name.length > 0);
|
||||
} else if (arg === "--thinking" && i + 1 < args.length) {
|
||||
const level = args[++i];
|
||||
if (isValidThinkingLevel(level)) {
|
||||
@@ -160,6 +177,10 @@ export function parseArgs(args: string[]): Args {
|
||||
}
|
||||
} else if (arg === "--verbose") {
|
||||
result.verbose = true;
|
||||
} else if (arg === "--approve" || arg === "-a") {
|
||||
result.projectTrustOverride = true;
|
||||
} else if (arg === "--no-approve" || arg === "-na") {
|
||||
result.projectTrustOverride = false;
|
||||
} else if (arg === "--offline") {
|
||||
result.offline = true;
|
||||
} else if (arg.startsWith("@")) {
|
||||
@@ -208,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
|
||||
@@ -224,15 +245,19 @@ ${chalk.bold("Options:")}
|
||||
--continue, -c Continue previous session
|
||||
--resume, -r Select a session to resume
|
||||
--session <path|id> Use specific session file or partial UUID
|
||||
--session-id <id> Use exact project session ID, creating it if missing
|
||||
--fork <path|id> Fork specific session file or partial UUID into a new session
|
||||
--session-dir <dir> Directory for session storage and lookup
|
||||
--no-session Don't save session (ephemeral)
|
||||
--name, -n <name> Set session display name
|
||||
--models <patterns> Comma-separated model patterns for Ctrl+P cycling
|
||||
Supports globs (anthropic/*, *sonnet*) and fuzzy matching
|
||||
--no-tools, -nt Disable all tools by default (built-in and extension)
|
||||
--no-builtin-tools, -nbt Disable built-in tools by default but keep extension/custom tools enabled
|
||||
--tools, -t <tools> Comma-separated allowlist of tool names to enable
|
||||
Applies to built-in, extension, and custom tools
|
||||
--exclude-tools, -xt <tools> Comma-separated denylist of tool names to disable
|
||||
Applies to built-in, extension, and custom tools
|
||||
--thinking <level> Set thinking level: off, minimal, low, medium, high, xhigh
|
||||
--extension, -e <path> Load an extension file (can be used multiple times)
|
||||
--no-extensions, -ne Disable extension discovery (explicit -e paths still work)
|
||||
@@ -246,6 +271,8 @@ ${chalk.bold("Options:")}
|
||||
--export <file> Export session file to HTML and exit
|
||||
--list-models [search] List available models (with optional fuzzy search)
|
||||
--verbose Force verbose startup (overrides quietStartup setting)
|
||||
--approve, -a Trust project-local files for this run
|
||||
--no-approve, -na Ignore project-local files for this run
|
||||
--offline Disable startup network operations (same as PI_OFFLINE=1)
|
||||
--help, -h Show this help
|
||||
--version, -v Show version number
|
||||
@@ -271,6 +298,9 @@ ${chalk.bold("Examples:")}
|
||||
# Continue previous session
|
||||
${APP_NAME} --continue "What did we discuss?"
|
||||
|
||||
# Start a named session
|
||||
${APP_NAME} --name "Refactor auth module"
|
||||
|
||||
# Use different model
|
||||
${APP_NAME} --provider openai --model gpt-4o-mini "Help me refactor this code"
|
||||
|
||||
@@ -295,6 +325,9 @@ ${chalk.bold("Examples:")}
|
||||
# Read-only mode (no file modifications possible)
|
||||
${APP_NAME} --tools read,grep,find,ls -p "Review the code in src/"
|
||||
|
||||
# Disable one tool while keeping the rest available
|
||||
${APP_NAME} --exclude-tools ask_question
|
||||
|
||||
# Export a session file to HTML
|
||||
${APP_NAME} --export ~/${CONFIG_DIR_NAME}/agent/sessions/--path--/session.jsonl
|
||||
${APP_NAME} --export session.jsonl output.html
|
||||
@@ -302,6 +335,7 @@ ${chalk.bold("Examples:")}
|
||||
${chalk.bold("Environment Variables:")}
|
||||
ANTHROPIC_API_KEY - Anthropic Claude API key
|
||||
ANTHROPIC_OAUTH_TOKEN - Anthropic OAuth token (alternative to API key)
|
||||
ANT_LING_API_KEY - Ant Ling API key
|
||||
OPENAI_API_KEY - OpenAI GPT API key
|
||||
AZURE_OPENAI_API_KEY - Azure OpenAI API key
|
||||
AZURE_OPENAI_BASE_URL - Azure OpenAI/Cognitive Services base URL (e.g. https://{resource}.openai.azure.com)
|
||||
@@ -309,6 +343,7 @@ ${chalk.bold("Environment Variables:")}
|
||||
AZURE_OPENAI_API_VERSION - Azure OpenAI API version (default: v1)
|
||||
AZURE_OPENAI_DEPLOYMENT_NAME_MAP - Azure OpenAI model=deployment map (comma-separated)
|
||||
DEEPSEEK_API_KEY - DeepSeek API key
|
||||
NVIDIA_API_KEY - NVIDIA NIM API key
|
||||
GEMINI_API_KEY - Google Gemini API key
|
||||
GROQ_API_KEY - Groq API key
|
||||
CEREBRAS_API_KEY - Cerebras API key
|
||||
@@ -318,6 +353,7 @@ ${chalk.bold("Environment Variables:")}
|
||||
OPENROUTER_API_KEY - OpenRouter API key
|
||||
AI_GATEWAY_API_KEY - Vercel AI Gateway API key
|
||||
ZAI_API_KEY - ZAI API key
|
||||
ZAI_CODING_CN_API_KEY - ZAI Coding Plan API key (China)
|
||||
MISTRAL_API_KEY - Mistral API key
|
||||
MINIMAX_API_KEY - MiniMax API key
|
||||
MOONSHOT_API_KEY - Moonshot AI API key
|
||||
|
||||
@@ -50,13 +50,12 @@ export async function processFileArguments(fileArgs: string[], options?: Process
|
||||
if (mimeType) {
|
||||
// Handle image file
|
||||
const content = await readFile(absolutePath);
|
||||
const base64Content = content.toString("base64");
|
||||
|
||||
let attachment: ImageContent;
|
||||
let dimensionNote: string | undefined;
|
||||
|
||||
if (autoResizeImages) {
|
||||
const resized = await resizeImage({ type: "image", data: base64Content, mimeType });
|
||||
const resized = await resizeImage(content, mimeType);
|
||||
if (!resized) {
|
||||
text += `<file name="${absolutePath}">[Image omitted: could not be resized below the inline image size limit.]</file>\n`;
|
||||
continue;
|
||||
@@ -71,7 +70,7 @@ export async function processFileArguments(fileArgs: string[], options?: Process
|
||||
attachment = {
|
||||
type: "image",
|
||||
mimeType,
|
||||
data: base64Content,
|
||||
data: content.toString("base64"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
62
packages/coding-agent/src/cli/project-trust.ts
Normal file
62
packages/coding-agent/src/cli/project-trust.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import chalk from "chalk";
|
||||
import type { ProjectTrustContext } from "../core/extensions/types.ts";
|
||||
import type { AppMode } from "../core/project-trust.ts";
|
||||
import type { SettingsManager } from "../core/settings-manager.ts";
|
||||
import { showStartupInput, showStartupSelector } from "./startup-ui.ts";
|
||||
|
||||
export function createProjectTrustContext(options: {
|
||||
cwd: string;
|
||||
mode: AppMode;
|
||||
settingsManager: SettingsManager;
|
||||
hasUI: boolean;
|
||||
}): ProjectTrustContext {
|
||||
return {
|
||||
cwd: options.cwd,
|
||||
mode: options.mode === "interactive" ? "tui" : options.mode,
|
||||
hasUI: options.hasUI,
|
||||
ui: {
|
||||
select: async (title, selectOptions) => {
|
||||
if (!options.hasUI) {
|
||||
return undefined;
|
||||
}
|
||||
if (options.mode !== "interactive") {
|
||||
return undefined;
|
||||
}
|
||||
return showStartupSelector(
|
||||
options.settingsManager,
|
||||
title,
|
||||
selectOptions.map((option) => ({ label: option, value: option })),
|
||||
);
|
||||
},
|
||||
confirm: async (title, message) => {
|
||||
if (!options.hasUI) {
|
||||
return false;
|
||||
}
|
||||
if (options.mode !== "interactive") {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
(await showStartupSelector(options.settingsManager, `${title}\n${message}`, [
|
||||
{ label: "Yes", value: true },
|
||||
{ label: "No", value: false },
|
||||
])) ?? false
|
||||
);
|
||||
},
|
||||
input: async (title, placeholder) => {
|
||||
if (!options.hasUI) {
|
||||
return undefined;
|
||||
}
|
||||
if (options.mode !== "interactive") {
|
||||
return undefined;
|
||||
}
|
||||
return showStartupInput(options.settingsManager, title, placeholder);
|
||||
},
|
||||
notify: (message, type = "info") => {
|
||||
if (options.mode !== "interactive") {
|
||||
const color = type === "error" ? chalk.red : type === "warning" ? chalk.yellow : chalk.cyan;
|
||||
console.error(color(message));
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
181
packages/coding-agent/src/cli/startup-ui.ts
Normal file
181
packages/coding-agent/src/cli/startup-ui.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui";
|
||||
import { existsSync } from "fs";
|
||||
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";
|
||||
import { ExtensionInputComponent } from "../modes/interactive/components/extension-input.ts";
|
||||
import { ExtensionSelectorComponent } from "../modes/interactive/components/extension-selector.ts";
|
||||
import {
|
||||
FirstTimeSetupComponent,
|
||||
type FirstTimeSetupResult,
|
||||
} from "../modes/interactive/components/first-time-setup.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());
|
||||
setKeybindings(KeybindingsManager.create());
|
||||
const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor());
|
||||
ui.setClearOnShrink(settingsManager.getClearOnShrink());
|
||||
return ui;
|
||||
}
|
||||
|
||||
async function clearStartupTui(ui: TUI): Promise<void> {
|
||||
ui.clear();
|
||||
ui.requestRender();
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
if (process.env[ENV_AGENT_DIR]) {
|
||||
return false;
|
||||
}
|
||||
return !existsSync(settingsPath);
|
||||
}
|
||||
|
||||
export async function showStartupSelector<T>(
|
||||
settingsManager: SettingsManager,
|
||||
title: string,
|
||||
options: Array<{ label: string; value: T }>,
|
||||
): Promise<T | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const ui = createStartupTui(settingsManager);
|
||||
|
||||
let settled = false;
|
||||
const finish = async (result: T | undefined) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
await clearStartupTui(ui);
|
||||
ui.stop();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const selector = new ExtensionSelectorComponent(
|
||||
title,
|
||||
options.map((option) => option.label),
|
||||
(option) => void finish(options.find((entry) => entry.label === option)?.value),
|
||||
() => void finish(undefined),
|
||||
{ tui: ui },
|
||||
);
|
||||
ui.addChild(selector);
|
||||
ui.setFocus(selector);
|
||||
ui.start();
|
||||
});
|
||||
}
|
||||
|
||||
/** Show the first-time setup dialog and persist the result */
|
||||
export async function showFirstTimeSetup(settingsManager: SettingsManager): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const ui = createStartupTui(settingsManager);
|
||||
|
||||
let settled = false;
|
||||
const finish = async (result: FirstTimeSetupResult | undefined) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (result) {
|
||||
settingsManager.setTheme(result.theme);
|
||||
settingsManager.setEnableAnalytics(result.shareAnalytics);
|
||||
await settingsManager.flush();
|
||||
}
|
||||
await clearStartupTui(ui);
|
||||
ui.stop();
|
||||
resolve();
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
export async function showStartupInput(
|
||||
settingsManager: SettingsManager,
|
||||
title: string,
|
||||
placeholder?: string,
|
||||
): Promise<string | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const ui = createStartupTui(settingsManager);
|
||||
|
||||
let settled = false;
|
||||
const finish = async (result: string | undefined) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
input.dispose();
|
||||
await clearStartupTui(ui);
|
||||
ui.stop();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const input = new ExtensionInputComponent(
|
||||
title,
|
||||
placeholder,
|
||||
(value) => void finish(value),
|
||||
() => void finish(undefined),
|
||||
{
|
||||
tui: ui,
|
||||
},
|
||||
);
|
||||
ui.addChild(input);
|
||||
ui.setFocus(input);
|
||||
ui.start();
|
||||
});
|
||||
}
|
||||
@@ -109,13 +109,27 @@ function getSelfUpdateCommandForMethod(
|
||||
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", updatePackageName]),
|
||||
makeSelfUpdateCommandStep("pnpm", [
|
||||
"install",
|
||||
"-g",
|
||||
"--ignore-scripts",
|
||||
"--config.minimumReleaseAge=0",
|
||||
...binDirArgs,
|
||||
updatePackageName,
|
||||
]),
|
||||
updatePackageName === installedPackageName
|
||||
? undefined
|
||||
: makeSelfUpdateCommandStep("pnpm", ["remove", "-g", installedPackageName]),
|
||||
: makeSelfUpdateCommandStep("pnpm", ["remove", "-g", ...binDirArgs, installedPackageName]),
|
||||
);
|
||||
}
|
||||
case "yarn":
|
||||
return makeSelfUpdateCommand(
|
||||
makeSelfUpdateCommandStep("yarn", ["global", "add", "--ignore-scripts", updatePackageName]),
|
||||
@@ -125,7 +139,13 @@ function getSelfUpdateCommandForMethod(
|
||||
);
|
||||
case "bun":
|
||||
return makeSelfUpdateCommand(
|
||||
makeSelfUpdateCommandStep("bun", ["install", "-g", "--ignore-scripts", updatePackageName]),
|
||||
makeSelfUpdateCommandStep("bun", [
|
||||
"install",
|
||||
"-g",
|
||||
"--ignore-scripts",
|
||||
"--minimum-release-age=0",
|
||||
updatePackageName,
|
||||
]),
|
||||
updatePackageName === installedPackageName
|
||||
? undefined
|
||||
: makeSelfUpdateCommandStep("bun", ["uninstall", "-g", installedPackageName]),
|
||||
@@ -139,6 +159,7 @@ function getSelfUpdateCommandForMethod(
|
||||
"install",
|
||||
"-g",
|
||||
"--ignore-scripts",
|
||||
"--min-release-age=0",
|
||||
updatePackageName,
|
||||
]);
|
||||
const uninstallStep =
|
||||
@@ -192,7 +213,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"]);
|
||||
@@ -439,7 +462,13 @@ interface PackageJson {
|
||||
};
|
||||
}
|
||||
|
||||
const pkg = JSON.parse(readFileSync(getPackageJsonPath(), "utf-8")) as PackageJson;
|
||||
let pkg: PackageJson = {};
|
||||
try {
|
||||
pkg = JSON.parse(readFileSync(getPackageJsonPath(), "utf-8")) as PackageJson;
|
||||
} catch (e: unknown) {
|
||||
const err = e as NodeJS.ErrnoException;
|
||||
if (err.code !== "ENOENT") throw e;
|
||||
}
|
||||
|
||||
const piConfigName: string | undefined = pkg.piConfig?.name;
|
||||
export const PACKAGE_NAME: string = pkg.name || "@earendil-works/pi-coding-agent";
|
||||
|
||||
@@ -3,7 +3,12 @@ import { basename, join, resolve } from "node:path";
|
||||
import { resolvePath } from "../utils/paths.ts";
|
||||
import type { AgentSession } from "./agent-session.ts";
|
||||
import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agent-session-services.ts";
|
||||
import type { ReplacedSessionContext, SessionShutdownEvent, SessionStartEvent } from "./extensions/index.ts";
|
||||
import type {
|
||||
ProjectTrustContext,
|
||||
ReplacedSessionContext,
|
||||
SessionShutdownEvent,
|
||||
SessionStartEvent,
|
||||
} from "./extensions/index.ts";
|
||||
import { emitSessionShutdownEvent } from "./extensions/runner.ts";
|
||||
import type { CreateAgentSessionResult } from "./sdk.ts";
|
||||
import { assertSessionCwdExists } from "./session-cwd.ts";
|
||||
@@ -32,6 +37,7 @@ export type CreateAgentSessionRuntimeFactory = (options: {
|
||||
agentDir: string;
|
||||
sessionManager: SessionManager;
|
||||
sessionStartEvent?: SessionStartEvent;
|
||||
projectTrustContext?: ProjectTrustContext;
|
||||
}) => Promise<CreateAgentSessionRuntimeResult>;
|
||||
|
||||
/**
|
||||
@@ -186,7 +192,11 @@ export class AgentSessionRuntime {
|
||||
|
||||
async switchSession(
|
||||
sessionPath: string,
|
||||
options?: { cwdOverride?: string; withSession?: (ctx: ReplacedSessionContext) => Promise<void> },
|
||||
options?: {
|
||||
cwdOverride?: string;
|
||||
withSession?: (ctx: ReplacedSessionContext) => Promise<void>;
|
||||
projectTrustContextFactory?: (cwd: string) => ProjectTrustContext;
|
||||
},
|
||||
): Promise<{ cancelled: boolean }> {
|
||||
const beforeResult = await this.emitBeforeSwitch("resume", sessionPath);
|
||||
if (beforeResult.cancelled) {
|
||||
@@ -203,6 +213,7 @@ export class AgentSessionRuntime {
|
||||
agentDir: this.services.agentDir,
|
||||
sessionManager,
|
||||
sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile },
|
||||
projectTrustContext: options?.projectTrustContextFactory?.(sessionManager.getCwd()),
|
||||
}),
|
||||
);
|
||||
await this.finishSessionReplacement(options?.withSession);
|
||||
@@ -221,7 +232,9 @@ export class AgentSessionRuntime {
|
||||
|
||||
const previousSessionFile = this.session.sessionFile;
|
||||
const sessionDir = this.session.sessionManager.getSessionDir();
|
||||
const sessionManager = SessionManager.create(this.cwd, sessionDir);
|
||||
const sessionManager = this.session.sessionManager.isPersisted()
|
||||
? SessionManager.create(this.cwd, sessionDir)
|
||||
: SessionManager.inMemory(this.cwd);
|
||||
if (options?.parentSession) {
|
||||
sessionManager.newSession({ parentSession: options.parentSession });
|
||||
}
|
||||
|
||||
@@ -6,7 +6,12 @@ import { resolvePath } from "../utils/paths.ts";
|
||||
import { AuthStorage } from "./auth-storage.ts";
|
||||
import type { SessionStartEvent, ToolDefinition } from "./extensions/index.ts";
|
||||
import { ModelRegistry } from "./model-registry.ts";
|
||||
import { DefaultResourceLoader, type DefaultResourceLoaderOptions, type ResourceLoader } from "./resource-loader.ts";
|
||||
import {
|
||||
DefaultResourceLoader,
|
||||
type DefaultResourceLoaderOptions,
|
||||
type ResourceLoader,
|
||||
type ResourceLoaderReloadOptions,
|
||||
} from "./resource-loader.ts";
|
||||
import { type CreateAgentSessionOptions, type CreateAgentSessionResult, createAgentSession } from "./sdk.ts";
|
||||
import type { SessionManager } from "./session-manager.ts";
|
||||
import { SettingsManager } from "./settings-manager.ts";
|
||||
@@ -38,6 +43,7 @@ export interface CreateAgentSessionServicesOptions {
|
||||
modelRegistry?: ModelRegistry;
|
||||
extensionFlagValues?: Map<string, boolean | string>;
|
||||
resourceLoaderOptions?: Omit<DefaultResourceLoaderOptions, "cwd" | "agentDir" | "settingsManager">;
|
||||
resourceLoaderReloadOptions?: ResourceLoaderReloadOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,6 +60,7 @@ export interface CreateAgentSessionFromServicesOptions {
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
scopedModels?: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>;
|
||||
tools?: string[];
|
||||
excludeTools?: CreateAgentSessionOptions["excludeTools"];
|
||||
noTools?: CreateAgentSessionOptions["noTools"];
|
||||
customTools?: ToolDefinition[];
|
||||
}
|
||||
@@ -141,7 +148,7 @@ export async function createAgentSessionServices(
|
||||
agentDir,
|
||||
settingsManager,
|
||||
});
|
||||
await resourceLoader.reload();
|
||||
await resourceLoader.reload(options.resourceLoaderReloadOptions);
|
||||
|
||||
const diagnostics: AgentSessionRuntimeDiagnostic[] = [];
|
||||
const extensionsResult = resourceLoader.getExtensions();
|
||||
@@ -192,6 +199,7 @@ export async function createAgentSessionFromServices(
|
||||
thinkingLevel: options.thinkingLevel,
|
||||
scopedModels: options.scopedModels,
|
||||
tools: options.tools,
|
||||
excludeTools: options.excludeTools,
|
||||
noTools: options.noTools,
|
||||
customTools: options.customTools,
|
||||
sessionStartEvent: options.sessionStartEvent,
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
resetApiProviders,
|
||||
streamSimple,
|
||||
} from "@earendil-works/pi-ai";
|
||||
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,
|
||||
@@ -56,6 +57,7 @@ import {
|
||||
type ContextUsage,
|
||||
type ExtensionCommandContextActions,
|
||||
type ExtensionErrorListener,
|
||||
type ExtensionMode,
|
||||
ExtensionRunner,
|
||||
type ExtensionUIContext,
|
||||
type InputSource,
|
||||
@@ -170,6 +172,8 @@ export interface AgentSessionConfig {
|
||||
initialActiveToolNames?: string[];
|
||||
/** Optional allowlist of tool names. When provided, only these tool names are exposed. */
|
||||
allowedToolNames?: string[];
|
||||
/** Optional denylist of tool names. When provided, these tool names are not exposed. */
|
||||
excludedToolNames?: string[];
|
||||
/**
|
||||
* Override base tools (useful for custom runtimes).
|
||||
*
|
||||
@@ -185,6 +189,7 @@ export interface AgentSessionConfig {
|
||||
|
||||
export interface ExtensionBindings {
|
||||
uiContext?: ExtensionUIContext;
|
||||
mode?: ExtensionMode;
|
||||
commandContextActions?: ExtensionCommandContextActions;
|
||||
abortHandler?: () => void;
|
||||
shutdownHandler?: ShutdownHandler;
|
||||
@@ -238,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
|
||||
// ============================================================================
|
||||
@@ -294,9 +307,11 @@ export class AgentSession {
|
||||
private _extensionRunnerRef?: { current?: ExtensionRunner };
|
||||
private _initialActiveToolNames?: string[];
|
||||
private _allowedToolNames?: Set<string>;
|
||||
private _excludedToolNames?: Set<string>;
|
||||
private _baseToolsOverride?: Record<string, AgentTool>;
|
||||
private _sessionStartEvent: SessionStartEvent;
|
||||
private _extensionUIContext?: ExtensionUIContext;
|
||||
private _extensionMode: ExtensionMode = "print";
|
||||
private _extensionCommandContextActions?: ExtensionCommandContextActions;
|
||||
private _extensionAbortHandler?: () => void;
|
||||
private _extensionShutdownHandler?: ShutdownHandler;
|
||||
@@ -328,6 +343,7 @@ export class AgentSession {
|
||||
this._extensionRunnerRef = config.extensionRunnerRef;
|
||||
this._initialActiveToolNames = config.initialActiveToolNames;
|
||||
this._allowedToolNames = config.allowedToolNames ? new Set(config.allowedToolNames) : undefined;
|
||||
this._excludedToolNames = config.excludedToolNames ? new Set(config.excludedToolNames) : undefined;
|
||||
this._baseToolsOverride = config.baseToolsOverride;
|
||||
this._sessionStartEvent = config.sessionStartEvent ?? { type: "session_start", reason: "startup" };
|
||||
|
||||
@@ -350,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) {
|
||||
@@ -359,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);
|
||||
@@ -376,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 } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -708,6 +726,16 @@ export class AgentSession {
|
||||
* Call this when completely done with the session.
|
||||
*/
|
||||
dispose(): void {
|
||||
try {
|
||||
this.abortRetry();
|
||||
this.abortCompaction();
|
||||
this.abortBranchSummary();
|
||||
this.abortBash();
|
||||
this.agent.abort();
|
||||
} catch {
|
||||
// Dispose must succeed even if an abort hook throws.
|
||||
}
|
||||
|
||||
this._extensionRunner.invalidate(
|
||||
"This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().",
|
||||
);
|
||||
@@ -759,13 +787,14 @@ export class AgentSession {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all configured tools with name, description, parameter schema, and source metadata.
|
||||
* Get all configured tools with name, description, parameter schema, prompt guidelines, and source metadata.
|
||||
*/
|
||||
getAllTools(): ToolInfo[] {
|
||||
return Array.from(this._toolDefinitions.values()).map(({ definition, sourceInfo }) => ({
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
parameters: definition.parameters,
|
||||
promptGuidelines: definition.promptGuidelines,
|
||||
sourceInfo,
|
||||
}));
|
||||
}
|
||||
@@ -955,7 +984,13 @@ export class AgentSession {
|
||||
this._retryAttempt = 0;
|
||||
}
|
||||
|
||||
return await this._checkCompaction(msg);
|
||||
if (await this._checkCompaction(msg)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// The agent loop drains both queues before emitting agent_end. Any messages
|
||||
// here were queued by agent_end extension handlers and need a continuation.
|
||||
return this.agent.hasQueuedMessages();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -992,6 +1027,7 @@ export class AgentSession {
|
||||
currentText,
|
||||
currentImages,
|
||||
options?.source ?? "interactive",
|
||||
this.isStreaming ? options?.streamingBehavior : undefined,
|
||||
);
|
||||
if (inputResult.action === "handled") {
|
||||
preflightResult?.(true);
|
||||
@@ -1589,6 +1625,11 @@ export class AgentSession {
|
||||
// Queue Mode Management
|
||||
// =========================================================================
|
||||
|
||||
private syncQueueModesFromSettings(): void {
|
||||
this.agent.steeringMode = this.settingsManager.getSteeringMode();
|
||||
this.agent.followUpMode = this.settingsManager.getFollowUpMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set steering message mode.
|
||||
* Saves to settings.
|
||||
@@ -1627,7 +1668,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();
|
||||
@@ -1686,6 +1727,7 @@ export class AgentSession {
|
||||
this._compactionAbortController.signal,
|
||||
this.thinkingLevel,
|
||||
this.agent.streamFn,
|
||||
env,
|
||||
);
|
||||
summary = result.summary;
|
||||
firstKeptEntryId = result.firstKeptEntryId;
|
||||
@@ -1701,6 +1743,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
|
||||
@@ -1715,10 +1758,11 @@ export class AgentSession {
|
||||
});
|
||||
}
|
||||
|
||||
const compactionResult = {
|
||||
const compactionResult: CompactionResult = {
|
||||
summary,
|
||||
firstKeptEntryId,
|
||||
tokensBefore,
|
||||
estimatedTokensAfter,
|
||||
details,
|
||||
};
|
||||
this._emit({
|
||||
@@ -1799,8 +1843,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",
|
||||
@@ -1821,7 +1874,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
|
||||
@@ -1858,56 +1911,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;
|
||||
|
||||
@@ -1959,6 +1995,7 @@ export class AgentSession {
|
||||
this._autoCompactionAbortController.signal,
|
||||
this.thinkingLevel,
|
||||
this.agent.streamFn,
|
||||
env,
|
||||
);
|
||||
summary = compactResult.summary;
|
||||
firstKeptEntryId = compactResult.firstKeptEntryId;
|
||||
@@ -1981,6 +2018,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
|
||||
@@ -1999,6 +2037,7 @@ export class AgentSession {
|
||||
summary,
|
||||
firstKeptEntryId,
|
||||
tokensBefore,
|
||||
estimatedTokensAfter,
|
||||
details,
|
||||
};
|
||||
this._emit({ type: "compaction_end", reason, result, aborted: false, willRetry });
|
||||
@@ -2017,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;
|
||||
@@ -2050,6 +2091,9 @@ export class AgentSession {
|
||||
if (bindings.uiContext !== undefined) {
|
||||
this._extensionUIContext = bindings.uiContext;
|
||||
}
|
||||
if (bindings.mode !== undefined) {
|
||||
this._extensionMode = bindings.mode;
|
||||
}
|
||||
if (bindings.commandContextActions !== undefined) {
|
||||
this._extensionCommandContextActions = bindings.commandContextActions;
|
||||
}
|
||||
@@ -2122,7 +2166,7 @@ export class AgentSession {
|
||||
}
|
||||
|
||||
private _applyExtensionBindings(runner: ExtensionRunner): void {
|
||||
runner.setUIContext(this._extensionUIContext);
|
||||
runner.setUIContext(this._extensionUIContext, this._extensionMode);
|
||||
runner.bindCommandContext(this._extensionCommandContextActions);
|
||||
|
||||
this._extensionErrorUnsubscriber?.();
|
||||
@@ -2219,6 +2263,7 @@ export class AgentSession {
|
||||
{
|
||||
getModel: () => this.model,
|
||||
isIdle: () => !this.isStreaming,
|
||||
isProjectTrusted: () => this.settingsManager.isProjectTrusted(),
|
||||
getSignal: () => this.agent.signal,
|
||||
abort: () => {
|
||||
if (this._extensionAbortHandler) {
|
||||
@@ -2244,6 +2289,7 @@ export class AgentSession {
|
||||
})();
|
||||
},
|
||||
getSystemPrompt: () => this.systemPrompt,
|
||||
getSystemPromptOptions: () => this._baseSystemPromptOptions,
|
||||
},
|
||||
{
|
||||
registerProvider: (name, config) => {
|
||||
@@ -2262,7 +2308,9 @@ export class AgentSession {
|
||||
const previousRegistryNames = new Set(this._toolRegistry.keys());
|
||||
const previousActiveToolNames = this.getActiveToolNames();
|
||||
const allowedToolNames = this._allowedToolNames;
|
||||
const isAllowedTool = (name: string): boolean => !allowedToolNames || allowedToolNames.has(name);
|
||||
const excludedToolNames = this._excludedToolNames;
|
||||
const isAllowedTool = (name: string): boolean =>
|
||||
(!allowedToolNames || allowedToolNames.has(name)) && !excludedToolNames?.has(name);
|
||||
|
||||
const registeredTools = this._extensionRunner.getAllRegisteredTools();
|
||||
const allCustomTools = [
|
||||
@@ -2407,6 +2455,7 @@ export class AgentSession {
|
||||
const previousFlagValues = this._extensionRunner.getFlagValues();
|
||||
await emitSessionShutdownEvent(this._extensionRunner, { type: "session_shutdown", reason: "reload" });
|
||||
await this.settingsManager.reload();
|
||||
this.syncQueueModesFromSettings();
|
||||
resetApiProviders();
|
||||
await this._resourceLoader.reload();
|
||||
this._buildRuntime({
|
||||
@@ -2430,6 +2479,12 @@ export class AgentSession {
|
||||
// Auto-Retry
|
||||
// =========================================================================
|
||||
|
||||
private _isNonRetryableProviderLimitError(errorMessage: string): boolean {
|
||||
return /GoUsageLimitError|FreeUsageLimitError|Monthly usage limit reached|available balance|insufficient_quota|out of budget|quota exceeded|billing/i.test(
|
||||
errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is retryable (overloaded, rate limit, server errors).
|
||||
* Context overflow errors are NOT retryable (handled by compaction instead).
|
||||
@@ -2442,6 +2497,7 @@ export class AgentSession {
|
||||
if (isContextOverflow(message, contextWindow)) return false;
|
||||
|
||||
const err = message.errorMessage;
|
||||
if (this._isNonRetryableProviderLimitError(err)) return false;
|
||||
// Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504, service unavailable, network/connection errors (including connection lost), WebSocket transport closes/errors, fetch failed, premature stream endings, HTTP/2 closed before response, terminated, retry delay exceeded
|
||||
return /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|stream ended before message_stop|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i.test(
|
||||
err,
|
||||
@@ -2747,16 +2803,18 @@ 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,
|
||||
reserveTokens: branchSummarySettings.reserveTokens,
|
||||
streamFn: this.agent.streamFn,
|
||||
});
|
||||
if (result.aborted) {
|
||||
return { cancelled: true, aborted: true };
|
||||
@@ -2979,7 +3037,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 = {
|
||||
@@ -45,6 +46,8 @@ type LockResult<T> = {
|
||||
next?: string;
|
||||
};
|
||||
|
||||
const AUTH_FILE_WRITE_OPTIONS = { encoding: "utf-8", mode: 0o600 } as const;
|
||||
|
||||
export interface AuthStorageBackend {
|
||||
withLock<T>(fn: (current: string | undefined) => LockResult<T>): T;
|
||||
withLockAsync<T>(fn: (current: string | undefined) => Promise<LockResult<T>>): Promise<T>;
|
||||
@@ -66,7 +69,7 @@ export class FileAuthStorageBackend implements AuthStorageBackend {
|
||||
|
||||
private ensureFileExists(): void {
|
||||
if (!existsSync(this.authPath)) {
|
||||
writeFileSync(this.authPath, "{}", "utf-8");
|
||||
writeFileSync(this.authPath, "{}", AUTH_FILE_WRITE_OPTIONS);
|
||||
chmodSync(this.authPath, 0o600);
|
||||
}
|
||||
}
|
||||
@@ -108,7 +111,7 @@ export class FileAuthStorageBackend implements AuthStorageBackend {
|
||||
const current = existsSync(this.authPath) ? readFileSync(this.authPath, "utf-8") : undefined;
|
||||
const { result, next } = fn(current);
|
||||
if (next !== undefined) {
|
||||
writeFileSync(this.authPath, next, "utf-8");
|
||||
writeFileSync(this.authPath, next, AUTH_FILE_WRITE_OPTIONS);
|
||||
chmodSync(this.authPath, 0o600);
|
||||
}
|
||||
return result;
|
||||
@@ -153,7 +156,7 @@ export class FileAuthStorageBackend implements AuthStorageBackend {
|
||||
const { result, next } = await fn(current);
|
||||
throwIfCompromised();
|
||||
if (next !== undefined) {
|
||||
writeFileSync(this.authPath, next, "utf-8");
|
||||
writeFileSync(this.authPath, next, AUTH_FILE_WRITE_OPTIONS);
|
||||
chmodSync(this.authPath, 0o600);
|
||||
}
|
||||
throwIfCompromised();
|
||||
@@ -301,6 +304,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.
|
||||
*/
|
||||
@@ -469,7 +480,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") {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* a summary of the branch being left so context isn't lost.
|
||||
*/
|
||||
|
||||
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import type { Model } from "@earendil-works/pi-ai";
|
||||
import type { AgentMessage, StreamFn } from "@earendil-works/pi-agent-core";
|
||||
import type { Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
|
||||
import { completeSimple } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
convertToLlm,
|
||||
@@ -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 */
|
||||
@@ -77,6 +79,8 @@ export interface GenerateBranchSummaryOptions {
|
||||
replaceInstructions?: boolean;
|
||||
/** Tokens reserved for prompt + LLM response (default 16384) */
|
||||
reserveTokens?: number;
|
||||
/** Optional session stream function. Used to preserve SDK request behavior without mutating agent state. */
|
||||
streamFn?: StreamFn;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -284,7 +288,17 @@ export async function generateBranchSummary(
|
||||
entries: SessionEntry[],
|
||||
options: GenerateBranchSummaryOptions,
|
||||
): Promise<BranchSummaryResult> {
|
||||
const { model, apiKey, headers, signal, customInstructions, replaceInstructions, reserveTokens = 16384 } = options;
|
||||
const {
|
||||
model,
|
||||
apiKey,
|
||||
headers,
|
||||
env,
|
||||
signal,
|
||||
customInstructions,
|
||||
replaceInstructions,
|
||||
reserveTokens = 16384,
|
||||
streamFn,
|
||||
} = options;
|
||||
|
||||
// Token budget = context window minus reserved space for prompt + response
|
||||
const contextWindow = model.contextWindow || 128000;
|
||||
@@ -320,12 +334,14 @@ export async function generateBranchSummary(
|
||||
},
|
||||
];
|
||||
|
||||
// Call LLM for summarization
|
||||
const response = await completeSimple(
|
||||
model,
|
||||
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
||||
{ apiKey, headers, signal, maxTokens: 2048 },
|
||||
);
|
||||
// Call LLM for summarization. Prefer the session stream function so SDK
|
||||
// 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, env, signal, maxTokens: 2048 };
|
||||
const response = streamFn
|
||||
? await (await streamFn(model, context, requestOptions)).result()
|
||||
: await completeSimple(model, context, requestOptions);
|
||||
|
||||
// Check if aborted or errored
|
||||
if (response.stopReason === "aborted") {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -225,6 +226,24 @@ export function shouldCompact(contextTokens: number, contextWindow: number, sett
|
||||
// Cut point detection
|
||||
// ============================================================================
|
||||
|
||||
const ESTIMATED_IMAGE_CHARS = 4800;
|
||||
|
||||
function estimateTextAndImageContentChars(content: string | Array<{ type: string; text?: string }>): number {
|
||||
if (typeof content === "string") {
|
||||
return content.length;
|
||||
}
|
||||
|
||||
let chars = 0;
|
||||
for (const block of content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
chars += block.text.length;
|
||||
} else if (block.type === "image") {
|
||||
chars += ESTIMATED_IMAGE_CHARS;
|
||||
}
|
||||
}
|
||||
return chars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate token count for a message using chars/4 heuristic.
|
||||
* This is conservative (overestimates tokens).
|
||||
@@ -234,16 +253,9 @@ export function estimateTokens(message: AgentMessage): number {
|
||||
|
||||
switch (message.role) {
|
||||
case "user": {
|
||||
const content = (message as { content: string | Array<{ type: string; text?: string }> }).content;
|
||||
if (typeof content === "string") {
|
||||
chars = content.length;
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
chars += block.text.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
chars = estimateTextAndImageContentChars(
|
||||
(message as { content: string | Array<{ type: string; text?: string }> }).content,
|
||||
);
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
case "assistant": {
|
||||
@@ -261,18 +273,7 @@ export function estimateTokens(message: AgentMessage): number {
|
||||
}
|
||||
case "custom":
|
||||
case "toolResult": {
|
||||
if (typeof message.content === "string") {
|
||||
chars = message.content.length;
|
||||
} else {
|
||||
for (const block of message.content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
chars += block.text.length;
|
||||
}
|
||||
if (block.type === "image") {
|
||||
chars += 4800; // Estimate images as 4000 chars, or 1200 tokens
|
||||
}
|
||||
}
|
||||
}
|
||||
chars = estimateTextAndImageContentChars(message.content);
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
case "bashExecution": {
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -165,6 +165,6 @@ export function serializeConversation(messages: Message[]): string {
|
||||
// Summarization System Prompt
|
||||
// ============================================================================
|
||||
|
||||
export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.
|
||||
export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified.
|
||||
|
||||
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`;
|
||||
|
||||
3
packages/coding-agent/src/core/experimental.ts
Normal file
3
packages/coding-agent/src/core/experimental.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function areExperimentalFeaturesEnabled(): boolean {
|
||||
return process.env.PI_EXPERIMENTAL === "1";
|
||||
}
|
||||
@@ -605,9 +605,24 @@
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function sanitizeMarkdownUrl(value) {
|
||||
const href = String(value || '').trim().replace(/[\x00-\x1f\x7f]/g, '');
|
||||
if (!href) return href;
|
||||
|
||||
const scheme = href.match(/^([A-Za-z][A-Za-z0-9+.-]*):/);
|
||||
if (scheme && !/^(https?|mailto|tel|ftp)$/i.test(scheme[1])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return href;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1566,10 +1581,11 @@
|
||||
}
|
||||
},
|
||||
renderer: {
|
||||
// Sanitize link URLs to prevent javascript:/vbscript:/data: XSS
|
||||
// Sanitize link URLs with a scheme allow-list. Browsers strip C0
|
||||
// controls from schemes, so strip them before checking and emitting.
|
||||
link(token) {
|
||||
const href = (token.href || '').trim();
|
||||
if (/^\s*(javascript|vbscript|data):/i.test(href)) {
|
||||
const href = sanitizeMarkdownUrl(token.href);
|
||||
if (href === null) {
|
||||
return this.parser.parseInline(token.tokens);
|
||||
}
|
||||
let out = '<a href="' + escapeHtml(href) + '"';
|
||||
@@ -1579,10 +1595,10 @@
|
||||
out += '>' + this.parser.parseInline(token.tokens) + '</a>';
|
||||
return out;
|
||||
},
|
||||
// Sanitize image src URLs
|
||||
// Sanitize image src URLs with the same scheme allow-list.
|
||||
image(token) {
|
||||
const href = (token.href || '').trim();
|
||||
if (/^\s*(javascript|vbscript|data):/i.test(href)) {
|
||||
const href = sanitizeMarkdownUrl(token.href);
|
||||
if (href === null) {
|
||||
return escapeHtml(token.text || '');
|
||||
}
|
||||
let out = '<img src="' + escapeHtml(href) + '" alt="' + escapeHtml(token.text || '') + '"';
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -66,6 +66,7 @@ export type {
|
||||
ExtensionFactory,
|
||||
ExtensionFlag,
|
||||
ExtensionHandler,
|
||||
ExtensionMode,
|
||||
// Runtime
|
||||
ExtensionRuntime,
|
||||
ExtensionShortcut,
|
||||
@@ -97,6 +98,11 @@ export type {
|
||||
MessageUpdateEvent,
|
||||
ModelSelectEvent,
|
||||
ModelSelectSource,
|
||||
ProjectTrustContext,
|
||||
ProjectTrustEvent,
|
||||
ProjectTrustEventDecision,
|
||||
ProjectTrustEventResult,
|
||||
ProjectTrustHandler,
|
||||
// Provider Registration
|
||||
ProviderConfig,
|
||||
ProviderModelConfig,
|
||||
|
||||
@@ -117,6 +117,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.
|
||||
@@ -328,7 +352,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)
|
||||
@@ -339,7 +378,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -370,11 +415,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}` };
|
||||
}
|
||||
@@ -410,15 +456,28 @@ export async function loadExtensionFromFactory(
|
||||
/**
|
||||
* Load extensions from paths.
|
||||
*/
|
||||
export async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise<LoadExtensionsResult> {
|
||||
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 runtime = createExtensionRuntime();
|
||||
const resolvedRuntime = runtime ?? createExtensionRuntime();
|
||||
|
||||
for (const extPath of paths) {
|
||||
const { extension, error } = await loadExtension(extPath, resolvedCwd, resolvedEventBus, runtime);
|
||||
const { extension, error } = await loadExtension(
|
||||
extPath,
|
||||
resolvedCwd,
|
||||
resolvedEventBus,
|
||||
resolvedRuntime,
|
||||
cacheToken,
|
||||
);
|
||||
|
||||
if (error) {
|
||||
errors.push({ path: extPath, error });
|
||||
@@ -433,10 +492,28 @@ export async function loadExtensions(paths: string[], cwd: string, eventBus?: Ev
|
||||
return {
|
||||
extensions,
|
||||
errors,
|
||||
runtime,
|
||||
runtime: resolvedRuntime,
|
||||
};
|
||||
}
|
||||
|
||||
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[];
|
||||
|
||||
@@ -28,15 +28,20 @@ import type {
|
||||
ExtensionError,
|
||||
ExtensionEvent,
|
||||
ExtensionFlag,
|
||||
ExtensionMode,
|
||||
ExtensionRuntime,
|
||||
ExtensionShortcut,
|
||||
ExtensionUIContext,
|
||||
InputEvent,
|
||||
InputEventResult,
|
||||
InputSource,
|
||||
LoadExtensionsResult,
|
||||
MessageEndEvent,
|
||||
MessageEndEventResult,
|
||||
MessageRenderer,
|
||||
ProjectTrustContext,
|
||||
ProjectTrustEvent,
|
||||
ProjectTrustEventResult,
|
||||
ProviderConfig,
|
||||
RegisteredCommand,
|
||||
RegisteredTool,
|
||||
@@ -115,6 +120,7 @@ interface BeforeAgentStartCombinedResult {
|
||||
type RunnerEmitEvent = Exclude<
|
||||
ExtensionEvent,
|
||||
| ToolCallEvent
|
||||
| ProjectTrustEvent
|
||||
| ToolResultEvent
|
||||
| UserBashEvent
|
||||
| ContextEvent
|
||||
@@ -188,6 +194,38 @@ export async function emitSessionShutdownEvent(
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function emitProjectTrustEvent(
|
||||
extensionsResult: LoadExtensionsResult,
|
||||
event: ProjectTrustEvent,
|
||||
ctx: ProjectTrustContext,
|
||||
): Promise<{ result?: ProjectTrustEventResult; errors: ExtensionError[] }> {
|
||||
const errors: ExtensionError[] = [];
|
||||
for (const ext of extensionsResult.extensions) {
|
||||
// A single extension may register multiple handlers for the same event.
|
||||
// The first project_trust handler that returns yes/no wins; undecided falls through.
|
||||
const handlers = ext.handlers.get("project_trust");
|
||||
if (!handlers || handlers.length === 0) continue;
|
||||
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
const handlerResult = (await handler(event, ctx)) as ProjectTrustEventResult;
|
||||
if (handlerResult.trusted === "undecided") {
|
||||
continue;
|
||||
}
|
||||
return { result: handlerResult, errors };
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
extensionPath: ext.path,
|
||||
event: event.type,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return { errors };
|
||||
}
|
||||
|
||||
const noOpUIContext: ExtensionUIContext = {
|
||||
select: async () => undefined,
|
||||
confirm: async () => false,
|
||||
@@ -225,12 +263,14 @@ export class ExtensionRunner {
|
||||
private extensions: Extension[];
|
||||
private runtime: ExtensionRuntime;
|
||||
private uiContext: ExtensionUIContext;
|
||||
private mode: ExtensionMode = "print";
|
||||
private cwd: string;
|
||||
private sessionManager: SessionManager;
|
||||
private modelRegistry: ModelRegistry;
|
||||
private errorListeners: Set<ExtensionErrorListener> = new Set();
|
||||
private getModel: () => Model<any> | undefined = () => undefined;
|
||||
private isIdleFn: () => boolean = () => true;
|
||||
private isProjectTrustedFn: () => boolean = () => true;
|
||||
private getSignalFn: () => AbortSignal | undefined = () => undefined;
|
||||
private waitForIdleFn: () => Promise<void> = async () => {};
|
||||
private abortFn: () => void = () => {};
|
||||
@@ -238,6 +278,7 @@ export class ExtensionRunner {
|
||||
private getContextUsageFn: () => ContextUsage | undefined = () => undefined;
|
||||
private compactFn: (options?: CompactOptions) => void = () => {};
|
||||
private getSystemPromptFn: () => string = () => "";
|
||||
private getSystemPromptOptionsFn: () => BuildSystemPromptOptions = () => ({ cwd: this.cwd });
|
||||
private newSessionHandler: NewSessionHandler = async () => ({ cancelled: false });
|
||||
private forkHandler: ForkHandler = async () => ({ cancelled: false });
|
||||
private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false });
|
||||
@@ -290,6 +331,7 @@ export class ExtensionRunner {
|
||||
// Context actions (required)
|
||||
this.getModel = contextActions.getModel;
|
||||
this.isIdleFn = contextActions.isIdle;
|
||||
this.isProjectTrustedFn = contextActions.isProjectTrusted;
|
||||
this.getSignalFn = contextActions.getSignal;
|
||||
this.abortFn = contextActions.abort;
|
||||
this.hasPendingMessagesFn = contextActions.hasPendingMessages;
|
||||
@@ -297,6 +339,7 @@ export class ExtensionRunner {
|
||||
this.getContextUsageFn = contextActions.getContextUsage;
|
||||
this.compactFn = contextActions.compact;
|
||||
this.getSystemPromptFn = contextActions.getSystemPrompt;
|
||||
this.getSystemPromptOptionsFn = contextActions.getSystemPromptOptions ?? (() => ({ cwd: this.cwd }));
|
||||
|
||||
// Flush provider registrations queued during extension loading
|
||||
for (const { name, config, extensionPath } of this.runtime.pendingProviderRegistrations) {
|
||||
@@ -354,8 +397,9 @@ export class ExtensionRunner {
|
||||
this.reloadHandler = async () => {};
|
||||
}
|
||||
|
||||
setUIContext(uiContext?: ExtensionUIContext): void {
|
||||
setUIContext(uiContext?: ExtensionUIContext, mode: ExtensionMode = "print"): void {
|
||||
this.uiContext = uiContext ?? noOpUIContext;
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
getUIContext(): ExtensionUIContext {
|
||||
@@ -578,6 +622,10 @@ export class ExtensionRunner {
|
||||
runner.assertActive();
|
||||
return runner.uiContext;
|
||||
},
|
||||
get mode() {
|
||||
runner.assertActive();
|
||||
return runner.mode;
|
||||
},
|
||||
get hasUI() {
|
||||
runner.assertActive();
|
||||
return runner.hasUI();
|
||||
@@ -602,6 +650,10 @@ export class ExtensionRunner {
|
||||
runner.assertActive();
|
||||
return runner.isIdleFn();
|
||||
},
|
||||
isProjectTrusted: () => {
|
||||
runner.assertActive();
|
||||
return runner.isProjectTrustedFn();
|
||||
},
|
||||
get signal() {
|
||||
runner.assertActive();
|
||||
return runner.getSignalFn();
|
||||
@@ -641,6 +693,10 @@ export class ExtensionRunner {
|
||||
{},
|
||||
Object.getOwnPropertyDescriptors(this.createContext()),
|
||||
) as ExtensionCommandContext;
|
||||
context.getSystemPromptOptions = () => {
|
||||
this.assertActive();
|
||||
return this.getSystemPromptOptionsFn();
|
||||
};
|
||||
context.waitForIdle = () => {
|
||||
this.assertActive();
|
||||
return this.waitForIdleFn();
|
||||
@@ -1036,7 +1092,12 @@ export class ExtensionRunner {
|
||||
}
|
||||
|
||||
/** Emit input event. Transforms chain, "handled" short-circuits. */
|
||||
async emitInput(text: string, images: ImageContent[] | undefined, source: InputSource): Promise<InputEventResult> {
|
||||
async emitInput(
|
||||
text: string,
|
||||
images: ImageContent[] | undefined,
|
||||
source: InputSource,
|
||||
streamingBehavior?: "steer" | "followUp",
|
||||
): Promise<InputEventResult> {
|
||||
const ctx = this.createContext();
|
||||
let currentText = text;
|
||||
let currentImages = images;
|
||||
@@ -1044,7 +1105,13 @@ export class ExtensionRunner {
|
||||
for (const ext of this.extensions) {
|
||||
for (const handler of ext.handlers.get("input") ?? []) {
|
||||
try {
|
||||
const event: InputEvent = { type: "input", text: currentText, images: currentImages, source };
|
||||
const event: InputEvent = {
|
||||
type: "input",
|
||||
text: currentText,
|
||||
images: currentImages,
|
||||
source,
|
||||
streamingBehavior,
|
||||
};
|
||||
const result = (await handler(event, ctx)) as InputEventResult | undefined;
|
||||
if (result?.action === "handled") return result;
|
||||
if (result?.action === "transform") {
|
||||
|
||||
@@ -295,10 +295,14 @@ export interface CompactOptions {
|
||||
/**
|
||||
* Context passed to extension event handlers.
|
||||
*/
|
||||
export type ExtensionMode = "tui" | "rpc" | "json" | "print";
|
||||
|
||||
export interface ExtensionContext {
|
||||
/** UI methods for user interaction */
|
||||
ui: ExtensionUIContext;
|
||||
/** Whether UI is available (false in print/RPC mode) */
|
||||
/** Current run mode. Use "tui" to guard terminal-only UI such as custom components. */
|
||||
mode: ExtensionMode;
|
||||
/** Whether dialog-capable UI is available (true in TUI and RPC modes) */
|
||||
hasUI: boolean;
|
||||
/** Current working directory */
|
||||
cwd: string;
|
||||
@@ -310,6 +314,8 @@ export interface ExtensionContext {
|
||||
model: Model<any> | undefined;
|
||||
/** Whether the agent is idle (not streaming) */
|
||||
isIdle(): boolean;
|
||||
/** Whether project-local trust is active for this context. */
|
||||
isProjectTrusted(): boolean;
|
||||
/** The current abort signal, or undefined when the agent is not streaming. */
|
||||
signal: AbortSignal | undefined;
|
||||
/** Abort the current agent operation */
|
||||
@@ -331,6 +337,9 @@ export interface ExtensionContext {
|
||||
* Includes session control methods only safe in user-initiated commands.
|
||||
*/
|
||||
export interface ExtensionCommandContext extends ExtensionContext {
|
||||
/** Get the current base system-prompt construction options. */
|
||||
getSystemPromptOptions(): BuildSystemPromptOptions;
|
||||
|
||||
/** Wait for the agent to finish streaming */
|
||||
waitForIdle(): Promise<void>;
|
||||
|
||||
@@ -488,9 +497,33 @@ export function defineTool<TParams extends TSchema, TDetails = unknown, TState =
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Resource Events
|
||||
// Startup/Resource Events
|
||||
// ============================================================================
|
||||
|
||||
export interface ProjectTrustEvent {
|
||||
type: "project_trust";
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export type ProjectTrustEventDecision = "yes" | "no" | "undecided";
|
||||
|
||||
export interface ProjectTrustEventResult {
|
||||
trusted: ProjectTrustEventDecision;
|
||||
remember?: boolean;
|
||||
}
|
||||
|
||||
export interface ProjectTrustContext {
|
||||
cwd: string;
|
||||
mode: ExtensionMode;
|
||||
hasUI: boolean;
|
||||
ui: Pick<ExtensionUIContext, "select" | "confirm" | "input" | "notify">;
|
||||
}
|
||||
|
||||
export type ProjectTrustHandler = (
|
||||
event: ProjectTrustEvent,
|
||||
ctx: ProjectTrustContext,
|
||||
) => Promise<ProjectTrustEventResult> | ProjectTrustEventResult;
|
||||
|
||||
/** Fired after session_start to allow extensions to provide additional resource paths. */
|
||||
export interface ResourcesDiscoverEvent {
|
||||
type: "resources_discover";
|
||||
@@ -756,6 +789,8 @@ export interface InputEvent {
|
||||
images?: ImageContent[];
|
||||
/** Where the input came from */
|
||||
source: InputSource;
|
||||
/** How the input will be delivered during streaming, or undefined when idle */
|
||||
streamingBehavior?: "steer" | "followUp";
|
||||
}
|
||||
|
||||
/** Result from input event handler */
|
||||
@@ -948,6 +983,7 @@ export function isToolCallEventType(toolName: string, event: ToolCallEvent): boo
|
||||
|
||||
/** Union of all event types */
|
||||
export type ExtensionEvent =
|
||||
| ProjectTrustEvent
|
||||
| ResourcesDiscoverEvent
|
||||
| SessionEvent
|
||||
| ContextEvent
|
||||
@@ -1086,6 +1122,7 @@ export interface ExtensionAPI {
|
||||
// Event Subscription
|
||||
// =========================================================================
|
||||
|
||||
on(event: "project_trust", handler: ProjectTrustHandler): void;
|
||||
on(event: "resources_discover", handler: ExtensionHandler<ResourcesDiscoverEvent, ResourcesDiscoverResult>): void;
|
||||
on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
|
||||
on(
|
||||
@@ -1211,7 +1248,7 @@ export interface ExtensionAPI {
|
||||
/** Get the list of currently active tool names. */
|
||||
getActiveTools(): string[];
|
||||
|
||||
/** Get all configured tools with parameter schema and source metadata. */
|
||||
/** Get all configured tools with parameter schema, prompt guidelines, and source metadata. */
|
||||
getAllTools(): ToolInfo[];
|
||||
|
||||
/** Set the active tools by name. */
|
||||
@@ -1254,7 +1291,7 @@ export interface ExtensionAPI {
|
||||
* // Register a new provider with custom models
|
||||
* pi.registerProvider("my-proxy", {
|
||||
* baseUrl: "https://proxy.example.com",
|
||||
* apiKey: "PROXY_API_KEY",
|
||||
* apiKey: "$PROXY_API_KEY",
|
||||
* api: "anthropic-messages",
|
||||
* models: [
|
||||
* {
|
||||
@@ -1320,7 +1357,7 @@ export interface ProviderConfig {
|
||||
name?: string;
|
||||
/** Base URL for the API endpoint. Required when defining models. */
|
||||
baseUrl?: string;
|
||||
/** API key or environment variable name. Required when defining models (unless oauth provided). */
|
||||
/** API key literal, env interpolation ($ENV_VAR or ${ENV_VAR}), or leading !command. Required when defining models (unless oauth provided). */
|
||||
apiKey?: string;
|
||||
/** API type. Required at provider or model level when defining models. */
|
||||
api?: Api;
|
||||
@@ -1422,8 +1459,8 @@ export type GetSessionNameHandler = () => string | undefined;
|
||||
|
||||
export type GetActiveToolsHandler = () => string[];
|
||||
|
||||
/** Tool info with name, description, parameter schema, and source metadata */
|
||||
export type ToolInfo = Pick<ToolDefinition, "name" | "description" | "parameters"> & {
|
||||
/** Tool info with name, description, parameter schema, prompt guidelines, and source metadata. */
|
||||
export type ToolInfo = Pick<ToolDefinition, "name" | "description" | "parameters" | "promptGuidelines"> & {
|
||||
sourceInfo: SourceInfo;
|
||||
};
|
||||
|
||||
@@ -1493,6 +1530,7 @@ export interface ExtensionActions {
|
||||
export interface ExtensionContextActions {
|
||||
getModel: () => Model<any> | undefined;
|
||||
isIdle: () => boolean;
|
||||
isProjectTrusted: () => boolean;
|
||||
getSignal: () => AbortSignal | undefined;
|
||||
abort: () => void;
|
||||
hasPendingMessages: () => boolean;
|
||||
@@ -1500,6 +1538,7 @@ export interface ExtensionContextActions {
|
||||
getContextUsage: () => ContextUsage | undefined;
|
||||
compact: (options?: CompactOptions) => void;
|
||||
getSystemPrompt: () => string;
|
||||
getSystemPromptOptions?: () => BuildSystemPromptOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type ExecFileException, execFile, spawnSync } from "child_process";
|
||||
import { existsSync, type FSWatcher, readFileSync, statSync, unwatchFile, watchFile } from "fs";
|
||||
import { existsSync, type FSWatcher, readFileSync, type Stats, statSync, unwatchFile, watchFile } from "fs";
|
||||
import { dirname, join, resolve } from "path";
|
||||
import { closeWatcher, FS_WATCH_RETRY_DELAY_MS, watchWithErrorHandler } from "../utils/fs-watch.ts";
|
||||
|
||||
@@ -80,6 +80,18 @@ function resolveBranchWithGitAsync(repoDir: string): Promise<string | null> {
|
||||
});
|
||||
}
|
||||
|
||||
function isWslEnvironment(): boolean {
|
||||
return process.platform === "linux" && !!(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP);
|
||||
}
|
||||
|
||||
function isWindowsMountedRepoPath(repoDir: string): boolean {
|
||||
return /^\/mnt\/[a-z](?:\/|$)/i.test(repoDir);
|
||||
}
|
||||
|
||||
function shouldPollGitHead(repoDir: string): boolean {
|
||||
return isWslEnvironment() && isWindowsMountedRepoPath(repoDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides git branch and extension statuses - data not otherwise accessible to extensions.
|
||||
* Token stats, model info available via ctx.sessionManager and ctx.model.
|
||||
@@ -92,6 +104,8 @@ export class FooterDataProvider {
|
||||
private cachedBranch: string | null | undefined = undefined;
|
||||
private gitPaths: GitPaths | null | undefined = undefined;
|
||||
private headWatcher: FSWatcher | null = null;
|
||||
private headWatchFilePath: string | null = null;
|
||||
private headWatchFileListener: ((current: Stats, previous: Stats) => void) | null = null;
|
||||
private reftableWatcher: FSWatcher | null = null;
|
||||
private reftableTablesListWatcher: FSWatcher | null = null;
|
||||
private reftableTablesListPath: string | null = null;
|
||||
@@ -255,6 +269,11 @@ export class FooterDataProvider {
|
||||
private clearGitWatchers(): void {
|
||||
closeWatcher(this.headWatcher);
|
||||
this.headWatcher = null;
|
||||
if (this.headWatchFilePath && this.headWatchFileListener) {
|
||||
unwatchFile(this.headWatchFilePath, this.headWatchFileListener);
|
||||
this.headWatchFilePath = null;
|
||||
this.headWatchFileListener = null;
|
||||
}
|
||||
closeWatcher(this.reftableWatcher);
|
||||
this.reftableWatcher = null;
|
||||
closeWatcher(this.reftableTablesListWatcher);
|
||||
@@ -289,6 +308,8 @@ export class FooterDataProvider {
|
||||
this.clearGitWatchers();
|
||||
if (!this.gitPaths) return;
|
||||
|
||||
const pollGitHead = shouldPollGitHead(this.gitPaths.repoDir);
|
||||
|
||||
// Watch the directory containing HEAD, not HEAD itself.
|
||||
// Git uses atomic writes (write temp, rename over HEAD), which changes the inode.
|
||||
// fs.watch on a file stops working after the inode changes.
|
||||
@@ -301,7 +322,20 @@ export class FooterDataProvider {
|
||||
},
|
||||
() => this.handleGitWatcherError(),
|
||||
);
|
||||
if (!this.headWatcher) {
|
||||
if (pollGitHead) {
|
||||
this.headWatchFilePath = this.gitPaths.headPath;
|
||||
this.headWatchFileListener = (current, previous) => {
|
||||
if (
|
||||
current.mtimeMs !== previous.mtimeMs ||
|
||||
current.ctimeMs !== previous.ctimeMs ||
|
||||
current.size !== previous.size
|
||||
) {
|
||||
this.scheduleRefresh();
|
||||
}
|
||||
};
|
||||
watchFile(this.headWatchFilePath, { interval: 1000 }, this.headWatchFileListener);
|
||||
}
|
||||
if (!this.headWatcher && !pollGitHead) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export {
|
||||
export { type BashExecutorOptions, type BashResult, executeBashWithOperations } from "./bash-executor.ts";
|
||||
export type { CompactionResult } from "./compaction/index.ts";
|
||||
export { createEventBus, type EventBus, type EventBusController } from "./event-bus.ts";
|
||||
export { areExperimentalFeaturesEnabled } from "./experimental.ts";
|
||||
// Extensions system
|
||||
export {
|
||||
type AgentEndEvent,
|
||||
|
||||
@@ -25,11 +25,15 @@ import { type Static, Type } from "typebox";
|
||||
import { Compile } from "typebox/compile";
|
||||
import type { TLocalizedValidationError } from "typebox/error";
|
||||
import { getAgentDir } from "../config.ts";
|
||||
import { stripJsonComments } from "../utils/json.ts";
|
||||
import { normalizePath } from "../utils/paths.ts";
|
||||
import type { AuthStatus, AuthStorage } from "./auth-storage.ts";
|
||||
import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "./provider-display-names.ts";
|
||||
import {
|
||||
clearConfigValueCache,
|
||||
getConfigValueEnvVarNames,
|
||||
isCommandConfigValue,
|
||||
isConfigValueConfigured,
|
||||
resolveConfigValueOrThrow,
|
||||
resolveConfigValueUncached,
|
||||
resolveHeadersOrThrow,
|
||||
@@ -92,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()),
|
||||
@@ -110,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),
|
||||
@@ -121,6 +136,7 @@ const OpenAICompletionsCompatSchema = Type.Object({
|
||||
});
|
||||
|
||||
const OpenAIResponsesCompatSchema = Type.Object({
|
||||
supportsDeveloperRole: Type.Optional(Type.Boolean()),
|
||||
sendSessionIdHeader: Type.Optional(Type.Boolean()),
|
||||
supportsLongCacheRetention: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
@@ -128,6 +144,9 @@ const OpenAIResponsesCompatSchema = Type.Object({
|
||||
const AnthropicMessagesCompatSchema = Type.Object({
|
||||
supportsEagerToolInputStreaming: Type.Optional(Type.Boolean()),
|
||||
supportsLongCacheRetention: Type.Optional(Type.Boolean()),
|
||||
sendSessionAffinityHeaders: Type.Optional(Type.Boolean()),
|
||||
supportsCacheControlOnTools: Type.Optional(Type.Boolean()),
|
||||
forceAdaptiveThinking: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
const ProviderCompatSchema = Type.Union([
|
||||
@@ -215,13 +234,6 @@ function formatValidationPath(error: TLocalizedValidationError): string {
|
||||
return path || "root";
|
||||
}
|
||||
|
||||
/** Strip `//` line comments and trailing commas from JSON, leaving string literals untouched. */
|
||||
function stripJsonComments(input: string): string {
|
||||
return input
|
||||
.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (m) => (m[0] === '"' ? m : ""))
|
||||
.replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (m, tail) => tail ?? (m[0] === '"' ? m : ""));
|
||||
}
|
||||
|
||||
/** Provider override config (baseUrl, compat) without request auth/headers */
|
||||
interface ProviderOverride {
|
||||
baseUrl?: string;
|
||||
@@ -239,6 +251,7 @@ export type ResolvedRequestAuth =
|
||||
ok: true;
|
||||
apiKey?: string;
|
||||
headers?: Record<string, string>;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
@@ -287,6 +300,13 @@ function mergeCompat(
|
||||
};
|
||||
}
|
||||
|
||||
if (baseCompletions?.chatTemplateKwargs || overrideCompletions.chatTemplateKwargs) {
|
||||
mergedCompletions.chatTemplateKwargs = {
|
||||
...baseCompletions?.chatTemplateKwargs,
|
||||
...overrideCompletions.chatTemplateKwargs,
|
||||
};
|
||||
}
|
||||
|
||||
return merged as Model<Api>["compat"];
|
||||
}
|
||||
|
||||
@@ -638,9 +658,10 @@ export class ModelRegistry {
|
||||
* Get API key for a model.
|
||||
*/
|
||||
hasConfiguredAuth(model: Model<Api>): boolean {
|
||||
const providerApiKey = this.providerRequestConfigs.get(model.provider)?.apiKey;
|
||||
return (
|
||||
this.authStorage.hasAuth(model.provider) ||
|
||||
this.providerRequestConfigs.get(model.provider)?.apiKey !== undefined
|
||||
(providerApiKey !== undefined && isConfigValueConfigured(providerApiKey))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -682,17 +703,27 @@ export class ModelRegistry {
|
||||
async getApiKeyAndHeaders(model: Model<Api>): Promise<ResolvedRequestAuth> {
|
||||
try {
|
||||
const providerConfig = this.providerRequestConfigs.get(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 =
|
||||
@@ -711,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 {
|
||||
@@ -735,12 +767,15 @@ export class ModelRegistry {
|
||||
return authStatus;
|
||||
}
|
||||
|
||||
if (providerApiKey.startsWith("!")) {
|
||||
if (isCommandConfigValue(providerApiKey)) {
|
||||
return { configured: true, source: "models_json_command" };
|
||||
}
|
||||
|
||||
if (process.env[providerApiKey]) {
|
||||
return { configured: true, source: "environment", label: providerApiKey };
|
||||
const envVarNames = getConfigValueEnvVarNames(providerApiKey);
|
||||
if (envVarNames.length > 0) {
|
||||
return isConfigValueConfigured(providerApiKey)
|
||||
? { configured: true, source: "environment", label: envVarNames.join(", ") }
|
||||
: { configured: false };
|
||||
}
|
||||
|
||||
return { configured: true, source: "models_json_key" };
|
||||
@@ -772,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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,10 +13,12 @@ import type { ModelRegistry } from "./model-registry.ts";
|
||||
/** Default model IDs for each known provider */
|
||||
export const defaultModelPerProvider: Record<KnownProvider, string> = {
|
||||
"amazon-bedrock": "us.anthropic.claude-opus-4-6-v1",
|
||||
anthropic: "claude-opus-4-7",
|
||||
"ant-ling": "Ring-2.6-1T",
|
||||
anthropic: "claude-opus-4-8",
|
||||
openai: "gpt-5.4",
|
||||
"azure-openai-responses": "gpt-5.4",
|
||||
"openai-codex": "gpt-5.5",
|
||||
nvidia: "nvidia/nemotron-3-super-120b-a12b",
|
||||
deepseek: "deepseek-v4-pro",
|
||||
google: "gemini-3.1-pro-preview",
|
||||
"google-vertex": "gemini-3.1-pro-preview",
|
||||
@@ -27,6 +29,7 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
|
||||
groq: "openai/gpt-oss-120b",
|
||||
cerebras: "zai-glm-4.7",
|
||||
zai: "glm-5.1",
|
||||
"zai-coding-cn": "glm-5.1",
|
||||
mistral: "devstral-medium-latest",
|
||||
minimax: "MiniMax-M2.7",
|
||||
"minimax-cn": "MiniMax-M2.7",
|
||||
@@ -337,9 +340,10 @@ export interface ResolveCliModelResult {
|
||||
export function resolveCliModel(options: {
|
||||
cliProvider?: string;
|
||||
cliModel?: string;
|
||||
cliThinking?: ThinkingLevel;
|
||||
modelRegistry: ModelRegistry;
|
||||
}): ResolveCliModelResult {
|
||||
const { cliProvider, cliModel, modelRegistry } = options;
|
||||
const { cliProvider, cliModel, cliThinking, modelRegistry } = options;
|
||||
|
||||
if (!cliModel) {
|
||||
return { model: undefined, warning: undefined, error: undefined };
|
||||
@@ -418,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 };
|
||||
}
|
||||
|
||||
@@ -448,12 +473,31 @@ export function resolveCliModel(options: {
|
||||
}
|
||||
|
||||
if (provider) {
|
||||
const fallbackModel = buildFallbackModel(provider, pattern, availableModels);
|
||||
// Parse thinking level suffix from the pattern before building the fallback model,
|
||||
// but only when --thinking is not explicitly provided.
|
||||
// e.g. "zai-org/GLM-5.1-FP8:high" → modelId="zai-org/GLM-5.1-FP8", fallbackThinking="high"
|
||||
let fallbackPattern = pattern;
|
||||
let fallbackThinking: ThinkingLevel | undefined;
|
||||
if (!cliThinking) {
|
||||
const lastColon = pattern.lastIndexOf(":");
|
||||
if (lastColon !== -1) {
|
||||
const suffix = pattern.substring(lastColon + 1);
|
||||
if (isValidThinkingLevel(suffix)) {
|
||||
fallbackPattern = pattern.substring(0, lastColon);
|
||||
fallbackThinking = suffix;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 "${pattern}" not found for provider "${provider}". Using custom model id.`
|
||||
: `Model "${pattern}" not found for provider "${provider}". Using custom model id.`;
|
||||
return { model: fallbackModel, thinkingLevel: undefined, warning: fallbackWarning, error: undefined };
|
||||
? `${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, thinkingLevel: fallbackThinking, warning: fallbackWarning, error: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,42 @@ interface StdoutTakeoverState {
|
||||
|
||||
let stdoutTakeoverState: StdoutTakeoverState | undefined;
|
||||
|
||||
const RAW_STDOUT_RETRY_DELAY_MS = 10;
|
||||
|
||||
let rawStdoutWriteTail: Promise<void> = Promise.resolve();
|
||||
|
||||
function getRawStdoutWrite(): StdoutTakeoverState["rawStdoutWrite"] {
|
||||
if (stdoutTakeoverState) {
|
||||
return stdoutTakeoverState.rawStdoutWrite;
|
||||
}
|
||||
return process.stdout.write.bind(process.stdout) as StdoutTakeoverState["rawStdoutWrite"];
|
||||
}
|
||||
|
||||
async function writeRawStdoutChunk(text: string): Promise<void> {
|
||||
while (true) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
try {
|
||||
getRawStdoutWrite()(text, (error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
const writeError = error instanceof Error ? error : new Error(String(error));
|
||||
const code = (writeError as Error & { code?: unknown }).code;
|
||||
if (code !== "ENOBUFS" && code !== "EAGAIN" && code !== "EWOULDBLOCK") {
|
||||
throw writeError;
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, RAW_STDOUT_RETRY_DELAY_MS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function takeOverStdout(): void {
|
||||
if (stdoutTakeoverState) {
|
||||
return;
|
||||
@@ -47,28 +83,26 @@ export function isStdoutTakenOver(): boolean {
|
||||
}
|
||||
|
||||
export function writeRawStdout(text: string): void {
|
||||
if (stdoutTakeoverState) {
|
||||
stdoutTakeoverState.rawStdoutWrite(text);
|
||||
if (text.length === 0) {
|
||||
return;
|
||||
}
|
||||
process.stdout.write(text);
|
||||
rawStdoutWriteTail = rawStdoutWriteTail.then(() => writeRawStdoutChunk(text));
|
||||
void rawStdoutWriteTail.catch(() => {
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForRawStdoutBackpressure(): Promise<void> {
|
||||
while (true) {
|
||||
const tail = rawStdoutWriteTail;
|
||||
await tail;
|
||||
if (tail === rawStdoutWriteTail) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function flushRawStdout(): Promise<void> {
|
||||
if (stdoutTakeoverState) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stdoutTakeoverState?.rawStdoutWrite("", (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
process.stdout.write("", (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
await waitForRawStdoutBackpressure();
|
||||
await writeRawStdoutChunk("");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ChildProcess, ChildProcessByStdio } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
function getEnv(): NodeJS.ProcessEnv {
|
||||
if (process.platform !== "linux" || Object.keys(process.env).length > 0) {
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -206,6 +217,13 @@ function getHomeDir(): string {
|
||||
return process.env.HOME || homedir();
|
||||
}
|
||||
|
||||
export function getExtensionTempFolder(agentDir: string): string {
|
||||
const tempFolder = join(agentDir, "tmp", "extensions");
|
||||
mkdirSync(tempFolder, { recursive: true, mode: 0o700 });
|
||||
chmodSync(tempFolder, 0o700);
|
||||
return tempFolder;
|
||||
}
|
||||
|
||||
function prefixIgnorePattern(line: string, prefix: string): string | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return null;
|
||||
@@ -956,6 +974,7 @@ export class DefaultPackageManager implements PackageManager {
|
||||
async install(source: string, options?: { local?: boolean }): Promise<void> {
|
||||
const parsed = this.parseSource(source);
|
||||
const scope: SourceScope = options?.local ? "project" : "user";
|
||||
this.assertProjectTrustedForScope(scope);
|
||||
await this.withProgress("install", source, `Installing ${source}...`, async () => {
|
||||
if (parsed.type === "npm") {
|
||||
await this.installNpm(parsed, scope, false);
|
||||
@@ -984,6 +1003,7 @@ export class DefaultPackageManager implements PackageManager {
|
||||
async remove(source: string, options?: { local?: boolean }): Promise<void> {
|
||||
const parsed = this.parseSource(source);
|
||||
const scope: SourceScope = options?.local ? "project" : "user";
|
||||
this.assertProjectTrustedForScope(scope);
|
||||
await this.withProgress("remove", source, `Removing ${source}...`, async () => {
|
||||
if (parsed.type === "npm") {
|
||||
await this.uninstallNpm(parsed, scope);
|
||||
@@ -1047,14 +1067,15 @@ export class DefaultPackageManager implements PackageManager {
|
||||
|
||||
for (const entry of sources) {
|
||||
const parsed = this.parseSource(entry.source);
|
||||
if (parsed.type === "local" || parsed.pinned) {
|
||||
continue;
|
||||
}
|
||||
// Pinned npm versions are fixed. Pinned git refs are configured checkout targets,
|
||||
// so include them to reconcile an existing clone when the configured ref changes.
|
||||
if (parsed.type === "npm") {
|
||||
npmCandidates.push({ ...entry, parsed });
|
||||
continue;
|
||||
if (!parsed.pinned) {
|
||||
npmCandidates.push({ ...entry, parsed });
|
||||
}
|
||||
} else if (parsed.type === "git") {
|
||||
gitCandidates.push({ ...entry, parsed });
|
||||
}
|
||||
gitCandidates.push({ ...entry, parsed });
|
||||
}
|
||||
|
||||
const npmCheckTasks = npmCandidates.map((entry) => async () => ({
|
||||
@@ -1103,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;
|
||||
@@ -1118,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);
|
||||
@@ -1231,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;
|
||||
@@ -1384,7 +1404,9 @@ export class DefaultPackageManager implements PackageManager {
|
||||
type: "npm",
|
||||
spec,
|
||||
name,
|
||||
pinned: Boolean(version),
|
||||
version,
|
||||
range: getNpmVersionRange(version),
|
||||
pinned: isExactNpmVersion(version),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1401,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> {
|
||||
@@ -1426,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;
|
||||
}
|
||||
@@ -1445,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> {
|
||||
@@ -1665,6 +1690,12 @@ export class DefaultPackageManager implements PackageManager {
|
||||
return { name, version };
|
||||
}
|
||||
|
||||
private assertProjectTrustedForScope(scope: SourceScope): void {
|
||||
if (scope === "project" && !this.settingsManager.isProjectTrusted()) {
|
||||
throw new Error("Project is not trusted; refusing to access project package storage");
|
||||
}
|
||||
}
|
||||
|
||||
private getNpmCommand(): { command: string; args: string[] } {
|
||||
const configuredCommand = this.settingsManager.getNpmCommand();
|
||||
if (!configuredCommand || configuredCommand.length === 0) {
|
||||
@@ -1705,13 +1736,25 @@ export class DefaultPackageManager implements PackageManager {
|
||||
|
||||
private getNpmInstallArgs(specs: string[], installRoot: string): string[] {
|
||||
const packageManagerName = this.getPackageManagerName();
|
||||
// Extension packages run inside pi and resolve pi APIs through loader aliases/virtual modules.
|
||||
// Disable peer dependency resolution for managed installs (npm's --legacy-peer-deps, and
|
||||
// equivalent bun/pnpm settings) so package managers do not install or solve host-provided
|
||||
// @earendil-works/pi-* peers. Stale auto-installed pi peers can otherwise block updates.
|
||||
if (packageManagerName === "bun") {
|
||||
return ["install", ...specs, "--cwd", installRoot];
|
||||
return ["install", ...specs, "--cwd", installRoot, "--omit=peer"];
|
||||
}
|
||||
if (packageManagerName === "pnpm") {
|
||||
return ["install", ...specs, "--prefix", installRoot, "--config.strict-dep-builds=false"];
|
||||
return [
|
||||
"install",
|
||||
...specs,
|
||||
"--prefix",
|
||||
installRoot,
|
||||
"--config.auto-install-peers=false",
|
||||
"--config.strict-peer-dependencies=false",
|
||||
"--config.strict-dep-builds=false",
|
||||
];
|
||||
}
|
||||
return ["install", ...specs, "--prefix", installRoot];
|
||||
return ["install", ...specs, "--prefix", installRoot, "--legacy-peer-deps"];
|
||||
}
|
||||
|
||||
private async installNpm(source: NpmSource, scope: SourceScope, temporary: boolean): Promise<void> {
|
||||
@@ -1766,6 +1809,11 @@ export class DefaultPackageManager implements PackageManager {
|
||||
return;
|
||||
}
|
||||
|
||||
if (source.ref) {
|
||||
await this.ensureGitRef(targetDir, ["fetch", "origin", source.ref], "FETCH_HEAD");
|
||||
return;
|
||||
}
|
||||
|
||||
const target = await this.getLocalGitUpdateTarget(targetDir);
|
||||
await this.ensureGitRef(targetDir, target.fetchArgs, target.ref);
|
||||
}
|
||||
@@ -1778,7 +1826,8 @@ export class DefaultPackageManager implements PackageManager {
|
||||
cwd: targetDir,
|
||||
timeoutMs: NETWORK_TIMEOUT_MS,
|
||||
});
|
||||
const targetHead = await this.runCommandCapture("git", ["rev-parse", ref], {
|
||||
const commitRef = `${ref}^{commit}`;
|
||||
const targetHead = await this.runCommandCapture("git", ["rev-parse", commitRef], {
|
||||
cwd: targetDir,
|
||||
timeoutMs: NETWORK_TIMEOUT_MS,
|
||||
});
|
||||
@@ -1786,7 +1835,7 @@ export class DefaultPackageManager implements PackageManager {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.runCommand("git", ["reset", "--hard", ref], { cwd: targetDir });
|
||||
await this.runCommand("git", ["reset", "--hard", commitRef], { cwd: targetDir });
|
||||
|
||||
// Clean untracked files (extensions should be pristine)
|
||||
await this.runCommand("git", ["clean", "-fdx"], { cwd: targetDir });
|
||||
@@ -1867,6 +1916,7 @@ export class DefaultPackageManager implements PackageManager {
|
||||
return this.getTemporaryDir("npm");
|
||||
}
|
||||
if (scope === "project") {
|
||||
this.assertProjectTrustedForScope(scope);
|
||||
return join(this.cwd, CONFIG_DIR_NAME, "npm");
|
||||
}
|
||||
return join(this.agentDir, "npm");
|
||||
@@ -1907,6 +1957,7 @@ export class DefaultPackageManager implements PackageManager {
|
||||
return join(this.getTemporaryDir("npm"), "node_modules", source.name);
|
||||
}
|
||||
if (scope === "project") {
|
||||
this.assertProjectTrustedForScope(scope);
|
||||
return join(this.cwd, CONFIG_DIR_NAME, "npm", "node_modules", source.name);
|
||||
}
|
||||
return join(this.agentDir, "npm", "node_modules", source.name);
|
||||
@@ -1933,10 +1984,11 @@ export class DefaultPackageManager implements PackageManager {
|
||||
if (scope === "temporary") {
|
||||
return this.getTemporaryDir(`git-${source.host}`, source.path);
|
||||
}
|
||||
if (scope === "project") {
|
||||
return join(this.cwd, CONFIG_DIR_NAME, "git", source.host, source.path);
|
||||
const installRoot = this.getGitInstallRoot(scope);
|
||||
if (!installRoot) {
|
||||
throw new Error("Missing git install root");
|
||||
}
|
||||
return join(this.agentDir, "git", source.host, source.path);
|
||||
return this.resolveManagedPath(installRoot, source.host, source.path);
|
||||
}
|
||||
|
||||
private getGitInstallRoot(scope: SourceScope): string | undefined {
|
||||
@@ -1944,21 +1996,33 @@ export class DefaultPackageManager implements PackageManager {
|
||||
return undefined;
|
||||
}
|
||||
if (scope === "project") {
|
||||
this.assertProjectTrustedForScope(scope);
|
||||
return join(this.cwd, CONFIG_DIR_NAME, "git");
|
||||
}
|
||||
return join(this.agentDir, "git");
|
||||
}
|
||||
|
||||
private getTemporaryDir(prefix: string, suffix?: string): string {
|
||||
const root = this.resolveManagedPath(getExtensionTempFolder(this.agentDir), prefix);
|
||||
const hash = createHash("sha256")
|
||||
.update(`${prefix}-${suffix ?? ""}`)
|
||||
.digest("hex")
|
||||
.slice(0, 8);
|
||||
return join(tmpdir(), "pi-extensions", prefix, hash, suffix ?? "");
|
||||
return this.resolveManagedPath(root, hash, suffix ?? "");
|
||||
}
|
||||
|
||||
private resolveManagedPath(root: string, ...parts: string[]): string {
|
||||
const resolvedRoot = resolve(root);
|
||||
const resolvedPath = resolve(resolvedRoot, ...parts);
|
||||
if (resolvedPath !== resolvedRoot && !resolvedPath.startsWith(`${resolvedRoot}${sep}`)) {
|
||||
throw new Error(`Refusing to use path outside package install root: ${resolvedPath}`);
|
||||
}
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
private getBaseDirForScope(scope: SourceScope): string {
|
||||
if (scope === "project") {
|
||||
this.assertProjectTrustedForScope(scope);
|
||||
return join(this.cwd, CONFIG_DIR_NAME);
|
||||
}
|
||||
if (scope === "user") {
|
||||
@@ -2220,9 +2284,10 @@ export class DefaultPackageManager implements PackageManager {
|
||||
themes: join(projectBaseDir, "themes"),
|
||||
};
|
||||
const userAgentsSkillsDir = join(getHomeDir(), ".agents", "skills");
|
||||
const projectAgentsSkillDirs = collectAncestorAgentsSkillDirs(this.cwd).filter(
|
||||
(dir) => resolve(dir) !== resolve(userAgentsSkillsDir),
|
||||
);
|
||||
const projectTrusted = this.settingsManager.isProjectTrusted();
|
||||
const projectAgentsSkillDirs = projectTrusted
|
||||
? collectAncestorAgentsSkillDirs(this.cwd).filter((dir) => resolve(dir) !== resolve(userAgentsSkillsDir))
|
||||
: [];
|
||||
|
||||
const addResources = (
|
||||
resourceType: ResourceType,
|
||||
@@ -2238,23 +2303,25 @@ export class DefaultPackageManager implements PackageManager {
|
||||
}
|
||||
};
|
||||
|
||||
// Project extensions from .pi/
|
||||
addResources(
|
||||
"extensions",
|
||||
collectAutoExtensionEntries(projectDirs.extensions),
|
||||
projectMetadata,
|
||||
projectOverrides.extensions,
|
||||
projectBaseDir,
|
||||
);
|
||||
if (projectTrusted) {
|
||||
// Project extensions from .pi/
|
||||
addResources(
|
||||
"extensions",
|
||||
collectAutoExtensionEntries(projectDirs.extensions),
|
||||
projectMetadata,
|
||||
projectOverrides.extensions,
|
||||
projectBaseDir,
|
||||
);
|
||||
|
||||
// Project skills from .pi/
|
||||
addResources(
|
||||
"skills",
|
||||
collectAutoSkillEntries(projectDirs.skills, "pi"),
|
||||
projectMetadata,
|
||||
projectOverrides.skills,
|
||||
projectBaseDir,
|
||||
);
|
||||
// Project skills from .pi/
|
||||
addResources(
|
||||
"skills",
|
||||
collectAutoSkillEntries(projectDirs.skills, "pi"),
|
||||
projectMetadata,
|
||||
projectOverrides.skills,
|
||||
projectBaseDir,
|
||||
);
|
||||
}
|
||||
|
||||
// Project skills from .agents/ (each with its own baseDir)
|
||||
for (const agentsSkillsDir of projectAgentsSkillDirs) {
|
||||
@@ -2272,20 +2339,22 @@ export class DefaultPackageManager implements PackageManager {
|
||||
);
|
||||
}
|
||||
|
||||
addResources(
|
||||
"prompts",
|
||||
collectAutoPromptEntries(projectDirs.prompts),
|
||||
projectMetadata,
|
||||
projectOverrides.prompts,
|
||||
projectBaseDir,
|
||||
);
|
||||
addResources(
|
||||
"themes",
|
||||
collectAutoThemeEntries(projectDirs.themes),
|
||||
projectMetadata,
|
||||
projectOverrides.themes,
|
||||
projectBaseDir,
|
||||
);
|
||||
if (projectTrusted) {
|
||||
addResources(
|
||||
"prompts",
|
||||
collectAutoPromptEntries(projectDirs.prompts),
|
||||
projectMetadata,
|
||||
projectOverrides.prompts,
|
||||
projectBaseDir,
|
||||
);
|
||||
addResources(
|
||||
"themes",
|
||||
collectAutoThemeEntries(projectDirs.themes),
|
||||
projectMetadata,
|
||||
projectOverrides.themes,
|
||||
projectBaseDir,
|
||||
);
|
||||
}
|
||||
|
||||
// User extensions from ~/.pi/agent/
|
||||
addResources(
|
||||
|
||||
96
packages/coding-agent/src/core/project-trust.ts
Normal file
96
packages/coding-agent/src/core/project-trust.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
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,
|
||||
hasTrustRequiringProjectResources,
|
||||
type ProjectTrustOption,
|
||||
type ProjectTrustStore,
|
||||
} from "./trust-manager.ts";
|
||||
|
||||
export type AppMode = "interactive" | "print" | "json" | "rpc";
|
||||
|
||||
export interface ResolveProjectTrustedOptions {
|
||||
cwd: string;
|
||||
trustStore: ProjectTrustStore;
|
||||
trustOverride?: boolean;
|
||||
defaultProjectTrust?: DefaultProjectTrust;
|
||||
extensionsResult?: LoadExtensionsResult;
|
||||
projectTrustContext: ProjectTrustContext;
|
||||
onExtensionError?: (message: string) => void;
|
||||
}
|
||||
|
||||
function formatProjectTrustPrompt(cwd: string): string {
|
||||
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(
|
||||
cwd: string,
|
||||
ctx: ProjectTrustContext,
|
||||
): Promise<ProjectTrustOption | undefined> {
|
||||
const options = getProjectTrustOptions(cwd, { includeSessionOnly: true });
|
||||
const selected = await ctx.ui.select(
|
||||
formatProjectTrustPrompt(cwd),
|
||||
options.map((option) => option.label),
|
||||
);
|
||||
return options.find((option) => option.label === selected);
|
||||
}
|
||||
|
||||
function saveProjectTrustPromptResult(trustStore: ProjectTrustStore, result: ProjectTrustOption): void {
|
||||
if (result.updates.length > 0) {
|
||||
trustStore.setMany(result.updates);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveProjectTrusted(options: ResolveProjectTrustedOptions): Promise<boolean> {
|
||||
if (options.trustOverride !== undefined) {
|
||||
return options.trustOverride;
|
||||
}
|
||||
if (!hasTrustRequiringProjectResources(options.cwd)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.extensionsResult) {
|
||||
const { result, errors } = await emitProjectTrustEvent(
|
||||
options.extensionsResult,
|
||||
{ type: "project_trust", cwd: options.cwd },
|
||||
options.projectTrustContext,
|
||||
);
|
||||
for (const error of errors) {
|
||||
options.onExtensionError?.(`Extension "${error.extensionPath}" project_trust error: ${error.error}`);
|
||||
}
|
||||
if (result) {
|
||||
const trusted = result.trusted === "yes";
|
||||
if (result.remember === true) {
|
||||
options.trustStore.set(options.cwd, trusted);
|
||||
}
|
||||
return trusted;
|
||||
}
|
||||
}
|
||||
|
||||
const decision = options.trustStore.get(options.cwd);
|
||||
if (decision !== null) {
|
||||
return decision;
|
||||
}
|
||||
|
||||
switch (options.defaultProjectTrust ?? "ask") {
|
||||
case "always":
|
||||
return true;
|
||||
case "never":
|
||||
return false;
|
||||
case "ask":
|
||||
break;
|
||||
}
|
||||
|
||||
if (!options.projectTrustContext.hasUI) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selected = await selectProjectTrustOption(options.cwd, options.projectTrustContext);
|
||||
if (selected !== undefined) {
|
||||
saveProjectTrustPromptResult(options.trustStore, selected);
|
||||
return selected.trusted;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -59,46 +59,45 @@ export function parseCommandArgs(argsString: string): string[] {
|
||||
* Supports:
|
||||
* - $1, $2, ... for positional args
|
||||
* - $@ and $ARGUMENTS for all args
|
||||
* - ${N:-default} for positional arg N with default when missing/empty
|
||||
* - ${@:N} for args from Nth onwards (bash-style slicing)
|
||||
* - ${@:N:L} for L args starting from Nth
|
||||
*
|
||||
* Note: Replacement happens on the template string only. Argument values
|
||||
* Note: Replacement happens on the template string only. Argument and default values
|
||||
* containing patterns like $1, $@, or $ARGUMENTS are NOT recursively substituted.
|
||||
*/
|
||||
export function substituteArgs(content: string, args: string[]): string {
|
||||
let result = content;
|
||||
|
||||
// Replace $1, $2, etc. with positional args FIRST (before wildcards)
|
||||
// This prevents wildcard replacement values containing $<digit> patterns from being re-substituted
|
||||
result = result.replace(/\$(\d+)/g, (_, num) => {
|
||||
const index = parseInt(num, 10) - 1;
|
||||
return args[index] ?? "";
|
||||
});
|
||||
|
||||
// Replace ${@:start} or ${@:start:length} with sliced args (bash-style)
|
||||
// Process BEFORE simple $@ to avoid conflicts
|
||||
result = result.replace(/\$\{@:(\d+)(?::(\d+))?\}/g, (_, startStr, lengthStr) => {
|
||||
let start = parseInt(startStr, 10) - 1; // Convert to 0-indexed (user provides 1-indexed)
|
||||
// Treat 0 as 1 (bash convention: args start at 1)
|
||||
if (start < 0) start = 0;
|
||||
|
||||
if (lengthStr) {
|
||||
const length = parseInt(lengthStr, 10);
|
||||
return args.slice(start, start + length).join(" ");
|
||||
}
|
||||
return args.slice(start).join(" ");
|
||||
});
|
||||
|
||||
// Pre-compute all args joined (optimization)
|
||||
const allArgs = args.join(" ");
|
||||
|
||||
// Replace $ARGUMENTS with all args joined (new syntax, aligns with Claude, Codex, OpenCode)
|
||||
result = result.replace(/\$ARGUMENTS/g, allArgs);
|
||||
return content.replace(
|
||||
/\$\{(\d+):-([^}]*)\}|\$\{@:(\d+)(?::(\d+))?\}|\$(ARGUMENTS|@|\d+)/g,
|
||||
(_match, defaultNum, defaultValue, sliceStart, sliceLength, simple) => {
|
||||
if (defaultNum) {
|
||||
const index = parseInt(defaultNum, 10) - 1;
|
||||
const value = args[index];
|
||||
return value ? value : defaultValue;
|
||||
}
|
||||
|
||||
// Replace $@ with all args joined (existing syntax)
|
||||
result = result.replace(/\$@/g, allArgs);
|
||||
if (sliceStart) {
|
||||
let start = parseInt(sliceStart, 10) - 1; // Convert to 0-indexed (user provides 1-indexed)
|
||||
// Treat 0 as 1 (bash convention: args start at 1)
|
||||
if (start < 0) start = 0;
|
||||
|
||||
return result;
|
||||
if (sliceLength) {
|
||||
const length = parseInt(sliceLength, 10);
|
||||
return args.slice(start, start + length).join(" ");
|
||||
}
|
||||
return args.slice(start).join(" ");
|
||||
}
|
||||
|
||||
if (simple === "ARGUMENTS" || simple === "@") {
|
||||
return allArgs;
|
||||
}
|
||||
|
||||
const index = parseInt(simple, 10) - 1;
|
||||
return args[index] ?? "";
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function loadTemplateFromFile(filePath: string, sourceInfo: SourceInfo): PromptTemplate | null {
|
||||
|
||||
109
packages/coding-agent/src/core/provider-attribution.ts
Normal file
109
packages/coding-agent/src/core/provider-attribution.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { Api, Model } from "@earendil-works/pi-ai";
|
||||
import type { SettingsManager } from "./settings-manager.ts";
|
||||
import { isInstallTelemetryEnabled } from "./telemetry.ts";
|
||||
|
||||
const OPENROUTER_HOST = "openrouter.ai";
|
||||
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 {
|
||||
return new URL(baseUrl).hostname === expectedHost;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isOpenRouterModel(model: Model<Api>): boolean {
|
||||
return model.provider === "openrouter" || model.baseUrl.includes(OPENROUTER_HOST);
|
||||
}
|
||||
|
||||
function isNvidiaNimModel(model: Model<Api>): boolean {
|
||||
return model.provider === "nvidia" || matchesHost(model.baseUrl, NVIDIA_NIM_HOST);
|
||||
}
|
||||
|
||||
function isCloudflareModel(model: Model<Api>): boolean {
|
||||
return (
|
||||
model.provider === "cloudflare-workers-ai" ||
|
||||
model.provider === "cloudflare-ai-gateway" ||
|
||||
matchesHost(model.baseUrl, CLOUDFLARE_API_HOST) ||
|
||||
matchesHost(model.baseUrl, CLOUDFLARE_AI_GATEWAY_HOST)
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
): Record<string, string> | undefined {
|
||||
if (!isInstallTelemetryEnabled(settingsManager)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (isOpenRouterModel(model)) {
|
||||
return {
|
||||
"HTTP-Referer": "https://pi.dev",
|
||||
"X-OpenRouter-Title": "pi",
|
||||
"X-OpenRouter-Categories": "cli-agent",
|
||||
};
|
||||
}
|
||||
|
||||
if (isNvidiaNimModel(model)) {
|
||||
return {
|
||||
"X-BILLING-INVOKE-ORIGIN": "Pi",
|
||||
};
|
||||
}
|
||||
|
||||
if (isCloudflareModel(model)) {
|
||||
return {
|
||||
"User-Agent": "pi-coding-agent",
|
||||
};
|
||||
}
|
||||
|
||||
if (isVercelGatewayModel(model)) {
|
||||
return {
|
||||
"http-referer": "https://pi.dev",
|
||||
"x-title": "pi",
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getSessionHeaders(model: Model<Api>, sessionId: string | undefined): Record<string, string> | undefined {
|
||||
if (!sessionId) return undefined;
|
||||
if (
|
||||
model.provider !== "opencode" &&
|
||||
model.provider !== "opencode-go" &&
|
||||
!matchesHost(model.baseUrl, OPENCODE_HOST)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return { "x-opencode-session": sessionId, "x-opencode-client": "pi" };
|
||||
}
|
||||
|
||||
export function mergeProviderAttributionHeaders(
|
||||
model: Model<Api>,
|
||||
settingsManager: SettingsManager,
|
||||
sessionId: string | undefined,
|
||||
...headerSources: Array<Record<string, string> | undefined>
|
||||
): Record<string, string> | undefined {
|
||||
const merged = {
|
||||
...getSessionHeaders(model, sessionId),
|
||||
...getDefaultAttributionHeaders(model, settingsManager),
|
||||
};
|
||||
|
||||
for (const headers of headerSources) {
|
||||
if (headers) {
|
||||
Object.assign(merged, headers);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
anthropic: "Anthropic",
|
||||
"amazon-bedrock": "Amazon Bedrock",
|
||||
"ant-ling": "Ant Ling",
|
||||
"azure-openai-responses": "Azure OpenAI Responses",
|
||||
cerebras: "Cerebras",
|
||||
"cloudflare-ai-gateway": "Cloudflare AI Gateway",
|
||||
@@ -17,6 +18,7 @@ export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
"minimax-cn": "MiniMax (China)",
|
||||
moonshotai: "Moonshot AI",
|
||||
"moonshotai-cn": "Moonshot AI (China)",
|
||||
nvidia: "NVIDIA NIM",
|
||||
opencode: "OpenCode Zen",
|
||||
"opencode-go": "OpenCode Go",
|
||||
openai: "OpenAI",
|
||||
@@ -25,6 +27,7 @@ export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
"vercel-ai-gateway": "Vercel AI Gateway",
|
||||
xai: "xAI",
|
||||
zai: "ZAI",
|
||||
"zai-coding-cn": "ZAI Coding Plan (China)",
|
||||
xiaomi: "Xiaomi MiMo",
|
||||
"xiaomi-token-plan-cn": "Xiaomi MiMo Token Plan (China)",
|
||||
"xiaomi-token-plan-ams": "Xiaomi MiMo Token Plan (Amsterdam)",
|
||||
|
||||
@@ -8,27 +8,157 @@ import { getShellConfig } from "../utils/shell.ts";
|
||||
|
||||
// Cache for shell command results (persists for process lifetime)
|
||||
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_]*/;
|
||||
|
||||
type TemplatePart = { type: "literal"; value: string } | { type: "env"; name: string };
|
||||
|
||||
type ConfigValueReference = { type: "command"; config: string } | { type: "template"; parts: TemplatePart[] };
|
||||
|
||||
function appendLiteral(parts: TemplatePart[], value: string): void {
|
||||
if (!value) return;
|
||||
const previousPart = parts[parts.length - 1];
|
||||
if (previousPart?.type === "literal") {
|
||||
previousPart.value += value;
|
||||
return;
|
||||
}
|
||||
parts.push({ type: "literal", value });
|
||||
}
|
||||
|
||||
function parseConfigValueTemplate(config: string): TemplatePart[] {
|
||||
const parts: TemplatePart[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < config.length) {
|
||||
const dollarIndex = config.indexOf("$", index);
|
||||
if (dollarIndex < 0) {
|
||||
appendLiteral(parts, config.slice(index));
|
||||
break;
|
||||
}
|
||||
|
||||
appendLiteral(parts, config.slice(index, dollarIndex));
|
||||
const nextChar = config[dollarIndex + 1];
|
||||
|
||||
if (nextChar === "$" || nextChar === "!") {
|
||||
appendLiteral(parts, nextChar);
|
||||
index = dollarIndex + 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nextChar === "{") {
|
||||
const endIndex = config.indexOf("}", dollarIndex + 2);
|
||||
if (endIndex < 0) {
|
||||
appendLiteral(parts, "$");
|
||||
index = dollarIndex + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = config.slice(dollarIndex + 2, endIndex);
|
||||
if (ENV_VAR_NAME_RE.test(name)) {
|
||||
parts.push({ type: "env", name });
|
||||
} else {
|
||||
appendLiteral(parts, config.slice(dollarIndex, endIndex + 1));
|
||||
}
|
||||
index = endIndex + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = config.slice(dollarIndex + 1).match(ENV_VAR_NAME_PREFIX_RE);
|
||||
if (match) {
|
||||
parts.push({ type: "env", name: match[0] });
|
||||
index = dollarIndex + 1 + match[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
appendLiteral(parts, "$");
|
||||
index = dollarIndex + 1;
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
function parseConfigValueReference(config: string): ConfigValueReference {
|
||||
if (config.startsWith("!")) {
|
||||
return { type: "command", config };
|
||||
}
|
||||
|
||||
return { type: "template", parts: parseConfigValueTemplate(config) };
|
||||
}
|
||||
|
||||
function resolveEnvConfigValue(name: string, env?: Record<string, string>): string | undefined {
|
||||
return env?.[name] || process.env[name] || undefined;
|
||||
}
|
||||
|
||||
function getTemplateEnvVarNames(parts: TemplatePart[]): string[] {
|
||||
const names: string[] = [];
|
||||
for (const part of parts) {
|
||||
if (part.type !== "env" || names.includes(part.name)) continue;
|
||||
names.push(part.name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
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, env);
|
||||
if (envValue === undefined) return undefined;
|
||||
resolved += envValue;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function getConfigValueEnvVarName(config: string): string | undefined {
|
||||
const reference = parseConfigValueReference(config);
|
||||
if (reference.type !== "template") return undefined;
|
||||
return reference.parts.length === 1 && reference.parts[0]?.type === "env" ? reference.parts[0].name : undefined;
|
||||
}
|
||||
|
||||
export function getConfigValueEnvVarNames(config: string): string[] {
|
||||
const reference = parseConfigValueReference(config);
|
||||
return reference.type === "template" ? getTemplateEnvVarNames(reference.parts) : [];
|
||||
}
|
||||
|
||||
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, env?: Record<string, string>): boolean {
|
||||
return getMissingConfigValueEnvVarNames(config, env).length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a config value (API key, header value, etc.) to an actual value.
|
||||
* - If starts with "!", executes the rest as a shell command and uses stdout (cached)
|
||||
* - Otherwise checks environment variable first, then treats as literal (not cached)
|
||||
* - Interpolates "$ENV_VAR" or "${ENV_VAR}" references with the named environment variable
|
||||
* - 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 {
|
||||
if (config.startsWith("!")) {
|
||||
return executeCommand(config);
|
||||
export function resolveConfigValue(config: string, env?: Record<string, string>): string | undefined {
|
||||
const reference = parseConfigValueReference(config);
|
||||
if (reference.type === "command") {
|
||||
return executeCommand(reference.config);
|
||||
}
|
||||
const envValue = process.env[config];
|
||||
return envValue || config;
|
||||
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,
|
||||
});
|
||||
@@ -88,22 +218,33 @@ 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 {
|
||||
if (config.startsWith("!")) {
|
||||
return executeCommandUncached(config);
|
||||
export function resolveConfigValueUncached(config: string, env?: Record<string, string>): string | undefined {
|
||||
const reference = parseConfigValueReference(config);
|
||||
if (reference.type === "command") {
|
||||
return executeCommandUncached(reference.config);
|
||||
}
|
||||
const envValue = process.env[config];
|
||||
return envValue || config;
|
||||
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;
|
||||
}
|
||||
|
||||
if (config.startsWith("!")) {
|
||||
throw new Error(`Failed to resolve ${description} from shell command: ${config.slice(1)}`);
|
||||
const reference = parseConfigValueReference(config);
|
||||
if (reference.type === "command") {
|
||||
throw new Error(`Failed to resolve ${description} from shell command: ${reference.config.slice(1)}`);
|
||||
}
|
||||
|
||||
if (reference.type === "template") {
|
||||
const missingEnvVars = getMissingConfigValueEnvVarNames(config, env);
|
||||
if (missingEnvVars.length === 1) {
|
||||
throw new Error(`Failed to resolve ${description} from environment variable: ${missingEnvVars[0]}`);
|
||||
}
|
||||
if (missingEnvVars.length > 1) {
|
||||
throw new Error(`Failed to resolve ${description} from environment variables: ${missingEnvVars.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Failed to resolve ${description}`);
|
||||
@@ -112,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;
|
||||
}
|
||||
@@ -127,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,9 +9,14 @@ 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 } from "./package-manager.ts";
|
||||
import { DefaultPackageManager, type PathMetadata, type ResolvedResource } from "./package-manager.ts";
|
||||
import type { PromptTemplate } from "./prompt-templates.ts";
|
||||
import { loadPromptTemplates } from "./prompt-templates.ts";
|
||||
import { SettingsManager } from "./settings-manager.ts";
|
||||
@@ -25,6 +30,10 @@ export interface ResourceExtensionPaths {
|
||||
themePaths?: Array<{ path: string; metadata: PathMetadata }>;
|
||||
}
|
||||
|
||||
export interface ResourceLoaderReloadOptions {
|
||||
resolveProjectTrust?: (input: { extensionsResult: LoadExtensionsResult }) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface ResourceLoader {
|
||||
getExtensions(): LoadExtensionsResult;
|
||||
getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] };
|
||||
@@ -34,7 +43,7 @@ export interface ResourceLoader {
|
||||
getSystemPrompt(): string | undefined;
|
||||
getAppendSystemPrompt(): string[];
|
||||
extendResources(paths: ResourceExtensionPaths): void;
|
||||
reload(): Promise<void>;
|
||||
reload(options?: ResourceLoaderReloadOptions): Promise<void>;
|
||||
}
|
||||
|
||||
function resolvePromptInput(input: string | undefined, description: string): string | undefined {
|
||||
@@ -202,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);
|
||||
@@ -248,6 +258,7 @@ export class DefaultResourceLoader implements ResourceLoader {
|
||||
this.extensionThemeSourceInfos = new Map();
|
||||
this.lastPromptPaths = [];
|
||||
this.lastThemePaths = [];
|
||||
this.loaded = false;
|
||||
}
|
||||
|
||||
getExtensions(): LoadExtensionsResult {
|
||||
@@ -318,7 +329,27 @@ export class DefaultResourceLoader implements ResourceLoader {
|
||||
}
|
||||
}
|
||||
|
||||
async reload(): Promise<void> {
|
||||
async loadProjectTrustExtensions(): Promise<LoadExtensionsResult> {
|
||||
// Force untrusted project settings for the bootstrap pass. This keeps project-local
|
||||
// extensions/packages out while still loading user/global and temporary CLI extensions.
|
||||
this.settingsManager.setProjectTrusted(false);
|
||||
await this.settingsManager.reload();
|
||||
return this.loadCurrentExtensionSet({ includeInlineFactories: true });
|
||||
}
|
||||
|
||||
async reload(options?: ResourceLoaderReloadOptions): Promise<void> {
|
||||
if (this.loaded) {
|
||||
clearExtensionCache();
|
||||
}
|
||||
|
||||
let preTrustExtensions: LoadExtensionsResult | undefined;
|
||||
if (options?.resolveProjectTrust) {
|
||||
preTrustExtensions = await this.loadProjectTrustExtensions();
|
||||
const projectTrusted = await options.resolveProjectTrust({ extensionsResult: preTrustExtensions });
|
||||
this.settingsManager.setProjectTrusted(projectTrusted);
|
||||
}
|
||||
|
||||
// reload() preserves SettingsManager.projectTrusted and reloads settings for that trust state.
|
||||
await this.settingsManager.reload();
|
||||
const resolvedPaths = await this.packageManager.resolve();
|
||||
const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, {
|
||||
@@ -331,9 +362,7 @@ export class DefaultResourceLoader implements ResourceLoader {
|
||||
this.extensionThemeSourceInfos = new Map();
|
||||
|
||||
// Helper to extract enabled paths and store metadata
|
||||
const getEnabledResources = (
|
||||
resources: Array<{ path: string; enabled: boolean; metadata: PathMetadata }>,
|
||||
): Array<{ path: string; enabled: boolean; metadata: PathMetadata }> => {
|
||||
const getEnabledResources = (resources: ResolvedResource[]): ResolvedResource[] => {
|
||||
for (const r of resources) {
|
||||
if (!metadataByPath.has(r.path)) {
|
||||
metadataByPath.set(r.path, r.metadata);
|
||||
@@ -342,37 +371,14 @@ export class DefaultResourceLoader implements ResourceLoader {
|
||||
return resources.filter((r) => r.enabled);
|
||||
};
|
||||
|
||||
const getEnabledPaths = (
|
||||
resources: Array<{ path: string; enabled: boolean; metadata: PathMetadata }>,
|
||||
): string[] => getEnabledResources(resources).map((r) => r.path);
|
||||
const getEnabledPaths = (resources: ResolvedResource[]): string[] =>
|
||||
getEnabledResources(resources).map((r) => r.path);
|
||||
const enabledExtensions = getEnabledPaths(resolvedPaths.extensions);
|
||||
const enabledSkillResources = getEnabledResources(resolvedPaths.skills);
|
||||
const enabledPrompts = getEnabledPaths(resolvedPaths.prompts);
|
||||
const enabledThemes = getEnabledPaths(resolvedPaths.themes);
|
||||
|
||||
const mapSkillPath = (resource: { path: string; metadata: PathMetadata }): string => {
|
||||
if (resource.metadata.source !== "auto" && resource.metadata.origin !== "package") {
|
||||
return resource.path;
|
||||
}
|
||||
try {
|
||||
const stats = statSync(resource.path);
|
||||
if (!stats.isDirectory()) {
|
||||
return resource.path;
|
||||
}
|
||||
} catch {
|
||||
return resource.path;
|
||||
}
|
||||
const skillFile = join(resource.path, "SKILL.md");
|
||||
if (existsSync(skillFile)) {
|
||||
if (!metadataByPath.has(skillFile)) {
|
||||
metadataByPath.set(skillFile, resource.metadata);
|
||||
}
|
||||
return skillFile;
|
||||
}
|
||||
return resource.path;
|
||||
};
|
||||
|
||||
const enabledSkills = enabledSkillResources.map(mapSkillPath);
|
||||
const enabledSkills = enabledSkillResources.map((resource) => this.mapSkillPath(resource, metadataByPath));
|
||||
|
||||
// Add CLI paths metadata
|
||||
for (const r of cliExtensionPaths.extensions) {
|
||||
@@ -395,18 +401,7 @@ export class DefaultResourceLoader implements ResourceLoader {
|
||||
? cliEnabledExtensions
|
||||
: this.mergePaths(cliEnabledExtensions, enabledExtensions);
|
||||
|
||||
const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus);
|
||||
const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime);
|
||||
extensionsResult.extensions.push(...inlineExtensions.extensions);
|
||||
extensionsResult.errors.push(...inlineExtensions.errors);
|
||||
|
||||
// Detect extension conflicts (tools, commands, flags with same names from different extensions)
|
||||
// Keep all extensions loaded. Conflicts are reported as diagnostics, and precedence is handled by load order.
|
||||
const conflicts = this.detectExtensionConflicts(extensionsResult.extensions);
|
||||
for (const conflict of conflicts) {
|
||||
extensionsResult.errors.push({ path: conflict.path, error: conflict.message });
|
||||
}
|
||||
|
||||
const extensionsResult = await this.loadFinalExtensionSet(extensionPaths, preTrustExtensions);
|
||||
for (const p of this.additionalExtensionPaths) {
|
||||
if (isLocalPath(p)) {
|
||||
const resolved = this.resolveResourcePath(p);
|
||||
@@ -466,7 +461,12 @@ export class DefaultResourceLoader implements ResourceLoader {
|
||||
}
|
||||
|
||||
const agentsFiles = {
|
||||
agentsFiles: this.noContextFiles ? [] : loadProjectContextFiles({ cwd: this.cwd, agentDir: this.agentDir }),
|
||||
agentsFiles: this.noContextFiles
|
||||
? []
|
||||
: loadProjectContextFiles({
|
||||
cwd: this.cwd,
|
||||
agentDir: this.agentDir,
|
||||
}),
|
||||
};
|
||||
const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles;
|
||||
this.agentsFiles = resolvedAgentsFiles.agentsFiles;
|
||||
@@ -486,6 +486,116 @@ export class DefaultResourceLoader implements ResourceLoader {
|
||||
this.appendSystemPrompt = this.appendSystemPromptOverride
|
||||
? this.appendSystemPromptOverride(baseAppend)
|
||||
: baseAppend;
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
private async loadCurrentExtensionSet(options: { includeInlineFactories: boolean }): Promise<LoadExtensionsResult> {
|
||||
const resolvedPaths = await this.packageManager.resolve();
|
||||
const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, {
|
||||
temporary: true,
|
||||
});
|
||||
const enabledExtensions = resolvedPaths.extensions.filter((r) => r.enabled).map((r) => r.path);
|
||||
const cliEnabledExtensions = cliExtensionPaths.extensions.filter((r) => r.enabled).map((r) => r.path);
|
||||
const extensionPaths = this.noExtensions
|
||||
? cliEnabledExtensions
|
||||
: this.mergePaths(cliEnabledExtensions, enabledExtensions);
|
||||
const extensionsResult = await loadExtensionsCached(extensionPaths, this.cwd, this.eventBus);
|
||||
if (!options.includeInlineFactories) {
|
||||
return extensionsResult;
|
||||
}
|
||||
|
||||
const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime);
|
||||
extensionsResult.extensions.push(...inlineExtensions.extensions);
|
||||
extensionsResult.errors.push(...inlineExtensions.errors);
|
||||
return extensionsResult;
|
||||
}
|
||||
|
||||
private resolveExtensionLoadPath(path: string): string {
|
||||
return resolvePath(path, this.cwd, { normalizeUnicodeSpaces: true });
|
||||
}
|
||||
|
||||
private async loadFinalExtensionSet(
|
||||
extensionPaths: string[],
|
||||
preTrustExtensions: LoadExtensionsResult | undefined,
|
||||
): Promise<LoadExtensionsResult> {
|
||||
if (!preTrustExtensions) {
|
||||
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);
|
||||
this.addExtensionConflictDiagnostics(extensionsResult);
|
||||
return extensionsResult;
|
||||
}
|
||||
|
||||
const preloadedByPath = new Map(
|
||||
preTrustExtensions.extensions
|
||||
.filter((extension) => !extension.path.startsWith("<inline:"))
|
||||
.map((extension) => [extension.resolvedPath, extension]),
|
||||
);
|
||||
const failedPreloadPaths = new Set(
|
||||
preTrustExtensions.errors.map((error) => this.resolveExtensionLoadPath(error.path)),
|
||||
);
|
||||
const remainingPaths = extensionPaths.filter((path) => {
|
||||
const resolvedPath = this.resolveExtensionLoadPath(path);
|
||||
return !preloadedByPath.has(resolvedPath) && !failedPreloadPaths.has(resolvedPath);
|
||||
});
|
||||
const remainingExtensions = await loadExtensionsCached(
|
||||
remainingPaths,
|
||||
this.cwd,
|
||||
this.eventBus,
|
||||
preTrustExtensions.runtime,
|
||||
);
|
||||
const loadedByPath = new Map(preloadedByPath);
|
||||
for (const extension of remainingExtensions.extensions) {
|
||||
loadedByPath.set(extension.resolvedPath, extension);
|
||||
}
|
||||
|
||||
const inlineExtensions = preTrustExtensions.extensions.filter((extension) =>
|
||||
extension.path.startsWith("<inline:"),
|
||||
);
|
||||
const orderedExtensions = extensionPaths
|
||||
.map((path) => loadedByPath.get(this.resolveExtensionLoadPath(path)))
|
||||
.filter((extension): extension is Extension => extension !== undefined);
|
||||
orderedExtensions.push(...inlineExtensions);
|
||||
|
||||
const extensionsResult: LoadExtensionsResult = {
|
||||
extensions: orderedExtensions,
|
||||
errors: [...preTrustExtensions.errors, ...remainingExtensions.errors],
|
||||
runtime: preTrustExtensions.runtime,
|
||||
};
|
||||
this.addExtensionConflictDiagnostics(extensionsResult);
|
||||
return extensionsResult;
|
||||
}
|
||||
|
||||
private addExtensionConflictDiagnostics(extensionsResult: LoadExtensionsResult): void {
|
||||
// Detect extension conflicts (tools, commands, flags with same names from different extensions)
|
||||
// Keep all extensions loaded. Conflicts are reported as diagnostics, and precedence is handled by load order.
|
||||
const conflicts = this.detectExtensionConflicts(extensionsResult.extensions);
|
||||
for (const conflict of conflicts) {
|
||||
extensionsResult.errors.push({ path: conflict.path, error: conflict.message });
|
||||
}
|
||||
}
|
||||
|
||||
private mapSkillPath(resource: ResolvedResource, metadataByPath: Map<string, PathMetadata>): string {
|
||||
if (resource.metadata.source !== "auto" && resource.metadata.origin !== "package") {
|
||||
return resource.path;
|
||||
}
|
||||
try {
|
||||
const stats = statSync(resource.path);
|
||||
if (!stats.isDirectory()) {
|
||||
return resource.path;
|
||||
}
|
||||
} catch {
|
||||
return resource.path;
|
||||
}
|
||||
const skillFile = join(resource.path, "SKILL.md");
|
||||
if (existsSync(skillFile)) {
|
||||
if (!metadataByPath.has(skillFile)) {
|
||||
metadataByPath.set(skillFile, resource.metadata);
|
||||
}
|
||||
return skillFile;
|
||||
}
|
||||
return resource.path;
|
||||
}
|
||||
|
||||
private normalizeExtensionPaths(
|
||||
@@ -852,7 +962,7 @@ export class DefaultResourceLoader implements ResourceLoader {
|
||||
|
||||
private discoverSystemPromptFile(): string | undefined {
|
||||
const projectPath = join(this.cwd, CONFIG_DIR_NAME, "SYSTEM.md");
|
||||
if (existsSync(projectPath)) {
|
||||
if (this.settingsManager.isProjectTrusted() && existsSync(projectPath)) {
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
@@ -866,7 +976,7 @@ export class DefaultResourceLoader implements ResourceLoader {
|
||||
|
||||
private discoverAppendSystemPromptFile(): string | undefined {
|
||||
const projectPath = join(this.cwd, CONFIG_DIR_NAME, "APPEND_SYSTEM.md");
|
||||
if (existsSync(projectPath)) {
|
||||
if (this.settingsManager.isProjectTrusted() && existsSync(projectPath)) {
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,11 +11,11 @@ import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefi
|
||||
import { convertToLlm } from "./messages.ts";
|
||||
import { ModelRegistry } from "./model-registry.ts";
|
||||
import { findInitialModel } from "./model-resolver.ts";
|
||||
import { mergeProviderAttributionHeaders } from "./provider-attribution.ts";
|
||||
import type { ResourceLoader } from "./resource-loader.ts";
|
||||
import { DefaultResourceLoader } from "./resource-loader.ts";
|
||||
import { getDefaultSessionDir, SessionManager } from "./session-manager.ts";
|
||||
import { SettingsManager } from "./settings-manager.ts";
|
||||
import { isInstallTelemetryEnabled } from "./telemetry.ts";
|
||||
import { time } from "./timings.ts";
|
||||
import {
|
||||
createBashTool,
|
||||
@@ -65,6 +65,8 @@ export interface CreateAgentSessionOptions {
|
||||
* When provided, only the listed tool names are enabled.
|
||||
*/
|
||||
tools?: string[];
|
||||
/** Optional denylist of tool names to disable. Applies after `tools` when both are provided. */
|
||||
excludeTools?: string[];
|
||||
/** Custom tools to register (in addition to built-in tools). */
|
||||
customTools?: ToolDefinition[];
|
||||
|
||||
@@ -126,36 +128,6 @@ function getDefaultAgentDir(): string {
|
||||
return getAgentDir();
|
||||
}
|
||||
|
||||
function getAttributionHeaders(
|
||||
model: Model<any>,
|
||||
settingsManager: SettingsManager,
|
||||
): Record<string, string> | undefined {
|
||||
if (!isInstallTelemetryEnabled(settingsManager)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (model.provider === "openrouter" || model.baseUrl.includes("openrouter.ai")) {
|
||||
return {
|
||||
"HTTP-Referer": "https://pi.dev",
|
||||
"X-OpenRouter-Title": "pi",
|
||||
"X-OpenRouter-Categories": "cli-agent",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
model.provider === "cloudflare-workers-ai" ||
|
||||
model.provider === "cloudflare-ai-gateway" ||
|
||||
model.baseUrl.includes("api.cloudflare.com") ||
|
||||
model.baseUrl.includes("gateway.ai.cloudflare.com")
|
||||
) {
|
||||
return {
|
||||
"User-Agent": "pi-coding-agent",
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an AgentSession with the specified options.
|
||||
*
|
||||
@@ -271,11 +243,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
|
||||
const defaultActiveToolNames: ToolName[] = ["read", "bash", "edit", "write"];
|
||||
const allowedToolNames = options.tools ?? (options.noTools === "all" ? [] : undefined);
|
||||
const initialActiveToolNames: string[] = options.tools
|
||||
? [...options.tools]
|
||||
: options.noTools
|
||||
? []
|
||||
: defaultActiveToolNames;
|
||||
const excludedToolNames = options.excludeTools;
|
||||
const excludedToolNameSet = excludedToolNames ? new Set(excludedToolNames) : undefined;
|
||||
const initialActiveToolNames: string[] = (
|
||||
options.tools ? [...options.tools] : options.noTools ? [] : defaultActiveToolNames
|
||||
).filter((name) => !excludedToolNameSet?.has(name));
|
||||
|
||||
let agent: Agent;
|
||||
|
||||
@@ -331,18 +303,30 @@ 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 attributionHeaders = getAttributionHeaders(model, settingsManager);
|
||||
const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs();
|
||||
// SDKs treat timeout=0 as 0ms (immediate timeout), not "no timeout".
|
||||
// Use max int32 to effectively disable the timeout.
|
||||
const effectiveTimeoutMs = httpIdleTimeoutMs === 0 ? 2147483647 : httpIdleTimeoutMs;
|
||||
const timeoutMs = options?.timeoutMs ?? providerRetrySettings.timeoutMs ?? effectiveTimeoutMs;
|
||||
const websocketConnectTimeoutMs =
|
||||
options?.websocketConnectTimeoutMs ?? settingsManager.getWebSocketConnectTimeoutMs();
|
||||
return streamSimple(model, context, {
|
||||
...options,
|
||||
apiKey: auth.apiKey,
|
||||
timeoutMs: options?.timeoutMs ?? providerRetrySettings.timeoutMs,
|
||||
env,
|
||||
timeoutMs,
|
||||
websocketConnectTimeoutMs,
|
||||
maxRetries: options?.maxRetries ?? providerRetrySettings.maxRetries,
|
||||
maxRetryDelayMs: options?.maxRetryDelayMs ?? providerRetrySettings.maxRetryDelayMs,
|
||||
headers:
|
||||
attributionHeaders || auth.headers || options?.headers
|
||||
? { ...attributionHeaders, ...auth.headers, ...options?.headers }
|
||||
: undefined,
|
||||
headers: mergeProviderAttributionHeaders(
|
||||
model,
|
||||
settingsManager,
|
||||
options?.sessionId,
|
||||
auth.headers,
|
||||
options?.headers,
|
||||
),
|
||||
});
|
||||
},
|
||||
onPayload: async (payload, _model) => {
|
||||
@@ -401,6 +385,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
modelRegistry,
|
||||
initialActiveToolNames,
|
||||
allowedToolNames,
|
||||
excludedToolNames,
|
||||
extensionRunnerRef,
|
||||
sessionStartEvent: options.sessionStartEvent,
|
||||
});
|
||||
|
||||
@@ -4,17 +4,19 @@ import { randomUUID } from "crypto";
|
||||
import {
|
||||
appendFileSync,
|
||||
closeSync,
|
||||
createReadStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
readSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "fs";
|
||||
import { readdir, readFile, stat } from "fs/promises";
|
||||
import { readdir, stat } from "fs/promises";
|
||||
import { join, resolve } from "path";
|
||||
import { createInterface } from "readline";
|
||||
import { StringDecoder } from "string_decoder";
|
||||
import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts";
|
||||
import { normalizePath, resolvePath } from "../utils/paths.ts";
|
||||
import {
|
||||
@@ -202,6 +204,14 @@ function createSessionId(): string {
|
||||
return uuidv7();
|
||||
}
|
||||
|
||||
export function assertValidSessionId(id: string): void {
|
||||
if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(id)) {
|
||||
throw new Error(
|
||||
"Session id must be non-empty, contain only alphanumeric characters, '-', '_', and '.', and start and end with an alphanumeric character",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate a unique short ID (8 hex chars, collision-checked) */
|
||||
function generateId(byId: { has(id: string): boolean }): string {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
@@ -347,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";
|
||||
@@ -425,70 +436,120 @@ export function buildSessionContext(
|
||||
* Compute the default session directory for a cwd.
|
||||
* Encodes cwd into a safe directory name under ~/.pi/agent/sessions/.
|
||||
*/
|
||||
export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string {
|
||||
function getDefaultSessionDirPath(cwd: string, agentDir: string = getDefaultAgentDir()): string {
|
||||
const resolvedCwd = resolvePath(cwd);
|
||||
const resolvedAgentDir = resolvePath(agentDir);
|
||||
const safePath = `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
||||
const sessionDir = join(resolvedAgentDir, "sessions", safePath);
|
||||
return join(resolvedAgentDir, "sessions", safePath);
|
||||
}
|
||||
|
||||
export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string {
|
||||
const sessionDir = getDefaultSessionDirPath(cwd, agentDir);
|
||||
if (!existsSync(sessionDir)) {
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
}
|
||||
return sessionDir;
|
||||
}
|
||||
|
||||
const SESSION_READ_BUFFER_SIZE = 1024 * 1024;
|
||||
|
||||
function parseSessionEntryLine(line: string): FileEntry | null {
|
||||
if (!line.trim()) return null;
|
||||
try {
|
||||
return JSON.parse(line) as FileEntry;
|
||||
} catch {
|
||||
// Skip malformed lines
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Exported for testing */
|
||||
export function loadEntriesFromFile(filePath: string): FileEntry[] {
|
||||
const resolvedFilePath = normalizePath(filePath);
|
||||
if (!existsSync(resolvedFilePath)) return [];
|
||||
|
||||
const content = readFileSync(resolvedFilePath, "utf8");
|
||||
const entries: FileEntry[] = [];
|
||||
const lines = content.trim().split("\n");
|
||||
const fd = openSync(resolvedFilePath, "r");
|
||||
try {
|
||||
const decoder = new StringDecoder("utf8");
|
||||
const buffer = Buffer.allocUnsafe(SESSION_READ_BUFFER_SIZE);
|
||||
let pending = "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line) as FileEntry;
|
||||
entries.push(entry);
|
||||
} catch {
|
||||
// Skip malformed lines
|
||||
while (true) {
|
||||
const bytesRead = readSync(fd, buffer, 0, buffer.length, null);
|
||||
if (bytesRead === 0) break;
|
||||
|
||||
pending += decoder.write(buffer.subarray(0, bytesRead));
|
||||
let lineStart = 0;
|
||||
let newlineIndex = pending.indexOf("\n", lineStart);
|
||||
while (newlineIndex !== -1) {
|
||||
const entry = parseSessionEntryLine(pending.slice(lineStart, newlineIndex));
|
||||
if (entry) entries.push(entry);
|
||||
lineStart = newlineIndex + 1;
|
||||
newlineIndex = pending.indexOf("\n", lineStart);
|
||||
}
|
||||
pending = pending.slice(lineStart);
|
||||
}
|
||||
|
||||
pending += decoder.end();
|
||||
const finalEntry = parseSessionEntryLine(pending);
|
||||
if (finalEntry) entries.push(finalEntry);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
|
||||
// Validate session header
|
||||
if (entries.length === 0) return entries;
|
||||
const header = entries[0];
|
||||
if (header.type !== "session" || typeof (header as any).id !== "string") {
|
||||
if (header.type !== "session" || typeof (header as { id?: unknown }).id !== "string") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function isValidSessionFile(filePath: string): boolean {
|
||||
function readSessionHeader(filePath: string): SessionHeader | null {
|
||||
try {
|
||||
const fd = openSync(filePath, "r");
|
||||
const buffer = Buffer.alloc(512);
|
||||
const bytesRead = readSync(fd, buffer, 0, 512, 0);
|
||||
closeSync(fd);
|
||||
const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n")[0];
|
||||
if (!firstLine) return false;
|
||||
const header = JSON.parse(firstLine);
|
||||
return header.type === "session" && typeof header.id === "string";
|
||||
if (!firstLine) return null;
|
||||
const header = JSON.parse(firstLine) as Record<string, unknown>;
|
||||
if (header.type !== "session" || typeof header.id !== "string") {
|
||||
return null;
|
||||
}
|
||||
return header as unknown as SessionHeader;
|
||||
} catch {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionHeaderCwd(header: SessionHeader): string | undefined {
|
||||
const cwd = (header as { cwd?: unknown }).cwd;
|
||||
return typeof cwd === "string" ? cwd : undefined;
|
||||
}
|
||||
|
||||
function sessionCwdMatches(cwd: string | undefined, resolvedCwd: string): boolean {
|
||||
return cwd !== undefined && cwd !== "" && resolvePath(cwd) === resolvedCwd;
|
||||
}
|
||||
|
||||
/** Exported for testing */
|
||||
export function findMostRecentSession(sessionDir: string): string | null {
|
||||
export function findMostRecentSession(sessionDir: string, cwd?: string): string | null {
|
||||
const resolvedSessionDir = normalizePath(sessionDir);
|
||||
const resolvedCwd = cwd ? resolvePath(cwd) : undefined;
|
||||
try {
|
||||
const files = readdirSync(resolvedSessionDir)
|
||||
.filter((f) => f.endsWith(".jsonl"))
|
||||
.map((f) => join(resolvedSessionDir, f))
|
||||
.filter(isValidSessionFile)
|
||||
.map((path) => ({ path, mtime: statSync(path).mtime }))
|
||||
.map((path) => ({ path, header: readSessionHeader(path) }))
|
||||
.filter(
|
||||
(file): file is { path: string; header: SessionHeader } =>
|
||||
file.header !== null &&
|
||||
(!resolvedCwd || sessionCwdMatches(getSessionHeaderCwd(file.header), resolvedCwd)),
|
||||
)
|
||||
.map(({ path }) => ({ path, mtime: statSync(path).mtime }))
|
||||
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
||||
|
||||
return files[0]?.path || null;
|
||||
@@ -512,80 +573,59 @@ function extractTextContent(message: Message): string {
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function getLastActivityTime(entries: FileEntry[]): number | undefined {
|
||||
let lastActivityTime: number | undefined;
|
||||
function getMessageActivityTime(entry: SessionMessageEntry): number | undefined {
|
||||
const message = entry.message;
|
||||
if (!isMessageWithContent(message)) return undefined;
|
||||
if (message.role !== "user" && message.role !== "assistant") return undefined;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.type !== "message") continue;
|
||||
|
||||
const message = (entry as SessionMessageEntry).message;
|
||||
if (!isMessageWithContent(message)) continue;
|
||||
if (message.role !== "user" && message.role !== "assistant") continue;
|
||||
|
||||
const msgTimestamp = (message as { timestamp?: number }).timestamp;
|
||||
if (typeof msgTimestamp === "number") {
|
||||
lastActivityTime = Math.max(lastActivityTime ?? 0, msgTimestamp);
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryTimestamp = (entry as SessionEntryBase).timestamp;
|
||||
if (typeof entryTimestamp === "string") {
|
||||
const t = new Date(entryTimestamp).getTime();
|
||||
if (!Number.isNaN(t)) {
|
||||
lastActivityTime = Math.max(lastActivityTime ?? 0, t);
|
||||
}
|
||||
}
|
||||
const msgTimestamp = (message as { timestamp?: number }).timestamp;
|
||||
if (typeof msgTimestamp === "number") {
|
||||
return msgTimestamp;
|
||||
}
|
||||
|
||||
return lastActivityTime;
|
||||
}
|
||||
|
||||
function getSessionModifiedDate(entries: FileEntry[], header: SessionHeader, statsMtime: Date): Date {
|
||||
const lastActivityTime = getLastActivityTime(entries);
|
||||
if (typeof lastActivityTime === "number" && lastActivityTime > 0) {
|
||||
return new Date(lastActivityTime);
|
||||
}
|
||||
|
||||
const headerTime = typeof header.timestamp === "string" ? new Date(header.timestamp).getTime() : NaN;
|
||||
return !Number.isNaN(headerTime) ? new Date(headerTime) : statsMtime;
|
||||
const t = new Date(entry.timestamp).getTime();
|
||||
return Number.isNaN(t) ? undefined : t;
|
||||
}
|
||||
|
||||
async function buildSessionInfo(filePath: string): Promise<SessionInfo | null> {
|
||||
try {
|
||||
const content = await readFile(filePath, "utf8");
|
||||
const entries: FileEntry[] = [];
|
||||
const lines = content.trim().split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
entries.push(JSON.parse(line) as FileEntry);
|
||||
} catch {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
const header = entries[0];
|
||||
if (header.type !== "session") return null;
|
||||
|
||||
const stats = await stat(filePath);
|
||||
let header: SessionHeader | null = null;
|
||||
let messageCount = 0;
|
||||
let firstMessage = "";
|
||||
const allMessages: string[] = [];
|
||||
let name: string | undefined;
|
||||
let lastActivityTime: number | undefined;
|
||||
|
||||
const rl = createInterface({
|
||||
input: createReadStream(filePath, { encoding: "utf8" }),
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
for await (const line of rl) {
|
||||
const entry = parseSessionEntryLine(line);
|
||||
if (!entry) continue;
|
||||
|
||||
if (!header) {
|
||||
if (entry.type !== "session") return null;
|
||||
header = entry;
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
// Extract session name (use latest, including explicit clears)
|
||||
if (entry.type === "session_info") {
|
||||
const infoEntry = entry as SessionInfoEntry;
|
||||
name = infoEntry.name?.trim() || undefined;
|
||||
name = entry.name?.trim() || undefined;
|
||||
}
|
||||
|
||||
if (entry.type !== "message") continue;
|
||||
messageCount++;
|
||||
|
||||
const message = (entry as SessionMessageEntry).message;
|
||||
const activityTime = getMessageActivityTime(entry);
|
||||
if (typeof activityTime === "number") {
|
||||
lastActivityTime = Math.max(lastActivityTime ?? 0, activityTime);
|
||||
}
|
||||
|
||||
const message = entry.message;
|
||||
if (!isMessageWithContent(message)) continue;
|
||||
if (message.role !== "user" && message.role !== "assistant") continue;
|
||||
|
||||
@@ -598,18 +638,25 @@ async function buildSessionInfo(filePath: string): Promise<SessionInfo | null> {
|
||||
}
|
||||
}
|
||||
|
||||
const cwd = typeof (header as SessionHeader).cwd === "string" ? (header as SessionHeader).cwd : "";
|
||||
const parentSessionPath = (header as SessionHeader).parentSession;
|
||||
if (!header) return null;
|
||||
|
||||
const modified = getSessionModifiedDate(entries, header as SessionHeader, stats.mtime);
|
||||
const cwd = typeof header.cwd === "string" ? header.cwd : "";
|
||||
const parentSessionPath = header.parentSession;
|
||||
const headerTime = typeof header.timestamp === "string" ? new Date(header.timestamp).getTime() : NaN;
|
||||
const modified =
|
||||
typeof lastActivityTime === "number" && lastActivityTime > 0
|
||||
? new Date(lastActivityTime)
|
||||
: !Number.isNaN(headerTime)
|
||||
? new Date(headerTime)
|
||||
: stats.mtime;
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
id: (header as SessionHeader).id,
|
||||
id: header.id,
|
||||
cwd,
|
||||
name,
|
||||
parentSessionPath,
|
||||
created: new Date((header as SessionHeader).timestamp),
|
||||
created: new Date(header.timestamp),
|
||||
modified,
|
||||
messageCount,
|
||||
firstMessage: firstMessage || "(no messages)",
|
||||
@@ -721,7 +768,13 @@ export class SessionManager {
|
||||
private labelTimestampsById: Map<string, string> = new Map();
|
||||
private leafId: string | null = null;
|
||||
|
||||
private constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) {
|
||||
private constructor(
|
||||
cwd: string,
|
||||
sessionDir: string,
|
||||
sessionFile: string | undefined,
|
||||
persist: boolean,
|
||||
newSessionOptions?: NewSessionOptions,
|
||||
) {
|
||||
this.cwd = resolvePath(cwd);
|
||||
this.sessionDir = normalizePath(sessionDir);
|
||||
this.persist = persist;
|
||||
@@ -732,7 +785,7 @@ export class SessionManager {
|
||||
if (sessionFile) {
|
||||
this.setSessionFile(sessionFile);
|
||||
} else {
|
||||
this.newSession();
|
||||
this.newSession(newSessionOptions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -770,6 +823,9 @@ export class SessionManager {
|
||||
}
|
||||
|
||||
newSession(options?: NewSessionOptions): string | undefined {
|
||||
if (options?.id !== undefined) {
|
||||
assertValidSessionId(options.id);
|
||||
}
|
||||
this.sessionId = options?.id ?? createSessionId();
|
||||
const timestamp = new Date().toISOString();
|
||||
const header: SessionHeader = {
|
||||
@@ -816,8 +872,14 @@ export class SessionManager {
|
||||
|
||||
private _rewriteFile(): void {
|
||||
if (!this.persist || !this.sessionFile) return;
|
||||
const content = `${this.fileEntries.map((e) => JSON.stringify(e)).join("\n")}\n`;
|
||||
writeFileSync(this.sessionFile, content);
|
||||
const fd = openSync(this.sessionFile, "w");
|
||||
try {
|
||||
for (const entry of this.fileEntries) {
|
||||
writeFileSync(fd, `${JSON.stringify(entry)}\n`);
|
||||
}
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
isPersisted(): boolean {
|
||||
@@ -832,6 +894,10 @@ export class SessionManager {
|
||||
return this.sessionDir;
|
||||
}
|
||||
|
||||
usesDefaultSessionDir(): boolean {
|
||||
return this.sessionDir === getDefaultSessionDirPath(this.cwd);
|
||||
}
|
||||
|
||||
getSessionId(): string {
|
||||
return this.sessionId;
|
||||
}
|
||||
@@ -845,14 +911,23 @@ export class SessionManager {
|
||||
|
||||
const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant");
|
||||
if (!hasAssistant) {
|
||||
// Mark as not flushed so when assistant arrives, all entries get written
|
||||
this.flushed = false;
|
||||
if (this.flushed) {
|
||||
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
|
||||
} else {
|
||||
// Mark as not flushed so when assistant arrives, all entries get written
|
||||
this.flushed = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.flushed) {
|
||||
for (const e of this.fileEntries) {
|
||||
appendFileSync(this.sessionFile, `${JSON.stringify(e)}\n`);
|
||||
const fd = openSync(this.sessionFile, "wx");
|
||||
try {
|
||||
for (const e of this.fileEntries) {
|
||||
writeFileSync(fd, `${JSON.stringify(e)}\n`);
|
||||
}
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
this.flushed = true;
|
||||
} else {
|
||||
@@ -1078,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;
|
||||
}
|
||||
|
||||
@@ -1216,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();
|
||||
@@ -1308,9 +1392,9 @@ export class SessionManager {
|
||||
* @param cwd Working directory (stored in session header)
|
||||
* @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).
|
||||
*/
|
||||
static create(cwd: string, sessionDir?: string): SessionManager {
|
||||
static create(cwd: string, sessionDir?: string, options?: NewSessionOptions): SessionManager {
|
||||
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
|
||||
return new SessionManager(cwd, dir, undefined, true);
|
||||
return new SessionManager(cwd, dir, undefined, true, options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1337,7 +1421,8 @@ export class SessionManager {
|
||||
*/
|
||||
static continueRecent(cwd: string, sessionDir?: string): SessionManager {
|
||||
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
|
||||
const mostRecent = findMostRecentSession(dir);
|
||||
const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd);
|
||||
const mostRecent = findMostRecentSession(dir, filterCwd ? cwd : undefined);
|
||||
if (mostRecent) {
|
||||
return new SessionManager(cwd, dir, mostRecent, true);
|
||||
}
|
||||
@@ -1356,7 +1441,12 @@ export class SessionManager {
|
||||
* @param targetCwd Target working directory (where the new session will be stored)
|
||||
* @param sessionDir Optional session directory. If omitted, uses default for targetCwd.
|
||||
*/
|
||||
static forkFrom(sourcePath: string, targetCwd: string, sessionDir?: string): SessionManager {
|
||||
static forkFrom(
|
||||
sourcePath: string,
|
||||
targetCwd: string,
|
||||
sessionDir?: string,
|
||||
options?: NewSessionOptions,
|
||||
): SessionManager {
|
||||
const resolvedSourcePath = resolvePath(sourcePath);
|
||||
const resolvedTargetCwd = resolvePath(targetCwd);
|
||||
const sourceEntries = loadEntriesFromFile(resolvedSourcePath);
|
||||
@@ -1375,7 +1465,10 @@ export class SessionManager {
|
||||
}
|
||||
|
||||
// Create new session file with new ID but forked content
|
||||
const newSessionId = createSessionId();
|
||||
if (options?.id !== undefined) {
|
||||
assertValidSessionId(options.id);
|
||||
}
|
||||
const newSessionId = options?.id ?? createSessionId();
|
||||
const timestamp = new Date().toISOString();
|
||||
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
|
||||
const newSessionFile = join(dir, `${fileTimestamp}_${newSessionId}.jsonl`);
|
||||
@@ -1389,7 +1482,7 @@ export class SessionManager {
|
||||
cwd: resolvedTargetCwd,
|
||||
parentSession: resolvedSourcePath,
|
||||
};
|
||||
appendFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`);
|
||||
writeFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`, { flag: "wx" });
|
||||
|
||||
// Copy all non-header entries from source
|
||||
for (const entry of sourceEntries) {
|
||||
@@ -1409,7 +1502,11 @@ export class SessionManager {
|
||||
*/
|
||||
static async list(cwd: string, sessionDir?: string, onProgress?: SessionListProgress): Promise<SessionInfo[]> {
|
||||
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
|
||||
const sessions = await listSessionsFromDir(dir, onProgress);
|
||||
const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd);
|
||||
const resolvedCwd = resolvePath(cwd);
|
||||
const sessions = (await listSessionsFromDir(dir, onProgress)).filter(
|
||||
(session) => !filterCwd || sessionCwdMatches(session.cwd, resolvedCwd),
|
||||
);
|
||||
sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
||||
return sessions;
|
||||
}
|
||||
@@ -1418,7 +1515,21 @@ export class SessionManager {
|
||||
* List all sessions across all project directories.
|
||||
* @param onProgress Optional callback for progress updates (loaded, total)
|
||||
*/
|
||||
static async listAll(onProgress?: SessionListProgress): Promise<SessionInfo[]> {
|
||||
static async listAll(onProgress?: SessionListProgress): Promise<SessionInfo[]>;
|
||||
static async listAll(sessionDir?: string, onProgress?: SessionListProgress): Promise<SessionInfo[]>;
|
||||
static async listAll(
|
||||
sessionDirOrOnProgress?: string | SessionListProgress,
|
||||
onProgress?: SessionListProgress,
|
||||
): Promise<SessionInfo[]> {
|
||||
const customSessionDir =
|
||||
typeof sessionDirOrOnProgress === "string" ? normalizePath(sessionDirOrOnProgress) : undefined;
|
||||
const progress = typeof sessionDirOrOnProgress === "function" ? sessionDirOrOnProgress : onProgress;
|
||||
if (customSessionDir) {
|
||||
const sessions = await listSessionsFromDir(customSessionDir, progress);
|
||||
sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
||||
return sessions;
|
||||
}
|
||||
|
||||
const sessionsDir = getSessionsDir();
|
||||
|
||||
try {
|
||||
@@ -1448,7 +1559,7 @@ export class SessionManager {
|
||||
|
||||
const results = await buildSessionInfosWithConcurrency(allFiles, () => {
|
||||
loaded++;
|
||||
onProgress?.(loaded, totalFiles);
|
||||
progress?.(loaded, totalFiles);
|
||||
});
|
||||
|
||||
for (const info of results) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Transport } from "@earendil-works/pi-ai";
|
||||
import { randomUUID } from "crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import lockfile from "proper-lockfile";
|
||||
@@ -57,6 +58,8 @@ export interface WarningSettings {
|
||||
anthropicExtraUsage?: boolean; // default: true
|
||||
}
|
||||
|
||||
export type DefaultProjectTrust = "ask" | "always" | "never";
|
||||
|
||||
export type TransportSetting = Transport;
|
||||
|
||||
/**
|
||||
@@ -90,10 +93,13 @@ export interface Settings {
|
||||
shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
|
||||
quietStartup?: boolean;
|
||||
showChangelogOnStartup?: boolean;
|
||||
defaultProjectTrust?: DefaultProjectTrust; // default: "ask"; global setting only
|
||||
shellCommandPrefix?: string; // Prefix prepended to every bash command (e.g., "shopt -s expand_aliases" for alias support)
|
||||
npmCommand?: string[]; // Command used for npm package lookup/install operations, argv-style (e.g., ["mise", "exec", "node@20", "--", "npm"])
|
||||
collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
|
||||
enableInstallTelemetry?: boolean; // default: true - anonymous version/update ping after changelog-detected updates
|
||||
enableAnalytics?: boolean; // default: false - opt-in analytics data sharing
|
||||
trackingId?: string; // analytics tracking identifier, generated when analytics is enabled
|
||||
packages?: PackageSource[]; // Array of npm/git package sources (string or object with filtering)
|
||||
extensions?: string[]; // Array of local extension file paths or directories
|
||||
skills?: string[]; // Array of local skill file paths or directories
|
||||
@@ -112,7 +118,9 @@ 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
|
||||
}
|
||||
|
||||
/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
|
||||
@@ -146,8 +154,23 @@ function deepMergeSettings(base: Settings, overrides: Settings): Settings {
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseTimeoutSetting(value: unknown, settingName: string): number | undefined {
|
||||
const timeoutMs = parseHttpIdleTimeoutMs(value);
|
||||
if (timeoutMs !== undefined) {
|
||||
return timeoutMs;
|
||||
}
|
||||
if (value !== undefined) {
|
||||
throw new Error(`Invalid ${settingName} setting: ${String(value)}`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export type SettingsScope = "global" | "project";
|
||||
|
||||
export interface SettingsManagerCreateOptions {
|
||||
projectTrusted?: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsStorage {
|
||||
withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void;
|
||||
}
|
||||
@@ -248,6 +271,7 @@ export class SettingsManager {
|
||||
private globalSettings: Settings;
|
||||
private projectSettings: Settings;
|
||||
private settings: Settings;
|
||||
private projectTrusted: boolean;
|
||||
private modifiedFields = new Set<keyof Settings>(); // Track global fields modified during session
|
||||
private modifiedNestedFields = new Map<keyof Settings, Set<string>>(); // Track global nested field modifications
|
||||
private modifiedProjectFields = new Set<keyof Settings>(); // Track project fields modified during session
|
||||
@@ -264,10 +288,12 @@ export class SettingsManager {
|
||||
globalLoadError: Error | null = null,
|
||||
projectLoadError: Error | null = null,
|
||||
initialErrors: SettingsError[] = [],
|
||||
projectTrusted = true,
|
||||
) {
|
||||
this.storage = storage;
|
||||
this.globalSettings = initialGlobal;
|
||||
this.projectSettings = initialProject;
|
||||
this.projectTrusted = projectTrusted;
|
||||
this.globalSettingsLoadError = globalLoadError;
|
||||
this.projectSettingsLoadError = projectLoadError;
|
||||
this.errors = [...initialErrors];
|
||||
@@ -275,15 +301,20 @@ export class SettingsManager {
|
||||
}
|
||||
|
||||
/** Create a SettingsManager that loads from files */
|
||||
static create(cwd: string, agentDir: string = getAgentDir()): SettingsManager {
|
||||
static create(
|
||||
cwd: string,
|
||||
agentDir: string = getAgentDir(),
|
||||
options: SettingsManagerCreateOptions = {},
|
||||
): SettingsManager {
|
||||
const storage = new FileSettingsStorage(cwd, agentDir);
|
||||
return SettingsManager.fromStorage(storage);
|
||||
return SettingsManager.fromStorage(storage, options);
|
||||
}
|
||||
|
||||
/** Create a SettingsManager from an arbitrary storage backend */
|
||||
static fromStorage(storage: SettingsStorage): SettingsManager {
|
||||
static fromStorage(storage: SettingsStorage, options: SettingsManagerCreateOptions = {}): SettingsManager {
|
||||
const projectTrusted = options.projectTrusted ?? true;
|
||||
const globalLoad = SettingsManager.tryLoadFromStorage(storage, "global");
|
||||
const projectLoad = SettingsManager.tryLoadFromStorage(storage, "project");
|
||||
const projectLoad = SettingsManager.tryLoadFromStorage(storage, "project", projectTrusted);
|
||||
const initialErrors: SettingsError[] = [];
|
||||
if (globalLoad.error) {
|
||||
initialErrors.push({ scope: "global", error: globalLoad.error });
|
||||
@@ -299,6 +330,7 @@ export class SettingsManager {
|
||||
globalLoad.error,
|
||||
projectLoad.error,
|
||||
initialErrors,
|
||||
projectTrusted,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -310,7 +342,11 @@ export class SettingsManager {
|
||||
return SettingsManager.fromStorage(storage);
|
||||
}
|
||||
|
||||
private static loadFromStorage(storage: SettingsStorage, scope: SettingsScope): Settings {
|
||||
private static loadFromStorage(storage: SettingsStorage, scope: SettingsScope, projectTrusted = true): Settings {
|
||||
if (scope === "project" && !projectTrusted) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let content: string | undefined;
|
||||
storage.withLock(scope, (current) => {
|
||||
content = current;
|
||||
@@ -327,9 +363,10 @@ export class SettingsManager {
|
||||
private static tryLoadFromStorage(
|
||||
storage: SettingsStorage,
|
||||
scope: SettingsScope,
|
||||
projectTrusted = true,
|
||||
): { settings: Settings; error: Error | null } {
|
||||
try {
|
||||
return { settings: SettingsManager.loadFromStorage(storage, scope), error: null };
|
||||
return { settings: SettingsManager.loadFromStorage(storage, scope, projectTrusted), error: null };
|
||||
} catch (error) {
|
||||
return { settings: {}, error: error as Error };
|
||||
}
|
||||
@@ -405,6 +442,35 @@ export class SettingsManager {
|
||||
return structuredClone(this.projectSettings);
|
||||
}
|
||||
|
||||
isProjectTrusted(): boolean {
|
||||
return this.projectTrusted;
|
||||
}
|
||||
|
||||
setProjectTrusted(trusted: boolean): void {
|
||||
if (this.projectTrusted === trusted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.projectTrusted = trusted;
|
||||
this.modifiedProjectFields.clear();
|
||||
this.modifiedProjectNestedFields.clear();
|
||||
|
||||
if (!trusted) {
|
||||
this.projectSettings = {};
|
||||
this.projectSettingsLoadError = null;
|
||||
this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);
|
||||
return;
|
||||
}
|
||||
|
||||
const projectLoad = SettingsManager.tryLoadFromStorage(this.storage, "project", trusted);
|
||||
this.projectSettings = projectLoad.settings;
|
||||
this.projectSettingsLoadError = projectLoad.error;
|
||||
if (projectLoad.error) {
|
||||
this.recordError("project", projectLoad.error);
|
||||
}
|
||||
this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);
|
||||
}
|
||||
|
||||
async reload(): Promise<void> {
|
||||
await this.writeQueue;
|
||||
const globalLoad = SettingsManager.tryLoadFromStorage(this.storage, "global");
|
||||
@@ -421,7 +487,7 @@ export class SettingsManager {
|
||||
this.modifiedProjectFields.clear();
|
||||
this.modifiedProjectNestedFields.clear();
|
||||
|
||||
const projectLoad = SettingsManager.tryLoadFromStorage(this.storage, "project");
|
||||
const projectLoad = SettingsManager.tryLoadFromStorage(this.storage, "project", this.projectTrusted);
|
||||
if (!projectLoad.error) {
|
||||
this.projectSettings = projectLoad.settings;
|
||||
this.projectSettingsLoadError = null;
|
||||
@@ -460,6 +526,12 @@ export class SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
private assertProjectTrustedForWrite(): void {
|
||||
if (!this.projectTrusted) {
|
||||
throw new Error("Project is not trusted; refusing to write project settings");
|
||||
}
|
||||
}
|
||||
|
||||
private recordError(scope: SettingsScope, error: unknown): void {
|
||||
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
||||
this.errors.push({ scope, error: normalizedError });
|
||||
@@ -479,6 +551,9 @@ export class SettingsManager {
|
||||
private enqueueWrite(scope: SettingsScope, task: () => void): void {
|
||||
this.writeQueue = this.writeQueue
|
||||
.then(() => {
|
||||
if (scope === "project") {
|
||||
this.assertProjectTrustedForWrite();
|
||||
}
|
||||
task();
|
||||
this.clearModifiedScope(scope);
|
||||
})
|
||||
@@ -543,6 +618,7 @@ export class SettingsManager {
|
||||
}
|
||||
|
||||
private saveProjectSettings(settings: Settings): void {
|
||||
this.assertProjectTrustedForWrite();
|
||||
this.projectSettings = structuredClone(settings);
|
||||
this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);
|
||||
|
||||
@@ -558,6 +634,14 @@ export class SettingsManager {
|
||||
});
|
||||
}
|
||||
|
||||
private updateProjectSettings(field: keyof Settings, update: (settings: Settings) => void): void {
|
||||
this.assertProjectTrustedForWrite();
|
||||
const projectSettings = structuredClone(this.projectSettings);
|
||||
update(projectSettings);
|
||||
this.markProjectModified(field);
|
||||
this.saveProjectSettings(projectSettings);
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
await this.writeQueue;
|
||||
}
|
||||
@@ -631,8 +715,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 {
|
||||
@@ -723,15 +814,7 @@ export class SettingsManager {
|
||||
}
|
||||
|
||||
getHttpIdleTimeoutMs(): number {
|
||||
const value = this.settings.httpIdleTimeoutMs;
|
||||
const timeoutMs = parseHttpIdleTimeoutMs(value);
|
||||
if (timeoutMs !== undefined) {
|
||||
return timeoutMs;
|
||||
}
|
||||
if (value !== undefined) {
|
||||
throw new Error(`Invalid httpIdleTimeoutMs setting: ${String(value)}`);
|
||||
}
|
||||
return DEFAULT_HTTP_IDLE_TIMEOUT_MS;
|
||||
return parseTimeoutSetting(this.settings.httpIdleTimeoutMs, "httpIdleTimeoutMs") ?? DEFAULT_HTTP_IDLE_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
setHttpIdleTimeoutMs(timeoutMs: number): void {
|
||||
@@ -751,6 +834,10 @@ export class SettingsManager {
|
||||
};
|
||||
}
|
||||
|
||||
getWebSocketConnectTimeoutMs(): number | undefined {
|
||||
return parseTimeoutSetting(this.settings.websocketConnectTimeoutMs, "websocketConnectTimeoutMs");
|
||||
}
|
||||
|
||||
getHideThinkingBlock(): boolean {
|
||||
return this.settings.hideThinkingBlock ?? false;
|
||||
}
|
||||
@@ -791,6 +878,17 @@ export class SettingsManager {
|
||||
this.save();
|
||||
}
|
||||
|
||||
getDefaultProjectTrust(): DefaultProjectTrust {
|
||||
const value = this.globalSettings.defaultProjectTrust;
|
||||
return value === "always" || value === "never" ? value : "ask";
|
||||
}
|
||||
|
||||
setDefaultProjectTrust(defaultProjectTrust: DefaultProjectTrust): void {
|
||||
this.globalSettings.defaultProjectTrust = defaultProjectTrust;
|
||||
this.markModified("defaultProjectTrust");
|
||||
this.save();
|
||||
}
|
||||
|
||||
getShellCommandPrefix(): string | undefined {
|
||||
return this.settings.shellCommandPrefix;
|
||||
}
|
||||
@@ -831,6 +929,25 @@ export class SettingsManager {
|
||||
this.save();
|
||||
}
|
||||
|
||||
getEnableAnalytics(): boolean {
|
||||
return this.settings.enableAnalytics ?? false;
|
||||
}
|
||||
|
||||
getTrackingId(): string | undefined {
|
||||
return this.settings.trackingId;
|
||||
}
|
||||
|
||||
/** Set the analytics opt-in preference; generates a tracking identifier on first opt-in */
|
||||
setEnableAnalytics(enabled: boolean): void {
|
||||
this.globalSettings.enableAnalytics = enabled;
|
||||
this.markModified("enableAnalytics");
|
||||
if (enabled && !this.globalSettings.trackingId) {
|
||||
this.globalSettings.trackingId = randomUUID();
|
||||
this.markModified("trackingId");
|
||||
}
|
||||
this.save();
|
||||
}
|
||||
|
||||
getPackages(): PackageSource[] {
|
||||
return [...(this.settings.packages ?? [])];
|
||||
}
|
||||
@@ -842,10 +959,9 @@ export class SettingsManager {
|
||||
}
|
||||
|
||||
setProjectPackages(packages: PackageSource[]): void {
|
||||
const projectSettings = structuredClone(this.projectSettings);
|
||||
projectSettings.packages = packages;
|
||||
this.markProjectModified("packages");
|
||||
this.saveProjectSettings(projectSettings);
|
||||
this.updateProjectSettings("packages", (settings) => {
|
||||
settings.packages = packages;
|
||||
});
|
||||
}
|
||||
|
||||
getExtensionPaths(): string[] {
|
||||
@@ -859,10 +975,9 @@ export class SettingsManager {
|
||||
}
|
||||
|
||||
setProjectExtensionPaths(paths: string[]): void {
|
||||
const projectSettings = structuredClone(this.projectSettings);
|
||||
projectSettings.extensions = paths;
|
||||
this.markProjectModified("extensions");
|
||||
this.saveProjectSettings(projectSettings);
|
||||
this.updateProjectSettings("extensions", (settings) => {
|
||||
settings.extensions = paths;
|
||||
});
|
||||
}
|
||||
|
||||
getSkillPaths(): string[] {
|
||||
@@ -876,10 +991,9 @@ export class SettingsManager {
|
||||
}
|
||||
|
||||
setProjectSkillPaths(paths: string[]): void {
|
||||
const projectSettings = structuredClone(this.projectSettings);
|
||||
projectSettings.skills = paths;
|
||||
this.markProjectModified("skills");
|
||||
this.saveProjectSettings(projectSettings);
|
||||
this.updateProjectSettings("skills", (settings) => {
|
||||
settings.skills = paths;
|
||||
});
|
||||
}
|
||||
|
||||
getPromptTemplatePaths(): string[] {
|
||||
@@ -893,10 +1007,9 @@ export class SettingsManager {
|
||||
}
|
||||
|
||||
setProjectPromptTemplatePaths(paths: string[]): void {
|
||||
const projectSettings = structuredClone(this.projectSettings);
|
||||
projectSettings.prompts = paths;
|
||||
this.markProjectModified("prompts");
|
||||
this.saveProjectSettings(projectSettings);
|
||||
this.updateProjectSettings("prompts", (settings) => {
|
||||
settings.prompts = paths;
|
||||
});
|
||||
}
|
||||
|
||||
getThemePaths(): string[] {
|
||||
@@ -910,10 +1023,9 @@ export class SettingsManager {
|
||||
}
|
||||
|
||||
setProjectThemePaths(paths: string[]): void {
|
||||
const projectSettings = structuredClone(this.projectSettings);
|
||||
projectSettings.themes = paths;
|
||||
this.markProjectModified("themes");
|
||||
this.saveProjectSettings(projectSettings);
|
||||
this.updateProjectSettings("themes", (settings) => {
|
||||
settings.themes = paths;
|
||||
});
|
||||
}
|
||||
|
||||
getEnableSkillCommands(): boolean {
|
||||
|
||||
@@ -30,6 +30,7 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<BuiltinSlashCommand> = [
|
||||
{ name: "fork", description: "Create a new fork from a previous user message" },
|
||||
{ name: "clone", description: "Duplicate the current session at the current position" },
|
||||
{ name: "tree", description: "Navigate session tree (switch branches)" },
|
||||
{ name: "trust", description: "Save project trust decision for future sessions" },
|
||||
{ name: "login", description: "Configure provider authentication" },
|
||||
{ name: "logout", description: "Remove provider authentication" },
|
||||
{ name: "new", description: "Start a new session" },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user