merge: sync upstream pi-mono main into sproutclaw
Some checks failed
CI / build-check-test (push) Has been cancelled

Merge upstream/main (195 commits) while keeping sproutclaw-specific
README, webui workflow docs, and restored upstream .pi prompt templates.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-05-22 20:05:31 +08:00
642 changed files with 18785 additions and 26436 deletions

View File

@@ -4,10 +4,146 @@
### Added
- Added Together AI to built-in provider setup, `/login` API-key auth, and default model resolution ([#3624](https://github.com/earendil-works/pi-mono/pull/3624) by [@Nutlope](https://github.com/Nutlope)).
- Added a standard unified patch to edit tool result details for SDK consumers ([#4821](https://github.com/earendil-works/pi/issues/4821)).
### Changed
- Changed the root development install documentation to use `npm install --ignore-scripts` ([#4868](https://github.com/earendil-works/pi/issues/4868)).
### Fixed
- 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)).
## [0.75.4] - 2026-05-20
### New Features
- **Hardened npm install and release path** - Pi now ships the CLI with a generated shrinkwrap for transitive dependencies, blocks accidental lockfile changes, verifies dependency pinning and lifecycle-script allowlists in checks, disables lifecycle scripts for self-update and local release installs where supported, and smoke-tests isolated npm and Bun installs before release. See [Supply-chain hardening](../../README.md#supply-chain-hardening).
### Added
- Added interactive update notes after `pi update` runs, so users can see the installed version's changelog before continuing ([#4724](https://github.com/earendil-works/pi-mono/pull/4724) by [@mitsuhiko](https://github.com/mitsuhiko)).
- Exported image resize utilities from the package root for SDK consumers ([#4775](https://github.com/earendil-works/pi-mono/pull/4775) by [@xl0](https://github.com/xl0)).
### Changed
- Changed source syntax to avoid TypeScript constructs that require JavaScript emit, keeping core sources compatible with Node.js strip-only TypeScript checks.
- Removed web UI workspace references from the CLI package and dropped the package-level development watch script.
- Published npm installs now include an `npm-shrinkwrap.json` to lock transitive dependencies for the CLI package.
- Improved terminal theme detection for light/dark and truecolor handling.
- Changed self-update package-manager commands to disable lifecycle scripts during reinstall.
### Fixed
- Fixed the system prompt to tell models to resolve pi docs and examples under the absolute package paths before reading topic-specific relative references ([#4752](https://github.com/earendil-works/pi/issues/4752)).
- Fixed extension `ctx.abort()` during tool-call preflight to stop later confirmations and restore queued interactive input like Escape ([#4276](https://github.com/earendil-works/pi/issues/4276)).
- Fixed AgentSession retry, compaction, and event settlement to use the awaited agent lifecycle instead of a separate event queue, and added `willRetry` to `agent_end` session events.
- Fixed forked session runtime state to keep the active session id aligned with the fork target ([#4799](https://github.com/earendil-works/pi-mono/pull/4799) by [@Perlence](https://github.com/Perlence)).
- Fixed the subagent extension's parallel mode to return useful per-task output and failed-task diagnostics to the parent model instead of 100-character previews ([#4710](https://github.com/earendil-works/pi/issues/4710)).
- Fixed Windows local bash execution to hide helper console windows when launched from background SDK processes ([#4699](https://github.com/earendil-works/pi/issues/4699)).
- Fixed managed npm extension folders to set cloud-sync ignore metadata where supported ([#4763](https://github.com/earendil-works/pi/issues/4763)).
- Fixed HTTP idle timeout configuration so long-running provider streams can avoid premature idle disconnects ([#4759](https://github.com/earendil-works/pi-mono/pull/4759) by [@mitsuhiko](https://github.com/mitsuhiko)).
- Fixed default system prompt boundaries to use explicit XML tags for clearer file separation ([#4709](https://github.com/earendil-works/pi-mono/pull/4709) by [@herrnel](https://github.com/herrnel)).
- Fixed HTML share/export sidebar clicks for shared tool entries to scroll to the rendered tool call ([#4664](https://github.com/earendil-works/pi-mono/pull/4664) by [@yzhg1983](https://github.com/yzhg1983)).
- Fixed theme palettes to set explicit text colors and avoid terminal-default color drift.
- Fixed truecolor detection to align terminal image rendering and interactive theme decisions.
- Fixed loader indicator startup inherited from `@earendil-works/pi-tui` so initialization cannot run before frames are available.
- Fixed OpenAI-compatible default output token requests inherited from `@earendil-works/pi-ai` to avoid reserving impossible context windows on servers such as vLLM ([#4675](https://github.com/earendil-works/pi/issues/4675)).
- Fixed OpenAI prompt cache keys inherited from `@earendil-works/pi-ai` to stay within the 64-character provider limit ([#4720](https://github.com/earendil-works/pi/issues/4720)).
- Fixed Windows npm-family package commands for fnm-managed Node.js installs that expose both extensionless Unix scripts and `.cmd` shims ([#4793](https://github.com/earendil-works/pi/issues/4793)).
## [0.75.3] - 2026-05-18
### Fixed
- Fixed undici 8 HTTP/2 destroyed-session races crashing the Node CLI by preserving the previous HTTP/1.1-only fetch dispatcher behavior ([#4681](https://github.com/earendil-works/pi/issues/4681)).
## [0.75.2] - 2026-05-18
### Fixed
- Fixed Bun-compiled release binaries failing to start when Bun's built-in undici shim lacks npm undici's `install` export ([#4661](https://github.com/earendil-works/pi-mono/pull/4661) by [@dmasiero](https://github.com/dmasiero)).
- Fixed Xiaomi MiMo generated model metadata to replay assistant tool-call messages with `reasoning_content` for thinking-mode multi-turn requests, inherited from `@earendil-works/pi-ai` ([#4678](https://github.com/earendil-works/pi/issues/4678)).
- Fixed Windows external editor handoff so vim/nvim can receive input after opening from the TUI ([#4612](https://github.com/earendil-works/pi/issues/4612)).
- Fixed Windows npm self-updates to move loaded native dependency packages out of the active install before reinstalling pi ([#4157](https://github.com/earendil-works/pi/issues/4157)).
- Fixed `pi update --self` detection for pnpm v11 global installs whose package path resolves through the pnpm store ([#4647](https://github.com/earendil-works/pi/issues/4647)).
- Fixed Windows pnpm self-updates to resolve pnpm command shims and run through pnpm instead of requiring manual updates ([#4157](https://github.com/earendil-works/pi/issues/4157)).
- Fixed Windows npm-family command execution to use cross-spawn instead of parsing `.cmd` shim internals ([#4665](https://github.com/earendil-works/pi/issues/4665)).
## [0.75.1] - 2026-05-18
### Fixed
- Fixed config selectors to scale their visible row count to terminal height ([#4243](https://github.com/earendil-works/pi-mono/pull/4243) by [@samjonester](https://github.com/samjonester)).
- Fixed Anthropic-compatible API-key requests to ignore unrelated `ANTHROPIC_AUTH_TOKEN` environment values, avoiding invalid bearer credentials for providers such as Xiaomi MiMo inherited from `@earendil-works/pi-ai` ([#4342](https://github.com/earendil-works/pi/issues/4342)).
- Fixed Amazon Bedrock message conversion to skip unknown content blocks instead of failing the stream, inherited from `@earendil-works/pi-ai` ([#4223](https://github.com/earendil-works/pi/issues/4223)).
- Fixed Azure OpenAI Responses and OpenAI Responses error formatting to prefix HTTP status codes onto `errorMessage`, so transient 5xx and 429 errors are correctly matched by the agent-level auto-retry classifier inherited from `@earendil-works/pi-ai` ([#4232](https://github.com/earendil-works/pi/issues/4232)).
- Fixed OpenCode Go Kimi reasoning replay by normalizing streamed `reasoning` fields back to `reasoning_content` for OpenCode Go only, inherited from `@earendil-works/pi-ai` ([#4251](https://github.com/earendil-works/pi/issues/4251)).
- Fixed Xiaomi MiMo model metadata to use the OpenAI-compatible endpoints and `openai-completions` API, restoring multi-turn thinking/tool-call sessions inherited from `@earendil-works/pi-ai` ([#4505](https://github.com/earendil-works/pi/issues/4505)).
- Fixed JSON parse failures for compressed fetch responses under Node 26.0 by installing undici fetch globals alongside pi's global dispatcher ([#4650](https://github.com/earendil-works/pi/issues/4650), [#4652](https://github.com/earendil-works/pi/issues/4652), [#4653](https://github.com/earendil-works/pi/issues/4653)).
- Fixed npm-family package commands on Windows to avoid shell argument splitting when install prefixes contain spaces ([#4623](https://github.com/earendil-works/pi/issues/4623)).
### Removed
- Removed non-working OpenAI Codex fast model variants inherited from `@earendil-works/pi-ai`.
## [0.75.0] - 2026-05-17
### Breaking Changes
- Raised the minimum supported Node.js version to 22.19.0.
### Fixed
- Fixed compaction summary calls to use custom agent stream functions, preserving proxy-backed LLM routing ([#4484](https://github.com/earendil-works/pi/issues/4484)).
- Fixed system prompt and context file boundaries to use explicit XML tags instead of Markdown headings, reducing inconsistent boundary ingestion by models ([#4541](https://github.com/earendil-works/pi-mono/pull/4541) by [@herrnel](https://github.com/herrnel)).
- Fixed OpenAI Codex generated model metadata to use the current upstream model list inherited from `@earendil-works/pi-ai` ([#4603](https://github.com/earendil-works/pi-mono/pull/4603) by [@mattiacerutti](https://github.com/mattiacerutti)).
- Fixed GitHub Copilot GPT model thinking metadata inherited from `@earendil-works/pi-ai` to map unsupported minimal thinking to low ([#4622](https://github.com/earendil-works/pi-mono/pull/4622) by [@mattiacerutti](https://github.com/mattiacerutti)).
- Fixed user-scoped npm pi packages to install under `~/.pi/agent/npm/` instead of npm's global package root, avoiding permission errors with system-managed Node installs ([#4587](https://github.com/earendil-works/pi/issues/4587)).
- Fixed Mistral requests failing after the global fetch proxy/timeout workaround by removing the custom fetch override and using undici 8 dispatcher support instead ([#4619](https://github.com/earendil-works/pi/issues/4619)).
- Fixed default output token requests for models whose advertised output limit is effectively their full context window, avoiding impossible provider requests inherited from `@earendil-works/pi-ai` ([#4614](https://github.com/earendil-works/pi/issues/4614)).
## [0.74.1] - 2026-05-16
### New Features
- **Image generation support** - Added image generation APIs, generated image model metadata, and built-in OpenRouter image generation support inherited from `@earendil-works/pi-ai`.
- **Together AI provider** - Added Together AI as a built-in provider with `/login` API-key auth, default model resolution, and setup docs. See [README.md#providers--models](README.md#providers--models) and [docs/providers.md](docs/providers.md).
- **Windows ARM64 standalone binaries** - Added standalone release artifacts for Windows ARM64.
- **Improved terminal and markdown rendering** - Added markdown list indentation, task-list checkbox rendering, large markdown robustness, and inline image placement fixes inherited from `@earendil-works/pi-tui`.
### Added
- Added image generation support from `@earendil-works/pi-ai`, including image generation APIs, image model metadata, and built-in OpenRouter image generation support ([#3887](https://github.com/earendil-works/pi-mono/pull/3887) by [@cristinaponcela](https://github.com/cristinaponcela)).
- Added Together AI to built-in provider setup, `/login` API-key auth, and default model resolution ([#3624](https://github.com/earendil-works/pi-mono/pull/3624) by [@Nutlope](https://github.com/Nutlope)).
- Added Windows ARM64 standalone binary release artifacts ([#4458](https://github.com/earendil-works/pi/pull/4458) by [@brianmichel](https://github.com/brianmichel)).
### Fixed
- Fixed Node 26 OpenAI-compatible streams timing out after five idle minutes by routing global fetch through pi's undici dispatcher ([#4519](https://github.com/earendil-works/pi/issues/4519)).
- Fixed pnpm global package installs by resolving the global package root from pnpm's layout.
- Fixed macOS clipboard access errors under sandboxed pasteboard denial so they do not abort the process ([#4492](https://github.com/earendil-works/pi/issues/4492)).
- Fixed the scoped model startup hint to show the configured model-cycle keybinding ([#4508](https://github.com/earendil-works/pi/issues/4508)).
- Fixed resource path display to disambiguate package/resource names that collide across package locations.
- Fixed `fd` auto-download on macOS x86_64 by pinning the last release that ships an Intel macOS binary ([#4559](https://github.com/earendil-works/pi/issues/4559)).
- Fixed skill diagnostics to stop warning when a skill name differs from its parent directory ([#4534](https://github.com/earendil-works/pi/issues/4534)).
- Fixed prompt template argument parsing to split unquoted multiline input on newlines ([#4553](https://github.com/earendil-works/pi/issues/4553)).
- Fixed `--resume` session listing to cap in-flight session metadata loads and avoid OOM on large session histories ([#4583](https://github.com/earendil-works/pi/issues/4583)).
- Fixed interactive error messages to render with trailing spacing so reload errors do not run into resource listings ([#4510](https://github.com/earendil-works/pi/issues/4510)).
- Fixed `.agents` package provenance metadata to survive package-manager scans.
- Fixed nested code fences in the Termux setup documentation so the example AGENTS.md renders correctly ([#4503](https://github.com/earendil-works/pi/issues/4503)).
- Fixed tool output expansion while extension confirmation dialogs are focused ([#4429](https://github.com/earendil-works/pi/issues/4429)).
- Fixed auto-retry for Anthropic streams that end before `message_stop` ([#4433](https://github.com/earendil-works/pi/issues/4433)).
- Fixed compaction summary calls to clamp requested output tokens to model limits.
- Fixed uncaught interactive-mode exceptions to restore the terminal before exiting ([#4426](https://github.com/earendil-works/pi-mono/pull/4426) by [@ofa1](https://github.com/ofa1)).
- Fixed ANSI stripping to match `strip-ansi` behavior after dependency removal.
- Fixed UUIDv7 sequence generation shared by session IDs after dependency removal.
- Fixed OpenRouter cached-token usage accounting, Fireworks caching compatibility, and OpenAI Codex WebSocket proxy handling inherited from `@earendil-works/pi-ai`.
- Fixed markdown list wrapping, task-list checkboxes, large markdown rendering, WezTerm Kitty keyboard escape handling, and short-viewport inline image placement inherited from `@earendil-works/pi-tui`.
- Fixed theme sharing across package scopes so extensions do not crash with `Theme not initialized` ([#4333](https://github.com/earendil-works/pi/issues/4333)).
- Fixed keybinding hints to show Option instead of Alt on macOS ([#4289](https://github.com/earendil-works/pi/issues/4289)).
- Fixed the interactive update notification to render the changelog as an OSC 8 hyperlink when the terminal supports hyperlinks ([#4280](https://github.com/earendil-works/pi/issues/4280)).

View File

@@ -68,13 +68,15 @@ I regularly publish my own `pi-mono` work sessions here:
## Quick Start
```bash
curl -fsSL https://pi.dev/install.sh | sh
npm install -g --ignore-scripts @earendil-works/pi-coding-agent
```
Or with npm:
`--ignore-scripts` disables dependency lifecycle scripts during install. Pi does not require install scripts for normal npm installs.
Installer alternative:
```bash
npm install -g @earendil-works/pi-coding-agent
curl -fsSL https://pi.dev/install.sh | sh
```
Authenticate with an API key:
@@ -406,7 +408,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 global npm. Use `-l` for project-local installs (`.pi/git/`, `.pi/npm/`). 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`, 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`:

View File

@@ -23,6 +23,7 @@ See these complete provider examples:
- [Unregister Provider](#unregister-provider)
- [OAuth Support](#oauth-support)
- [Custom Streaming API](#custom-streaming-api)
- [Context Overflow Errors](#context-overflow-errors)
- [Testing Your Implementation](#testing-your-implementation)
- [Config Reference](#config-reference)
- [Model Definition Reference](#model-definition-reference)
@@ -506,6 +507,60 @@ output.usage.totalTokens = output.usage.input + output.usage.output +
calculateCost(model, output.usage);
```
### Context Overflow Errors
When a request exceeds the model's context window, pi can recover automatically by compacting the conversation and retrying. This recovery only kicks in if pi recognizes the failure as an overflow.
Detection runs on the finalized assistant message:
- `stopReason === "error"`
- `errorMessage` matches one of pi's known overflow patterns (see [`packages/ai/src/utils/overflow.ts`](https://github.com/earendil-works/pi-mono/blob/main/packages/ai/src/utils/overflow.ts))
If your provider returns overflow errors with a message pi does not recognize, normalize the error from the same extension that registers the provider. Use a `message_end` handler to rewrite the assistant message so its `errorMessage` starts with a phrase pi recognizes. The generic fallback `context_length_exceeded` is the safest choice.
```typescript
const MY_PROVIDER_OVERFLOW_PATTERN = /your provider's overflow phrase/i;
export default function (pi: ExtensionAPI) {
pi.registerProvider("my-provider", { /* ... */ });
pi.on("message_end", (event, ctx) => {
const message = event.message;
if (message.role !== "assistant") return;
if (message.stopReason !== "error") return;
if (
message.provider !== "my-provider" &&
ctx.model?.provider !== "my-provider"
)
return;
const errorMessage = message.errorMessage ?? "";
if (errorMessage.includes("context_length_exceeded")) return;
if (!MY_PROVIDER_OVERFLOW_PATTERN.test(errorMessage)) return;
return {
message: {
...message,
errorMessage: `context_length_exceeded: ${errorMessage}`,
},
};
});
}
```
`message_end` runs before pi tracks the assistant message for auto-compaction, so the rewritten `errorMessage` is what pi checks. With this in place, pi will:
1. Detect the overflow from `errorMessage`.
2. Drop the failed assistant message from live context.
3. Run compaction.
4. Retry the request once.
Guard the rewrite carefully:
- Scope it to your provider (`message.provider` and `ctx.model?.provider`) so unrelated errors from other providers are untouched.
- Match a provider-specific pattern, not pi's generic overflow patterns. Rewriting rate-limit or throttling errors (`rate limit`, `too many requests`) would falsely trigger compaction instead of pi's normal retry-with-backoff path.
- Skip when `errorMessage` already includes `context_length_exceeded` so the handler is idempotent.
### Registration
Register your stream function:

View File

@@ -162,7 +162,7 @@ export default function (pi: ExtensionAPI) {
pi.on("event_name", async (event, ctx) => {
// ctx.ui for user interaction
const ok = await ctx.ui.confirm("Title", "Are you sure?");
ctx.ui.notify("Done!", "success");
ctx.ui.notify("Done!", "info");
ctx.ui.setStatus("my-ext", "Processing..."); // Footer status
ctx.ui.setWidget("my-ext", ["Line 1", "Line 2"]); // Widget above editor (default)
});

View File

@@ -4,18 +4,28 @@ Pi is a minimal terminal coding harness. It is designed to stay small at the cor
## Quick start
On linux or mac you can install Pi with curl:
Install Pi with npm:
```bash
npm install -g --ignore-scripts @earendil-works/pi-coding-agent
```
`--ignore-scripts` disables dependency lifecycle scripts during install. Pi does not require install scripts for normal npm installs.
On Linux or macOS, you can also use the installer:
```bash
curl -fsSL https://pi.dev/install.sh | sh
```
Or alternatively with npm:
To uninstall pi itself, use npm for curl and npm installs:
```bash
npm install -g @earendil-works/pi-coding-agent
npm uninstall -g @earendil-works/pi-coding-agent
```
For pnpm, Yarn, or Bun installs, use the matching global remove command: `pnpm remove -g @earendil-works/pi-coding-agent`, `yarn global remove @earendil-works/pi-coding-agent`, or `bun uninstall -g @earendil-works/pi-coding-agent`.
Then run it in a project directory:
```bash

View File

@@ -36,7 +36,9 @@ pi update npm:@foo/bar # update one package
pi update --extension npm:@foo/bar
```
By default, `install` and `remove` write to global 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.
These commands manage pi packages, not 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.
To try a package without installing it, use `--extension` or `-e`. This installs to a temporary directory for the current run only:
@@ -57,7 +59,7 @@ npm:pkg
```
- Versioned specs are pinned and skipped by package updates (`pi update`, `pi update --extensions`).
- Global installs use `npm install -g`.
- 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`.
@@ -83,9 +85,9 @@ 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 pin the package and skip package updates (`pi update`, `pi update --extensions`).
- 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.
- Cloned to `~/.pi/agent/git/<host>/<path>` (global) or `.pi/git/<host>/<path>` (project).
- Runs `npm install` after clone or pull if `package.json` exists.
- Runs `npm install` after clone, pull, or pinned ref change if `package.json` exists.
**SSH examples:**
```bash

View File

@@ -7,9 +7,31 @@ This page gets you from install to a useful first pi session.
Pi is distributed as an npm package:
```bash
npm install -g @earendil-works/pi-coding-agent
npm install -g --ignore-scripts @earendil-works/pi-coding-agent
```
`--ignore-scripts` disables dependency lifecycle scripts during install. Pi does not require install scripts for normal npm installs.
### Uninstall
Use the package manager that installed pi. The curl installer uses npm globally, so curl and npm installs are removed with npm:
```bash
# curl installer or npm install -g
npm uninstall -g @earendil-works/pi-coding-agent
# pnpm
pnpm remove -g @earendil-works/pi-coding-agent
# Yarn
yarn global remove @earendil-works/pi-coding-agent
# Bun
bun uninstall -g @earendil-works/pi-coding-agent
```
Uninstalling pi leaves settings, credentials, sessions, and installed pi packages in `~/.pi/agent/`.
Then start pi in the project directory you want it to work on:
```bash

View File

@@ -54,7 +54,7 @@ The main factory function for a single `AgentSession`.
`createAgentSession()` uses a `ResourceLoader` to supply extensions, skills, prompt templates, themes, and context files. If you do not provide one, it uses `DefaultResourceLoader` with standard discovery.
```typescript
import { createAgentSession } from "@earendil-works/pi-coding-agent";
import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
// Minimal: defaults with DefaultResourceLoader
const { session } = await createAgentSession();
@@ -62,7 +62,7 @@ const { session } = await createAgentSession();
// Custom: override specific options
const { session } = await createAgentSession({
model: myModel,
tools: [readTool, bashTool],
tools: ["read", "bash"],
sessionManager: SessionManager.inMemory(),
});
```
@@ -466,64 +466,52 @@ const { session } = await createAgentSession({ resourceLoader: loader });
### Tools
```typescript
import {
codingTools, // read, bash, edit, write (default)
readOnlyTools, // read, grep, find, ls
readTool, bashTool, editTool, writeTool,
grepTool, findTool, lsTool,
} from "@earendil-works/pi-coding-agent";
Specify which built-in tools to enable:
// Use built-in tool set
- Built-in tool names: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`
- 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
The `edit` tool returns `details.diff` for Pi's TUI display and `details.patch` as a standard unified patch for SDK consumers.
```typescript
import { createAgentSession } from "@earendil-works/pi-coding-agent";
// Read-only mode
const { session } = await createAgentSession({
tools: readOnlyTools,
tools: ["read", "grep", "find", "ls"],
});
// Pick specific tools
const { session } = await createAgentSession({
tools: [readTool, bashTool, grepTool],
tools: ["read", "bash", "grep"],
});
```
#### Tools with Custom cwd
**Important:** The pre-built tool instances (`readTool`, `bashTool`, etc.) use `process.cwd()` for path resolution. When you specify a custom `cwd` AND provide explicit `tools`, you must use the tool factory functions to ensure paths resolve correctly:
When you pass a custom `cwd`, `createAgentSession()` builds selected built-in tools for that cwd.
```typescript
import {
createCodingTools, // Creates [read, bash, edit, write] for specific cwd
createReadOnlyTools, // Creates [read, grep, find, ls] for specific cwd
createReadTool,
createBashTool,
createEditTool,
createWriteTool,
createGrepTool,
createFindTool,
createLsTool,
} from "@earendil-works/pi-coding-agent";
import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
const cwd = "/path/to/project";
// Use factory for tool sets
// Use default tools for custom cwd
const { session } = await createAgentSession({
cwd,
tools: createCodingTools(cwd), // Tools resolve paths relative to cwd
sessionManager: SessionManager.inMemory(cwd),
});
// Or pick specific tools
// Or pick specific tools for custom cwd
const { session } = await createAgentSession({
cwd,
tools: [createReadTool(cwd), createBashTool(cwd), createGrepTool(cwd)],
tools: ["read", "bash", "grep"],
sessionManager: SessionManager.inMemory(cwd),
});
```
**When you don't need factories:**
- If you omit `tools`, pi automatically creates them with the correct `cwd`
- If you use `process.cwd()` as your `cwd`, the pre-built instances work fine
**When you must use factories:**
- When you specify both `cwd` (different from `process.cwd()`) AND `tools`
> See [examples/sdk/05-tools.ts](../examples/sdk/05-tools.ts)
### Custom Tools
@@ -556,6 +544,8 @@ Use `defineTool()` for standalone definitions and arrays like `customTools: [myT
Custom tools passed via `customTools` are combined with extension-registered tools. Extensions loaded by the ResourceLoader can also register tools via `pi.registerTool()`.
If you pass `tools`, include each custom or extension tool name you want enabled, for example `tools: ["read", "bash", "my_tool"]`.
> See [examples/sdk/05-tools.ts](../examples/sdk/05-tools.ts)
### Extensions
@@ -885,12 +875,10 @@ import { getModel } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import {
AuthStorage,
bashTool,
createAgentSession,
DefaultResourceLoader,
defineTool,
ModelRegistry,
readTool,
SessionManager,
SettingsManager,
} from "@earendil-works/pi-coding-agent";
@@ -944,7 +932,7 @@ const { session } = await createAgentSession({
authStorage,
modelRegistry,
tools: [readTool, bashTool],
tools: ["read", "bash", "status"],
customTools: [statusTool],
resourceLoader: loader,
@@ -1123,13 +1111,7 @@ defineTool
SessionManager
SettingsManager
// Built-in tools (use process.cwd())
codingTools
readOnlyTools
readTool, bashTool, editTool, writeTool
grepTool, findTool, lsTool
// Tool factories (for custom cwd)
// Tool factories
createCodingTools
createReadOnlyTools
createReadTool, createBashTool, createEditTool, createWriteTool

View File

@@ -153,9 +153,7 @@ When a provider requests a retry delay longer than `retry.provider.maxRetryDelay
}
```
`npmCommand` is used for all npm package-manager operations, including installs, uninstalls, and dependency installs inside git packages. Use argv-style entries exactly as the process should be launched. When `npmCommand` is configured, git package dependency installs use plain `install` to avoid npm-specific flags in wrappers or alternate package managers.
Normally the package manager's global modules location is queried using `root -g`. As a special case, if the first element of `npmCommand` is `"bun"`, the modules location will instead be queried with `pm bin -g`.
`npmCommand` is used for all npm package-manager operations, including installs, uninstalls, and dependency installs inside git packages. User-scoped npm packages install under `~/.pi/agent/npm/`; project-scoped npm packages install under `.pi/npm/`. Use argv-style entries exactly as the process should be launched. When `npmCommand` is configured, git package dependency installs use plain `install` to avoid npm-specific flags in wrappers or alternate package managers.
### Sessions

View File

@@ -4,7 +4,7 @@
Skills are self-contained capability packages that the agent loads on-demand. A skill provides specialized workflows, setup instructions, helper scripts, and reference documentation for specific tasks.
Pi implements the [Agent Skills standard](https://agentskills.io/specification), warning about violations but remaining lenient.
Pi implements the [Agent Skills standard](https://agentskills.io/specification), warning about most violations but remaining lenient. Pi allows skill names to differ from their parent directory even though the standard disallows it; that rule is suboptimal for shared skill directories used across multiple agent harnesses.
## Table of Contents
@@ -140,7 +140,7 @@ Per the [Agent Skills specification](https://agentskills.io/specification#frontm
| Field | Required | Description |
|-------|----------|-------------|
| `name` | Yes | Max 64 chars. Lowercase a-z, 0-9, hyphens. Must match parent directory. |
| `name` | Yes | Max 64 chars. Lowercase a-z, 0-9, hyphens. Unlike the standard, Pi does not require this to match the parent directory because that standard requirement is suboptimal for shared skill directories. |
| `description` | Yes | Max 1024 chars. What the skill does and when to use it. |
| `license` | No | License name or reference to bundled file. |
| `compatibility` | No | Max 500 chars. Environment requirements. |
@@ -154,7 +154,7 @@ Per the [Agent Skills specification](https://agentskills.io/specification#frontm
- Lowercase letters, numbers, hyphens only
- No leading/trailing hyphens
- No consecutive hyphens
- Must match parent directory name
Pi does not require the name to match the parent directory. The Agent Skills standard does, but that requirement is suboptimal for shared skill directories used by multiple tools.
Valid: `pdf-processing`, `data-analysis`, `code-review`
Invalid: `PDF-Processing`, `-pdf`, `pdf--processing`
@@ -177,7 +177,6 @@ description: Helps with PDFs.
Pi validates skills against the Agent Skills standard. Most issues produce warnings but still load the skill:
- Name doesn't match parent directory
- Name exceeds 64 characters or contains invalid characters
- Name starts/ends with hyphen or has consecutive hyphens
- Description exceeds 1024 characters

View File

@@ -17,7 +17,7 @@ pkg update && pkg upgrade
pkg install nodejs termux-api git
# Install pi
npm install -g @earendil-works/pi-coding-agent
npm install -g --ignore-scripts @earendil-works/pi-coding-agent
# Create config directory
mkdir -p ~/.pi/agent
@@ -36,7 +36,7 @@ Image clipboard is not supported on Termux (the `ctrl+v` image paste feature wil
Create `~/.pi/agent/AGENTS.md` to help the agent understand the Termux environment:
```markdown
````markdown
# Agent Environment: Termux on Android
## Location
@@ -91,7 +91,7 @@ termux-camera-photo out.jpg # Take photo
- Termux:API app must be installed for `termux-*` commands
- Use `pkg install termux-api` for the command-line tools
- Storage permission needed for `/storage/emulated/0` access
```
````
## Limitations

View File

@@ -137,6 +137,8 @@ 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).
See [Pi Packages](packages.md) for package sources and security notes.
### Modes

View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "pi-extension-custom-provider-anthropic",
"private": true,
"version": "0.74.0",
"version": "0.75.4",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
@@ -14,6 +14,6 @@
]
},
"dependencies": {
"@anthropic-ai/sdk": "^0.52.0"
"@anthropic-ai/sdk": "0.52.0"
}
}

View File

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

View File

@@ -12,7 +12,7 @@ import { type Api, type Context, type Model, registerApiProvider, streamSimple }
import { readFileSync } from "fs";
import { getAgentDir } from "packages/coding-agent/src/config.js";
import { join } from "path";
import { MODELS, streamGitLabDuo } from "./index.js";
import { MODELS, streamGitLabDuo } from "./index.ts";
const MODEL_MAP = new Map(MODELS.map((m) => [m.id, m]));

View File

@@ -7,8 +7,8 @@
import type { Component } from "@earendil-works/pi-tui";
import { isKeyRelease, type TUI } from "@earendil-works/pi-tui";
import type { DoomEngine } from "./doom-engine.js";
import { DoomKeys, mapKeyToDoom } from "./doom-keys.js";
import type { DoomEngine } from "./doom-engine.ts";
import { DoomKeys, mapKeyToDoom } from "./doom-keys.ts";
function renderHalfBlock(
rgba: Uint8Array,

View File

@@ -10,9 +10,9 @@
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { DoomOverlayComponent } from "./doom-component.js";
import { DoomEngine } from "./doom-engine.js";
import { ensureWadFile } from "./wad-finder.js";
import { DoomOverlayComponent } from "./doom-component.ts";
import { DoomEngine } from "./doom-engine.ts";
import { ensureWadFile } from "./wad-finder.ts";
// Persistent engine instance - survives between invocations
let activeEngine: DoomEngine | null = null;

View File

@@ -303,7 +303,11 @@ function sleep(ms: number): Promise<void> {
// Base overlay component with common rendering
abstract class BaseOverlay {
constructor(protected theme: Theme) {}
protected theme: Theme;
constructor(theme: Theme) {
this.theme = theme;
}
protected box(lines: string[], width: number, title?: string): string[] {
const th = this.theme;
@@ -330,12 +334,13 @@ abstract class BaseOverlay {
// Anchor position test
class AnchorTestComponent extends BaseOverlay {
constructor(
theme: Theme,
private anchor: OverlayAnchor,
private done: (result: "next" | "confirm" | "cancel") => void,
) {
private anchor: OverlayAnchor;
private done: (result: "next" | "confirm" | "cancel") => void;
constructor(theme: Theme, anchor: OverlayAnchor, done: (result: "next" | "confirm" | "cancel") => void) {
super(theme);
this.anchor = anchor;
this.done = done;
}
handleInput(data: string): void {
@@ -368,12 +373,17 @@ class AnchorTestComponent extends BaseOverlay {
// Margin/offset test
class MarginTestComponent extends BaseOverlay {
private config: { name: string; options: OverlayOptions };
private done: (result: "next" | "close") => void;
constructor(
theme: Theme,
private config: { name: string; options: OverlayOptions },
private done: (result: "next" | "close") => void,
config: { name: string; options: OverlayOptions },
done: (result: "next" | "close") => void,
) {
super(theme);
this.config = config;
this.done = done;
}
handleInput(data: string): void {
@@ -403,13 +413,15 @@ class MarginTestComponent extends BaseOverlay {
// Stacked overlay test
class StackOverlayComponent extends BaseOverlay {
constructor(
theme: Theme,
private num: number,
private position: string,
private done: (result: string) => void,
) {
private num: number;
private position: string;
private done: (result: string) => void;
constructor(theme: Theme, num: number, position: string, done: (result: string) => void) {
super(theme);
this.num = num;
this.position = position;
this.done = done;
}
handleInput(data: string): void {
@@ -446,19 +458,19 @@ class StackOverlayComponent extends BaseOverlay {
// Streaming overflow test - spawns real process with colored output (original crash scenario)
class StreamingOverflowComponent extends BaseOverlay {
private tui: TUI;
private lines: string[] = [];
private proc: ReturnType<typeof spawn> | null = null;
private scrollOffset = 0;
private maxVisibleLines = 15;
private finished = false;
private disposed = false;
private done: () => void;
constructor(
private tui: TUI,
theme: Theme,
private done: () => void,
) {
constructor(tui: TUI, theme: Theme, done: () => void) {
super(theme);
this.tui = tui;
this.done = done;
this.startProcess();
}
@@ -579,11 +591,11 @@ class StreamingOverflowComponent extends BaseOverlay {
// Edge position test
class EdgeTestComponent extends BaseOverlay {
constructor(
theme: Theme,
private done: () => void,
) {
private done: () => void;
constructor(theme: Theme, done: () => void) {
super(theme);
this.done = done;
}
handleInput(data: string): void {
@@ -614,12 +626,17 @@ class EdgeTestComponent extends BaseOverlay {
// Percentage positioning test
class PercentTestComponent extends BaseOverlay {
private config: { name: string; row: number; col: number };
private done: (result: "next" | "close") => void;
constructor(
theme: Theme,
private config: { name: string; row: number; col: number },
private done: (result: "next" | "close") => void,
config: { name: string; row: number; col: number },
done: (result: "next" | "close") => void,
) {
super(theme);
this.config = config;
this.done = done;
}
handleInput(data: string): void {
@@ -649,11 +666,11 @@ class PercentTestComponent extends BaseOverlay {
// MaxHeight test - renders 20 lines, truncated to 10 by maxHeight
class MaxHeightTestComponent extends BaseOverlay {
constructor(
theme: Theme,
private done: () => void,
) {
private done: () => void;
constructor(theme: Theme, done: () => void) {
super(theme);
this.done = done;
}
handleInput(data: string): void {
@@ -684,15 +701,15 @@ class MaxHeightTestComponent extends BaseOverlay {
// Responsive sidepanel - demonstrates percentage width and visibility callback
class SidepanelComponent extends BaseOverlay {
private tui: TUI;
private items = ["Dashboard", "Messages", "Settings", "Help", "About"];
private selectedIndex = 0;
private done: () => void;
constructor(
private tui: TUI,
theme: Theme,
private done: () => void,
) {
constructor(tui: TUI, theme: Theme, done: () => void) {
super(theme);
this.tui = tui;
this.done = done;
}
handleInput(data: string): void {
@@ -745,18 +762,18 @@ class SidepanelComponent extends BaseOverlay {
// Animation demo - proves overlays can handle real-time updates like pi-doom
class AnimationDemoComponent extends BaseOverlay {
private tui: TUI;
private frame = 0;
private interval: ReturnType<typeof setInterval> | null = null;
private fps = 0;
private lastFpsUpdate = Date.now();
private framesSinceLastFps = 0;
private done: () => void;
constructor(
private tui: TUI,
theme: Theme,
private done: () => void,
) {
constructor(tui: TUI, theme: Theme, done: () => void) {
super(theme);
this.tui = tui;
this.done = done;
this.startAnimation();
}
@@ -860,15 +877,15 @@ function hslToRgb(h: number, s: number, l: number): [number, number, number] {
// Toggle demo - demonstrates OverlayHandle.setHidden() via onHandle callback
class ToggleDemoComponent extends BaseOverlay {
private tui: TUI;
private toggleCount = 0;
private isToggling = false;
private done: () => void;
constructor(
private tui: TUI,
theme: Theme,
private done: () => void,
) {
constructor(tui: TUI, theme: Theme, done: () => void) {
super(theme);
this.tui = tui;
this.done = done;
}
handleInput(data: string): void {
@@ -923,19 +940,19 @@ class ToggleDemoComponent extends BaseOverlay {
class PassiveDemoController extends BaseOverlay {
focused = false;
private tui: TUI;
private typed = "";
private timerComponent: TimerPanel;
private timerHandle: OverlayHandle | null = null;
private interval: ReturnType<typeof setInterval> | null = null;
private inputCount = 0;
private lastInputDebug = "";
private done: () => void;
constructor(
private tui: TUI,
theme: Theme,
private done: () => void,
) {
constructor(tui: TUI, theme: Theme, done: () => void) {
super(theme);
this.tui = tui;
this.done = done;
this.timerComponent = new TimerPanel(theme);
this.timerHandle = this.tui.showOverlay(this.timerComponent, {
nonCapturing: true,
@@ -1015,16 +1032,16 @@ class TimerPanel extends BaseOverlay {
// === Focus cycling demo ===
class FocusDemoController extends BaseOverlay {
private tui: TUI;
private panels: FocusPanel[] = [];
private handles: OverlayHandle[] = [];
private focusIndex = -1;
private done: () => void;
constructor(
private tui: TUI,
theme: Theme,
private done: () => void,
) {
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"];
@@ -1107,16 +1124,22 @@ class FocusDemoController extends BaseOverlay {
class FocusPanel extends BaseOverlay {
handle: OverlayHandle | null = null;
readonly label: string;
private color: "error" | "success" | "accent";
private onTab: () => void;
private onClose: () => void;
constructor(
theme: Theme,
label: string,
private color: "error" | "success" | "accent",
private onTab: () => void,
private onClose: () => void,
color: "error" | "success" | "accent",
onTab: () => void,
onClose: () => void,
) {
super(theme);
this.label = label;
this.color = color;
this.onTab = onTab;
this.onClose = onClose;
}
handleInput(data: string): void {
@@ -1155,19 +1178,19 @@ class FocusPanel extends BaseOverlay {
// === Streaming input panel test (/overlay-streaming) ===
class StreamingInputController extends BaseOverlay {
private tui: TUI;
private panels: StreamingInputPanel[] = [];
private handles: OverlayHandle[] = [];
private focusIndex = -1; // -1 = controller focused, 0-2 = panel focused
private streamLines: string[] = [];
private streamInterval: ReturnType<typeof setInterval> | null = null;
private lineCount = 0;
private done: () => void;
constructor(
private tui: TUI,
theme: Theme,
private done: () => void,
) {
constructor(tui: TUI, theme: Theme, done: () => void) {
super(theme);
this.tui = tui;
this.done = done;
// Create 3 input panels as non-capturing overlays
const colors = ["error", "success", "accent"] as const;
@@ -1287,17 +1310,25 @@ class StreamingInputController extends BaseOverlay {
class StreamingInputPanel implements Component {
handle: OverlayHandle | null = null;
private theme: Theme;
private typed = "";
readonly label: string;
private color: "error" | "success" | "accent";
private onTab: () => void;
private onClose: () => void;
constructor(
private theme: Theme,
theme: Theme,
label: string,
private color: "error" | "success" | "accent",
private onTab: () => void,
private onClose: () => void,
color: "error" | "success" | "accent",
onTab: () => void,
onClose: () => void,
) {
this.theme = theme;
this.label = label;
this.color = color;
this.onTab = onTab;
this.onClose = onClose;
}
handleInput(data: string): void {

View File

@@ -42,10 +42,13 @@ class OverlayTestComponent implements Focusable {
{ label: "Cancel", hasInput: false, text: "", cursor: 0 },
];
constructor(
private theme: Theme,
private done: (result: { action: string; query?: string } | undefined) => void,
) {}
private theme: Theme;
private done: (result: { action: string; query?: string } | undefined) => void;
constructor(theme: Theme, done: (result: { action: string; query?: string } | undefined) => void) {
this.theme = theme;
this.done = done;
}
handleInput(data: string): void {
if (matchesKey(data, "escape")) {

View File

@@ -16,7 +16,7 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { AssistantMessage, TextContent } from "@earendil-works/pi-ai";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { Key } from "@earendil-works/pi-tui";
import { extractTodoItems, isSafeCommand, markCompletedSteps, type TodoItem } from "./utils.js";
import { extractTodoItems, isSafeCommand, markCompletedSteps, type TodoItem } from "./utils.ts";
// Tools
const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls", "questionnaire"];

View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "pi-extension-sandbox",
"private": true,
"version": "1.4.0",
"version": "1.5.4",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
@@ -14,6 +14,6 @@
]
},
"dependencies": {
"@anthropic-ai/sandbox-runtime": "^0.0.26"
"@anthropic-ai/sandbox-runtime": "0.0.26"
}
}

View File

@@ -113,6 +113,8 @@ Use a chain: first have scout find the read tool, then have planner suggest impr
- Shows all tasks with live status (⏳ running, ✓ done, ✗ failed)
- Updates as each task makes progress
- Shows "2/3 done, 1 running" status
- Returns each completed task's final output to the parent model, capped at 50 KB per task
- Returns failure diagnostics from stderr/error messages when a child exits before producing output
**Tool call formatting** (mimics built-in tools):
- `$ command` for bash
@@ -168,5 +170,6 @@ Project agents override user agents with the same name when `agentScope: "both"`
## Limitations
- Output truncated to last 10 items in collapsed view (expand to see all)
- Parallel model-visible output is capped at 50 KB per task; full results remain in tool details
- Agents discovered fresh on each invocation (allows editing mid-session)
- Parallel mode limited to 8 tasks, 4 concurrent

View File

@@ -22,11 +22,12 @@ import { StringEnum } from "@earendil-works/pi-ai";
import { type ExtensionAPI, 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.js";
import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.ts";
const MAX_PARALLEL_TASKS = 8;
const MAX_CONCURRENCY = 4;
const COLLAPSED_ITEM_COUNT = 10;
const PER_TASK_OUTPUT_CAP = 50 * 1024;
function formatTokens(count: number): string {
if (count < 1000) return count.toString();
@@ -172,6 +173,28 @@ function getFinalOutput(messages: Message[]): string {
return "";
}
function isFailedResult(result: SingleResult): boolean {
return result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
}
function getResultOutput(result: SingleResult): string {
if (isFailedResult(result)) {
return result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
}
return getFinalOutput(result.messages) || "(no output)";
}
function truncateParallelOutput(output: string): string {
const byteLength = Buffer.byteLength(output, "utf8");
if (byteLength <= PER_TASK_OUTPUT_CAP) return output;
let truncated = output.slice(0, PER_TASK_OUTPUT_CAP);
while (Buffer.byteLength(truncated, "utf8") > PER_TASK_OUTPUT_CAP) {
truncated = truncated.slice(0, -1);
}
return `${truncated}\n\n[Output truncated: ${byteLength - Buffer.byteLength(truncated, "utf8")} bytes omitted. Full output preserved in tool details.]`;
}
type DisplayItem = { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record<string, any> };
function getDisplayItems(messages: Message[]): DisplayItem[] {
@@ -534,11 +557,9 @@ export default function (pi: ExtensionAPI) {
);
results.push(result);
const isError =
result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
const isError = isFailedResult(result);
if (isError) {
const errorMsg =
result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
const errorMsg = getResultOutput(result);
return {
content: [{ type: "text", text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` }],
details: makeDetails("chain")(results),
@@ -617,17 +638,19 @@ export default function (pi: ExtensionAPI) {
return result;
});
const successCount = results.filter((r) => r.exitCode === 0).length;
const successCount = results.filter((r) => !isFailedResult(r)).length;
const summaries = results.map((r) => {
const output = getFinalOutput(r.messages);
const preview = output.slice(0, 100) + (output.length > 100 ? "..." : "");
return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${preview || "(no output)"}`;
const output = truncateParallelOutput(getResultOutput(r));
const status = isFailedResult(r)
? `failed${r.stopReason && r.stopReason !== "end" ? ` (${r.stopReason})` : ""}`
: "completed";
return `### [${r.agent}] ${status}\n\n${output}`;
});
return {
content: [
{
type: "text",
text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n")}`,
text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n---\n\n")}`,
},
],
details: makeDetails("parallel")(results),
@@ -646,10 +669,9 @@ export default function (pi: ExtensionAPI) {
onUpdate,
makeDetails("single"),
);
const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
const isError = isFailedResult(result);
if (isError) {
const errorMsg =
result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
const errorMsg = getResultOutput(result);
return {
content: [{ type: "text", text: `Agent ${result.stopReason || "failed"}: ${errorMsg}` }],
details: makeDetails("single")([result]),
@@ -740,7 +762,7 @@ export default function (pi: ExtensionAPI) {
if (details.mode === "single" && details.results.length === 1) {
const r = details.results[0];
const isError = r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted";
const isError = isFailedResult(r);
const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
const displayItems = getDisplayItems(r.messages);
const finalOutput = getFinalOutput(r.messages);
@@ -893,8 +915,8 @@ export default function (pi: ExtensionAPI) {
if (details.mode === "parallel") {
const running = details.results.filter((r) => r.exitCode === -1).length;
const successCount = details.results.filter((r) => r.exitCode === 0).length;
const failCount = details.results.filter((r) => r.exitCode > 0).length;
const successCount = details.results.filter((r) => r.exitCode !== -1 && !isFailedResult(r)).length;
const failCount = details.results.filter((r) => r.exitCode !== -1 && isFailedResult(r)).length;
const isRunning = running > 0;
const icon = isRunning
? theme.fg("warning", "⏳")
@@ -916,7 +938,7 @@ export default function (pi: ExtensionAPI) {
);
for (const r of details.results) {
const rIcon = r.exitCode === 0 ? theme.fg("success", "") : theme.fg("error", "");
const rIcon = isFailedResult(r) ? theme.fg("error", "") : theme.fg("success", "");
const displayItems = getDisplayItems(r.messages);
const finalOutput = getFinalOutput(r.messages);
@@ -963,9 +985,9 @@ export default function (pi: ExtensionAPI) {
const rIcon =
r.exitCode === -1
? theme.fg("warning", "⏳")
: r.exitCode === 0
? theme.fg("success", "")
: theme.fg("error", "");
: isFailedResult(r)
? theme.fg("error", "")
: theme.fg("success", "");
const displayItems = getDisplayItems(r.messages);
text += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", r.agent)} ${rIcon}`;
if (displayItems.length === 0)

View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "pi-extension-with-deps",
"private": true,
"version": "0.74.0",
"version": "0.75.4",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
@@ -14,9 +14,9 @@
]
},
"dependencies": {
"ms": "^2.1.3"
"ms": "2.1.3"
},
"devDependencies": {
"@types/ms": "^2.1.0"
"@types/ms": "2.1.0"
}
}

View File

@@ -9,14 +9,18 @@ import { createAgentSession } from "@earendil-works/pi-coding-agent";
const { session } = await createAgentSession();
session.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
try {
session.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
await session.prompt("What files are in the current directory?");
session.state.messages.forEach((msg) => {
console.log(msg);
});
console.log();
await session.prompt("What files are in the current directory?");
session.state.messages.forEach((msg) => {
console.log(msg);
});
console.log();
} finally {
session.dispose();
}

View File

@@ -38,12 +38,16 @@ if (available.length > 0) {
modelRegistry,
});
session.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
try {
session.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
await session.prompt("Say hello in one sentence.");
console.log();
await session.prompt("Say hello in one sentence.");
console.log();
} finally {
session.dispose();
}
}

View File

@@ -30,15 +30,19 @@ const { session: session1 } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
});
session1.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
try {
session1.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
console.log("=== Replace prompt ===");
await session1.prompt("What is 2 + 2?");
console.log("\n");
console.log("=== Replace prompt ===");
await session1.prompt("What is 2 + 2?");
console.log("\n");
} finally {
session1.dispose();
}
// Option 2: Append instructions to the default prompt
const loader2 = new DefaultResourceLoader({
@@ -56,12 +60,16 @@ const { session: session2 } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
});
session2.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
try {
session2.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
console.log("=== Modify prompt ===");
await session2.prompt("List 3 benefits of TypeScript.");
console.log();
console.log("=== Modify prompt ===");
await session2.prompt("List 3 benefits of TypeScript.");
console.log();
} finally {
session2.dispose();
}

View File

@@ -47,9 +47,9 @@ if (diagnostics.length > 0) {
console.log("Warnings:", diagnostics);
}
await createAgentSession({
const { session } = await createAgentSession({
resourceLoader: loader,
sessionManager: SessionManager.inMemory(),
});
console.log("Session created with filtered skills");
session.dispose();

View File

@@ -13,32 +13,36 @@
import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
// Read-only mode (no edit/write)
await createAgentSession({
const { session: readOnlySession } = await createAgentSession({
tools: ["read", "grep", "find", "ls"],
sessionManager: SessionManager.inMemory(),
});
console.log("Read-only session created");
readOnlySession.dispose();
// Custom tool selection
await createAgentSession({
const { session: customToolsSession } = await createAgentSession({
tools: ["read", "bash", "grep"],
sessionManager: SessionManager.inMemory(),
});
console.log("Custom tools session created");
customToolsSession.dispose();
// With custom cwd
const customCwd = "/path/to/project";
await createAgentSession({
const { session: customCwdSession } = await createAgentSession({
cwd: customCwd,
tools: ["read", "bash", "edit", "write"],
sessionManager: SessionManager.inMemory(customCwd),
});
console.log("Custom cwd session created");
customCwdSession.dispose();
// Or pick specific tools for custom cwd
await createAgentSession({
const { session: specificToolsSession } = await createAgentSession({
cwd: customCwd,
tools: ["read", "bash", "grep"],
sessionManager: SessionManager.inMemory(customCwd),
});
console.log("Specific tools with custom cwd session created");
specificToolsSession.dispose();

View File

@@ -42,14 +42,18 @@ const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
});
session.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
try {
session.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
await session.prompt("List files in the current directory.");
console.log();
await session.prompt("List files in the current directory.");
console.log();
} finally {
session.dispose();
}
// Example extension file (./my-logging-extension.ts):
/*

View File

@@ -39,9 +39,9 @@ for (const file of discovered) {
console.log(` - ${file.path} (${file.content.length} chars)`);
}
await createAgentSession({
const { session } = await createAgentSession({
resourceLoader: loader,
sessionManager: SessionManager.inMemory(),
});
console.log(`Session created with ${discovered.length + 1} context files`);
session.dispose();

View File

@@ -43,9 +43,9 @@ for (const template of discovered) {
console.log(` /${template.name}: ${template.description}`);
}
await createAgentSession({
const { session } = await createAgentSession({
resourceLoader: loader,
sessionManager: SessionManager.inMemory(),
});
console.log(`Session created with ${discovered.length + 1} prompt templates`);
session.dispose();

View File

@@ -11,38 +11,42 @@ import { AuthStorage, createAgentSession, ModelRegistry, SessionManager } from "
const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);
await createAgentSession({
const { session: defaultAuthSession } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage,
modelRegistry,
});
console.log("Session with default auth storage and model registry");
defaultAuthSession.dispose();
// Custom auth storage location
const customAuthStorage = AuthStorage.create("/tmp/my-app/auth.json");
const customModelRegistry = ModelRegistry.create(customAuthStorage, "/tmp/my-app/models.json");
await createAgentSession({
const { session: customAuthSession } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage: customAuthStorage,
modelRegistry: customModelRegistry,
});
console.log("Session with custom auth storage location");
customAuthSession.dispose();
// Runtime API key override (not persisted to disk)
authStorage.setRuntimeApiKey("anthropic", "sk-my-temp-key");
await createAgentSession({
const { session: runtimeKeySession } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage,
modelRegistry,
});
console.log("Session with runtime API key override");
runtimeKeySession.dispose();
// No models.json - only built-in models
const simpleRegistry = ModelRegistry.inMemory(authStorage);
await createAgentSession({
const { session: builtInModelsSession } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage,
modelRegistry: simpleRegistry,
});
console.log("Session with only built-in models");
builtInModelsSession.dispose();

View File

@@ -19,12 +19,12 @@ settingsManager.applyOverrides({
retry: { enabled: true, maxRetries: 5, baseDelayMs: 1000 },
});
await createAgentSession({
const { session: customSettingsSession } = await createAgentSession({
settingsManager,
sessionManager: SessionManager.inMemory(),
});
console.log("Session created with custom settings");
customSettingsSession.dispose();
// Setters update memory immediately and queue persistence writes.
// Call flush() when you need a durability boundary.
@@ -45,9 +45,9 @@ const inMemorySettings = SettingsManager.inMemory({
retry: { enabled: false },
});
await createAgentSession({
const { session: testSession } = await createAgentSession({
settingsManager: inMemorySettings,
sessionManager: SessionManager.inMemory(),
});
console.log("Test session created with in-memory settings");
testSession.dispose();

View File

@@ -11,12 +11,14 @@ const { session: inMemory } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
});
console.log("In-memory session:", inMemory.sessionFile ?? "(none)");
inMemory.dispose();
// New persistent session
const { session: newSession } = await createAgentSession({
sessionManager: SessionManager.create(process.cwd()),
});
console.log("New session file:", newSession.sessionFile);
newSession.dispose();
// Continue most recent session (or create new if none)
const { session: continued, modelFallbackMessage } = await createAgentSession({
@@ -24,6 +26,7 @@ const { session: continued, modelFallbackMessage } = await createAgentSession({
});
if (modelFallbackMessage) console.log("Note:", modelFallbackMessage);
console.log("Continued session:", continued.sessionFile);
continued.dispose();
// List and open specific session
const sessions = await SessionManager.list(process.cwd());
@@ -37,6 +40,7 @@ if (sessions.length > 0) {
sessionManager: SessionManager.open(sessions[0].path),
});
console.log(`\nOpened: ${opened.sessionId}`);
opened.dispose();
}
// Custom session directory (no cwd encoding)

View File

@@ -63,11 +63,15 @@ const { session } = await createAgentSession({
settingsManager,
});
session.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
try {
session.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
await session.prompt("List files in the current directory.");
console.log();
await session.prompt("List files in the current directory.");
console.log();
} finally {
session.dispose();
}

View File

@@ -12,7 +12,7 @@ The runtime example shows how to build a recreate function that closes over proc
| `02-custom-model.ts` | Select model and thinking level |
| `03-custom-prompt.ts` | Replace or modify system prompt |
| `04-skills.ts` | Discover, filter, or replace skills |
| `05-tools.ts` | Built-in tools, custom tools |
| `05-tools.ts` | Built-in tool allowlists |
| `06-extensions.ts` | Logging, blocking, result modification |
| `07-context-files.ts` | AGENTS.md context files |
| `08-slash-commands.ts` | File-based slash commands |
@@ -40,9 +40,6 @@ import {
ModelRegistry,
SessionManager,
SettingsManager,
codingTools,
readOnlyTools,
readTool, bashTool, editTool, writeTool,
} from "@earendil-works/pi-coding-agent";
// Auth and models setup
@@ -64,7 +61,7 @@ await loader.reload();
const { session } = await createAgentSession({ resourceLoader: loader, authStorage, modelRegistry });
// Read-only
const { session } = await createAgentSession({ tools: readOnlyTools, authStorage, modelRegistry });
const { session } = await createAgentSession({ tools: ["read", "grep", "find", "ls"], authStorage, modelRegistry });
// In-memory
const { session } = await createAgentSession({
@@ -92,8 +89,8 @@ const { session } = await createAgentSession({
authStorage: customAuth,
modelRegistry: customRegistry,
resourceLoader,
tools: [readTool, bashTool],
customTools: [{ tool: myTool }],
tools: ["read", "bash", "my_tool"],
customTools: [myTool],
sessionManager: SessionManager.inMemory(),
settingsManager: SettingsManager.inMemory(),
});
@@ -117,7 +114,7 @@ await session.prompt("Hello");
| `agentDir` | `~/.pi/agent` | Config directory |
| `model` | From settings/first available | Model to use |
| `thinkingLevel` | From settings/"off" | off, low, medium, high |
| `tools` | `codingTools` | Built-in tools |
| `tools` | `["read", "bash", "edit", "write"]` built-ins | Allowlist tool names across built-in, extension, and custom tools |
| `customTools` | `[]` | Additional tool definitions |
| `resourceLoader` | DefaultResourceLoader | Resource loader for extensions, skills, prompts, themes |
| `sessionManager` | `SessionManager.create(cwd)` | Persistence |

1789
packages/coding-agent/npm-shrinkwrap.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-coding-agent",
"version": "0.74.0",
"version": "0.75.4",
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
"type": "module",
"piConfig": {
@@ -25,40 +25,37 @@
"dist",
"docs",
"examples",
"CHANGELOG.md"
"CHANGELOG.md",
"npm-shrinkwrap.json"
],
"scripts": {
"clean": "shx rm -rf dist",
"dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput",
"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",
"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",
"prepublishOnly": "npm run clean && npm run build"
"shrinkwrap": "node ../../scripts/generate-coding-agent-shrinkwrap.mjs",
"prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap"
},
"dependencies": {
"@earendil-works/pi-agent-core": "^0.74.0",
"@earendil-works/pi-ai": "^0.74.0",
"@earendil-works/pi-tui": "^0.74.0",
"@silvia-odwyer/photon-node": "^0.3.4",
"chalk": "^5.5.0",
"cli-highlight": "^2.1.11",
"diff": "^8.0.2",
"extract-zip": "^2.0.1",
"file-type": "^21.1.1",
"glob": "^13.0.1",
"hosted-git-info": "^9.0.2",
"ignore": "^7.0.5",
"jiti": "^2.7.0",
"marked": "^15.0.12",
"minimatch": "^10.2.3",
"proper-lockfile": "^4.1.2",
"strip-ansi": "^7.1.0",
"typebox": "^1.1.24",
"undici": "^7.19.1",
"uuid": "^14.0.0",
"yaml": "^2.8.2"
"@earendil-works/pi-agent-core": "^0.75.4",
"@earendil-works/pi-ai": "^0.75.4",
"@earendil-works/pi-tui": "^0.75.4",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
"cross-spawn": "7.0.6",
"diff": "8.0.4",
"glob": "13.0.6",
"highlight.js": "10.7.3",
"hosted-git-info": "9.0.3",
"ignore": "7.0.5",
"jiti": "2.7.0",
"minimatch": "10.2.5",
"proper-lockfile": "4.1.2",
"typebox": "1.1.38",
"undici": "8.3.0",
"yaml": "2.9.0"
},
"overrides": {
"rimraf": "6.1.2",
@@ -67,17 +64,18 @@
}
},
"optionalDependencies": {
"@mariozechner/clipboard": "^0.3.5"
"@mariozechner/clipboard": "0.3.6"
},
"devDependencies": {
"@types/diff": "^7.0.2",
"@types/hosted-git-info": "^3.0.5",
"@types/ms": "^2.1.0",
"@types/node": "^24.3.0",
"@types/proper-lockfile": "^4.1.4",
"shx": "^0.4.0",
"typescript": "^5.7.3",
"vitest": "^3.2.4"
"@types/cross-spawn": "6.0.6",
"@types/diff": "7.0.2",
"@types/hosted-git-info": "3.0.5",
"@types/ms": "2.1.0",
"@types/node": "24.12.4",
"@types/proper-lockfile": "4.1.4",
"shx": "0.4.0",
"typescript": "5.9.3",
"vitest": "3.2.4"
},
"keywords": [
"coding-agent",
@@ -95,6 +93,6 @@
"directory": "packages/coding-agent"
},
"engines": {
"node": ">=20.6.0"
"node": ">=22.19.0"
}
}

View File

@@ -1,12 +1,12 @@
#!/usr/bin/env node
import { APP_NAME } from "../config.js";
import { APP_NAME } from "../config.ts";
process.title = APP_NAME;
process.emitWarning = (() => {}) as typeof process.emitWarning;
import { restoreSandboxEnv } from "./restore-sandbox-env.js";
import { restoreSandboxEnv } from "./restore-sandbox-env.ts";
restoreSandboxEnv();
await import("./register-bedrock.js");
await import("../cli.js");
await import("./register-bedrock.ts");
await import("../cli.ts");

View File

@@ -5,18 +5,16 @@
*
* Test with: npx tsx src/cli-new.ts [args...]
*/
import { EnvHttpProxyAgent, setGlobalDispatcher } from "undici";
import { APP_NAME } from "./config.js";
import { main } from "./main.js";
import { APP_NAME } from "./config.ts";
import { configureHttpDispatcher } from "./core/http-dispatcher.ts";
import { main } from "./main.ts";
process.title = APP_NAME;
process.env.PI_CODING_AGENT = "true";
process.emitWarning = (() => {}) as typeof process.emitWarning;
// bodyTimeout/headersTimeout default to 300s in undici; long local-LLM stalls
// (e.g. vLLM buffering a large tool call) exceed that and abort the SSE stream
// with UND_ERR_BODY_TIMEOUT. Disable both — provider SDKs enforce their own
// AbortController-based deadlines via retry.provider.timeoutMs.
setGlobalDispatcher(new EnvHttpProxyAgent({ bodyTimeout: 0, headersTimeout: 0 }));
// Configure undici's global dispatcher before provider SDKs issue requests.
// Runtime settings are applied once SettingsManager has loaded global/project settings.
configureHttpDispatcher();
main(process.argv.slice(2));

View File

@@ -4,8 +4,8 @@
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import chalk from "chalk";
import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR, ENV_SESSION_DIR } from "../config.js";
import type { ExtensionFlag } from "../core/extensions/types.js";
import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR, ENV_SESSION_DIR } from "../config.ts";
import type { ExtensionFlag } from "../core/extensions/types.ts";
export type Mode = "text" | "json" | "rpc";

View File

@@ -3,10 +3,10 @@
*/
import { ProcessTerminal, TUI } from "@earendil-works/pi-tui";
import type { ResolvedPaths } from "../core/package-manager.js";
import type { SettingsManager } from "../core/settings-manager.js";
import { ConfigSelectorComponent } from "../modes/interactive/components/config-selector.js";
import { initTheme, stopThemeWatcher } from "../modes/interactive/theme/theme.js";
import type { ResolvedPaths } from "../core/package-manager.ts";
import type { SettingsManager } from "../core/settings-manager.ts";
import { ConfigSelectorComponent } from "../modes/interactive/components/config-selector.ts";
import { initTheme, stopThemeWatcher } from "../modes/interactive/theme/theme.ts";
export interface ConfigSelectorOptions {
resolvedPaths: ResolvedPaths;
@@ -43,6 +43,7 @@ export async function selectConfig(options: ConfigSelectorOptions): Promise<void
process.exit(0);
},
() => ui.requestRender(),
ui.terminal.rows,
);
ui.addChild(selector);

View File

@@ -6,9 +6,9 @@ import { access, readFile, stat } from "node:fs/promises";
import type { ImageContent } from "@earendil-works/pi-ai";
import chalk from "chalk";
import { resolve } from "path";
import { resolveReadPath } from "../core/tools/path-utils.js";
import { formatDimensionNote, resizeImage } from "../utils/image-resize.js";
import { detectSupportedImageMimeTypeFromFile } from "../utils/mime.js";
import { resolveReadPath } from "../core/tools/path-utils.ts";
import { formatDimensionNote, resizeImage } from "../utils/image-resize.ts";
import { detectSupportedImageMimeTypeFromFile } from "../utils/mime.ts";
export interface ProcessedFiles {
text: string;

View File

@@ -1,5 +1,5 @@
import type { ImageContent } from "@earendil-works/pi-ai";
import type { Args } from "./args.js";
import type { Args } from "./args.ts";
export interface InitialMessageInput {
parsed: Args;

View File

@@ -5,8 +5,8 @@
import type { Api, Model } from "@earendil-works/pi-ai";
import { fuzzyFilter } from "@earendil-works/pi-tui";
import chalk from "chalk";
import { formatNoModelsAvailableMessage } from "../core/auth-guidance.js";
import type { ModelRegistry } from "../core/model-registry.js";
import { formatNoModelsAvailableMessage } from "../core/auth-guidance.ts";
import type { ModelRegistry } from "../core/model-registry.ts";
/**
* Format a number as human-readable (e.g., 200000 -> "200K", 1000000 -> "1M")

View File

@@ -3,9 +3,9 @@
*/
import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui";
import { KeybindingsManager } from "../core/keybindings.js";
import type { SessionInfo, SessionListProgress } from "../core/session-manager.js";
import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.js";
import { KeybindingsManager } from "../core/keybindings.ts";
import type { SessionInfo, SessionListProgress } from "../core/session-manager.ts";
import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.ts";
type SessionsLoader = (onProgress?: SessionListProgress) => Promise<SessionInfo[]>;

View File

@@ -1,9 +1,9 @@
import { spawnSync } from "child_process";
import { accessSync, constants, existsSync, readFileSync, realpathSync } from "fs";
import { homedir } from "os";
import { basename, dirname, join, resolve, sep, win32 } from "path";
import { fileURLToPath } from "url";
import { shouldUseWindowsShell } from "./utils/child-process.js";
import { spawnProcessSync } from "./utils/child-process.ts";
import { normalizePath } from "./utils/paths.ts";
// =============================================================================
// Package Detection
@@ -111,21 +111,21 @@ function getSelfUpdateCommandForMethod(
return undefined;
case "pnpm":
return makeSelfUpdateCommand(
makeSelfUpdateCommandStep("pnpm", ["install", "-g", updatePackageName]),
makeSelfUpdateCommandStep("pnpm", ["install", "-g", "--ignore-scripts", updatePackageName]),
updatePackageName === installedPackageName
? undefined
: makeSelfUpdateCommandStep("pnpm", ["remove", "-g", installedPackageName]),
);
case "yarn":
return makeSelfUpdateCommand(
makeSelfUpdateCommandStep("yarn", ["global", "add", updatePackageName]),
makeSelfUpdateCommandStep("yarn", ["global", "add", "--ignore-scripts", updatePackageName]),
updatePackageName === installedPackageName
? undefined
: makeSelfUpdateCommandStep("yarn", ["global", "remove", installedPackageName]),
);
case "bun":
return makeSelfUpdateCommand(
makeSelfUpdateCommandStep("bun", ["install", "-g", updatePackageName]),
makeSelfUpdateCommandStep("bun", ["install", "-g", "--ignore-scripts", updatePackageName]),
updatePackageName === installedPackageName
? undefined
: makeSelfUpdateCommandStep("bun", ["uninstall", "-g", installedPackageName]),
@@ -134,7 +134,13 @@ function getSelfUpdateCommandForMethod(
const [command = "npm", ...npmArgs] = npmCommand ?? [];
const inferred = npmCommand?.length ? undefined : getInferredNpmInstall();
const prefixArgs = [...npmArgs, ...(inferred ? ["--prefix", inferred.prefix] : [])];
const installStep = makeSelfUpdateCommandStep(command, [...prefixArgs, "install", "-g", updatePackageName]);
const installStep = makeSelfUpdateCommandStep(command, [
...prefixArgs,
"install",
"-g",
"--ignore-scripts",
updatePackageName,
]);
const uninstallStep =
updatePackageName === installedPackageName
? undefined
@@ -151,10 +157,9 @@ function readCommandOutput(
args: string[],
options: { requireSuccess?: boolean } = {},
): string | undefined {
const result = spawnSync(command, args, {
const result = spawnProcessSync(command, args, {
encoding: "utf-8",
stdio: ["ignore", "pipe", "pipe"],
shell: shouldUseWindowsShell(command),
});
if (result.status === 0) return result.stdout.trim() || undefined;
if (options.requireSuccess) {
@@ -207,16 +212,18 @@ function getGlobalPackageRoots(method: InstallMethod, _packageName: string, npmC
}
}
function normalizeExistingPathForComparison(path: string): string | undefined {
function normalizeExistingPathForComparison(path: string, resolveSymlinks: boolean): string | undefined {
const resolvedPath = resolve(path);
if (!existsSync(resolvedPath)) {
return undefined;
}
let normalizedPath: string;
try {
normalizedPath = realpathSync(resolvedPath);
} catch {
return undefined;
let normalizedPath = resolvedPath;
if (resolveSymlinks) {
try {
normalizedPath = realpathSync(resolvedPath);
} catch {
return undefined;
}
}
if (process.platform === "win32") {
normalizedPath = normalizedPath.toLowerCase();
@@ -224,6 +231,29 @@ function normalizeExistingPathForComparison(path: string): string | undefined {
return normalizedPath;
}
function getPathComparisonCandidates(path: string): string[] {
return Array.from(
new Set(
[normalizeExistingPathForComparison(path, false), normalizeExistingPathForComparison(path, true)].filter(
(candidate): candidate is string => !!candidate,
),
),
);
}
function getEntrypointPackageDir(): string | undefined {
const entrypoint = process.argv[1];
if (!entrypoint) return undefined;
let dir = dirname(entrypoint);
while (dir !== dirname(dir)) {
if (existsSync(join(dir, "package.json"))) {
return dir;
}
dir = dirname(dir);
}
return undefined;
}
function isSelfUpdatePathWritable(): boolean {
const packageDir = getPackageDir();
try {
@@ -236,17 +266,14 @@ function isSelfUpdatePathWritable(): boolean {
}
function isManagedByGlobalPackageManager(method: InstallMethod, packageName: string, npmCommand?: string[]): boolean {
const packageDir = normalizeExistingPathForComparison(getPackageDir());
return (
!!packageDir &&
getGlobalPackageRoots(method, packageName, npmCommand).some((root) => {
const normalizedRoot = normalizeExistingPathForComparison(root);
return (
!!normalizedRoot &&
packageDir.startsWith(normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`)
);
})
);
const packageDirs = [getPackageDir(), getEntrypointPackageDir()].filter((dir): dir is string => !!dir);
const packageDirCandidates = packageDirs.flatMap((dir) => getPathComparisonCandidates(dir));
return getGlobalPackageRoots(method, packageName, npmCommand).some((root) => {
return getPathComparisonCandidates(root).some((normalizedRoot) => {
const rootPrefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`;
return packageDirCandidates.some((packageDir) => packageDir.startsWith(rootPrefix));
});
});
}
export function getSelfUpdateCommand(
@@ -304,9 +331,7 @@ export function getPackageDir(): string {
// Allow override via environment variable (useful for Nix/Guix where store paths tokenize poorly)
const envDir = process.env.PI_PACKAGE_DIR;
if (envDir) {
if (envDir === "~") return homedir();
if (envDir.startsWith("~/")) return homedir() + envDir.slice(1);
return envDir;
return normalizePath(envDir);
}
if (isBunBinary) {
@@ -428,9 +453,7 @@ export const ENV_AGENT_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_DIR`;
export const ENV_SESSION_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_SESSION_DIR`;
export function expandTildePath(path: string): string {
if (path === "~") return homedir();
if (path.startsWith("~/")) return homedir() + path.slice(1);
return path;
return normalizePath(path);
}
const DEFAULT_SHARE_VIEWER_URL = "https://pi.dev/session/";

View File

@@ -1,12 +1,13 @@
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import type { AgentSession } from "./agent-session.js";
import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agent-session-services.js";
import type { ReplacedSessionContext, SessionShutdownEvent, SessionStartEvent } from "./extensions/index.js";
import { emitSessionShutdownEvent } from "./extensions/runner.js";
import type { CreateAgentSessionResult } from "./sdk.js";
import { assertSessionCwdExists } from "./session-cwd.js";
import { SessionManager } from "./session-manager.js";
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 { emitSessionShutdownEvent } from "./extensions/runner.ts";
import type { CreateAgentSessionResult } from "./sdk.ts";
import { assertSessionCwdExists } from "./session-cwd.ts";
import { SessionManager } from "./session-manager.ts";
/**
* Result returned by runtime creation.
@@ -67,14 +68,25 @@ function extractUserMessageText(content: string | Array<{ type: string; text?: s
export class AgentSessionRuntime {
private rebindSession?: (session: AgentSession) => Promise<void>;
private beforeSessionInvalidate?: () => void;
private _session: AgentSession;
private _services: AgentSessionServices;
private readonly createRuntime: CreateAgentSessionRuntimeFactory;
private _diagnostics: AgentSessionRuntimeDiagnostic[];
private _modelFallbackMessage?: string;
constructor(
private _session: AgentSession,
private _services: AgentSessionServices,
private readonly createRuntime: CreateAgentSessionRuntimeFactory,
private _diagnostics: AgentSessionRuntimeDiagnostic[] = [],
private _modelFallbackMessage?: string,
) {}
_session: AgentSession,
_services: AgentSessionServices,
createRuntime: CreateAgentSessionRuntimeFactory,
_diagnostics: AgentSessionRuntimeDiagnostic[] = [],
_modelFallbackMessage?: string,
) {
this._session = _session;
this._services = _services;
this.createRuntime = createRuntime;
this._diagnostics = _diagnostics;
this._modelFallbackMessage = _modelFallbackMessage;
}
get services(): AgentSessionServices {
return this._services;
@@ -281,12 +293,11 @@ export class AgentSessionRuntime {
return { cancelled: false, selectedText };
}
const sourceManager = SessionManager.open(currentSessionFile, sessionDir);
const forkedSessionPath = sourceManager.createBranchedSession(targetLeafId);
const sessionManager = SessionManager.open(currentSessionFile, sessionDir);
const forkedSessionPath = sessionManager.createBranchedSession(targetLeafId);
if (!forkedSessionPath) {
throw new Error("Failed to create forked session");
}
const sessionManager = SessionManager.open(forkedSessionPath, sessionDir);
await this.teardownCurrent("fork", sessionManager.getSessionFile());
this.apply(
await this.createRuntime({
@@ -327,7 +338,7 @@ export class AgentSessionRuntime {
* @throws {MissingSessionCwdError} When the imported session cwd cannot be resolved and no override is provided.
*/
async importFromJsonl(inputPath: string, cwdOverride?: string): Promise<{ cancelled: boolean }> {
const resolvedPath = resolve(inputPath);
const resolvedPath = resolvePath(inputPath);
if (!existsSync(resolvedPath)) {
throw new SessionImportFileNotFoundError(resolvedPath);
}
@@ -406,4 +417,4 @@ export {
type CreateAgentSessionServicesOptions,
createAgentSessionFromServices,
createAgentSessionServices,
} from "./agent-session-services.js";
} from "./agent-session-services.ts";

View File

@@ -1,14 +1,15 @@
import { join } from "node:path";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { Model } from "@earendil-works/pi-ai";
import { getAgentDir } from "../config.js";
import { AuthStorage } from "./auth-storage.js";
import type { SessionStartEvent, ToolDefinition } from "./extensions/index.js";
import { ModelRegistry } from "./model-registry.js";
import { DefaultResourceLoader, type DefaultResourceLoaderOptions, type ResourceLoader } from "./resource-loader.js";
import { type CreateAgentSessionOptions, type CreateAgentSessionResult, createAgentSession } from "./sdk.js";
import type { SessionManager } from "./session-manager.js";
import { SettingsManager } from "./settings-manager.js";
import { getAgentDir } from "../config.ts";
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 { type CreateAgentSessionOptions, type CreateAgentSessionResult, createAgentSession } from "./sdk.ts";
import type { SessionManager } from "./session-manager.ts";
import { SettingsManager } from "./settings-manager.ts";
/**
* Non-fatal issues collected while creating services or sessions.
@@ -129,8 +130,8 @@ function applyExtensionFlagValues(
export async function createAgentSessionServices(
options: CreateAgentSessionServicesOptions,
): Promise<AgentSessionServices> {
const cwd = options.cwd;
const agentDir = options.agentDir ?? getAgentDir();
const cwd = resolvePath(options.cwd);
const agentDir = options.agentDir ? resolvePath(options.agentDir) : getAgentDir();
const authStorage = options.authStorage ?? AuthStorage.create(join(agentDir, "auth.json"));
const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
const modelRegistry = options.modelRegistry ?? ModelRegistry.create(authStorage, join(agentDir, "models.json"));

View File

@@ -14,7 +14,7 @@
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { basename, dirname, resolve } from "node:path";
import { basename, dirname } from "node:path";
import type {
Agent,
AgentEvent,
@@ -31,12 +31,14 @@ import {
isContextOverflow,
modelsAreEqual,
resetApiProviders,
streamSimple,
} from "@earendil-works/pi-ai";
import { theme } from "../modes/interactive/theme/theme.js";
import { stripFrontmatter } from "../utils/frontmatter.js";
import { sleep } from "../utils/sleep.js";
import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.js";
import { type BashResult, executeBashWithOperations } from "./bash-executor.js";
import { 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";
import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.ts";
import { type BashResult, executeBashWithOperations } from "./bash-executor.ts";
import {
type CompactionResult,
calculateContextTokens,
@@ -46,10 +48,10 @@ import {
generateBranchSummary,
prepareCompaction,
shouldCompact,
} from "./compaction/index.js";
import { DEFAULT_THINKING_LEVEL } from "./defaults.js";
import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.js";
import { createToolHtmlRenderer } from "./export-html/tool-renderer.js";
} from "./compaction/index.ts";
import { DEFAULT_THINKING_LEVEL } from "./defaults.ts";
import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.ts";
import { createToolHtmlRenderer } from "./export-html/tool-renderer.ts";
import {
type ContextUsage,
type ExtensionCommandContextActions,
@@ -74,21 +76,21 @@ import {
type TurnEndEvent,
type TurnStartEvent,
wrapRegisteredTools,
} from "./extensions/index.js";
import { emitSessionShutdownEvent } from "./extensions/runner.js";
import type { BashExecutionMessage, CustomMessage } from "./messages.js";
import type { ModelRegistry } from "./model-registry.js";
import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.js";
import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.js";
import type { BranchSummaryEntry, CompactionEntry, SessionManager } from "./session-manager.js";
import { CURRENT_SESSION_VERSION, getLatestCompactionEntry, type SessionHeader } from "./session-manager.js";
import type { SettingsManager } from "./settings-manager.js";
import type { SlashCommandInfo } from "./slash-commands.js";
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.js";
import { type BuildSystemPromptOptions, buildSystemPrompt } from "./system-prompt.js";
import { type BashOperations, createLocalBashOperations } from "./tools/bash.js";
import { createAllToolDefinitions } from "./tools/index.js";
import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.js";
} from "./extensions/index.ts";
import { emitSessionShutdownEvent } from "./extensions/runner.ts";
import type { BashExecutionMessage, CustomMessage } from "./messages.ts";
import type { ModelRegistry } from "./model-registry.ts";
import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.ts";
import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.ts";
import type { BranchSummaryEntry, CompactionEntry, SessionManager } from "./session-manager.ts";
import { CURRENT_SESSION_VERSION, getLatestCompactionEntry, type SessionHeader } from "./session-manager.ts";
import type { SettingsManager } from "./settings-manager.ts";
import type { SlashCommandInfo } from "./slash-commands.ts";
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts";
import { type BuildSystemPromptOptions, buildSystemPrompt } from "./system-prompt.ts";
import { type BashOperations, createLocalBashOperations } from "./tools/bash.ts";
import { createAllToolDefinitions } from "./tools/index.ts";
import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.ts";
// ============================================================================
// Skill Block Parsing
@@ -119,7 +121,12 @@ export function parseSkillBlock(text: string): ParsedSkillBlock | null {
/** Session-specific events that extend the core AgentEvent */
export type AgentSessionEvent =
| AgentEvent
| Exclude<AgentEvent, { type: "agent_end" }>
| {
type: "agent_end";
messages: AgentMessage[];
willRetry: boolean;
}
| {
type: "queue_update";
steering: readonly string[];
@@ -179,6 +186,7 @@ export interface AgentSessionConfig {
export interface ExtensionBindings {
uiContext?: ExtensionUIContext;
commandContextActions?: ExtensionCommandContextActions;
abortHandler?: () => void;
shutdownHandler?: ShutdownHandler;
onError?: ExtensionErrorListener;
}
@@ -251,7 +259,6 @@ export class AgentSession {
// Event subscription state
private _unsubscribeAgent?: () => void;
private _eventListeners: AgentSessionEventListener[] = [];
private _agentEventQueue: Promise<void> = Promise.resolve();
/** Tracks pending steering messages for UI display. Removed when delivered. */
private _steeringMessages: string[] = [];
@@ -271,8 +278,6 @@ export class AgentSession {
// Retry state
private _retryAbortController: AbortController | undefined = undefined;
private _retryAttempt = 0;
private _retryPromise: Promise<void> | undefined = undefined;
private _retryResolve: (() => void) | undefined = undefined;
// Bash execution state
private _bashAbortController: AbortController | undefined = undefined;
@@ -293,6 +298,7 @@ export class AgentSession {
private _sessionStartEvent: SessionStartEvent;
private _extensionUIContext?: ExtensionUIContext;
private _extensionCommandContextActions?: ExtensionCommandContextActions;
private _extensionAbortHandler?: () => void;
private _extensionShutdownHandler?: ShutdownHandler;
private _extensionErrorListener?: ExtensionErrorListener;
private _extensionErrorUnsubscriber?: () => void;
@@ -367,6 +373,18 @@ export class AgentSession {
throw new Error(formatNoApiKeyFoundMessage(model.provider));
}
private async _getCompactionRequestAuth(model: Model<any>): Promise<{
apiKey?: string;
headers?: 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 } : {};
}
/**
* Install tool hooks once on the Agent instance.
*
@@ -382,8 +400,6 @@ export class AgentSession {
return undefined;
}
await this._agentEventQueue;
try {
return await runner.emitToolCall({
type: "tool_call",
@@ -450,54 +466,7 @@ export class AgentSession {
private _lastAssistantMessage: AssistantMessage | undefined = undefined;
/** Internal handler for agent events - shared by subscribe and reconnect */
private _handleAgentEvent = (event: AgentEvent): void => {
// Create retry promise synchronously before queueing async processing.
// Agent.emit() calls this handler synchronously, and prompt() calls waitForRetry()
// as soon as agent.prompt() resolves. If _retryPromise is created only inside
// _processAgentEvent, slow earlier queued events can delay agent_end processing
// and waitForRetry() can miss the in-flight retry.
this._createRetryPromiseForAgentEnd(event);
this._agentEventQueue = this._agentEventQueue.then(
() => this._processAgentEvent(event),
() => this._processAgentEvent(event),
);
// Keep queue alive if an event handler fails
this._agentEventQueue.catch(() => {});
};
private _createRetryPromiseForAgentEnd(event: AgentEvent): void {
if (event.type !== "agent_end" || this._retryPromise) {
return;
}
const settings = this.settingsManager.getRetrySettings();
if (!settings.enabled) {
return;
}
const lastAssistant = this._findLastAssistantInMessages(event.messages);
if (!lastAssistant || !this._isRetryableError(lastAssistant)) {
return;
}
this._retryPromise = new Promise((resolve) => {
this._retryResolve = resolve;
});
}
private _findLastAssistantInMessages(messages: AgentMessage[]): AssistantMessage | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message.role === "assistant") {
return message as AssistantMessage;
}
}
return undefined;
}
private async _processAgentEvent(event: AgentEvent): Promise<void> {
private _handleAgentEvent = async (event: AgentEvent): Promise<void> => {
// When a user message starts, check if it's from either queue and remove it BEFORE emitting
// This ensures the UI sees the updated queue state
if (event.type === "message_start" && event.message.role === "user") {
@@ -524,7 +493,7 @@ export class AgentSession {
await this._emitExtensionEvent(event);
// Notify all listeners
this._emit(event);
this._emit(event.type === "agent_end" ? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event);
// Handle session persistence
if (event.type === "message_end") {
@@ -568,30 +537,21 @@ export class AgentSession {
}
}
}
};
// Check auto-retry and auto-compaction after agent completes
if (event.type === "agent_end" && this._lastAssistantMessage) {
const msg = this._lastAssistantMessage;
this._lastAssistantMessage = undefined;
private _willRetryAfterAgentEnd(event: Extract<AgentEvent, { type: "agent_end" }>): boolean {
const settings = this.settingsManager.getRetrySettings();
if (!settings.enabled || this._retryAttempt >= settings.maxRetries) {
return false;
}
// Check for retryable errors first (overloaded, rate limit, server errors)
if (this._isRetryableError(msg)) {
const didRetry = await this._handleRetryableError(msg);
if (didRetry) return; // Retry was initiated, don't proceed to compaction
for (let i = event.messages.length - 1; i >= 0; i--) {
const message = event.messages[i];
if (message.role === "assistant") {
return this._isRetryableError(message as AssistantMessage);
}
this._resolveRetry();
await this._checkCompaction(msg);
}
}
/** Resolve the pending retry promise */
private _resolveRetry(): void {
if (this._retryResolve) {
this._retryResolve();
this._retryResolve = undefined;
this._retryPromise = undefined;
}
return false;
}
/** Extract text content from a message */
@@ -617,7 +577,7 @@ export class AgentSession {
private _replaceMessageInPlace(target: AgentMessage, replacement: AgentMessage): void {
// Agent-core stores the finalized message object in its state before emitting message_end.
// SessionManager persistence happens later in _processAgentEvent() with event.message.
// SessionManager persistence happens later in _handleAgentEvent() with event.message.
// Mutating this object in place keeps agent state, later turn/agent events, listeners,
// and the eventual SessionManager.appendMessage(event.message) persistence in sync.
if (target === replacement) {
@@ -963,6 +923,41 @@ export class AgentSession {
// Prompting
// =========================================================================
private async _runAgentPrompt(messages: AgentMessage | AgentMessage[]): Promise<void> {
try {
await this.agent.prompt(messages);
while (await this._handlePostAgentRun()) {
await this.agent.continue();
}
} finally {
this._flushPendingBashMessages();
}
}
private async _handlePostAgentRun(): Promise<boolean> {
const msg = this._lastAssistantMessage;
this._lastAssistantMessage = undefined;
if (!msg) {
return false;
}
if (this._isRetryableError(msg) && (await this._prepareRetry(msg))) {
return true;
}
if (msg.stopReason === "error" && this._retryAttempt > 0) {
this._emit({
type: "auto_retry_end",
success: false,
attempt: this._retryAttempt,
finalError: msg.errorMessage,
});
this._retryAttempt = 0;
}
return await this._checkCompaction(msg);
}
/**
* Send a prompt to the agent.
* - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming
@@ -1053,8 +1048,15 @@ export class AgentSession {
// Check if we need to compact before sending (catches aborted responses)
const lastAssistant = this._findLastAssistantMessage();
if (lastAssistant) {
await this._checkCompaction(lastAssistant, false);
if (lastAssistant && (await this._checkCompaction(lastAssistant, false))) {
try {
await this.agent.continue();
while (await this._handlePostAgentRun()) {
await this.agent.continue();
}
} finally {
this._flushPendingBashMessages();
}
}
// Build messages array (custom message if any, then user message)
@@ -1114,8 +1116,7 @@ export class AgentSession {
}
preflightResult?.(true);
await this.agent.prompt(messages);
await this.waitForRetry();
await this._runAgentPrompt(messages);
}
/**
@@ -1301,7 +1302,7 @@ export class AgentSession {
this.agent.steer(appMessage);
}
} else if (options?.triggerTurn) {
await this.agent.prompt(appMessage);
await this._runAgentPrompt(appMessage);
} else {
this.agent.state.messages.push(appMessage);
this.sessionManager.appendCustomMessageEntry(
@@ -1626,7 +1627,7 @@ export class AgentSession {
throw new Error(formatNoModelSelectedMessage());
}
const { apiKey, headers } = await this._getRequiredRequestAuth(this.model);
const { apiKey, headers } = await this._getCompactionRequestAuth(this.model);
const pathEntries = this.sessionManager.getBranch();
const settings = this.settingsManager.getCompactionSettings();
@@ -1684,6 +1685,7 @@ export class AgentSession {
customInstructions,
this._compactionAbortController.signal,
this.thinkingLevel,
this.agent.streamFn,
);
summary = result.summary;
firstKeptEntryId = result.firstKeptEntryId;
@@ -1771,12 +1773,12 @@ export class AgentSession {
* @param assistantMessage The assistant message to check
* @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
*/
private async _checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise<void> {
private async _checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise<boolean> {
const settings = this.settingsManager.getCompactionSettings();
if (!settings.enabled) return;
if (!settings.enabled) return false;
// Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false
if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return;
if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return false;
const contextWindow = this.model?.contextWindow ?? 0;
@@ -1794,7 +1796,7 @@ export class AgentSession {
const assistantIsFromBeforeCompaction =
compactionEntry !== null && assistantMessage.timestamp <= new Date(compactionEntry.timestamp).getTime();
if (assistantIsFromBeforeCompaction) {
return;
return false;
}
// Case 1: Overflow - LLM returned context overflow error
@@ -1809,7 +1811,7 @@ export class AgentSession {
errorMessage:
"Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model.",
});
return;
return false;
}
this._overflowRecoveryAttempted = true;
@@ -1819,8 +1821,7 @@ export class AgentSession {
if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
this.agent.state.messages = messages.slice(0, -1);
}
await this._runAutoCompaction("overflow", true);
return;
return await this._runAutoCompaction("overflow", true);
}
// Case 2: Threshold - context is getting large
@@ -1830,7 +1831,7 @@ export class AgentSession {
if (assistantMessage.stopReason === "error") {
const messages = this.agent.state.messages;
const estimate = estimateContextTokens(messages);
if (estimate.lastUsageIndex === null) return; // No usage data at all
if (estimate.lastUsageIndex === null) return false; // No usage data at all
// Verify the usage source is post-compaction. Kept pre-compaction messages
// have stale usage reflecting the old (larger) context and would falsely
// trigger compaction right after one just finished.
@@ -1840,21 +1841,22 @@ export class AgentSession {
usageMsg.role === "assistant" &&
(usageMsg as AssistantMessage).timestamp <= new Date(compactionEntry.timestamp).getTime()
) {
return;
return false;
}
contextTokens = estimate.tokens;
} else {
contextTokens = calculateContextTokens(assistantMessage.usage);
}
if (shouldCompact(contextTokens, contextWindow, settings)) {
await this._runAutoCompaction("threshold", false);
return await this._runAutoCompaction("threshold", false);
}
return false;
}
/**
* Internal: Run auto-compaction with events.
*/
private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise<void> {
private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise<boolean> {
const settings = this.settingsManager.getCompactionSettings();
this._emit({ type: "compaction_start", reason });
@@ -1869,21 +1871,28 @@ export class AgentSession {
aborted: false,
willRetry: false,
});
return;
return false;
}
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;
let apiKey: string | undefined;
let headers: 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;
} else {
({ apiKey, headers } = await this._getCompactionRequestAuth(this.model));
}
const { apiKey, headers } = authResult;
const pathEntries = this.sessionManager.getBranch();
@@ -1896,7 +1905,7 @@ export class AgentSession {
aborted: false,
willRetry: false,
});
return;
return false;
}
let extensionCompaction: CompactionResult | undefined;
@@ -1919,7 +1928,7 @@ export class AgentSession {
aborted: true,
willRetry: false,
});
return;
return false;
}
if (extensionResult?.compaction) {
@@ -1949,6 +1958,7 @@ export class AgentSession {
undefined,
this._autoCompactionAbortController.signal,
this.thinkingLevel,
this.agent.streamFn,
);
summary = compactResult.summary;
firstKeptEntryId = compactResult.firstKeptEntryId;
@@ -1964,7 +1974,7 @@ export class AgentSession {
aborted: true,
willRetry: false,
});
return;
return false;
}
this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension);
@@ -1999,17 +2009,12 @@ export class AgentSession {
if (lastMsg?.role === "assistant" && (lastMsg as AssistantMessage).stopReason === "error") {
this.agent.state.messages = messages.slice(0, -1);
}
setTimeout(() => {
this.agent.continue().catch(() => {});
}, 100);
} else if (this.agent.hasQueuedMessages()) {
// Auto-compaction can complete while follow-up/steering/custom messages are waiting.
// Kick the loop so queued messages are actually delivered.
setTimeout(() => {
this.agent.continue().catch(() => {});
}, 100);
return true;
}
// Auto-compaction can complete while follow-up/steering/custom messages are waiting.
// Continue once so queued messages are delivered.
return this.agent.hasQueuedMessages();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "compaction failed";
this._emit({
@@ -2023,6 +2028,7 @@ export class AgentSession {
? `Context overflow recovery failed: ${errorMessage}`
: `Auto-compaction failed: ${errorMessage}`,
});
return false;
} finally {
this._autoCompactionAbortController = undefined;
}
@@ -2047,6 +2053,9 @@ export class AgentSession {
if (bindings.commandContextActions !== undefined) {
this._extensionCommandContextActions = bindings.commandContextActions;
}
if (bindings.abortHandler !== undefined) {
this._extensionAbortHandler = bindings.abortHandler;
}
if (bindings.shutdownHandler !== undefined) {
this._extensionShutdownHandler = bindings.shutdownHandler;
}
@@ -2211,7 +2220,13 @@ export class AgentSession {
getModel: () => this.model,
isIdle: () => !this.isStreaming,
getSignal: () => this.agent.signal,
abort: () => this.abort(),
abort: () => {
if (this._extensionAbortHandler) {
this._extensionAbortHandler();
return;
}
void this.abort();
},
hasPendingMessages: () => this.pendingMessageCount > 0,
shutdown: () => {
this._extensionShutdownHandler?.();
@@ -2427,43 +2442,27 @@ export class AgentSession {
if (isContextOverflow(message, contextWindow)) return false;
const err = message.errorMessage;
// 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, request ended without sending chunks, 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|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i.test(
// 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,
);
}
/**
* Handle retryable errors with exponential backoff.
* @returns true if retry was initiated, false if max retries exceeded or disabled
* Prepare a retryable error for continuation with exponential backoff.
* @returns true if the caller should continue the agent, false otherwise
*/
private async _handleRetryableError(message: AssistantMessage): Promise<boolean> {
private async _prepareRetry(message: AssistantMessage): Promise<boolean> {
const settings = this.settingsManager.getRetrySettings();
if (!settings.enabled) {
this._resolveRetry();
return false;
}
// Retry promise is created synchronously in _handleAgentEvent for agent_end.
// Keep a defensive fallback here in case a future refactor bypasses that path.
if (!this._retryPromise) {
this._retryPromise = new Promise((resolve) => {
this._retryResolve = resolve;
});
}
this._retryAttempt++;
if (this._retryAttempt > settings.maxRetries) {
// Max retries exceeded, emit final failure and reset
this._emit({
type: "auto_retry_end",
success: false,
attempt: this._retryAttempt - 1,
finalError: message.errorMessage,
});
this._retryAttempt = 0;
this._resolveRetry(); // Resolve so waitForRetry() completes
// Preserve the completed attempt count so post-run handling can emit the final failure.
this._retryAttempt--;
return false;
}
@@ -2491,24 +2490,16 @@ export class AgentSession {
// Aborted during sleep - emit end event so UI can clean up
const attempt = this._retryAttempt;
this._retryAttempt = 0;
this._retryAbortController = undefined;
this._emit({
type: "auto_retry_end",
success: false,
attempt,
finalError: "Retry cancelled",
});
this._resolveRetry();
return false;
} finally {
this._retryAbortController = undefined;
}
this._retryAbortController = undefined;
// Retry via continue() - use setTimeout to break out of event handler chain
setTimeout(() => {
this.agent.continue().catch(() => {
// Retry failed - will be caught by next agent_end
});
}, 0);
return true;
}
@@ -2518,26 +2509,11 @@ export class AgentSession {
*/
abortRetry(): void {
this._retryAbortController?.abort();
// Note: _retryAttempt is reset in the catch block of _autoRetry
this._resolveRetry();
}
/**
* Wait for any in-progress retry to complete.
* Returns immediately if no retry is in progress.
*/
private async waitForRetry(): Promise<void> {
if (!this._retryPromise) {
return;
}
await this._retryPromise;
await this.agent.waitForIdle();
}
/** Whether auto-retry is currently in progress */
get isRetrying(): boolean {
return this._retryPromise !== undefined;
return this._retryAbortController !== undefined;
}
/** Whether auto-retry is enabled */
@@ -3026,7 +3002,10 @@ export class AgentSession {
* @returns The resolved output file path.
*/
exportToJsonl(outputPath?: string): string {
const filePath = resolve(outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`);
const filePath = resolvePath(
outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`,
process.cwd(),
);
const dir = dirname(filePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });

View File

@@ -1,5 +1,5 @@
import { join } from "node:path";
import { getDocsPath } from "../config.js";
import { getDocsPath } from "../config.ts";
const UNKNOWN_PROVIDER = "unknown";

View File

@@ -17,8 +17,9 @@ import { getOAuthApiKey, getOAuthProvider, getOAuthProviders } from "@earendil-w
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { dirname, join } from "path";
import lockfile from "proper-lockfile";
import { getAgentDir } from "../config.js";
import { resolveConfigValue } from "./resolve-config-value.js";
import { getAgentDir } from "../config.ts";
import { normalizePath } from "../utils/paths.ts";
import { resolveConfigValue } from "./resolve-config-value.ts";
export type ApiKeyCredential = {
type: "api_key";
@@ -50,7 +51,11 @@ export interface AuthStorageBackend {
}
export class FileAuthStorageBackend implements AuthStorageBackend {
constructor(private authPath: string = join(getAgentDir(), "auth.json")) {}
private authPath: string;
constructor(authPath: string = join(getAgentDir(), "auth.json")) {
this.authPath = normalizePath(authPath);
}
private ensureParentDir(): void {
const dir = dirname(this.authPath);
@@ -194,8 +199,10 @@ export class AuthStorage {
private fallbackResolver?: (provider: string) => string | undefined;
private loadError: Error | null = null;
private errors: Error[] = [];
private storage: AuthStorageBackend;
private constructor(private storage: AuthStorageBackend) {
private constructor(storage: AuthStorageBackend) {
this.storage = storage;
this.reload();
}

View File

@@ -10,10 +10,10 @@ import { randomBytes } from "node:crypto";
import { createWriteStream, type WriteStream } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import stripAnsi from "strip-ansi";
import { sanitizeBinaryOutput } from "../utils/shell.js";
import type { BashOperations } from "./tools/bash.js";
import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.js";
import { stripAnsi } from "../utils/ansi.ts";
import { sanitizeBinaryOutput } from "../utils/shell.ts";
import type { BashOperations } from "./tools/bash.ts";
import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.ts";
// ============================================================================
// Types

View File

@@ -13,9 +13,9 @@ import {
createBranchSummaryMessage,
createCompactionSummaryMessage,
createCustomMessage,
} from "../messages.js";
import type { ReadonlySessionManager, SessionEntry } from "../session-manager.js";
import { estimateTokens } from "./compaction.js";
} from "../messages.ts";
import type { ReadonlySessionManager, SessionEntry } from "../session-manager.ts";
import { estimateTokens } from "./compaction.ts";
import {
computeFileLists,
createFileOps,
@@ -24,7 +24,7 @@ import {
formatFileOperations,
SUMMARIZATION_SYSTEM_PROMPT,
serializeConversation,
} from "./utils.js";
} from "./utils.ts";
// ============================================================================
// Types
@@ -44,7 +44,7 @@ export interface BranchSummaryDetails {
modifiedFiles: string[];
}
export type { FileOperations } from "./utils.js";
export type { FileOperations } from "./utils.ts";
export interface BranchPreparation {
/** Messages extracted for summarization, in chronological order */

View File

@@ -5,16 +5,16 @@
* and after compaction the session is reloaded.
*/
import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { AssistantMessage, Model, Usage } from "@earendil-works/pi-ai";
import type { AgentMessage, StreamFn, ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { AssistantMessage, Context, Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai";
import { completeSimple } from "@earendil-works/pi-ai";
import {
convertToLlm,
createBranchSummaryMessage,
createCompactionSummaryMessage,
createCustomMessage,
} from "../messages.js";
import { buildSessionContext, type CompactionEntry, type SessionEntry } from "../session-manager.js";
} from "../messages.ts";
import { buildSessionContext, type CompactionEntry, type SessionEntry } from "../session-manager.ts";
import {
computeFileLists,
createFileOps,
@@ -23,7 +23,7 @@ import {
formatFileOperations,
SUMMARIZATION_SYSTEM_PROMPT,
serializeConversation,
} from "./utils.js";
} from "./utils.ts";
// ============================================================================
// File Operation Tracking
@@ -523,6 +523,34 @@ Use this EXACT format:
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
function createSummarizationOptions(
model: Model<any>,
maxTokens: number,
apiKey: string | undefined,
headers: Record<string, string> | undefined,
signal: AbortSignal | undefined,
thinkingLevel: ThinkingLevel | undefined,
): SimpleStreamOptions {
const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers };
if (model.reasoning && thinkingLevel && thinkingLevel !== "off") {
options.reasoning = thinkingLevel;
}
return options;
}
async function completeSummarization(
model: Model<any>,
context: Context,
options: SimpleStreamOptions,
streamFn?: StreamFn,
): Promise<AssistantMessage> {
if (!streamFn) {
return completeSimple(model, context, options);
}
const stream = await streamFn(model, context, options);
return stream.result();
}
/**
* Generate a summary of the conversation using the LLM.
* If previousSummary is provided, uses the update prompt to merge.
@@ -531,14 +559,18 @@ export async function generateSummary(
currentMessages: AgentMessage[],
model: Model<any>,
reserveTokens: number,
apiKey: string,
apiKey: string | undefined,
headers?: Record<string, string>,
signal?: AbortSignal,
customInstructions?: string,
previousSummary?: string,
thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn,
): Promise<string> {
const maxTokens = Math.floor(0.8 * reserveTokens);
const maxTokens = Math.min(
Math.floor(0.8 * reserveTokens),
model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY,
);
// Use update prompt if we have a previous summary, otherwise initial prompt
let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT;
@@ -566,15 +598,13 @@ export async function generateSummary(
},
];
const completionOptions =
model.reasoning && thinkingLevel && thinkingLevel !== "off"
? { maxTokens, signal, apiKey, headers, reasoning: thinkingLevel }
: { maxTokens, signal, apiKey, headers };
const completionOptions = createSummarizationOptions(model, maxTokens, apiKey, headers, signal, thinkingLevel);
const response = await completeSimple(
const response = await completeSummarization(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
completionOptions,
streamFn,
);
if (response.stopReason === "error") {
@@ -717,11 +747,12 @@ Be concise. Focus on what's needed to understand the kept suffix.`;
export async function compact(
preparation: CompactionPreparation,
model: Model<any>,
apiKey: string,
apiKey: string | undefined,
headers?: Record<string, string>,
customInstructions?: string,
signal?: AbortSignal,
thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn,
): Promise<CompactionResult> {
const {
firstKeptEntryId,
@@ -751,6 +782,7 @@ export async function compact(
customInstructions,
previousSummary,
thinkingLevel,
streamFn,
)
: Promise.resolve("No prior history."),
generateTurnPrefixSummary(
@@ -761,6 +793,7 @@ export async function compact(
headers,
signal,
thinkingLevel,
streamFn,
),
]);
// Merge into single summary
@@ -777,6 +810,7 @@ export async function compact(
customInstructions,
previousSummary,
thinkingLevel,
streamFn,
);
}
@@ -803,12 +837,16 @@ async function generateTurnPrefixSummary(
messages: AgentMessage[],
model: Model<any>,
reserveTokens: number,
apiKey: string,
apiKey: string | undefined,
headers?: Record<string, string>,
signal?: AbortSignal,
thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn,
): Promise<string> {
const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix
const maxTokens = Math.min(
Math.floor(0.5 * reserveTokens),
model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY,
); // Smaller budget for turn prefix
const llmMessages = convertToLlm(messages);
const conversationText = serializeConversation(llmMessages);
const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
@@ -820,12 +858,11 @@ async function generateTurnPrefixSummary(
},
];
const response = await completeSimple(
const response = await completeSummarization(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
model.reasoning && thinkingLevel && thinkingLevel !== "off"
? { maxTokens, signal, apiKey, headers, reasoning: thinkingLevel }
: { maxTokens, signal, apiKey, headers },
createSummarizationOptions(model, maxTokens, apiKey, headers, signal, thinkingLevel),
streamFn,
);
if (response.stopReason === "error") {

View File

@@ -2,6 +2,6 @@
* Compaction and summarization utilities.
*/
export * from "./branch-summarization.js";
export * from "./compaction.js";
export * from "./utils.js";
export * from "./branch-summarization.ts";
export * from "./compaction.ts";
export * from "./utils.ts";

View File

@@ -3,7 +3,7 @@
*/
import { spawn } from "node:child_process";
import { waitForChildProcess } from "../utils/child-process.js";
import { waitForChildProcess } from "../utils/child-process.ts";
/**
* Options for executing shell commands.

View File

@@ -1,11 +1,12 @@
import type { AgentState } from "@earendil-works/pi-agent-core";
import { existsSync, readFileSync, writeFileSync } from "fs";
import { basename, join } from "path";
import { APP_NAME, getExportTemplateDir } from "../../config.js";
import { getResolvedThemeColors, getThemeExportColors } from "../../modes/interactive/theme/theme.js";
import type { ToolDefinition } from "../extensions/types.js";
import type { SessionEntry } from "../session-manager.js";
import { SessionManager } from "../session-manager.js";
import { APP_NAME, getExportTemplateDir } from "../../config.ts";
import { getResolvedThemeColors, getThemeExportColors } from "../../modes/interactive/theme/theme.ts";
import { normalizePath, resolvePath } from "../../utils/paths.ts";
import type { ToolDefinition } from "../extensions/types.ts";
import type { SessionEntry } from "../session-manager.ts";
import { SessionManager } from "../session-manager.ts";
/**
* Interface for rendering custom tools to HTML.
@@ -270,7 +271,7 @@ export async function exportSessionToHtml(
const html = generateHtml(sessionData, opts.themeName);
let outputPath = opts.outputPath;
let outputPath = opts.outputPath ? normalizePath(opts.outputPath) : undefined;
if (!outputPath) {
const sessionBasename = basename(sessionFile, ".jsonl");
outputPath = `${APP_NAME}-session-${sessionBasename}.html`;
@@ -286,12 +287,13 @@ export async function exportSessionToHtml(
*/
export async function exportFromFile(inputPath: string, options?: ExportOptions | string): Promise<string> {
const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {};
const resolvedInputPath = resolvePath(inputPath);
if (!existsSync(inputPath)) {
throw new Error(`File not found: ${inputPath}`);
if (!existsSync(resolvedInputPath)) {
throw new Error(`File not found: ${resolvedInputPath}`);
}
const sm = SessionManager.open(inputPath);
const sm = SessionManager.open(resolvedInputPath);
const sessionData: SessionData = {
header: sm.getHeader(),
@@ -303,9 +305,9 @@ export async function exportFromFile(inputPath: string, options?: ExportOptions
const html = generateHtml(sessionData, opts.themeName);
let outputPath = opts.outputPath;
let outputPath = opts.outputPath ? normalizePath(opts.outputPath) : undefined;
if (!outputPath) {
const inputBasename = basename(inputPath, ".jsonl");
const inputBasename = basename(resolvedInputPath, ".jsonl");
outputPath = `${APP_NAME}-session-${inputBasename}.html`;
}

View File

@@ -910,7 +910,8 @@
'</div>';
};
let html = `<div class="tool-execution ${statusClass}">`;
const toolDomId = `tool-call-${escapeHtml(call.id)}`;
let html = `<div class="tool-execution ${statusClass}" id="${toolDomId}">`;
const args = call.arguments || {};
const name = call.name;
@@ -1445,6 +1446,16 @@
// Cache for rendered entry DOM nodes
const entryCache = new Map();
function getScrollTargetElementId(entryId) {
const entry = byId.get(entryId);
if (entry?.type === 'message' && entry.message.role === 'toolResult' && entry.message.toolCallId) {
// getElementById() matches the parsed DOM id attribute, whose HTML entities
// were already resolved from the escaped id rendered by renderToolCall().
return `tool-call-${entry.message.toolCallId}`;
}
return `entry-${entryId}`;
}
function renderEntryToNode(entry) {
// Check cache first
if (entryCache.has(entry.id)) {
@@ -1506,9 +1517,12 @@
if (scrollMode === 'bottom') {
content.scrollTop = content.scrollHeight;
} else if (scrollMode === 'target') {
// If scrollToEntryId is provided, scroll to that specific entry
// If scrollToEntryId is provided, scroll to that specific entry.
// Tool result entries are rendered inside their assistant tool-call block,
// so route them to the visible tool-call element instead.
const scrollTargetId = scrollToEntryId || targetId;
const targetEl = document.getElementById(`entry-${scrollTargetId}`);
const targetEl = document.getElementById(getScrollTargetElementId(scrollTargetId)) ||
document.getElementById(`entry-${scrollTargetId}`);
if (targetEl) {
targetEl.scrollIntoView({ block: 'center' });
// Briefly highlight the target message

View File

@@ -7,9 +7,9 @@
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import type { Component } from "@earendil-works/pi-tui";
import type { Theme } from "../../modes/interactive/theme/theme.js";
import type { ToolDefinition, ToolRenderContext } from "../extensions/types.js";
import { ansiLinesToHtml } from "./ansi-to-html.js";
import type { Theme } from "../../modes/interactive/theme/theme.ts";
import type { ToolDefinition, ToolRenderContext } from "../extensions/types.ts";
import { ansiLinesToHtml } from "./ansi-to-html.ts";
export interface ToolHtmlRendererDeps {
/** Function to look up tool definition by name */

View File

@@ -2,14 +2,14 @@
* Extension system for lifecycle events and custom tools.
*/
export type { SlashCommandInfo, SlashCommandSource } from "../slash-commands.js";
export type { SourceInfo } from "../source-info.js";
export type { SlashCommandInfo, SlashCommandSource } from "../slash-commands.ts";
export type { SourceInfo } from "../source-info.ts";
export {
createExtensionRuntime,
discoverAndLoadExtensions,
loadExtensionFromFactory,
loadExtensions,
} from "./loader.js";
} from "./loader.ts";
export type {
ExtensionErrorListener,
ForkHandler,
@@ -17,8 +17,8 @@ export type {
NewSessionHandler,
ShutdownHandler,
SwitchSessionHandler,
} from "./runner.js";
export { ExtensionRunner } from "./runner.js";
} from "./runner.ts";
export { ExtensionRunner } from "./runner.ts";
export type {
AfterProviderResponseEvent,
AgentEndEvent,
@@ -156,7 +156,7 @@ export type {
WorkingIndicatorOptions,
WriteToolCallEvent,
WriteToolResultEvent,
} from "./types.js";
} from "./types.ts";
// Type guards
export {
defineTool,
@@ -168,5 +168,5 @@ export {
isReadToolResult,
isToolCallEventType,
isWriteToolResult,
} from "./types.js";
export { wrapRegisteredTool, wrapRegisteredTools } from "./wrapper.js";
} from "./types.ts";
export { wrapRegisteredTool, wrapRegisteredTools } from "./wrapper.ts";

View File

@@ -5,7 +5,6 @@
import * as fs from "node:fs";
import { createRequire } from "node:module";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import * as _bundledPiAgentCore from "@earendil-works/pi-agent-core";
@@ -20,14 +19,15 @@ import { createJiti } from "jiti";
import * as _bundledTypebox from "typebox";
import * as _bundledTypeboxCompile from "typebox/compile";
import * as _bundledTypeboxValue from "typebox/value";
import { CONFIG_DIR_NAME, getAgentDir, isBunBinary } from "../../config.js";
import { CONFIG_DIR_NAME, getAgentDir, isBunBinary } from "../../config.ts";
// NOTE: This import works because loader.ts exports are NOT re-exported from index.ts,
// avoiding a circular dependency. Extensions can import from @earendil-works/pi-coding-agent.
import * as _bundledPiCodingAgent from "../../index.js";
import { createEventBus, type EventBus } from "../event-bus.js";
import type { ExecOptions } from "../exec.js";
import { execCommand } from "../exec.js";
import { createSyntheticSourceInfo } from "../source-info.js";
import * as _bundledPiCodingAgent from "../../index.ts";
import { resolvePath } from "../../utils/paths.ts";
import { createEventBus, type EventBus } from "../event-bus.ts";
import type { ExecOptions } from "../exec.ts";
import { execCommand } from "../exec.ts";
import { createSyntheticSourceInfo } from "../source-info.ts";
import type {
Extension,
ExtensionAPI,
@@ -38,7 +38,7 @@ import type {
ProviderConfig,
RegisteredCommand,
ToolDefinition,
} from "./types.js";
} from "./types.ts";
/** Modules available to extensions via virtualModules (for compiled Bun binary) */
const VIRTUAL_MODULES: Record<string, unknown> = {
@@ -115,31 +115,6 @@ function getAliases(): Record<string, string> {
return _aliases;
}
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
function normalizeUnicodeSpaces(str: string): string {
return str.replace(UNICODE_SPACES, " ");
}
function expandPath(p: string): string {
const normalized = normalizeUnicodeSpaces(p);
if (normalized.startsWith("~/")) {
return path.join(os.homedir(), normalized.slice(2));
}
if (normalized.startsWith("~")) {
return path.join(os.homedir(), normalized.slice(1));
}
return normalized;
}
function resolvePath(extPath: string, cwd: string): string {
const expanded = expandPath(extPath);
if (path.isAbsolute(expanded)) {
return expanded;
}
return path.resolve(cwd, expanded);
}
type HandlerFn = (...args: unknown[]) => Promise<unknown>;
/**
@@ -236,7 +211,7 @@ function createExtensionAPI(
shortcut: KeyId,
options: {
description?: string;
handler: (ctx: import("./types.js").ExtensionContext) => Promise<void> | void;
handler: (ctx: import("./types.ts").ExtensionContext) => Promise<void> | void;
},
): void {
runtime.assertActive();
@@ -396,7 +371,7 @@ async function loadExtension(
eventBus: EventBus,
runtime: ExtensionRuntime,
): Promise<{ extension: Extension | null; error: string | null }> {
const resolvedPath = resolvePath(extensionPath, cwd);
const resolvedPath = resolvePath(extensionPath, cwd, { normalizeUnicodeSpaces: true });
try {
const factory = await loadExtensionModule(resolvedPath);
@@ -426,7 +401,8 @@ export async function loadExtensionFromFactory(
extensionPath = "<inline>",
): Promise<Extension> {
const extension = createExtension(extensionPath, extensionPath);
const api = createExtensionAPI(extension, runtime, cwd, eventBus);
const resolvedCwd = resolvePath(cwd);
const api = createExtensionAPI(extension, runtime, resolvedCwd, eventBus);
await factory(api);
return extension;
}
@@ -437,11 +413,12 @@ export async function loadExtensionFromFactory(
export async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise<LoadExtensionsResult> {
const extensions: Extension[] = [];
const errors: Array<{ path: string; error: string }> = [];
const resolvedCwd = resolvePath(cwd);
const resolvedEventBus = eventBus ?? createEventBus();
const runtime = createExtensionRuntime();
for (const extPath of paths) {
const { extension, error } = await loadExtension(extPath, cwd, resolvedEventBus, runtime);
const { extension, error } = await loadExtension(extPath, resolvedCwd, resolvedEventBus, runtime);
if (error) {
errors.push({ path: extPath, error });
@@ -578,6 +555,8 @@ export async function discoverAndLoadExtensions(
agentDir: string = getAgentDir(),
eventBus?: EventBus,
): Promise<LoadExtensionsResult> {
const resolvedCwd = resolvePath(cwd);
const resolvedAgentDir = resolvePath(agentDir);
const allPaths: string[] = [];
const seen = new Set<string>();
@@ -592,16 +571,16 @@ export async function discoverAndLoadExtensions(
};
// 1. Project-local extensions: cwd/${CONFIG_DIR_NAME}/extensions/
const localExtDir = path.join(cwd, CONFIG_DIR_NAME, "extensions");
const localExtDir = path.join(resolvedCwd, CONFIG_DIR_NAME, "extensions");
addPaths(discoverExtensionsInDir(localExtDir));
// 2. Global extensions: agentDir/extensions/
const globalExtDir = path.join(agentDir, "extensions");
const globalExtDir = path.join(resolvedAgentDir, "extensions");
addPaths(discoverExtensionsInDir(globalExtDir));
// 3. Explicitly configured paths
for (const p of configuredPaths) {
const resolved = resolvePath(p, cwd);
const resolved = resolvePath(p, resolvedCwd, { normalizeUnicodeSpaces: true });
if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
// Check for package.json with pi manifest or index.ts
const entries = resolveExtensionEntries(resolved);
@@ -617,5 +596,5 @@ export async function discoverAndLoadExtensions(
addPaths([resolved]);
}
return loadExtensions(allPaths, cwd, eventBus);
return loadExtensions(allPaths, resolvedCwd, eventBus);
}

View File

@@ -5,12 +5,12 @@
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { ImageContent, Model } from "@earendil-works/pi-ai";
import type { KeyId } from "@earendil-works/pi-tui";
import { type Theme, theme } from "../../modes/interactive/theme/theme.js";
import type { ResourceDiagnostic } from "../diagnostics.js";
import type { KeybindingsConfig } from "../keybindings.js";
import type { ModelRegistry } from "../model-registry.js";
import type { SessionManager } from "../session-manager.js";
import type { BuildSystemPromptOptions } from "../system-prompt.js";
import { type Theme, theme } from "../../modes/interactive/theme/theme.ts";
import type { ResourceDiagnostic } from "../diagnostics.ts";
import type { KeybindingsConfig } from "../keybindings.ts";
import type { ModelRegistry } from "../model-registry.ts";
import type { SessionManager } from "../session-manager.ts";
import type { BuildSystemPromptOptions } from "../system-prompt.ts";
import type {
BeforeAgentStartEvent,
BeforeAgentStartEventResult,
@@ -55,7 +55,7 @@ import type {
ToolResultEventResult,
UserBashEvent,
UserBashEventResult,
} from "./types.js";
} from "./types.ts";
// Extension shortcuts compete with canonical keybinding ids from keybindings.json.
// Only editor-global shortcuts are reserved here. Picker-specific bindings are not.

View File

@@ -40,27 +40,27 @@ import type {
TUI,
} from "@earendil-works/pi-tui";
import type { Static, TSchema } from "typebox";
import type { Theme } from "../../modes/interactive/theme/theme.js";
import type { BashResult } from "../bash-executor.js";
import type { CompactionPreparation, CompactionResult } from "../compaction/index.js";
import type { EventBus } from "../event-bus.js";
import type { ExecOptions, ExecResult } from "../exec.js";
import type { ReadonlyFooterDataProvider } from "../footer-data-provider.js";
import type { KeybindingsManager } from "../keybindings.js";
import type { CustomMessage } from "../messages.js";
import type { ModelRegistry } from "../model-registry.js";
import type { Theme } from "../../modes/interactive/theme/theme.ts";
import type { BashResult } from "../bash-executor.ts";
import type { CompactionPreparation, CompactionResult } from "../compaction/index.ts";
import type { EventBus } from "../event-bus.ts";
import type { ExecOptions, ExecResult } from "../exec.ts";
import type { ReadonlyFooterDataProvider } from "../footer-data-provider.ts";
import type { KeybindingsManager } from "../keybindings.ts";
import type { CustomMessage } from "../messages.ts";
import type { ModelRegistry } from "../model-registry.ts";
import type {
BranchSummaryEntry,
CompactionEntry,
ReadonlySessionManager,
SessionEntry,
SessionManager,
} from "../session-manager.js";
import type { SlashCommandInfo } from "../slash-commands.js";
import type { SourceInfo } from "../source-info.js";
import type { BuildSystemPromptOptions } from "../system-prompt.js";
import type { BashOperations } from "../tools/bash.js";
import type { EditToolDetails } from "../tools/edit.js";
} from "../session-manager.ts";
import type { SlashCommandInfo } from "../slash-commands.ts";
import type { SourceInfo } from "../source-info.ts";
import type { BuildSystemPromptOptions } from "../system-prompt.ts";
import type { BashOperations } from "../tools/bash.ts";
import type { EditToolDetails } from "../tools/edit.ts";
import type {
BashToolDetails,
BashToolInput,
@@ -74,12 +74,12 @@ import type {
ReadToolDetails,
ReadToolInput,
WriteToolInput,
} from "../tools/index.js";
} from "../tools/index.ts";
export type { ExecOptions, ExecResult } from "../exec.js";
export type { BuildSystemPromptOptions } from "../system-prompt.js";
export type { ExecOptions, ExecResult } from "../exec.ts";
export type { BuildSystemPromptOptions } from "../system-prompt.ts";
export type { AgentToolResult, AgentToolUpdateCallback, ToolExecutionMode };
export type { AppKeybinding, KeybindingsManager } from "../keybindings.js";
export type { AppKeybinding, KeybindingsManager } from "../keybindings.ts";
// ============================================================================
// UI Context

View File

@@ -6,9 +6,9 @@
*/
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { wrapToolDefinition, wrapToolDefinitions } from "../tools/tool-definition-wrapper.js";
import type { ExtensionRunner } from "./runner.js";
import type { RegisteredTool } from "./types.js";
import { wrapToolDefinition, wrapToolDefinitions } from "../tools/tool-definition-wrapper.ts";
import type { ExtensionRunner } from "./runner.ts";
import type { RegisteredTool } from "./types.ts";
/**
* Wrap a RegisteredTool into an AgentTool.

View File

@@ -1,7 +1,7 @@
import { type ExecFileException, execFile, spawnSync } from "child_process";
import { existsSync, type FSWatcher, readFileSync, statSync, unwatchFile, watchFile } from "fs";
import { dirname, join, resolve } from "path";
import { closeWatcher, FS_WATCH_RETRY_DELAY_MS, watchWithErrorHandler } from "../utils/fs-watch.js";
import { closeWatcher, FS_WATCH_RETRY_DELAY_MS, watchWithErrorHandler } from "../utils/fs-watch.ts";
type GitPaths = {
repoDir: string;

View File

@@ -0,0 +1,55 @@
import * as undici from "undici";
export const DEFAULT_HTTP_IDLE_TIMEOUT_MS = 300_000;
export const HTTP_IDLE_TIMEOUT_CHOICES = [
{ label: "30 sec", timeoutMs: 30_000 },
{ label: "1 min", timeoutMs: 60_000 },
{ label: "2 min", timeoutMs: 120_000 },
{ label: "5 min", timeoutMs: 300_000 },
{ label: "disabled", timeoutMs: 0 },
] as const;
export function parseHttpIdleTimeoutMs(value: unknown): number | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.toLowerCase() === "disabled") {
return 0;
}
if (trimmed.length === 0) {
return undefined;
}
return parseHttpIdleTimeoutMs(Number(trimmed));
}
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
return undefined;
}
return Math.floor(value);
}
export function formatHttpIdleTimeoutMs(timeoutMs: number): string {
const choice = HTTP_IDLE_TIMEOUT_CHOICES.find((item) => item.timeoutMs === timeoutMs);
if (choice) {
return choice.label;
}
return `${timeoutMs / 1000} sec`;
}
export function configureHttpDispatcher(timeoutMs: number = DEFAULT_HTTP_IDLE_TIMEOUT_MS): void {
const normalizedTimeoutMs = parseHttpIdleTimeoutMs(timeoutMs);
if (normalizedTimeoutMs === undefined) {
throw new Error(`Invalid HTTP idle timeout: ${String(timeoutMs)}`);
}
undici.setGlobalDispatcher(
new undici.EnvHttpProxyAgent({
allowH2: false,
bodyTimeout: normalizedTimeoutMs,
headersTimeout: normalizedTimeoutMs,
}),
);
// 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?.();
}

View File

@@ -10,13 +10,13 @@ export {
type ModelCycleResult,
type PromptOptions,
type SessionStats,
} from "./agent-session.js";
} from "./agent-session.ts";
export {
AgentSessionRuntime,
type CreateAgentSessionRuntimeFactory,
type CreateAgentSessionRuntimeResult,
createAgentSessionRuntime,
} from "./agent-session-runtime.js";
} from "./agent-session-runtime.ts";
export {
type AgentSessionRuntimeDiagnostic,
type AgentSessionServices,
@@ -24,10 +24,10 @@ export {
type CreateAgentSessionServicesOptions,
createAgentSessionFromServices,
createAgentSessionServices,
} from "./agent-session-services.js";
export { type BashExecutorOptions, type BashResult, executeBashWithOperations } from "./bash-executor.js";
export type { CompactionResult } from "./compaction/index.js";
export { createEventBus, type EventBus, type EventBusController } from "./event-bus.js";
} from "./agent-session-services.ts";
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";
// Extensions system
export {
type AgentEndEvent,
@@ -73,5 +73,5 @@ export {
type TurnEndEvent,
type TurnStartEvent,
type WorkingIndicatorOptions,
} from "./extensions/index.js";
export { createSyntheticSourceInfo } from "./source-info.js";
} from "./extensions/index.ts";
export { createSyntheticSourceInfo } from "./source-info.ts";

View File

@@ -8,7 +8,7 @@ import {
} from "@earendil-works/pi-tui";
import { existsSync, readFileSync } from "fs";
import { join } from "path";
import { getAgentDir } from "../config.js";
import { getAgentDir } from "../config.ts";
export interface AppKeybindings {
"app.interrupt": true;

View File

@@ -24,15 +24,16 @@ import { join } from "path";
import { type Static, Type } from "typebox";
import { Compile } from "typebox/compile";
import type { TLocalizedValidationError } from "typebox/error";
import { getAgentDir } from "../config.js";
import type { AuthStatus, AuthStorage } from "./auth-storage.js";
import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "./provider-display-names.js";
import { getAgentDir } from "../config.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,
resolveConfigValueOrThrow,
resolveConfigValueUncached,
resolveHeadersOrThrow,
} from "./resolve-config-value.js";
} from "./resolve-config-value.ts";
// Schema for OpenRouter routing preferences
const PercentileCutoffsSchema = Type.Object({
@@ -334,11 +335,12 @@ export class ModelRegistry {
private modelRequestHeaders: Map<string, Record<string, string>> = new Map();
private registeredProviders: Map<string, ProviderConfigInput> = new Map();
private loadError: string | undefined = undefined;
readonly authStorage: AuthStorage;
private modelsJsonPath: string | undefined;
private constructor(
readonly authStorage: AuthStorage,
private modelsJsonPath: string | undefined,
) {
private constructor(authStorage: AuthStorage, modelsJsonPath: string | undefined) {
this.authStorage = authStorage;
this.modelsJsonPath = modelsJsonPath ? normalizePath(modelsJsonPath) : undefined;
this.loadModels();
}

View File

@@ -6,9 +6,9 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { type Api, type KnownProvider, type Model, modelsAreEqual } from "@earendil-works/pi-ai";
import chalk from "chalk";
import { minimatch } from "minimatch";
import { isValidThinkingLevel } from "../cli/args.js";
import { DEFAULT_THINKING_LEVEL } from "./defaults.js";
import type { ModelRegistry } from "./model-registry.js";
import { isValidThinkingLevel } from "../cli/args.ts";
import { DEFAULT_THINKING_LEVEL } from "./defaults.ts";
import type { ModelRegistry } from "./model-registry.ts";
/** Default model IDs for each known provider */
export const defaultModelPerProvider: Record<KnownProvider, string> = {

View File

@@ -1,4 +1,4 @@
import { type ChildProcess, type ChildProcessByStdio, spawn, spawnSync } from "node:child_process";
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";
@@ -27,12 +27,12 @@ import type { Readable } from "node:stream";
import { globSync } from "glob";
import ignore from "ignore";
import { minimatch } from "minimatch";
import { CONFIG_DIR_NAME } from "../config.js";
import { shouldUseWindowsShell } from "../utils/child-process.js";
import { type GitSource, parseGitUrl } from "../utils/git.js";
import { canonicalizePath, isLocalPath } from "../utils/paths.js";
import { isStdoutTakenOver } from "./output-guard.js";
import type { PackageSource, SettingsManager } from "./settings-manager.js";
import { CONFIG_DIR_NAME } from "../config.ts";
import { spawnProcess, spawnProcessSync } from "../utils/child-process.ts";
import { type GitSource, parseGitUrl } from "../utils/git.ts";
import { canonicalizePath, isLocalPath, markPathIgnoredByCloudSync, resolvePath } from "../utils/paths.ts";
import { isStdoutTakenOver } from "./output-guard.ts";
import type { PackageSource, SettingsManager } from "./settings-manager.ts";
const NETWORK_TIMEOUT_MS = 10000;
const UPDATE_CHECK_CONCURRENCY = 4;
@@ -763,8 +763,8 @@ export class DefaultPackageManager implements PackageManager {
private progressCallback: ProgressCallback | undefined;
constructor(options: PackageManagerOptions) {
this.cwd = options.cwd;
this.agentDir = options.agentDir;
this.cwd = resolvePath(options.cwd);
this.agentDir = resolvePath(options.agentDir);
this.settingsManager = options.settingsManager;
}
@@ -778,9 +778,21 @@ export class DefaultPackageManager implements PackageManager {
scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings();
const currentPackages = currentSettings.packages ?? [];
const normalizedSource = this.normalizePackageSourceForSettings(source, scope);
const exists = currentPackages.some((existing) => this.packageSourcesMatch(existing, source, scope));
if (exists) {
return false;
const matchIndex = currentPackages.findIndex((existing) => this.packageSourcesMatch(existing, source, scope));
if (matchIndex !== -1) {
const existing = currentPackages[matchIndex];
if (this.getPackageSourceString(existing) === normalizedSource) {
return false;
}
const nextPackages = [...currentPackages];
nextPackages[matchIndex] =
typeof existing === "string" ? normalizedSource : { ...existing, source: normalizedSource };
if (scope === "project") {
this.settingsManager.setProjectPackages(nextPackages);
} else {
this.settingsManager.setPackages(nextPackages);
}
return true;
}
const nextPackages = [...currentPackages, normalizedSource];
if (scope === "project") {
@@ -1084,7 +1096,7 @@ export class DefaultPackageManager implements PackageManager {
}
private async shouldUpdateNpmSource(source: NpmSource, scope: InstalledSourceScope): Promise<boolean> {
const installedPath = this.getNpmInstallPath(source, scope);
const installedPath = this.getManagedNpmInstallPath(source, scope);
const installedVersion = existsSync(installedPath) ? this.getInstalledNpmVersion(installedPath) : undefined;
if (!installedVersion) {
return true;
@@ -1114,13 +1126,9 @@ export class DefaultPackageManager implements PackageManager {
}
private async installNpmBatch(specs: string[], scope: InstalledSourceScope): Promise<void> {
if (scope === "user") {
await this.runNpmCommand(["install", "-g", ...specs]);
return;
}
const installRoot = this.getNpmInstallRoot(scope, false);
this.ensureNpmProject(installRoot);
await this.runNpmCommand(["install", ...specs, "--prefix", installRoot]);
await this.runNpmCommand(this.getNpmInstallArgs(specs, installRoot));
}
async checkForAvailableUpdates(): Promise<PackageUpdate[]> {
@@ -1221,13 +1229,14 @@ export class DefaultPackageManager implements PackageManager {
};
if (parsed.type === "npm") {
const installedPath = this.getNpmInstallPath(parsed, scope);
let installedPath = this.getNpmInstallPath(parsed, scope);
const needsInstall =
!existsSync(installedPath) ||
(parsed.pinned && !(await this.installedNpmMatchesPinnedVersion(parsed, installedPath)));
if (needsInstall) {
const installed = await installMissing();
if (!installed) continue;
installedPath = this.getNpmInstallPath(parsed, scope);
}
metadata.baseDir = installedPath;
this.collectPackageResources(installedPath, accumulator, filter, metadata);
@@ -1668,6 +1677,14 @@ export class DefaultPackageManager implements PackageManager {
return { command, args };
}
private getPackageManagerName(): string {
const npmCommand = this.getNpmCommand();
const commandParts = [npmCommand.command, ...npmCommand.args];
const separatorIndex = commandParts.lastIndexOf("--");
const packageManagerCommand = separatorIndex >= 0 ? commandParts[separatorIndex + 1] : npmCommand.command;
return packageManagerCommand ? basename(packageManagerCommand).replace(/\.(cmd|exe)$/i, "") : "";
}
private async runNpmCommand(args: string[], options?: { cwd?: string }): Promise<void> {
const npmCommand = this.getNpmCommand();
await this.runCommand(npmCommand.command, [...npmCommand.args, ...args], options);
@@ -1686,31 +1703,44 @@ export class DefaultPackageManager implements PackageManager {
return this.runCommandSync(npmCommand.command, [...npmCommand.args, ...args]);
}
private async installNpm(source: NpmSource, scope: SourceScope, temporary: boolean): Promise<void> {
if (scope === "user" && !temporary) {
await this.runNpmCommand(["install", "-g", source.spec]);
return;
private getNpmInstallArgs(specs: string[], installRoot: string): string[] {
const packageManagerName = this.getPackageManagerName();
if (packageManagerName === "bun") {
return ["install", ...specs, "--cwd", installRoot];
}
if (packageManagerName === "pnpm") {
return ["install", ...specs, "--prefix", installRoot, "--config.strict-dep-builds=false"];
}
return ["install", ...specs, "--prefix", installRoot];
}
private async installNpm(source: NpmSource, scope: SourceScope, temporary: boolean): Promise<void> {
const installRoot = this.getNpmInstallRoot(scope, temporary);
this.ensureNpmProject(installRoot);
await this.runNpmCommand(["install", source.spec, "--prefix", installRoot]);
await this.runNpmCommand(this.getNpmInstallArgs([source.spec], installRoot));
}
private async uninstallNpm(source: NpmSource, scope: SourceScope): Promise<void> {
if (scope === "user") {
await this.runNpmCommand(["uninstall", "-g", source.name]);
return;
}
const installRoot = this.getNpmInstallRoot(scope, false);
if (!existsSync(installRoot)) {
return;
}
if (this.getPackageManagerName() === "bun") {
await this.runNpmCommand(["uninstall", source.name, "--cwd", installRoot]);
return;
}
await this.runNpmCommand(["uninstall", source.name, "--prefix", installRoot]);
}
private async installGit(source: GitSource, scope: SourceScope): Promise<void> {
const targetDir = this.getGitInstallPath(source, scope);
if (existsSync(targetDir)) {
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);
return;
}
const gitRoot = this.getGitInstallRoot(scope);
@@ -1737,23 +1767,26 @@ export class DefaultPackageManager implements PackageManager {
}
const target = await this.getLocalGitUpdateTarget(targetDir);
await this.ensureGitRef(targetDir, target.fetchArgs, target.ref);
}
private async ensureGitRef(targetDir: string, fetchArgs: string[], ref: string): Promise<void> {
// Fetch only the ref we will reset to, avoiding unrelated branch/tag noise.
await this.runCommand("git", target.fetchArgs, { cwd: targetDir });
await this.runCommand("git", fetchArgs, { cwd: targetDir });
const localHead = await this.runCommandCapture("git", ["rev-parse", "HEAD"], {
cwd: targetDir,
timeoutMs: NETWORK_TIMEOUT_MS,
});
const refreshedTargetHead = await this.runCommandCapture("git", ["rev-parse", target.ref], {
const targetHead = await this.runCommandCapture("git", ["rev-parse", ref], {
cwd: targetDir,
timeoutMs: NETWORK_TIMEOUT_MS,
});
if (localHead.trim() === refreshedTargetHead.trim()) {
if (localHead.trim() === targetHead.trim()) {
return;
}
await this.runCommand("git", ["reset", "--hard", target.ref], { cwd: targetDir });
await this.runCommand("git", ["reset", "--hard", ref], { cwd: targetDir });
// Clean untracked files (extensions should be pristine)
await this.runCommand("git", ["clean", "-fdx"], { cwd: targetDir });
@@ -1810,6 +1843,7 @@ export class DefaultPackageManager implements PackageManager {
if (!existsSync(installRoot)) {
mkdirSync(installRoot, { recursive: true });
}
markPathIgnoredByCloudSync(installRoot);
this.ensureGitIgnore(installRoot);
const packageJsonPath = join(installRoot, "package.json");
if (!existsSync(packageJsonPath)) {
@@ -1835,7 +1869,7 @@ export class DefaultPackageManager implements PackageManager {
if (scope === "project") {
return join(this.cwd, CONFIG_DIR_NAME, "npm");
}
return join(this.getGlobalNpmRoot(), "..");
return join(this.agentDir, "npm");
}
private getGlobalNpmRoot(): string {
@@ -1844,8 +1878,7 @@ export class DefaultPackageManager implements PackageManager {
if (this.globalNpmRoot && this.globalNpmRootCommandKey === commandKey) {
return this.globalNpmRoot;
}
const isBunPackageManager = npmCommand.command === "bun";
if (isBunPackageManager) {
if (this.getPackageManagerName() === "bun") {
const binDir = this.runNpmCommandSync(["pm", "bin", "-g"]).trim();
this.globalNpmRoot = join(dirname(binDir), "install", "global", "node_modules");
} else {
@@ -1855,14 +1888,45 @@ export class DefaultPackageManager implements PackageManager {
return this.globalNpmRoot;
}
private getNpmInstallPath(source: NpmSource, scope: SourceScope): string {
private getPnpmGlobalPackagePath(packageName: string): string | undefined {
if (this.getPackageManagerName() !== "pnpm") {
return undefined;
}
const output = this.runNpmCommandSync(["list", "-g", "--depth", "0", "--json"]);
const entries = JSON.parse(output) as Array<{ dependencies?: Record<string, { path?: string }> }>;
for (const entry of entries) {
const path = entry.dependencies?.[packageName]?.path;
if (path) return path;
}
return undefined;
}
private getManagedNpmInstallPath(source: NpmSource, scope: SourceScope): string {
if (scope === "temporary") {
return join(this.getTemporaryDir("npm"), "node_modules", source.name);
}
if (scope === "project") {
return join(this.cwd, CONFIG_DIR_NAME, "npm", "node_modules", source.name);
}
return join(this.getGlobalNpmRoot(), source.name);
return join(this.agentDir, "npm", "node_modules", source.name);
}
private getLegacyGlobalNpmInstallPath(source: NpmSource): string | undefined {
try {
return this.getPnpmGlobalPackagePath(source.name) ?? join(this.getGlobalNpmRoot(), source.name);
} catch {
return undefined;
}
}
private getNpmInstallPath(source: NpmSource, scope: SourceScope): string {
const managedPath = this.getManagedNpmInstallPath(source, scope);
if (scope !== "user" || existsSync(managedPath)) {
return managedPath;
}
const legacyPath = this.getLegacyGlobalNpmInstallPath(source);
return legacyPath && existsSync(legacyPath) ? legacyPath : managedPath;
}
private getGitInstallPath(source: GitSource, scope: SourceScope): string {
@@ -1904,19 +1968,11 @@ export class DefaultPackageManager implements PackageManager {
}
private resolvePath(input: string): string {
const trimmed = input.trim();
if (trimmed === "~") return getHomeDir();
if (trimmed.startsWith("~/")) return join(getHomeDir(), trimmed.slice(2));
if (trimmed.startsWith("~")) return join(getHomeDir(), trimmed.slice(1));
return resolve(this.cwd, trimmed);
return resolvePath(input, this.cwd, { homeDir: getHomeDir(), trim: true });
}
private resolvePathFromBase(input: string, baseDir: string): string {
const trimmed = input.trim();
if (trimmed === "~") return getHomeDir();
if (trimmed.startsWith("~/")) return join(getHomeDir(), trimmed.slice(2));
if (trimmed.startsWith("~")) return join(getHomeDir(), trimmed.slice(1));
return resolve(baseDir, trimmed);
return resolvePath(input, baseDir, { homeDir: getHomeDir(), trim: true });
}
private collectPackageResources(
@@ -2366,11 +2422,11 @@ export class DefaultPackageManager implements PackageManager {
}
private spawnCommand(command: string, args: string[], options?: { cwd?: string }): ChildProcess {
return spawn(command, args, {
const env = getEnv();
return spawnProcess(command, args, {
cwd: options?.cwd,
stdio: isStdoutTakenOver() ? ["ignore", 2, 2] : "inherit",
shell: shouldUseWindowsShell(command),
env: getEnv(),
env,
});
}
@@ -2380,11 +2436,11 @@ export class DefaultPackageManager implements PackageManager {
options?: { cwd?: string; env?: Record<string, string> },
): ChildProcessByStdio<null, Readable, Readable> {
const baseEnv = getEnv();
return spawn(command, args, {
const env = options?.env ? { ...baseEnv, ...options.env } : baseEnv;
return spawnProcess(command, args, {
cwd: options?.cwd,
stdio: ["ignore", "pipe", "pipe"],
shell: shouldUseWindowsShell(command),
env: options?.env ? { ...baseEnv, ...options.env } : baseEnv,
env,
});
}
@@ -2447,11 +2503,11 @@ export class DefaultPackageManager implements PackageManager {
}
private runCommandSync(command: string, args: string[]): string {
const result = spawnSync(command, args, {
const env = getEnv();
const result = spawnProcessSync(command, args, {
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
shell: shouldUseWindowsShell(command),
env: getEnv(),
env,
});
if (result.error || result.status !== 0) {
throw new Error(

View File

@@ -1,9 +1,9 @@
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
import { homedir } from "os";
import { basename, dirname, isAbsolute, join, resolve, sep } from "path";
import { CONFIG_DIR_NAME } from "../config.js";
import { parseFrontmatter } from "../utils/frontmatter.js";
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.js";
import { basename, dirname, join, resolve, sep } from "path";
import { CONFIG_DIR_NAME } from "../config.ts";
import { parseFrontmatter } from "../utils/frontmatter.ts";
import { resolvePath } from "../utils/paths.ts";
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts";
/**
* Represents a prompt template loaded from a markdown file
@@ -37,7 +37,7 @@ export function parseCommandArgs(argsString: string): string[] {
}
} else if (char === '"' || char === "'") {
inQuote = char;
} else if (char === " " || char === "\t") {
} else if (/\s/.test(char)) {
if (current) {
args.push(current);
current = "";
@@ -185,19 +185,6 @@ export interface LoadPromptTemplatesOptions {
includeDefaults: boolean;
}
function normalizePath(input: string): string {
const trimmed = input.trim();
if (trimmed === "~") return homedir();
if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2));
if (trimmed.startsWith("~")) return join(homedir(), trimmed.slice(1));
return trimmed;
}
function resolvePromptPath(p: string, cwd: string): string {
const normalized = normalizePath(p);
return isAbsolute(normalized) ? normalized : resolve(cwd, normalized);
}
/**
* Load all prompt templates from:
* 1. Global: agentDir/prompts/
@@ -205,14 +192,14 @@ function resolvePromptPath(p: string, cwd: string): string {
* 3. Explicit prompt paths
*/
export function loadPromptTemplates(options: LoadPromptTemplatesOptions): PromptTemplate[] {
const resolvedCwd = options.cwd;
const resolvedAgentDir = options.agentDir;
const resolvedCwd = resolvePath(options.cwd);
const resolvedAgentDir = resolvePath(options.agentDir);
const promptPaths = options.promptPaths;
const includeDefaults = options.includeDefaults;
const templates: PromptTemplate[] = [];
const globalPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir;
const globalPromptsDir = join(resolvedAgentDir, "prompts");
const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts");
const isUnderPath = (target: string, root: string): boolean => {
@@ -252,7 +239,7 @@ export function loadPromptTemplates(options: LoadPromptTemplatesOptions): Prompt
// 3. Load explicit prompt paths
for (const rawPath of promptPaths) {
const resolvedPath = resolvePromptPath(rawPath, resolvedCwd);
const resolvedPath = resolvePath(rawPath, resolvedCwd, { trim: true });
if (!existsSync(resolvedPath)) {
continue;
}
@@ -282,9 +269,11 @@ export function loadPromptTemplates(options: LoadPromptTemplatesOptions): Prompt
export function expandPromptTemplate(text: string, templates: PromptTemplate[]): string {
if (!text.startsWith("/")) return text;
const spaceIndex = text.indexOf(" ");
const templateName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
const argsString = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);
const match = text.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/);
if (!match) return text;
const templateName = match[1];
const argsString = match[2] ?? "";
const template = templates.find((t) => t.name === templateName);
if (template) {

View File

@@ -4,7 +4,7 @@
*/
import { execSync, spawnSync } from "child_process";
import { getShellConfig } from "../utils/shell.js";
import { getShellConfig } from "../utils/shell.ts";
// Cache for shell command results (persists for process lifetime)
const commandResultCache = new Map<string, string | undefined>();

View File

@@ -1,24 +1,23 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve, sep } from "node:path";
import chalk from "chalk";
import { CONFIG_DIR_NAME } from "../config.js";
import { loadThemeFromPath, type Theme } from "../modes/interactive/theme/theme.js";
import type { ResourceDiagnostic } from "./diagnostics.js";
import { CONFIG_DIR_NAME } from "../config.ts";
import { loadThemeFromPath, type Theme } from "../modes/interactive/theme/theme.ts";
import type { ResourceDiagnostic } from "./diagnostics.ts";
export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.js";
export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.ts";
import { canonicalizePath, isLocalPath } from "../utils/paths.js";
import { createEventBus, type EventBus } from "./event-bus.js";
import { createExtensionRuntime, loadExtensionFromFactory, loadExtensions } from "./extensions/loader.js";
import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.js";
import { DefaultPackageManager, type PathMetadata } from "./package-manager.js";
import type { PromptTemplate } from "./prompt-templates.js";
import { loadPromptTemplates } from "./prompt-templates.js";
import { SettingsManager } from "./settings-manager.js";
import type { Skill } from "./skills.js";
import { loadSkills } from "./skills.js";
import { createSourceInfo, type SourceInfo } from "./source-info.js";
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 type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.ts";
import { DefaultPackageManager, type PathMetadata } from "./package-manager.ts";
import type { PromptTemplate } from "./prompt-templates.ts";
import { loadPromptTemplates } from "./prompt-templates.ts";
import { SettingsManager } from "./settings-manager.ts";
import type { Skill } from "./skills.ts";
import { loadSkills } from "./skills.ts";
import { createSourceInfo, type SourceInfo } from "./source-info.ts";
export interface ResourceExtensionPaths {
skillPaths?: Array<{ path: string; metadata: PathMetadata }>;
@@ -77,8 +76,8 @@ export function loadProjectContextFiles(options: {
cwd: string;
agentDir: string;
}): Array<{ path: string; content: string }> {
const resolvedCwd = options.cwd;
const resolvedAgentDir = options.agentDir;
const resolvedCwd = resolvePath(options.cwd);
const resolvedAgentDir = resolvePath(options.agentDir);
const contextFiles: Array<{ path: string; content: string }> = [];
const seenPaths = new Set<string>();
@@ -205,8 +204,8 @@ export class DefaultResourceLoader implements ResourceLoader {
private lastThemePaths: string[];
constructor(options: DefaultResourceLoaderOptions) {
this.cwd = options.cwd;
this.agentDir = options.agentDir;
this.cwd = resolvePath(options.cwd);
this.agentDir = resolvePath(options.agentDir);
this.settingsManager = options.settingsManager ?? SettingsManager.create(this.cwd, this.agentDir);
this.eventBus = options.eventBus ?? createEventBus();
this.packageManager = new DefaultPackageManager({
@@ -409,8 +408,11 @@ export class DefaultResourceLoader implements ResourceLoader {
}
for (const p of this.additionalExtensionPaths) {
if (isLocalPath(p) && !existsSync(p)) {
extensionsResult.errors.push({ path: p, error: `Extension path does not exist: ${p}` });
if (isLocalPath(p)) {
const resolved = this.resolveResourcePath(p);
if (!existsSync(resolved)) {
extensionsResult.errors.push({ path: resolved, error: `Extension path does not exist: ${resolved}` });
}
}
}
this.extensionsResult = this.extensionsOverride ? this.extensionsOverride(extensionsResult) : extensionsResult;
@@ -423,8 +425,11 @@ export class DefaultResourceLoader implements ResourceLoader {
this.lastSkillPaths = skillPaths;
this.updateSkillsFromPaths(skillPaths, metadataByPath);
for (const p of this.additionalSkillPaths) {
if (isLocalPath(p) && !existsSync(p) && !this.skillDiagnostics.some((d) => d.path === p)) {
this.skillDiagnostics.push({ type: "error", message: "Skill path does not exist", path: p });
if (isLocalPath(p)) {
const resolved = this.resolveResourcePath(p);
if (!existsSync(resolved) && !this.skillDiagnostics.some((d) => d.path === resolved)) {
this.skillDiagnostics.push({ type: "error", message: "Skill path does not exist", path: resolved });
}
}
}
@@ -435,8 +440,15 @@ export class DefaultResourceLoader implements ResourceLoader {
this.lastPromptPaths = promptPaths;
this.updatePromptsFromPaths(promptPaths, metadataByPath);
for (const p of this.additionalPromptTemplatePaths) {
if (isLocalPath(p) && !existsSync(p) && !this.promptDiagnostics.some((d) => d.path === p)) {
this.promptDiagnostics.push({ type: "error", message: "Prompt template path does not exist", path: p });
if (isLocalPath(p)) {
const resolved = this.resolveResourcePath(p);
if (!existsSync(resolved) && !this.promptDiagnostics.some((d) => d.path === resolved)) {
this.promptDiagnostics.push({
type: "error",
message: "Prompt template path does not exist",
path: resolved,
});
}
}
}
@@ -447,8 +459,9 @@ export class DefaultResourceLoader implements ResourceLoader {
this.lastThemePaths = themePaths;
this.updateThemesFromPaths(themePaths, metadataByPath);
for (const p of this.additionalThemePaths) {
if (!existsSync(p) && !this.themeDiagnostics.some((d) => d.path === p)) {
this.themeDiagnostics.push({ type: "error", message: "Theme path does not exist", path: p });
const resolved = this.resolveResourcePath(p);
if (!existsSync(resolved) && !this.themeDiagnostics.some((d) => d.path === resolved)) {
this.themeDiagnostics.push({ type: "error", message: "Theme path does not exist", path: resolved });
}
}
@@ -478,10 +491,15 @@ export class DefaultResourceLoader implements ResourceLoader {
private normalizeExtensionPaths(
entries: Array<{ path: string; metadata: PathMetadata }>,
): Array<{ path: string; metadata: PathMetadata }> {
return entries.map((entry) => ({
path: this.resolveResourcePath(entry.path),
metadata: entry.metadata,
}));
return entries.map((entry) => {
const metadata = entry.metadata.baseDir
? { ...entry.metadata, baseDir: this.resolveResourcePath(entry.metadata.baseDir) }
: entry.metadata;
return {
path: this.resolveResourcePath(entry.path),
metadata,
};
});
}
private updateSkillsFromPaths(skillPaths: string[], metadataByPath?: Map<string, PathMetadata>): void {
@@ -674,16 +692,7 @@ export class DefaultResourceLoader implements ResourceLoader {
}
private resolveResourcePath(p: string): string {
const trimmed = p.trim();
let expanded = trimmed;
if (trimmed === "~") {
expanded = homedir();
} else if (trimmed.startsWith("~/")) {
expanded = join(homedir(), trimmed.slice(2));
} else if (trimmed.startsWith("~")) {
expanded = join(homedir(), trimmed.slice(1));
}
return resolve(this.cwd, expanded);
return resolvePath(p, this.cwd, { trim: true });
}
private loadThemes(
@@ -704,7 +713,7 @@ export class DefaultResourceLoader implements ResourceLoader {
}
for (const p of paths) {
const resolved = resolve(this.cwd, p);
const resolved = this.resolveResourcePath(p);
if (!existsSync(resolved)) {
diagnostics.push({ type: "warning", message: "theme path does not exist", path: resolved });
continue;

View File

@@ -1,21 +1,22 @@
import { join } from "node:path";
import { Agent, type AgentMessage, type ThinkingLevel } from "@earendil-works/pi-agent-core";
import { clampThinkingLevel, type Message, type Model, streamSimple } from "@earendil-works/pi-ai";
import { getAgentDir } from "../config.js";
import { AgentSession } from "./agent-session.js";
import { formatNoModelsAvailableMessage } from "./auth-guidance.js";
import { AuthStorage } from "./auth-storage.js";
import { DEFAULT_THINKING_LEVEL } from "./defaults.js";
import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.js";
import { convertToLlm } from "./messages.js";
import { ModelRegistry } from "./model-registry.js";
import { findInitialModel } from "./model-resolver.js";
import type { ResourceLoader } from "./resource-loader.js";
import { DefaultResourceLoader } from "./resource-loader.js";
import { getDefaultSessionDir, SessionManager } from "./session-manager.js";
import { SettingsManager } from "./settings-manager.js";
import { isInstallTelemetryEnabled } from "./telemetry.js";
import { time } from "./timings.js";
import { getAgentDir } from "../config.ts";
import { resolvePath } from "../utils/paths.ts";
import { AgentSession } from "./agent-session.ts";
import { formatNoModelsAvailableMessage } from "./auth-guidance.ts";
import { AuthStorage } from "./auth-storage.ts";
import { DEFAULT_THINKING_LEVEL } from "./defaults.ts";
import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.ts";
import { convertToLlm } from "./messages.ts";
import { ModelRegistry } from "./model-registry.ts";
import { findInitialModel } from "./model-resolver.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,
createCodingTools,
@@ -28,7 +29,7 @@ import {
createWriteTool,
type ToolName,
withFileMutationQueue,
} from "./tools/index.js";
} from "./tools/index.ts";
export interface CreateAgentSessionOptions {
/** Working directory for project-local discovery. Default: process.cwd() */
@@ -91,7 +92,7 @@ export interface CreateAgentSessionResult {
// Re-exports
export * from "./agent-session-runtime.js";
export * from "./agent-session-runtime.ts";
export type {
ExtensionAPI,
ExtensionCommandContext,
@@ -100,10 +101,10 @@ export type {
SlashCommandInfo,
SlashCommandSource,
ToolDefinition,
} from "./extensions/index.js";
export type { PromptTemplate } from "./prompt-templates.js";
export type { Skill } from "./skills.js";
export type { Tool } from "./tools/index.js";
} from "./extensions/index.ts";
export type { PromptTemplate } from "./prompt-templates.ts";
export type { Skill } from "./skills.ts";
export type { Tool } from "./tools/index.ts";
export {
withFileMutationQueue,
@@ -184,15 +185,15 @@ function getAttributionHeaders(
* await loader.reload();
* const { session } = await createAgentSession({
* model: myModel,
* tools: [readTool, bashTool],
* tools: ["read", "bash"],
* resourceLoader: loader,
* sessionManager: SessionManager.inMemory(),
* });
* ```
*/
export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise<CreateAgentSessionResult> {
const cwd = options.cwd ?? options.sessionManager?.getCwd() ?? process.cwd();
const agentDir = options.agentDir ?? getDefaultAgentDir();
const cwd = resolvePath(options.cwd ?? options.sessionManager?.getCwd() ?? process.cwd());
const agentDir = options.agentDir ? resolvePath(options.agentDir) : getDefaultAgentDir();
let resourceLoader = options.resourceLoader;
// Use provided or create AuthStorage and ModelRegistry

View File

@@ -1,4 +1,4 @@
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import { type AgentMessage, uuidv7 } from "@earendil-works/pi-agent-core";
import type { ImageContent, Message, TextContent } from "@earendil-works/pi-ai";
import { randomUUID } from "crypto";
import {
@@ -15,15 +15,15 @@ import {
} from "fs";
import { readdir, readFile, stat } from "fs/promises";
import { join, resolve } from "path";
import { v7 as uuidv7 } from "uuid";
import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.js";
import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts";
import { normalizePath, resolvePath } from "../utils/paths.ts";
import {
type BashExecutionMessage,
type CustomMessage,
createBranchSummaryMessage,
createCompactionSummaryMessage,
createCustomMessage,
} from "./messages.js";
} from "./messages.ts";
export const CURRENT_SESSION_VERSION = 3;
@@ -426,8 +426,10 @@ export function buildSessionContext(
* Encodes cwd into a safe directory name under ~/.pi/agent/sessions/.
*/
export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string {
const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
const sessionDir = join(agentDir, "sessions", safePath);
const resolvedCwd = resolvePath(cwd);
const resolvedAgentDir = resolvePath(agentDir);
const safePath = `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
const sessionDir = join(resolvedAgentDir, "sessions", safePath);
if (!existsSync(sessionDir)) {
mkdirSync(sessionDir, { recursive: true });
}
@@ -436,9 +438,10 @@ export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultA
/** Exported for testing */
export function loadEntriesFromFile(filePath: string): FileEntry[] {
if (!existsSync(filePath)) return [];
const resolvedFilePath = normalizePath(filePath);
if (!existsSync(resolvedFilePath)) return [];
const content = readFileSync(filePath, "utf8");
const content = readFileSync(resolvedFilePath, "utf8");
const entries: FileEntry[] = [];
const lines = content.trim().split("\n");
@@ -479,10 +482,11 @@ function isValidSessionFile(filePath: string): boolean {
/** Exported for testing */
export function findMostRecentSession(sessionDir: string): string | null {
const resolvedSessionDir = normalizePath(sessionDir);
try {
const files = readdirSync(sessionDir)
const files = readdirSync(resolvedSessionDir)
.filter((f) => f.endsWith(".jsonl"))
.map((f) => join(sessionDir, f))
.map((f) => join(resolvedSessionDir, f))
.filter(isValidSessionFile)
.map((path) => ({ path, mtime: statSync(path).mtime }))
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
@@ -618,6 +622,48 @@ async function buildSessionInfo(filePath: string): Promise<SessionInfo | null> {
export type SessionListProgress = (loaded: number, total: number) => void;
const MAX_CONCURRENT_SESSION_INFO_LOADS = 10;
async function buildSessionInfosWithConcurrency(
files: string[],
onLoaded: () => void,
): Promise<(SessionInfo | null)[]> {
const results: (SessionInfo | null)[] = new Array(files.length).fill(null);
const inFlight = new Set<Promise<void>>();
let nextIndex = 0;
const startNext = (): void => {
const index = nextIndex++;
const file = files[index];
if (!file) return;
let task: Promise<void>;
task = buildSessionInfo(file)
.then((info) => {
results[index] = info;
})
.catch(() => {
results[index] = null;
})
.finally(() => {
inFlight.delete(task);
onLoaded();
});
inFlight.add(task);
};
while (nextIndex < files.length || inFlight.size > 0) {
while (nextIndex < files.length && inFlight.size < MAX_CONCURRENT_SESSION_INFO_LOADS) {
startNext();
}
if (inFlight.size > 0) {
await Promise.race(inFlight);
}
}
return results;
}
async function listSessionsFromDir(
dir: string,
onProgress?: SessionListProgress,
@@ -635,14 +681,10 @@ async function listSessionsFromDir(
const total = progressTotal ?? files.length;
let loaded = 0;
const results = await Promise.all(
files.map(async (file) => {
const info = await buildSessionInfo(file);
loaded++;
onProgress?.(progressOffset + loaded, total);
return info;
}),
);
const results = await buildSessionInfosWithConcurrency(files, () => {
loaded++;
onProgress?.(progressOffset + loaded, total);
});
for (const info of results) {
if (info) {
sessions.push(info);
@@ -680,11 +722,11 @@ export class SessionManager {
private leafId: string | null = null;
private constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) {
this.cwd = cwd;
this.sessionDir = sessionDir;
this.cwd = resolvePath(cwd);
this.sessionDir = normalizePath(sessionDir);
this.persist = persist;
if (persist && sessionDir && !existsSync(sessionDir)) {
mkdirSync(sessionDir, { recursive: true });
if (persist && this.sessionDir && !existsSync(this.sessionDir)) {
mkdirSync(this.sessionDir, { recursive: true });
}
if (sessionFile) {
@@ -696,7 +738,7 @@ export class SessionManager {
/** Switch to a different session file (used for resume and branching) */
setSessionFile(sessionFile: string): void {
this.sessionFile = resolve(sessionFile);
this.sessionFile = resolvePath(sessionFile);
if (existsSync(this.sessionFile)) {
this.fileEntries = loadEntriesFromFile(this.sessionFile);
@@ -1267,7 +1309,7 @@ export class SessionManager {
* @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).
*/
static create(cwd: string, sessionDir?: string): SessionManager {
const dir = sessionDir ?? getDefaultSessionDir(cwd);
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
return new SessionManager(cwd, dir, undefined, true);
}
@@ -1278,13 +1320,14 @@ export class SessionManager {
* @param cwdOverride Optional cwd override instead of the session header cwd.
*/
static open(path: string, sessionDir?: string, cwdOverride?: string): SessionManager {
const resolvedPath = resolvePath(path);
// Extract cwd from session header if possible, otherwise use process.cwd()
const entries = loadEntriesFromFile(path);
const entries = loadEntriesFromFile(resolvedPath);
const header = entries.find((e) => e.type === "session") as SessionHeader | undefined;
const cwd = cwdOverride ?? header?.cwd ?? process.cwd();
// If no sessionDir provided, derive from file's parent directory
const dir = sessionDir ?? resolve(path, "..");
return new SessionManager(cwd, dir, path, true);
const dir = sessionDir ? normalizePath(sessionDir) : resolve(resolvedPath, "..");
return new SessionManager(cwd, dir, resolvedPath, true);
}
/**
@@ -1293,7 +1336,7 @@ export class SessionManager {
* @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).
*/
static continueRecent(cwd: string, sessionDir?: string): SessionManager {
const dir = sessionDir ?? getDefaultSessionDir(cwd);
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
const mostRecent = findMostRecentSession(dir);
if (mostRecent) {
return new SessionManager(cwd, dir, mostRecent, true);
@@ -1314,17 +1357,19 @@ export class SessionManager {
* @param sessionDir Optional session directory. If omitted, uses default for targetCwd.
*/
static forkFrom(sourcePath: string, targetCwd: string, sessionDir?: string): SessionManager {
const sourceEntries = loadEntriesFromFile(sourcePath);
const resolvedSourcePath = resolvePath(sourcePath);
const resolvedTargetCwd = resolvePath(targetCwd);
const sourceEntries = loadEntriesFromFile(resolvedSourcePath);
if (sourceEntries.length === 0) {
throw new Error(`Cannot fork: source session file is empty or invalid: ${sourcePath}`);
throw new Error(`Cannot fork: source session file is empty or invalid: ${resolvedSourcePath}`);
}
const sourceHeader = sourceEntries.find((e) => e.type === "session") as SessionHeader | undefined;
if (!sourceHeader) {
throw new Error(`Cannot fork: source session has no header: ${sourcePath}`);
throw new Error(`Cannot fork: source session has no header: ${resolvedSourcePath}`);
}
const dir = sessionDir ?? getDefaultSessionDir(targetCwd);
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(resolvedTargetCwd);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
@@ -1341,8 +1386,8 @@ export class SessionManager {
version: CURRENT_SESSION_VERSION,
id: newSessionId,
timestamp,
cwd: targetCwd,
parentSession: sourcePath,
cwd: resolvedTargetCwd,
parentSession: resolvedSourcePath,
};
appendFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`);
@@ -1353,7 +1398,7 @@ export class SessionManager {
}
}
return new SessionManager(targetCwd, dir, newSessionFile, true);
return new SessionManager(resolvedTargetCwd, dir, newSessionFile, true);
}
/**
@@ -1363,7 +1408,7 @@ export class SessionManager {
* @param onProgress Optional callback for progress updates (loaded, total)
*/
static async list(cwd: string, sessionDir?: string, onProgress?: SessionListProgress): Promise<SessionInfo[]> {
const dir = sessionDir ?? getDefaultSessionDir(cwd);
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
const sessions = await listSessionsFromDir(dir, onProgress);
sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
return sessions;
@@ -1401,14 +1446,10 @@ export class SessionManager {
const sessions: SessionInfo[] = [];
const allFiles = dirFiles.flat();
const results = await Promise.all(
allFiles.map(async (file) => {
const info = await buildSessionInfo(file);
loaded++;
onProgress?.(loaded, totalFiles);
return info;
}),
);
const results = await buildSessionInfosWithConcurrency(allFiles, () => {
loaded++;
onProgress?.(loaded, totalFiles);
});
for (const info of results) {
if (info) {

View File

@@ -1,9 +1,10 @@
import type { Transport } from "@earendil-works/pi-ai";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { homedir } from "os";
import { dirname, join } from "path";
import lockfile from "proper-lockfile";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.js";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts";
import { normalizePath, resolvePath } from "../utils/paths.ts";
import { DEFAULT_HTTP_IDLE_TIMEOUT_MS, parseHttpIdleTimeoutMs } from "./http-dispatcher.ts";
export interface CompactionSettings {
enabled?: boolean; // default: true
@@ -110,6 +111,7 @@ export interface Settings {
markdown?: MarkdownSettings;
warnings?: WarningSettings;
sessionDir?: string; // Custom session storage directory (same format as --session-dir CLI flag)
httpIdleTimeoutMs?: number; // HTTP header/body idle timeout in milliseconds; 0 disables it
}
/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
@@ -159,8 +161,10 @@ export class FileSettingsStorage implements SettingsStorage {
private projectSettingsPath: string;
constructor(cwd: string, agentDir: string) {
this.globalSettingsPath = join(agentDir, "settings.json");
this.projectSettingsPath = join(cwd, CONFIG_DIR_NAME, "settings.json");
const resolvedCwd = resolvePath(cwd);
const resolvedAgentDir = resolvePath(agentDir);
this.globalSettingsPath = join(resolvedAgentDir, "settings.json");
this.projectSettingsPath = join(resolvedCwd, CONFIG_DIR_NAME, "settings.json");
}
private acquireLockSyncWithRetry(path: string): () => void {
@@ -575,16 +579,7 @@ export class SettingsManager {
getSessionDir(): string | undefined {
const sessionDir = this.settings.sessionDir;
if (!sessionDir) {
return sessionDir;
}
if (sessionDir === "~") {
return homedir();
}
if (sessionDir.startsWith("~/")) {
return join(homedir(), sessionDir.slice(2));
}
return sessionDir;
return sessionDir ? normalizePath(sessionDir) : sessionDir;
}
getDefaultProvider(): string | undefined {
@@ -726,6 +721,27 @@ 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;
}
setHttpIdleTimeoutMs(timeoutMs: number): void {
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
throw new Error(`Invalid httpIdleTimeoutMs setting: ${String(timeoutMs)}`);
}
this.globalSettings.httpIdleTimeoutMs = Math.floor(timeoutMs);
this.markModified("httpIdleTimeoutMs");
this.save();
}
getProviderRetrySettings(): { timeoutMs?: number; maxRetries?: number; maxRetryDelayMs: number } {
return {
timeoutMs: this.settings.retry?.provider?.timeoutMs,

View File

@@ -1,12 +1,11 @@
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
import ignore from "ignore";
import { homedir } from "os";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "path";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.js";
import { parseFrontmatter } from "../utils/frontmatter.js";
import { canonicalizePath } from "../utils/paths.js";
import type { ResourceDiagnostic } from "./diagnostics.js";
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.js";
import { basename, dirname, join, relative, resolve, sep } from "path";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts";
import { parseFrontmatter } from "../utils/frontmatter.ts";
import { canonicalizePath, resolvePath } from "../utils/paths.ts";
import type { ResourceDiagnostic } from "./diagnostics.ts";
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts";
/** Max name length per spec */
const MAX_NAME_LENGTH = 64;
@@ -90,13 +89,9 @@ export interface LoadSkillsResult {
* Validate skill name per Agent Skills spec.
* Returns array of validation error messages (empty if valid).
*/
function validateName(name: string, parentDirName: string): string[] {
function validateName(name: string): string[] {
const errors: string[] = [];
if (name !== parentDirName) {
errors.push(`name "${name}" does not match parent directory "${parentDirName}"`);
}
if (name.length > MAX_NAME_LENGTH) {
errors.push(`name exceeds ${MAX_NAME_LENGTH} characters (${name.length})`);
}
@@ -301,7 +296,7 @@ function loadSkillFromFile(
const name = frontmatter.name || parentDirName;
// Validate name
const nameErrors = validateName(name, parentDirName);
const nameErrors = validateName(name);
for (const error of nameErrors) {
diagnostics.push({ type: "warning", message: error, path: filePath });
}
@@ -385,28 +380,16 @@ export interface LoadSkillsOptions {
includeDefaults: boolean;
}
function normalizePath(input: string): string {
const trimmed = input.trim();
if (trimmed === "~") return homedir();
if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2));
if (trimmed.startsWith("~")) return join(homedir(), trimmed.slice(1));
return trimmed;
}
function resolveSkillPath(p: string, cwd: string): string {
const normalized = normalizePath(p);
return isAbsolute(normalized) ? normalized : resolve(cwd, normalized);
}
/**
* Load skills from all configured locations.
* Returns skills and any validation diagnostics.
*/
export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult {
const { cwd, agentDir, skillPaths, includeDefaults } = options;
const { agentDir, skillPaths, includeDefaults } = options;
// Resolve agentDir - if not provided, use default from config
const resolvedAgentDir = agentDir ?? getAgentDir();
const resolvedCwd = resolvePath(options.cwd);
const resolvedAgentDir = resolvePath(agentDir ?? getAgentDir());
const skillMap = new Map<string, Skill>();
const realPathSet = new Set<string>();
@@ -446,11 +429,11 @@ export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult {
if (includeDefaults) {
addSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", true));
addSkills(loadSkillsFromDirInternal(resolve(cwd, CONFIG_DIR_NAME, "skills"), "project", true));
addSkills(loadSkillsFromDirInternal(resolve(resolvedCwd, CONFIG_DIR_NAME, "skills"), "project", true));
}
const userSkillsDir = join(resolvedAgentDir, "skills");
const projectSkillsDir = resolve(cwd, CONFIG_DIR_NAME, "skills");
const projectSkillsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "skills");
const isUnderPath = (target: string, root: string): boolean => {
const normalizedRoot = resolve(root);
@@ -470,7 +453,7 @@ export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult {
};
for (const rawPath of skillPaths) {
const resolvedPath = resolveSkillPath(rawPath, cwd);
const resolvedPath = resolvePath(rawPath, resolvedCwd, { trim: true });
if (!existsSync(resolvedPath)) {
allDiagnostics.push({ type: "warning", message: "skill path does not exist", path: resolvedPath });
continue;

View File

@@ -1,5 +1,5 @@
import { APP_NAME } from "../config.js";
import type { SourceInfo } from "./source-info.js";
import { APP_NAME } from "../config.ts";
import type { SourceInfo } from "./source-info.ts";
export type SlashCommandSource = "extension" | "prompt" | "skill";

View File

@@ -1,4 +1,4 @@
import type { PathMetadata } from "./package-manager.js";
import type { PathMetadata } from "./package-manager.ts";
export type SourceScope = "user" | "project" | "temporary";
export type SourceOrigin = "package" | "top-level";

View File

@@ -2,8 +2,8 @@
* System prompt construction and project context loading
*/
import { getDocsPath, getExamplesPath, getReadmePath } from "../config.js";
import { formatSkillsForPrompt, type Skill } from "./skills.js";
import { getDocsPath, getExamplesPath, getReadmePath } from "../config.ts";
import { formatSkillsForPrompt, type Skill } from "./skills.ts";
export interface BuildSystemPromptOptions {
/** Custom system prompt (replaces default). */
@@ -59,11 +59,12 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string {
// Append project context files
if (contextFiles.length > 0) {
prompt += "\n\n# Project Context\n\n";
prompt += "\n\n<project_context>\n\n";
prompt += "Project-specific instructions and guidelines:\n\n";
for (const { path: filePath, content } of contextFiles) {
prompt += `## ${filePath}\n\n${content}\n\n`;
prompt += `<project_instructions path="${filePath}">\n${content}\n</project_instructions>\n\n`;
}
prompt += "</project_context>\n";
}
// Append skills section (only if read tool is available)
@@ -142,6 +143,7 @@ Pi documentation (read only when the user asks about pi itself, its SDK, extensi
- Main documentation: ${readmePath}
- Additional docs: ${docsPath}
- Examples: ${examplesPath} (extensions, custom tools, SDK)
- When reading pi docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory
- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md)
- When working on pi topics, read the docs and examples, and follow .md cross-references before implementing
- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)`;
@@ -152,11 +154,12 @@ Pi documentation (read only when the user asks about pi itself, its SDK, extensi
// Append project context files
if (contextFiles.length > 0) {
prompt += "\n\n# Project Context\n\n";
prompt += "\n\n<project_context>\n\n";
prompt += "Project-specific instructions and guidelines:\n\n";
for (const { path: filePath, content } of contextFiles) {
prompt += `## ${filePath}\n\n${content}\n\n`;
prompt += `<project_instructions path="${filePath}">\n${content}\n</project_instructions>\n\n`;
}
prompt += "</project_context>\n";
}
// Append skills section (only if read tool is available)

View File

@@ -1,4 +1,4 @@
import type { SettingsManager } from "./settings-manager.js";
import type { SettingsManager } from "./settings-manager.ts";
function isTruthyEnvFlag(value: string | undefined): boolean {
if (!value) return false;

View File

@@ -3,22 +3,22 @@ import type { AgentTool } from "@earendil-works/pi-agent-core";
import { Container, Text, truncateToWidth } from "@earendil-works/pi-tui";
import { spawn } from "child_process";
import { type Static, Type } from "typebox";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate.js";
import { theme } from "../../modes/interactive/theme/theme.js";
import { waitForChildProcess } from "../../utils/child-process.js";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate.ts";
import { theme } from "../../modes/interactive/theme/theme.ts";
import { waitForChildProcess } from "../../utils/child-process.ts";
import {
getShellConfig,
getShellEnv,
killProcessTree,
trackDetachedChildPid,
untrackDetachedChildPid,
} from "../../utils/shell.js";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
import { OutputAccumulator } from "./output-accumulator.js";
import { getTextOutput, invalidArgText, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult } from "./truncate.js";
} from "../../utils/shell.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts";
import { OutputAccumulator } from "./output-accumulator.ts";
import { getTextOutput, invalidArgText, str } from "./render-utils.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult } from "./truncate.ts";
const bashSchema = Type.Object({
command: Type.String({ description: "Bash command to execute" }),
@@ -76,6 +76,7 @@ export function createLocalBashOperations(options?: { shellPath?: string }): Bas
detached: process.platform !== "win32",
env: env ?? getShellEnv(),
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
if (child.pid) trackDetachedChildPid(child.pid);
let timedOut = false;
@@ -199,7 +200,15 @@ function rebuildBashResultRenderComponent(
const state = component.state;
component.clear();
const output = getTextOutput(result as any, showImages).trim();
let output = getTextOutput(result as any, showImages).trim();
const truncation = result.details?.truncation;
const fullOutputPath = result.details?.fullOutputPath;
if (!options.isPartial && truncation?.truncated && fullOutputPath && output.endsWith("]")) {
const footerStart = output.lastIndexOf("\n\n[");
if (footerStart !== -1 && output.slice(footerStart).includes(fullOutputPath)) {
output = output.slice(0, footerStart).trimEnd();
}
}
if (output) {
const styledOutput = output
@@ -235,8 +244,6 @@ function rebuildBashResultRenderComponent(
}
}
const truncation = result.details?.truncation;
const fullOutputPath = result.details?.fullOutputPath;
if (truncation?.truncated || fullOutputPath) {
const warnings: string[] = [];
if (fullOutputPath) {

View File

@@ -6,7 +6,7 @@
import * as Diff from "diff";
import { constants } from "fs";
import { access, readFile } from "fs/promises";
import { resolveToCwd } from "./path-utils.js";
import { resolveToCwd } from "./path-utils.ts";
export function detectLineEnding(content: string): "\r\n" | "\n" {
const crlfIdx = content.indexOf("\r\n");
@@ -259,8 +259,16 @@ export function applyEditsToNormalizedContent(
return { baseContent, newContent };
}
/** Generate a standard unified patch. */
export function generateUnifiedPatch(path: string, oldContent: string, newContent: string, contextLines = 4): string {
return Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, {
context: contextLines,
headerOptions: Diff.FILE_HEADERS_ONLY,
});
}
/**
* Generate a unified diff string with line numbers and context.
* Generate a display-oriented diff string with line numbers and context.
* Returns both the diff string and the first changed line number (in the new file).
*/
export function generateDiffString(

View File

@@ -3,8 +3,8 @@ import { Box, Container, Spacer, Text } from "@earendil-works/pi-tui";
import { constants } from "fs";
import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "fs/promises";
import { type Static, Type } from "typebox";
import { renderDiff } from "../../modes/interactive/components/diff.js";
import type { ToolDefinition } from "../extensions/types.js";
import { renderDiff } from "../../modes/interactive/components/diff.ts";
import type { ToolDefinition } from "../extensions/types.ts";
import {
applyEditsToNormalizedContent,
computeEditsDiff,
@@ -13,14 +13,15 @@ import {
type EditDiffError,
type EditDiffResult,
generateDiffString,
generateUnifiedPatch,
normalizeToLF,
restoreLineEndings,
stripBom,
} from "./edit-diff.js";
import { withFileMutationQueue } from "./file-mutation-queue.js";
import { resolveToCwd } from "./path-utils.js";
import { invalidArgText, shortenPath, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
} from "./edit-diff.ts";
import { withFileMutationQueue } from "./file-mutation-queue.ts";
import { resolveToCwd } from "./path-utils.ts";
import { invalidArgText, shortenPath, str } from "./render-utils.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
type EditPreview = EditDiffResult | EditDiffError;
@@ -57,8 +58,10 @@ type LegacyEditToolInput = EditToolInput & {
};
export interface EditToolDetails {
/** Unified diff of the changes made */
/** Display-oriented diff of the changes made */
diff: string;
/** Standard unified patch of the changes made */
patch: string;
/** Line number of the first change in the new file (for editor navigation) */
firstChangedLine?: number;
}
@@ -190,7 +193,7 @@ function getRenderablePreviewInput(args: RenderableEditArgs | undefined): { path
function formatEditCall(
args: RenderableEditArgs | undefined,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
): string {
const invalidArg = invalidArgText(theme);
const rawPath = str(args?.file_path ?? args?.path);
@@ -203,7 +206,7 @@ function formatEditResult(
args: RenderableEditArgs | undefined,
preview: EditPreview | undefined,
result: EditToolResultLike,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
isError: boolean,
): string | undefined {
const rawPath = str(args?.file_path ?? args?.path);
@@ -231,7 +234,7 @@ function formatEditResult(
function getEditHeaderBg(
preview: EditPreview | undefined,
settledError: boolean | undefined,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
): (text: string) => string {
if (preview) {
if ("error" in preview) {
@@ -248,7 +251,7 @@ function getEditHeaderBg(
function buildEditCallComponent(
component: EditCallRenderComponent,
args: RenderableEditArgs | undefined,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
): EditCallRenderComponent {
component.setBgFn(getEditHeaderBg(component.preview, component.settledError, theme));
component.clear();
@@ -394,6 +397,7 @@ export function createEditToolDefinition(
}
const diffResult = generateDiffString(baseContent, newContent);
const patch = generateUnifiedPatch(path, baseContent, newContent);
resolve({
content: [
{
@@ -401,7 +405,7 @@ export function createEditToolDefinition(
text: `Successfully replaced ${edits.length} block(s) in ${path}.`,
},
],
details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine },
details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
});
} catch (error: unknown) {
// Clean up abort handler.

View File

@@ -5,13 +5,13 @@ import { spawn } from "child_process";
import { existsSync } from "fs";
import path from "path";
import { type Static, Type } from "typebox";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
import { ensureTool } from "../../utils/tools-manager.js";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
import { resolveToCwd } from "./path-utils.js";
import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
import { ensureTool } from "../../utils/tools-manager.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts";
import { resolveToCwd } from "./path-utils.ts";
import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts";
function toPosixPath(value: string): string {
return value.split(path.sep).join("/");
@@ -58,7 +58,7 @@ export interface FindToolOptions {
function formatFindCall(
args: { pattern: string; path?: string; limit?: number } | undefined,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
): string {
const pattern = str(args?.pattern);
const rawPath = str(args?.path);
@@ -82,7 +82,7 @@ function formatFindResult(
details?: FindToolDetails;
},
options: ToolRenderResultOptions,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
showImages: boolean,
): string {
const output = getTextOutput(result, showImages).trim();

View File

@@ -5,12 +5,12 @@ import { spawn } from "child_process";
import { readFileSync, statSync } from "fs";
import path from "path";
import { type Static, Type } from "typebox";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
import { ensureTool } from "../../utils/tools-manager.js";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
import { resolveToCwd } from "./path-utils.js";
import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
import { ensureTool } from "../../utils/tools-manager.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts";
import { resolveToCwd } from "./path-utils.ts";
import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
import {
DEFAULT_MAX_BYTES,
formatSize,
@@ -18,7 +18,7 @@ import {
type TruncationResult,
truncateHead,
truncateLine,
} from "./truncate.js";
} from "./truncate.ts";
const grepSchema = Type.Object({
pattern: Type.String({ description: "Search pattern (regex or literal string)" }),
@@ -66,7 +66,7 @@ export interface GrepToolOptions {
function formatGrepCall(
args: { pattern: string; path?: string; glob?: string; limit?: number } | undefined,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
): string {
const pattern = str(args?.pattern);
const rawPath = str(args?.path);
@@ -90,7 +90,7 @@ function formatGrepResult(
details?: GrepToolDetails;
},
options: ToolRenderResultOptions,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
showImages: boolean,
): string {
const output = getTextOutput(result, showImages).trim();

View File

@@ -8,7 +8,7 @@ export {
createBashTool,
createBashToolDefinition,
createLocalBashOperations,
} from "./bash.js";
} from "./bash.ts";
export {
createEditTool,
createEditToolDefinition,
@@ -16,8 +16,8 @@ export {
type EditToolDetails,
type EditToolInput,
type EditToolOptions,
} from "./edit.js";
export { withFileMutationQueue } from "./file-mutation-queue.js";
} from "./edit.ts";
export { withFileMutationQueue } from "./file-mutation-queue.ts";
export {
createFindTool,
createFindToolDefinition,
@@ -25,7 +25,7 @@ export {
type FindToolDetails,
type FindToolInput,
type FindToolOptions,
} from "./find.js";
} from "./find.ts";
export {
createGrepTool,
createGrepToolDefinition,
@@ -33,7 +33,7 @@ export {
type GrepToolDetails,
type GrepToolInput,
type GrepToolOptions,
} from "./grep.js";
} from "./grep.ts";
export {
createLsTool,
createLsToolDefinition,
@@ -41,7 +41,7 @@ export {
type LsToolDetails,
type LsToolInput,
type LsToolOptions,
} from "./ls.js";
} from "./ls.ts";
export {
createReadTool,
createReadToolDefinition,
@@ -49,7 +49,7 @@ export {
type ReadToolDetails,
type ReadToolInput,
type ReadToolOptions,
} from "./read.js";
} from "./read.ts";
export {
DEFAULT_MAX_BYTES,
DEFAULT_MAX_LINES,
@@ -59,24 +59,24 @@ export {
truncateHead,
truncateLine,
truncateTail,
} from "./truncate.js";
} from "./truncate.ts";
export {
createWriteTool,
createWriteToolDefinition,
type WriteOperations,
type WriteToolInput,
type WriteToolOptions,
} from "./write.js";
} from "./write.ts";
import type { AgentTool } from "@earendil-works/pi-agent-core";
import type { ToolDefinition } from "../extensions/types.js";
import { type BashToolOptions, createBashTool, createBashToolDefinition } from "./bash.js";
import { createEditTool, createEditToolDefinition, type EditToolOptions } from "./edit.js";
import { createFindTool, createFindToolDefinition, type FindToolOptions } from "./find.js";
import { createGrepTool, createGrepToolDefinition, type GrepToolOptions } from "./grep.js";
import { createLsTool, createLsToolDefinition, type LsToolOptions } from "./ls.js";
import { createReadTool, createReadToolDefinition, type ReadToolOptions } from "./read.js";
import { createWriteTool, createWriteToolDefinition, type WriteToolOptions } from "./write.js";
import type { ToolDefinition } from "../extensions/types.ts";
import { type BashToolOptions, createBashTool, createBashToolDefinition } from "./bash.ts";
import { createEditTool, createEditToolDefinition, type EditToolOptions } from "./edit.ts";
import { createFindTool, createFindToolDefinition, type FindToolOptions } from "./find.ts";
import { createGrepTool, createGrepToolDefinition, type GrepToolOptions } from "./grep.ts";
import { createLsTool, createLsToolDefinition, type LsToolOptions } from "./ls.ts";
import { createReadTool, createReadToolDefinition, type ReadToolOptions } from "./read.ts";
import { createWriteTool, createWriteToolDefinition, type WriteToolOptions } from "./write.ts";
export type Tool = AgentTool<any>;
export type ToolDef = ToolDefinition<any, any>;

View File

@@ -3,12 +3,12 @@ import { Text } from "@earendil-works/pi-tui";
import { existsSync, readdirSync, statSync } from "fs";
import nodePath from "path";
import { type Static, Type } from "typebox";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
import { resolveToCwd } from "./path-utils.js";
import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts";
import { resolveToCwd } from "./path-utils.ts";
import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts";
const lsSchema = Type.Object({
path: Type.Optional(Type.String({ description: "Directory to list (default: current directory)" })),
@@ -50,7 +50,7 @@ export interface LsToolOptions {
function formatLsCall(
args: { path?: string; limit?: number } | undefined,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
): string {
const rawPath = str(args?.path);
const path = rawPath !== null ? shortenPath(rawPath || ".") : null;
@@ -69,7 +69,7 @@ function formatLsResult(
details?: LsToolDetails;
},
options: ToolRenderResultOptions,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
showImages: boolean,
): string {
const output = getTextOutput(result, showImages).trim();

View File

@@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto";
import { createWriteStream, type WriteStream } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult, truncateTail } from "./truncate.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult, truncateTail } from "./truncate.ts";
export interface OutputAccumulatorOptions {
maxLines?: number;
@@ -45,8 +45,10 @@ export class OutputAccumulator {
private tailStartsAtLineBoundary = true;
private totalRawBytes = 0;
private totalDecodedBytes = 0;
private totalLines = 1;
private completedLines = 0;
private totalLines = 0;
private currentLineBytes = 0;
private hasOpenLine = false;
private finished = false;
private tempFilePath: string | undefined;
@@ -164,10 +166,14 @@ export class OutputAccumulator {
}
if (newlines === 0) {
this.currentLineBytes += bytes;
this.hasOpenLine = true;
} else {
this.totalLines += newlines;
this.currentLineBytes = byteLength(text.slice(lastNewline + 1));
this.completedLines += newlines;
const tail = text.slice(lastNewline + 1);
this.currentLineBytes = byteLength(tail);
this.hasOpenLine = tail.length > 0;
}
this.totalLines = this.completedLines + (this.hasOpenLine ? 1 : 0);
}
private trimTail(): void {

View File

@@ -1,12 +1,7 @@
import { accessSync, constants } from "node:fs";
import * as os from "node:os";
import { isAbsolute, resolve as resolvePath } from "node:path";
import { normalizePath, resolvePath } from "../../utils/paths.ts";
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
const NARROW_NO_BREAK_SPACE = "\u202F";
function normalizeUnicodeSpaces(str: string): string {
return str.replace(UNICODE_SPACES, " ");
}
function tryMacOSScreenshotPath(filePath: string): string {
return filePath.replace(/ (AM|PM)\./gi, `${NARROW_NO_BREAK_SPACE}$1.`);
@@ -32,19 +27,8 @@ function fileExists(filePath: string): boolean {
}
}
function normalizeAtPrefix(filePath: string): string {
return filePath.startsWith("@") ? filePath.slice(1) : filePath;
}
export function expandPath(filePath: string): string {
const normalized = normalizeUnicodeSpaces(normalizeAtPrefix(filePath));
if (normalized === "~") {
return os.homedir();
}
if (normalized.startsWith("~/")) {
return os.homedir() + normalized.slice(1);
}
return normalized;
return normalizePath(filePath, { normalizeUnicodeSpaces: true, stripAtPrefix: true });
}
/**
@@ -52,11 +36,7 @@ export function expandPath(filePath: string): string {
* Handles ~ expansion and absolute paths.
*/
export function resolveToCwd(filePath: string, cwd: string): string {
const expanded = expandPath(filePath);
if (isAbsolute(expanded)) {
return expanded;
}
return resolvePath(cwd, expanded);
return resolvePath(filePath, cwd, { normalizeUnicodeSpaces: true, stripAtPrefix: true });
}
export function resolveReadPath(filePath: string, cwd: string): string {

View File

@@ -5,17 +5,17 @@ import { Text } from "@earendil-works/pi-tui";
import { constants } from "fs";
import { access as fsAccess, readFile as fsReadFile } from "fs/promises";
import { type Static, Type } from "typebox";
import { getReadmePath } from "../../config.js";
import { keyHint, keyText } from "../../modes/interactive/components/keybinding-hints.js";
import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/interactive/theme/theme.js";
import { formatDimensionNote, resizeImage } from "../../utils/image-resize.js";
import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js";
import { formatPathRelativeToCwdOrAbsolute } from "../../utils/paths.js";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
import { resolveReadPath } from "./path-utils.js";
import { getTextOutput, invalidArgText, replaceTabs, shortenPath, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
import { getReadmePath } from "../../config.ts";
import { keyHint, keyText } from "../../modes/interactive/components/keybinding-hints.ts";
import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/interactive/theme/theme.ts";
import { formatDimensionNote, resizeImage } from "../../utils/image-resize.ts";
import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.ts";
import { formatPathRelativeToCwdOrAbsolute } from "../../utils/paths.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts";
import { resolveReadPath } from "./path-utils.ts";
import { getTextOutput, invalidArgText, replaceTabs, shortenPath, str } from "./render-utils.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts";
const readSchema = Type.Object({
path: Type.String({ description: "Path to the file to read (relative or absolute)" }),

View File

@@ -1,8 +1,8 @@
import * as os from "node:os";
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import { getCapabilities, getImageDimensions, imageFallback } from "@earendil-works/pi-tui";
import stripAnsi from "strip-ansi";
import { sanitizeBinaryOutput } from "../../utils/shell.js";
import { stripAnsi } from "../../utils/ansi.ts";
import { sanitizeBinaryOutput } from "../../utils/shell.ts";
export function shortenPath(path: unknown): string {
if (typeof path !== "string") return "";

View File

@@ -1,5 +1,5 @@
import type { AgentTool } from "@earendil-works/pi-agent-core";
import type { ExtensionContext, ToolDefinition } from "../extensions/types.js";
import type { ExtensionContext, ToolDefinition } from "../extensions/types.ts";
/** Wrap a ToolDefinition into an AgentTool for the core runtime. */
export function wrapToolDefinition<TDetails = unknown>(

View File

@@ -44,6 +44,17 @@ export interface TruncationOptions {
maxBytes?: number;
}
function splitLinesForCounting(content: string): string[] {
if (content.length === 0) {
return [];
}
const lines = content.split("\n");
if (content.endsWith("\n")) {
lines.pop();
}
return lines;
}
/**
* Format bytes as human-readable size.
*/
@@ -69,7 +80,7 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
const totalBytes = Buffer.byteLength(content, "utf-8");
const lines = content.split("\n");
const lines = splitLinesForCounting(content);
const totalLines = lines.length;
// Check if no truncation needed
@@ -159,7 +170,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
const totalBytes = Buffer.byteLength(content, "utf-8");
const lines = content.split("\n");
const lines = splitLinesForCounting(content);
const totalLines = lines.length;
// Check if no truncation needed

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