feat(coding-agent): ResourceLoader, package management, and /reload command (#645)

- Add ResourceLoader interface and DefaultResourceLoader implementation
- Add PackageManager for npm/git extension sources with install/remove/update
- Add session.reload() and session.bindExtensions() APIs
- Add /reload command in interactive mode
- Add CLI flags: --skill, --theme, --prompt-template, --no-themes, --no-prompt-templates
- Add pi install/remove/update commands for extension management
- Refactor settings.json to use arrays for skills, prompts, themes
- Remove legacy SkillsSettings source flags and filters
- Update SDK examples and documentation for ResourceLoader pattern
- Add theme registration and loadThemeFromPath for dynamic themes
- Add getShellEnv to include bin dir in PATH for bash commands
This commit is contained in:
Mario Zechner 2026-01-20 23:34:53 +01:00
parent 866d21c252
commit b846a4bfcf
51 changed files with 2724 additions and 1852 deletions

View file

@ -1,64 +0,0 @@
---
name: pi-tmux-test
description: Test pi's interactive mode via tmux. Use when you need to test TUI behavior, extensions, or interactive features programmatically.
---
# Testing Pi Interactively via tmux
Use tmux to test pi's interactive mode. This allows sending input and capturing output programmatically.
## Setup
```bash
# Kill any existing test session and create a new one
tmux kill-session -t pi-test 2>/dev/null
tmux new-session -d -s pi-test -c /Users/badlogic/workspaces/pi-mono -x 100 -y 30
# Start pi using the test script (runs via tsx, picks up source changes)
# Always use --no-session to avoid creating session files during testing
tmux send-keys -t pi-test "./pi-test.sh --no-session" Enter
# Wait for startup
sleep 4
tmux capture-pane -t pi-test -p
```
## Interaction
```bash
# Send input
tmux send-keys -t pi-test "your message here" Enter
# Wait and capture output
sleep 5
tmux capture-pane -t pi-test -p
# Send special keys
tmux send-keys -t pi-test Escape
tmux send-keys -t pi-test C-c # Ctrl+C
tmux send-keys -t pi-test C-d # Ctrl+D
```
## Cleanup
```bash
tmux kill-session -t pi-test
```
## Testing Extensions
Write extensions to /tmp and load with `-e`:
```bash
cat > /tmp/test-extension.ts << 'EOF'
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
export default function (pi: ExtensionAPI) {
// extension code
}
EOF
# Run pi with the extension
tmux send-keys -t pi-test "./pi-test.sh --no-session -e /tmp/test-extension.ts" Enter
```
Clean up after testing: `rm /tmp/test-extension.ts`

View file

@ -2,6 +2,22 @@
## [Unreleased] ## [Unreleased]
### Breaking Changes
- Resource loading now uses `ResourceLoader` only and settings.json uses arrays for extensions, skills, prompts, and themes ([#645](https://github.com/badlogic/pi-mono/issues/645))
- Removed `discoverAuthStorage` and `discoverModels` from the SDK. `AuthStorage` and `ModelRegistry` now default to `~/.pi/agent` paths unless you pass an `agentDir` ([#645](https://github.com/badlogic/pi-mono/issues/645))
### Added
- `markdown.codeBlockIndent` setting to customize code block indentation in rendered output
- Extension package management with `pi install`, `pi remove`, and `pi update` commands ([#645](https://github.com/badlogic/pi-mono/issues/645))
- `/reload` command to reload extensions, skills, prompts, and themes ([#645](https://github.com/badlogic/pi-mono/issues/645))
- CLI flags for `--skill`, `--prompt-template`, `--theme`, `--no-prompt-templates`, and `--no-themes` ([#645](https://github.com/badlogic/pi-mono/issues/645))
### Changed
- Skill, prompt template, and theme discovery now use settings and CLI path arrays instead of legacy filters ([#645](https://github.com/badlogic/pi-mono/issues/645))
## [0.49.3] - 2026-01-22 ## [0.49.3] - 2026-01-22
### Added ### Added

View file

@ -315,6 +315,7 @@ The agent reads, writes, and edits files, and executes commands via bash.
| `/new` | Start a new session | | `/new` | Start a new session |
| `/copy` | Copy last agent message to clipboard | | `/copy` | Copy last agent message to clipboard |
| `/compact [instructions]` | Manually compact conversation context | | `/compact [instructions]` | Manually compact conversation context |
| `/reload` | Reload extensions, skills, prompts, and themes |
### Editor Features ### Editor Features
@ -791,9 +792,10 @@ Global `~/.pi/agent/settings.json` stores persistent preferences:
"reserveTokens": 16384, "reserveTokens": 16384,
"keepRecentTokens": 20000 "keepRecentTokens": 20000
}, },
"skills": { "skills": ["/path/to/skills"],
"enabled": true "prompts": ["/path/to/prompts"],
}, "themes": ["/path/to/themes"],
"enableSkillCommands": true,
"retry": { "retry": {
"enabled": true, "enabled": true,
"maxRetries": 3, "maxRetries": 3,
@ -827,7 +829,10 @@ Global `~/.pi/agent/settings.json` stores persistent preferences:
| `compaction.enabled` | Enable auto-compaction | `true` | | `compaction.enabled` | Enable auto-compaction | `true` |
| `compaction.reserveTokens` | Tokens to reserve before compaction triggers | `16384` | | `compaction.reserveTokens` | Tokens to reserve before compaction triggers | `16384` |
| `compaction.keepRecentTokens` | Recent tokens to keep after compaction | `20000` | | `compaction.keepRecentTokens` | Recent tokens to keep after compaction | `20000` |
| `skills.enabled` | Enable skills discovery | `true` | | `skills` | Additional skill file or directory paths | `[]` |
| `prompts` | Additional prompt template paths | `[]` |
| `themes` | Additional theme file or directory paths | `[]` |
| `enableSkillCommands` | Register skills as `/skill:name` commands | `true` |
| `retry.enabled` | Auto-retry on transient errors | `true` | | `retry.enabled` | Auto-retry on transient errors | `true` |
| `retry.maxRetries` | Maximum retry attempts | `3` | | `retry.maxRetries` | Maximum retry attempts | `3` |
| `retry.baseDelayMs` | Base delay for exponential backoff | `2000` | | `retry.baseDelayMs` | Base delay for exponential backoff | `2000` |
@ -835,10 +840,10 @@ Global `~/.pi/agent/settings.json` stores persistent preferences:
| `images.autoResize` | Auto-resize images to 2000x2000 max for better model compatibility | `true` | | `images.autoResize` | Auto-resize images to 2000x2000 max for better model compatibility | `true` |
| `images.blockImages` | Prevent images from being sent to LLM providers | `false` | | `images.blockImages` | Prevent images from being sent to LLM providers | `false` |
| `showHardwareCursor` | Show terminal cursor while still positioning it for IME support | `false` | | `showHardwareCursor` | Show terminal cursor while still positioning it for IME support | `false` |
| `doubleEscapeAction` | Action for double-escape with empty editor: `tree` or `branch` | `tree` | | `doubleEscapeAction` | Action for double-escape with empty editor: `tree` or `fork` | `tree` |
| `editorPaddingX` | Horizontal padding for input editor (0-3) | `0` | | `editorPaddingX` | Horizontal padding for input editor (0-3) | `0` |
| `markdown.codeBlockIndent` | Prefix for each rendered code block line | `" "` | | `markdown.codeBlockIndent` | Prefix for each rendered code block line | `" "` |
| `extensions` | Additional extension file paths | `[]` | | `extensions` | Extension sources or file paths (npm:, git:, local) | `[]` |
--- ---
@ -852,6 +857,8 @@ Select theme via `/settings` or set in `~/.pi/agent/settings.json`.
**Custom themes:** Create `~/.pi/agent/themes/*.json`. Custom themes support live reload. **Custom themes:** Create `~/.pi/agent/themes/*.json`. Custom themes support live reload.
Add additional theme paths via `settings.json` `themes` array or `--theme <path>`. Disable automatic theme discovery with `--no-themes`.
```bash ```bash
mkdir -p ~/.pi/agent/themes mkdir -p ~/.pi/agent/themes
cp $(npm root -g)/@mariozechner/pi-coding-agent/dist/theme/dark.json ~/.pi/agent/themes/my-theme.json cp $(npm root -g)/@mariozechner/pi-coding-agent/dist/theme/dark.json ~/.pi/agent/themes/my-theme.json
@ -870,6 +877,8 @@ Define reusable prompts as Markdown files:
**Locations:** **Locations:**
- Global: `~/.pi/agent/prompts/*.md` - Global: `~/.pi/agent/prompts/*.md`
- Project: `.pi/prompts/*.md` - Project: `.pi/prompts/*.md`
- Additional paths from settings.json `prompts` array
- CLI `--prompt-template` paths
**Format:** **Format:**
@ -900,6 +909,8 @@ Usage: `/component Button "onClick handler" "disabled support"`
- `${@:N}` = arguments from the Nth position onwards (1-indexed) - `${@:N}` = arguments from the Nth position onwards (1-indexed)
- `${@:N:L}` = `L` arguments starting from the Nth position - `${@:N:L}` = `L` arguments starting from the Nth position
Disable prompt template discovery with `--no-prompt-templates`.
**Namespacing:** Subdirectories create prefixes. `.pi/prompts/frontend/component.md``/component (project:frontend)` **Namespacing:** Subdirectories create prefixes. `.pi/prompts/frontend/component.md``/component (project:frontend)`
@ -918,10 +929,12 @@ A skill provides specialized workflows, setup instructions, helper scripts, and
- YouTube transcript extraction - YouTube transcript extraction
**Skill locations:** **Skill locations:**
- Pi user: `~/.pi/agent/skills/**/SKILL.md` (recursive) - Global: `~/.pi/agent/skills/`
- Pi project: `.pi/skills/**/SKILL.md` (recursive) - Project: `.pi/skills/`
- Claude Code: `~/.claude/skills/*/SKILL.md` and `.claude/skills/*/SKILL.md` - Additional paths from settings.json `skills` array
- Codex CLI: `~/.codex/skills/**/SKILL.md` (recursive) - CLI `--skill` paths (additive even with `--no-skills`)
Use `enableSkillCommands` in settings to toggle `/skill:name` commands.
**Format:** **Format:**
@ -948,7 +961,7 @@ cd /path/to/brave-search && npm install
- `name`: Required. Must match parent directory name. Lowercase, hyphens, max 64 chars. - `name`: Required. Must match parent directory name. Lowercase, hyphens, max 64 chars.
- `description`: Required. Max 1024 chars. Determines when the skill is loaded. - `description`: Required. Max 1024 chars. Determines when the skill is loaded.
**Disable skills:** `pi --no-skills` or set `skills.enabled: false` in settings. **Disable skills:** `pi --no-skills` (automatic discovery off, explicit `--skill` paths still load).
> See [docs/skills.md](docs/skills.md) for details, examples, and links to skill repositories. pi can help you create new skills. > See [docs/skills.md](docs/skills.md) for details, examples, and links to skill repositories. pi can help you create new skills.
@ -967,7 +980,18 @@ Extensions are TypeScript modules that extend pi's behavior.
**Locations:** **Locations:**
- Global: `~/.pi/agent/extensions/*.ts` or `~/.pi/agent/extensions/*/index.ts` - Global: `~/.pi/agent/extensions/*.ts` or `~/.pi/agent/extensions/*/index.ts`
- Project: `.pi/extensions/*.ts` or `.pi/extensions/*/index.ts` - Project: `.pi/extensions/*.ts` or `.pi/extensions/*/index.ts`
- CLI: `--extension <path>` or `-e <path>` - Settings: `extensions` array supports file paths and `npm:` or `git:` sources
- CLI: `--extension <path>` or `-e <path>` (temporary for this run)
Install and remove extension sources with the CLI:
```bash
pi install npm:@foo/bar@1.0.0
pi install git:github.com/user/repo@v1
pi remove npm:@foo/bar
```
Use `-l` to install into project settings (`.pi/settings.json`).
**Dependencies:** Extensions can have their own dependencies. Place a `package.json` next to the extension (or in a parent directory), run `npm install`, and imports are resolved via [jiti](https://github.com/unjs/jiti). See [examples/extensions/with-deps/](examples/extensions/with-deps/). **Dependencies:** Extensions can have their own dependencies. Place a `package.json` next to the extension (or in a parent directory), run `npm install`, and imports are resolved via [jiti](https://github.com/unjs/jiti). See [examples/extensions/with-deps/](examples/extensions/with-deps/).
@ -1214,6 +1238,14 @@ await ctx.ui.custom((tui, theme, done) => ({
pi [options] [@files...] [messages...] pi [options] [@files...] [messages...]
``` ```
### Commands
| Command | Description |
|---------|-------------|
| `install <source> [-l]` | Install extension source and add to settings (`-l` for project) |
| `remove <source> [-l]` | Remove extension source from settings |
| `update [source]` | Update installed extensions (skips pinned sources) |
### Options ### Options
| Option | Description | | Option | Description |
@ -1236,8 +1268,12 @@ pi [options] [@files...] [messages...]
| `--thinking <level>` | Thinking level: `off`, `minimal`, `low`, `medium`, `high` | | `--thinking <level>` | Thinking level: `off`, `minimal`, `low`, `medium`, `high` |
| `--extension <path>`, `-e` | Load an extension file (can be used multiple times) | | `--extension <path>`, `-e` | Load an extension file (can be used multiple times) |
| `--no-extensions` | Disable extension discovery (explicit `-e` paths still work) | | `--no-extensions` | Disable extension discovery (explicit `-e` paths still work) |
| `--skill <path>` | Load a skill file or directory (can be used multiple times) |
| `--prompt-template <path>` | Load a prompt template file or directory (can be used multiple times) |
| `--theme <path>` | Load a theme file or directory (can be used multiple times) |
| `--no-skills` | Disable skills discovery and loading | | `--no-skills` | Disable skills discovery and loading |
| `--skills <patterns>` | Comma-separated glob patterns to filter skills (e.g., `git-*,docker`) | | `--no-prompt-templates` | Disable prompt template discovery and loading |
| `--no-themes` | Disable theme discovery and loading |
| `--export <file> [output]` | Export session to HTML | | `--export <file> [output]` | Export session to HTML |
| `--help`, `-h` | Show help | | `--help`, `-h` | Show help |
| `--version`, `-v` | Show version | | `--version`, `-v` | Show version |
@ -1339,10 +1375,10 @@ For adding new tools, see [Extensions](#extensions) in the Customization section
For embedding pi in Node.js/TypeScript applications, use the SDK: For embedding pi in Node.js/TypeScript applications, use the SDK:
```typescript ```typescript
import { createAgentSession, discoverAuthStorage, discoverModels, SessionManager } from "@mariozechner/pi-coding-agent"; import { AuthStorage, createAgentSession, ModelRegistry, SessionManager } from "@mariozechner/pi-coding-agent";
const authStorage = discoverAuthStorage(); const authStorage = new AuthStorage();
const modelRegistry = discoverModels(authStorage); const modelRegistry = new ModelRegistry(authStorage);
const { session } = await createAgentSession({ const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(), sessionManager: SessionManager.inMemory(),
@ -1361,15 +1397,13 @@ await session.prompt("What files are in the current directory?");
The SDK provides full control over: The SDK provides full control over:
- Model selection and thinking level - Model selection and thinking level
- System prompt (replace or modify)
- Tools (built-in subsets, custom tools) - Tools (built-in subsets, custom tools)
- Extensions (discovered or via paths) - Resources via `ResourceLoader` (extensions, skills, prompts, themes, context files, system prompt)
- Skills, context files, prompt templates
- Session persistence (`SessionManager`) - Session persistence (`SessionManager`)
- Settings (`SettingsManager`) - Settings (`SettingsManager`)
- API key resolution and OAuth - API key resolution and OAuth
**Philosophy:** "Omit to discover, provide to override." Omit an option and pi discovers from standard locations. Provide an option and your value is used. **Philosophy:** "Omit to discover, provide to override." Resource discovery is handled by `ResourceLoader`, while model and tool options still follow the omit or override pattern.
> See [SDK Documentation](docs/sdk.md) for the full API reference. See [examples/sdk/](examples/sdk/) for working examples from minimal to full control. > See [SDK Documentation](docs/sdk.md) for the full API reference. See [examples/sdk/](examples/sdk/) for working examples from minimal to full control.

View file

@ -115,10 +115,22 @@ Additional paths via `settings.json`:
```json ```json
{ {
"extensions": ["/path/to/extension.ts", "/path/to/extension/dir"] "extensions": [
"npm:@foo/bar@1.0.0",
"git:github.com/user/repo@v1",
"/path/to/extension.ts",
"/path/to/extension/dir"
]
} }
``` ```
Use `pi install` and `pi remove` to manage extension sources in settings:
```bash
pi install npm:@foo/bar@1.0.0
pi remove npm:@foo/bar
```
**Discovery rules:** **Discovery rules:**
1. **Direct files:** `extensions/*.ts` or `*.js` → loaded directly 1. **Direct files:** `extensions/*.ts` or `*.js` → loaded directly
@ -146,13 +158,17 @@ Additional paths via `settings.json`:
"zod": "^3.0.0" "zod": "^3.0.0"
}, },
"pi": { "pi": {
"extensions": ["./src/safety-gates.ts", "./src/custom-tools.ts"] "extensions": ["./src/safety-gates.ts", "./src/custom-tools.ts"],
"skills": ["./skills/"],
"prompts": ["./prompts/"],
"themes": ["./themes/dark.json"]
} }
} }
``` ```
The `package.json` approach enables: The `package.json` approach enables:
- Multiple extensions from one package - Multiple extensions from one package
- Skills, prompts, and themes declared alongside extensions
- Third-party npm dependencies (resolved via jiti) - Third-party npm dependencies (resolved via jiti)
- Nested source structure (no depth limit within the package) - Nested source structure (no depth limit within the package)
- Deployment to and installation from npm - Deployment to and installation from npm

View file

@ -16,11 +16,11 @@ See [examples/sdk/](../examples/sdk/) for working examples from minimal to full
## Quick Start ## Quick Start
```typescript ```typescript
import { createAgentSession, discoverAuthStorage, discoverModels, SessionManager } from "@mariozechner/pi-coding-agent"; import { AuthStorage, createAgentSession, ModelRegistry, SessionManager } from "@mariozechner/pi-coding-agent";
// Set up credential storage and model registry // Set up credential storage and model registry
const authStorage = discoverAuthStorage(); const authStorage = new AuthStorage();
const modelRegistry = discoverModels(authStorage); const modelRegistry = new ModelRegistry(authStorage);
const { session } = await createAgentSession({ const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(), sessionManager: SessionManager.inMemory(),
@ -51,20 +51,17 @@ The SDK is included in the main package. No separate installation needed.
The main factory function. Creates an `AgentSession` with configurable options. The main factory function. Creates an `AgentSession` with configurable options.
**Philosophy:** "Omit to discover, provide to override." `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.
- Omit an option → pi discovers/loads from standard locations
- Provide an option → your value is used, discovery skipped for that option
```typescript ```typescript
import { createAgentSession } from "@mariozechner/pi-coding-agent"; import { createAgentSession } from "@mariozechner/pi-coding-agent";
// Minimal: all defaults (discovers everything from cwd and ~/.pi/agent) // Minimal: defaults with DefaultResourceLoader
const { session } = await createAgentSession(); const { session } = await createAgentSession();
// Custom: override specific options // Custom: override specific options
const { session } = await createAgentSession({ const { session } = await createAgentSession({
model: myModel, model: myModel,
systemPrompt: "You are helpful.",
tools: [readTool, bashTool], tools: [readTool, bashTool],
sessionManager: SessionManager.inMemory(), sessionManager: SessionManager.inMemory(),
}); });
@ -251,7 +248,7 @@ session.subscribe((event) => {
```typescript ```typescript
const { session } = await createAgentSession({ const { session } = await createAgentSession({
// Working directory for project-local discovery // Working directory for DefaultResourceLoader discovery
cwd: process.cwd(), // default cwd: process.cwd(), // default
// Global config directory // Global config directory
@ -259,14 +256,14 @@ const { session } = await createAgentSession({
}); });
``` ```
`cwd` is used for: `cwd` is used by `DefaultResourceLoader` for:
- Project extensions (`.pi/extensions/`) - Project extensions (`.pi/extensions/`)
- Project skills (`.pi/skills/`) - Project skills (`.pi/skills/`)
- Project prompts (`.pi/prompts/`) - Project prompts (`.pi/prompts/`)
- Context files (`AGENTS.md` walking up from cwd) - Context files (`AGENTS.md` walking up from cwd)
- Session directory naming - Session directory naming
`agentDir` is used for: `agentDir` is used by `DefaultResourceLoader` for:
- Global extensions (`extensions/`) - Global extensions (`extensions/`)
- Global skills (`skills/`) - Global skills (`skills/`)
- Global prompts (`prompts/`) - Global prompts (`prompts/`)
@ -276,14 +273,16 @@ const { session } = await createAgentSession({
- Credentials (`auth.json`) - Credentials (`auth.json`)
- Sessions (`sessions/`) - Sessions (`sessions/`)
When you pass a custom `ResourceLoader`, `cwd` and `agentDir` no longer control resource discovery. They still influence session naming and tool path resolution.
### Model ### Model
```typescript ```typescript
import { getModel } from "@mariozechner/pi-ai"; import { getModel } from "@mariozechner/pi-ai";
import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent"; import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
const authStorage = discoverAuthStorage(); const authStorage = new AuthStorage();
const modelRegistry = discoverModels(authStorage); const modelRegistry = new ModelRegistry(authStorage);
// Find specific built-in model (doesn't check if API key exists) // Find specific built-in model (doesn't check if API key exists)
const opus = getModel("anthropic", "claude-opus-4-5"); const opus = getModel("anthropic", "claude-opus-4-5");
@ -327,11 +326,11 @@ API key resolution priority (handled by AuthStorage):
4. Fallback resolver (for custom provider keys from `models.json`) 4. Fallback resolver (for custom provider keys from `models.json`)
```typescript ```typescript
import { AuthStorage, ModelRegistry, discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent"; import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
// Default: uses ~/.pi/agent/auth.json and ~/.pi/agent/models.json // Default: uses ~/.pi/agent/auth.json and ~/.pi/agent/models.json
const authStorage = discoverAuthStorage(); const authStorage = new AuthStorage();
const modelRegistry = discoverModels(authStorage); const modelRegistry = new ModelRegistry(authStorage);
const { session } = await createAgentSession({ const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(), sessionManager: SessionManager.inMemory(),
@ -360,16 +359,17 @@ const simpleRegistry = new ModelRegistry(authStorage);
### System Prompt ### System Prompt
Use a `ResourceLoader` to override the system prompt:
```typescript ```typescript
const { session } = await createAgentSession({ import { createAgentSession, DefaultResourceLoader } from "@mariozechner/pi-coding-agent";
// Replace entirely
systemPrompt: "You are a helpful assistant.", const loader = new DefaultResourceLoader({
systemPromptOverride: () => "You are a helpful assistant.",
// Or modify default (receives default, returns modified)
systemPrompt: (defaultPrompt) => {
return `${defaultPrompt}\n\n## Additional Rules\n- Be concise`;
},
}); });
await loader.reload();
const { session } = await createAgentSession({ resourceLoader: loader });
``` ```
> See [examples/sdk/03-custom-prompt.ts](../examples/sdk/03-custom-prompt.ts) > See [examples/sdk/03-custom-prompt.ts](../examples/sdk/03-custom-prompt.ts)
@ -462,61 +462,45 @@ const { session } = await createAgentSession({
}); });
``` ```
Custom tools passed via `customTools` are combined with extension-registered tools. Extensions discovered from `~/.pi/agent/extensions/` and `.pi/extensions/` can also register tools via `pi.registerTool()`. Custom tools passed via `customTools` are combined with extension-registered tools. Extensions loaded by the ResourceLoader can also register tools via `pi.registerTool()`.
> See [examples/sdk/05-tools.ts](../examples/sdk/05-tools.ts) > See [examples/sdk/05-tools.ts](../examples/sdk/05-tools.ts)
### Extensions ### Extensions
By default, extensions are discovered from multiple locations: Extensions are loaded by the `ResourceLoader`. `DefaultResourceLoader` discovers extensions from `~/.pi/agent/extensions/`, `.pi/extensions/`, and settings.json extension sources.
- `~/.pi/agent/extensions/` (global)
- `.pi/extensions/` (project-local)
- Paths listed in `settings.json` `"extensions"` array
```typescript ```typescript
import { createAgentSession, type ExtensionFactory } from "@mariozechner/pi-coding-agent"; import { createAgentSession, DefaultResourceLoader } from "@mariozechner/pi-coding-agent";
// Inline extension factory const loader = new DefaultResourceLoader({
const myExtension: ExtensionFactory = (pi) => {
pi.on("tool_call", async (event, ctx) => {
console.log(`Tool: ${event.toolName}`);
});
pi.registerCommand("hello", {
description: "Say hello",
handler: async (args, ctx) => {
ctx.ui.notify("Hello!", "info");
},
});
};
// Pass inline extensions (skips file discovery)
const { session } = await createAgentSession({
extensions: [myExtension],
});
// Add paths to load (merged with discovery)
const { session } = await createAgentSession({
additionalExtensionPaths: ["/path/to/my-extension.ts"], additionalExtensionPaths: ["/path/to/my-extension.ts"],
extensionFactories: [
(pi) => {
pi.on("agent_start", () => {
console.log("[Inline Extension] Agent starting");
});
},
],
}); });
await loader.reload();
// Disable extension discovery entirely const { session } = await createAgentSession({ resourceLoader: loader });
const { session } = await createAgentSession({
extensions: [],
});
``` ```
Extensions can register tools, subscribe to events, add commands, and more. See [extensions.md](extensions.md) for the full API. Extensions can register tools, subscribe to events, add commands, and more. See [extensions.md](extensions.md) for the full API.
**Event Bus:** Extensions can communicate via `pi.events`. Pass a shared `eventBus` to `createAgentSession()` if you need to emit/listen from outside: **Event Bus:** Extensions can communicate via `pi.events`. Pass a shared `eventBus` to `DefaultResourceLoader` if you need to emit or listen from outside:
```typescript ```typescript
import { createAgentSession, createEventBus } from "@mariozechner/pi-coding-agent"; import { createEventBus, DefaultResourceLoader } from "@mariozechner/pi-coding-agent";
const eventBus = createEventBus(); const eventBus = createEventBus();
const { session } = await createAgentSession({ eventBus }); const loader = new DefaultResourceLoader({
eventBus,
});
await loader.reload();
// Listen for events from extensions
eventBus.on("my-extension:status", (data) => console.log(data)); eventBus.on("my-extension:status", (data) => console.log(data));
``` ```
@ -525,14 +509,13 @@ eventBus.on("my-extension:status", (data) => console.log(data));
### Skills ### Skills
```typescript ```typescript
import { createAgentSession, discoverSkills, type Skill } from "@mariozechner/pi-coding-agent"; import {
createAgentSession,
DefaultResourceLoader,
type Skill,
} from "@mariozechner/pi-coding-agent";
// Discover and filter const customSkill: Skill = {
const { skills: allSkills, warnings } = discoverSkills();
const filtered = allSkills.filter(s => s.name.includes("search"));
// Custom skill
const mySkill: Skill = {
name: "my-skill", name: "my-skill",
description: "Custom instructions", description: "Custom instructions",
filePath: "/path/to/SKILL.md", filePath: "/path/to/SKILL.md",
@ -540,20 +523,15 @@ const mySkill: Skill = {
source: "custom", source: "custom",
}; };
const { session } = await createAgentSession({ const loader = new DefaultResourceLoader({
skills: [...filtered, mySkill], skillsOverride: (current) => ({
skills: [...current.skills, customSkill],
diagnostics: current.diagnostics,
}),
}); });
await loader.reload();
// Disable skills const { session } = await createAgentSession({ resourceLoader: loader });
const { session } = await createAgentSession({
skills: [],
});
// Discovery with settings filter
const { skills } = discoverSkills(process.cwd(), undefined, {
ignoredSkills: ["browser-*"], // glob patterns to exclude
includeSkills: ["search-*"], // glob patterns to include (empty = all)
});
``` ```
> See [examples/sdk/04-skills.ts](../examples/sdk/04-skills.ts) > See [examples/sdk/04-skills.ts](../examples/sdk/04-skills.ts)
@ -561,26 +539,19 @@ const { skills } = discoverSkills(process.cwd(), undefined, {
### Context Files ### Context Files
```typescript ```typescript
import { createAgentSession, discoverContextFiles } from "@mariozechner/pi-coding-agent"; import { createAgentSession, DefaultResourceLoader } from "@mariozechner/pi-coding-agent";
// Discover AGENTS.md files const loader = new DefaultResourceLoader({
const discovered = discoverContextFiles(); agentsFilesOverride: (current) => ({
agentsFiles: [
// Add custom context ...current.agentsFiles,
const { session } = await createAgentSession({ { path: "/virtual/AGENTS.md", content: "# Guidelines\n\n- Be concise" },
contextFiles: [ ],
...discovered, }),
{
path: "/virtual/AGENTS.md",
content: "# Guidelines\n\n- Be concise\n- Use TypeScript",
},
],
}); });
await loader.reload();
// Disable context files const { session } = await createAgentSession({ resourceLoader: loader });
const { session } = await createAgentSession({
contextFiles: [],
});
``` ```
> See [examples/sdk/07-context-files.ts](../examples/sdk/07-context-files.ts) > See [examples/sdk/07-context-files.ts](../examples/sdk/07-context-files.ts)
@ -588,9 +559,11 @@ const { session } = await createAgentSession({
### Slash Commands ### Slash Commands
```typescript ```typescript
import { createAgentSession, discoverPromptTemplates, type PromptTemplate } from "@mariozechner/pi-coding-agent"; import {
createAgentSession,
const discovered = discoverPromptTemplates(); DefaultResourceLoader,
type PromptTemplate,
} from "@mariozechner/pi-coding-agent";
const customCommand: PromptTemplate = { const customCommand: PromptTemplate = {
name: "deploy", name: "deploy",
@ -599,9 +572,15 @@ const customCommand: PromptTemplate = {
content: "# Deploy\n\n1. Build\n2. Test\n3. Deploy", content: "# Deploy\n\n1. Build\n2. Test\n3. Deploy",
}; };
const { session } = await createAgentSession({ const loader = new DefaultResourceLoader({
promptTemplates: [...discovered, customCommand], promptsOverride: (current) => ({
prompts: [...current.prompts, customCommand],
diagnostics: current.diagnostics,
}),
}); });
await loader.reload();
const { session } = await createAgentSession({ resourceLoader: loader });
``` ```
> See [examples/sdk/08-prompt-templates.ts](../examples/sdk/08-prompt-templates.ts) > See [examples/sdk/08-prompt-templates.ts](../examples/sdk/08-prompt-templates.ts)
@ -719,62 +698,31 @@ Settings load from two locations and merge:
1. Global: `~/.pi/agent/settings.json` 1. Global: `~/.pi/agent/settings.json`
2. Project: `<cwd>/.pi/settings.json` 2. Project: `<cwd>/.pi/settings.json`
Project overrides global. Nested objects merge keys. Setters only modify global (project is read-only for version control). Project overrides global. Nested objects merge keys. Setters modify global settings by default.
> See [examples/sdk/10-settings.ts](../examples/sdk/10-settings.ts) > See [examples/sdk/10-settings.ts](../examples/sdk/10-settings.ts)
## Discovery Functions ## ResourceLoader
All discovery functions accept optional `cwd` and `agentDir` parameters. Use `DefaultResourceLoader` to discover extensions, skills, prompts, themes, and context files.
```typescript ```typescript
import { getModel } from "@mariozechner/pi-ai";
import { import {
AuthStorage, DefaultResourceLoader,
ModelRegistry, getAgentDir,
discoverAuthStorage,
discoverModels,
discoverSkills,
discoverExtensions,
discoverContextFiles,
discoverPromptTemplates,
loadSettings,
buildSystemPrompt,
createEventBus,
} from "@mariozechner/pi-coding-agent"; } from "@mariozechner/pi-coding-agent";
// Auth and Models const loader = new DefaultResourceLoader({
const authStorage = discoverAuthStorage(); // ~/.pi/agent/auth.json
const modelRegistry = discoverModels(authStorage); // + ~/.pi/agent/models.json
const allModels = modelRegistry.getAll(); // All models (built-in + custom)
const available = await modelRegistry.getAvailable(); // Only models with API keys
const model = modelRegistry.find("provider", "id"); // Find specific model
const builtIn = getModel("anthropic", "claude-opus-4-5"); // Built-in only
// Skills
const { skills, warnings } = discoverSkills(cwd, agentDir, skillsSettings);
// Extensions (async - loads TypeScript)
// Pass eventBus to share pi.events across extensions
const eventBus = createEventBus();
const { extensions, errors } = await discoverExtensions(eventBus, cwd, agentDir);
// Context files
const contextFiles = discoverContextFiles(cwd, agentDir);
// Prompt templates
const templates = discoverPromptTemplates(cwd, agentDir);
// Settings (global + project merged)
const settings = loadSettings(cwd, agentDir);
// Build system prompt manually
const prompt = buildSystemPrompt({
skills,
contextFiles,
appendPrompt: "Additional instructions",
cwd, cwd,
agentDir: getAgentDir(),
}); });
await loader.reload();
const extensions = loader.getExtensions();
const skills = loader.getSkills();
const prompts = loader.getPrompts();
const themes = loader.getThemes();
const contextFiles = loader.getAgentsFiles().agentsFiles;
``` ```
## Return Value ## Return Value
@ -808,12 +756,12 @@ import { Type } from "@sinclair/typebox";
import { import {
AuthStorage, AuthStorage,
createAgentSession, createAgentSession,
DefaultResourceLoader,
ModelRegistry, ModelRegistry,
SessionManager, SessionManager,
SettingsManager, SettingsManager,
readTool, readTool,
bashTool, bashTool,
type ExtensionFactory,
type ToolDefinition, type ToolDefinition,
} from "@mariozechner/pi-coding-agent"; } from "@mariozechner/pi-coding-agent";
@ -828,14 +776,6 @@ if (process.env.MY_KEY) {
// Model registry (no custom models.json) // Model registry (no custom models.json)
const modelRegistry = new ModelRegistry(authStorage); const modelRegistry = new ModelRegistry(authStorage);
// Inline extension
const auditExtension: ExtensionFactory = (pi) => {
pi.on("tool_call", async (event) => {
console.log(`[Audit] ${event.toolName}`);
return undefined;
});
};
// Inline tool // Inline tool
const statusTool: ToolDefinition = { const statusTool: ToolDefinition = {
name: "status", name: "status",
@ -857,24 +797,27 @@ const settingsManager = SettingsManager.inMemory({
retry: { enabled: true, maxRetries: 2 }, retry: { enabled: true, maxRetries: 2 },
}); });
const loader = new DefaultResourceLoader({
cwd: process.cwd(),
agentDir: "/custom/agent",
settingsManager,
systemPromptOverride: () => "You are a minimal assistant. Be concise.",
});
await loader.reload();
const { session } = await createAgentSession({ const { session } = await createAgentSession({
cwd: process.cwd(), cwd: process.cwd(),
agentDir: "/custom/agent", agentDir: "/custom/agent",
model, model,
thinkingLevel: "off", thinkingLevel: "off",
authStorage, authStorage,
modelRegistry, modelRegistry,
systemPrompt: "You are a minimal assistant. Be concise.",
tools: [readTool, bashTool], tools: [readTool, bashTool],
customTools: [statusTool], customTools: [statusTool],
extensions: [auditExtension], resourceLoader: loader,
skills: [],
contextFiles: [],
promptTemplates: [],
sessionManager: SessionManager.inMemory(), sessionManager: SessionManager.inMemory(),
settingsManager, settingsManager,
}); });
@ -976,21 +919,13 @@ createAgentSession
// Auth and Models // Auth and Models
AuthStorage AuthStorage
ModelRegistry ModelRegistry
discoverAuthStorage
discoverModels
// Discovery // Resource loading
discoverSkills DefaultResourceLoader
discoverExtensions type ResourceLoader
discoverContextFiles
discoverPromptTemplates
// Event Bus (for shared extension communication)
createEventBus createEventBus
// Helpers // Helpers
loadSettings
buildSystemPrompt
// Session management // Session management
SessionManager SessionManager
@ -1016,8 +951,6 @@ type ExtensionAPI
type ToolDefinition type ToolDefinition
type Skill type Skill
type PromptTemplate type PromptTemplate
type Settings
type SkillsSettings
type Tool type Tool
``` ```

View file

@ -141,64 +141,43 @@ Run the extraction script:
Skills are discovered from these locations (later wins on name collision): Skills are discovered from these locations (later wins on name collision):
1. `~/.codex/skills/**/SKILL.md` (Codex CLI, recursive) 1. `~/.pi/agent/skills/` (global)
2. `~/.claude/skills/*/SKILL.md` (Claude Code user, one level) 2. `<cwd>/.pi/skills/` (project)
3. `<cwd>/.claude/skills/*/SKILL.md` (Claude Code project, one level) 3. Paths listed in `settings.json` under `skills`
4. `~/.pi/agent/skills/**/SKILL.md` (Pi user, recursive) 4. CLI `--skill` paths (additive even with `--no-skills`)
5. `<cwd>/.pi/skills/**/SKILL.md` (Pi project, recursive)
Discovery rules for each directory:
- Direct `.md` files in the root
- Recursive `SKILL.md` files under subdirectories
## Configuration ## Configuration
Configure skill loading in `~/.pi/agent/settings.json`: Configure skill paths in `~/.pi/agent/settings.json`:
```json ```json
{ {
"skills": { "skills": ["~/my-skills-repo", "/path/to/skill/SKILL.md"],
"enabled": true, "enableSkillCommands": true
"enableCodexUser": true,
"enableClaudeUser": true,
"enableClaudeProject": true,
"enablePiUser": true,
"enablePiProject": true,
"enableSkillCommands": true,
"customDirectories": ["~/my-skills-repo"],
"ignoredSkills": ["deprecated-skill"],
"includeSkills": ["git-*", "docker"]
}
} }
``` ```
Use project-local settings in `<cwd>/.pi/settings.json` to scope skills to a single project.
| Setting | Default | Description | | Setting | Default | Description |
|---------|---------|-------------| |---------|---------|-------------|
| `enabled` | `true` | Master toggle for all skills | | `skills` | `[]` | Additional skill file or directory paths |
| `enableCodexUser` | `true` | Load from `~/.codex/skills/` |
| `enableClaudeUser` | `true` | Load from `~/.claude/skills/` |
| `enableClaudeProject` | `true` | Load from `<cwd>/.claude/skills/` |
| `enablePiUser` | `true` | Load from `~/.pi/agent/skills/` |
| `enablePiProject` | `true` | Load from `<cwd>/.pi/skills/` |
| `enableSkillCommands` | `true` | Register skills as `/skill:name` commands | | `enableSkillCommands` | `true` | Register skills as `/skill:name` commands |
| `customDirectories` | `[]` | Additional directories to scan (supports `~` expansion) |
| `ignoredSkills` | `[]` | Glob patterns to exclude (e.g., `["deprecated-*", "test-skill"]`) |
| `includeSkills` | `[]` | Glob patterns to include (empty = all; e.g., `["git-*", "docker"]`) |
**Note:** `ignoredSkills` takes precedence over both `includeSkills` in settings and the `--skills` CLI flag. A skill matching any ignore pattern will be excluded regardless of include patterns. ### CLI Additions
### CLI Filtering Use `--skill` to add skills for a specific invocation:
Use `--skills` to filter skills for a specific invocation:
```bash ```bash
# Only load specific skills pi --skill ~/my-skills/terraform
pi --skills git,docker pi --skill ./skills/custom-skill/SKILL.md
# Glob patterns
pi --skills "git-*,docker-*"
# All skills matching a prefix
pi --skills "aws-*"
``` ```
This overrides the `includeSkills` setting for the current session. Use `--no-skills` to disable automatic discovery (CLI `--skill` paths still load).
## How Skills Work ## How Skills Work
@ -224,9 +203,7 @@ Toggle skill commands via `/settings` or in `settings.json`:
```json ```json
{ {
"skills": { "enableSkillCommands": true
"enableSkillCommands": true
}
} }
``` ```
@ -285,12 +262,6 @@ cd /path/to/brave-search && npm install
\`\`\` \`\`\`
``` ```
## Compatibility
**Claude Code**: Pi reads skills from `~/.claude/skills/*/SKILL.md`. The `allowed-tools` and `model` frontmatter fields are ignored.
**Codex CLI**: Pi reads skills from `~/.codex/skills/` recursively. Hidden files/directories and symlinks are skipped.
## Skill Repositories ## Skill Repositories
For inspiration and ready-to-use skills: For inspiration and ready-to-use skills:
@ -305,13 +276,4 @@ CLI:
pi --no-skills pi --no-skills
``` ```
Settings (`~/.pi/agent/settings.json`): Use `--no-skills` to disable automatic discovery while keeping explicit `--skill` paths.
```json
{
"skills": {
"enabled": false
}
}
```
Use the granular `enable*` flags to disable individual sources (e.g., `enableClaudeUser: false` to skip `~/.claude/skills`).

View file

@ -5,11 +5,11 @@
*/ */
import { getModel } from "@mariozechner/pi-ai"; import { getModel } from "@mariozechner/pi-ai";
import { createAgentSession, discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent"; import { AuthStorage, createAgentSession, ModelRegistry } from "@mariozechner/pi-coding-agent";
// Set up auth storage and model registry // Set up auth storage and model registry
const authStorage = discoverAuthStorage(); const authStorage = new AuthStorage();
const modelRegistry = discoverModels(authStorage); const modelRegistry = new ModelRegistry(authStorage);
// Option 1: Find a specific built-in model by provider/id // Option 1: Find a specific built-in model by provider/id
const opus = getModel("anthropic", "claude-opus-4-5"); const opus = getModel("anthropic", "claude-opus-4-5");

View file

@ -4,12 +4,18 @@
* Shows how to replace or modify the default system prompt. * Shows how to replace or modify the default system prompt.
*/ */
import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent"; import { createAgentSession, DefaultResourceLoader, SessionManager } from "@mariozechner/pi-coding-agent";
// Option 1: Replace prompt entirely // Option 1: Replace prompt entirely
const { session: session1 } = await createAgentSession({ const loader1 = new DefaultResourceLoader({
systemPrompt: `You are a helpful assistant that speaks like a pirate. systemPromptOverride: () => `You are a helpful assistant that speaks like a pirate.
Always end responses with "Arrr!"`, Always end responses with "Arrr!"`,
appendSystemPromptOverride: () => [],
});
await loader1.reload();
const { session: session1 } = await createAgentSession({
resourceLoader: loader1,
sessionManager: SessionManager.inMemory(), sessionManager: SessionManager.inMemory(),
}); });
@ -23,13 +29,17 @@ console.log("=== Replace prompt ===");
await session1.prompt("What is 2 + 2?"); await session1.prompt("What is 2 + 2?");
console.log("\n"); console.log("\n");
// Option 2: Modify default prompt (receives default, returns modified) // Option 2: Append instructions to the default prompt
const { session: session2 } = await createAgentSession({ const loader2 = new DefaultResourceLoader({
systemPrompt: (defaultPrompt) => `${defaultPrompt} appendSystemPromptOverride: (base) => [
...base,
"## Additional Instructions\n- Always be concise\n- Use bullet points when listing things",
],
});
await loader2.reload();
## Additional Instructions const { session: session2 } = await createAgentSession({
- Always be concise resourceLoader: loader2,
- Use bullet points when listing things`,
sessionManager: SessionManager.inMemory(), sessionManager: SessionManager.inMemory(),
}); });

View file

@ -5,20 +5,7 @@
* Discover, filter, merge, or replace them. * Discover, filter, merge, or replace them.
*/ */
import { createAgentSession, discoverSkills, SessionManager, type Skill } from "@mariozechner/pi-coding-agent"; import { createAgentSession, DefaultResourceLoader, SessionManager, type Skill } from "@mariozechner/pi-coding-agent";
// Discover all skills from cwd/.pi/skills, ~/.pi/agent/skills, etc.
const { skills: allSkills, warnings } = discoverSkills();
console.log(
"Discovered skills:",
allSkills.map((s) => s.name),
);
if (warnings.length > 0) {
console.log("Warnings:", warnings);
}
// Filter to specific skills
const filteredSkills = allSkills.filter((s) => s.name.includes("browser") || s.name.includes("search"));
// Or define custom skills inline // Or define custom skills inline
const customSkill: Skill = { const customSkill: Skill = {
@ -29,19 +16,30 @@ const customSkill: Skill = {
source: "custom", source: "custom",
}; };
// Use filtered + custom skills const loader = new DefaultResourceLoader({
skillsOverride: (current) => {
const filteredSkills = current.skills.filter((s) => s.name.includes("browser") || s.name.includes("search"));
return {
skills: [...filteredSkills, customSkill],
diagnostics: current.diagnostics,
};
},
});
await loader.reload();
// Discover all skills from cwd/.pi/skills, ~/.pi/agent/skills, etc.
const discovered = loader.getSkills();
console.log(
"Discovered skills:",
discovered.skills.map((s) => s.name),
);
if (discovered.diagnostics.length > 0) {
console.log("Warnings:", discovered.diagnostics);
}
await createAgentSession({ await createAgentSession({
skills: [...filteredSkills, customSkill], resourceLoader: loader,
sessionManager: SessionManager.inMemory(), sessionManager: SessionManager.inMemory(),
}); });
console.log(`Session created with ${filteredSkills.length + 1} skills`); console.log("Session created with filtered skills");
// To disable all skills:
// skills: []
// To use discovery with filtering via settings:
// discoverSkills(process.cwd(), undefined, {
// ignoredSkills: ["browser-tools"], // glob patterns to exclude
// includeSkills: ["brave-*"], // glob patterns to include (empty = all)
// })

View file

@ -13,16 +13,25 @@
* export default function (pi: ExtensionAPI) { ... } * export default function (pi: ExtensionAPI) { ... }
*/ */
import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent"; import { createAgentSession, DefaultResourceLoader, SessionManager } from "@mariozechner/pi-coding-agent";
// Extensions are discovered automatically from standard locations. // Extensions are discovered automatically from standard locations.
// You can also add paths via: // You can also add paths via settings.json or DefaultResourceLoader options.
// 1. settings.json: { "extensions": ["./my-extension.ts"] }
// 2. additionalExtensionPaths option (see below)
// To add additional extension paths beyond discovery: const resourceLoader = new DefaultResourceLoader({
const { session } = await createAgentSession({
additionalExtensionPaths: ["./my-logging-extension.ts", "./my-safety-extension.ts"], additionalExtensionPaths: ["./my-logging-extension.ts", "./my-safety-extension.ts"],
extensionFactories: [
(pi) => {
pi.on("agent_start", () => {
console.log("[Inline Extension] Agent starting");
});
},
],
});
await resourceLoader.reload();
const { session } = await createAgentSession({
resourceLoader,
sessionManager: SessionManager.inMemory(), sessionManager: SessionManager.inMemory(),
}); });

View file

@ -4,33 +4,36 @@
* Context files provide project-specific instructions loaded into the system prompt. * Context files provide project-specific instructions loaded into the system prompt.
*/ */
import { createAgentSession, discoverContextFiles, SessionManager } from "@mariozechner/pi-coding-agent"; import { createAgentSession, DefaultResourceLoader, SessionManager } from "@mariozechner/pi-coding-agent";
// Discover AGENTS.md files walking up from cwd const loader = new DefaultResourceLoader({
const discovered = discoverContextFiles(); agentsFilesOverride: (current) => ({
console.log("Discovered context files:"); agentsFiles: [
for (const file of discovered) { ...current.agentsFiles,
console.log(` - ${file.path} (${file.content.length} chars)`); {
} path: "/virtual/AGENTS.md",
content: `# Project Guidelines
// Use custom context files
await createAgentSession({
contextFiles: [
...discovered,
{
path: "/virtual/AGENTS.md",
content: `# Project Guidelines
## Code Style ## Code Style
- Use TypeScript strict mode - Use TypeScript strict mode
- No any types - No any types
- Prefer const over let`, - Prefer const over let`,
}, },
], ],
}),
});
await loader.reload();
// Discover AGENTS.md files walking up from cwd
const discovered = loader.getAgentsFiles().agentsFiles;
console.log("Discovered context files:");
for (const file of discovered) {
console.log(` - ${file.path} (${file.content.length} chars)`);
}
await createAgentSession({
resourceLoader: loader,
sessionManager: SessionManager.inMemory(), sessionManager: SessionManager.inMemory(),
}); });
console.log(`Session created with ${discovered.length + 1} context files`); console.log(`Session created with ${discovered.length + 1} context files`);
// Disable context files:
// contextFiles: []

View file

@ -6,18 +6,11 @@
import { import {
createAgentSession, createAgentSession,
discoverPromptTemplates, DefaultResourceLoader,
type PromptTemplate, type PromptTemplate,
SessionManager, SessionManager,
} from "@mariozechner/pi-coding-agent"; } from "@mariozechner/pi-coding-agent";
// Discover templates from cwd/.pi/prompts/ and ~/.pi/agent/prompts/
const discovered = discoverPromptTemplates();
console.log("Discovered prompt templates:");
for (const template of discovered) {
console.log(` /${template.name}: ${template.description}`);
}
// Define custom templates // Define custom templates
const deployTemplate: PromptTemplate = { const deployTemplate: PromptTemplate = {
name: "deploy", name: "deploy",
@ -30,13 +23,24 @@ const deployTemplate: PromptTemplate = {
3. Deploy: npm run deploy`, 3. Deploy: npm run deploy`,
}; };
// Use discovered + custom templates const loader = new DefaultResourceLoader({
promptsOverride: (current) => ({
prompts: [...current.prompts, deployTemplate],
diagnostics: current.diagnostics,
}),
});
await loader.reload();
// Discover templates from cwd/.pi/prompts/ and ~/.pi/agent/prompts/
const discovered = loader.getPrompts().prompts;
console.log("Discovered prompt templates:");
for (const template of discovered) {
console.log(` /${template.name}: ${template.description}`);
}
await createAgentSession({ await createAgentSession({
promptTemplates: [...discovered, deployTemplate], resourceLoader: loader,
sessionManager: SessionManager.inMemory(), sessionManager: SessionManager.inMemory(),
}); });
console.log(`Session created with ${discovered.length + 1} prompt templates`); console.log(`Session created with ${discovered.length + 1} prompt templates`);
// Disable prompt templates:
// promptTemplates: []

View file

@ -4,19 +4,12 @@
* Configure API key resolution via AuthStorage and ModelRegistry. * Configure API key resolution via AuthStorage and ModelRegistry.
*/ */
import { import { AuthStorage, createAgentSession, ModelRegistry, SessionManager } from "@mariozechner/pi-coding-agent";
AuthStorage,
createAgentSession,
discoverAuthStorage,
discoverModels,
ModelRegistry,
SessionManager,
} from "@mariozechner/pi-coding-agent";
// Default: discoverAuthStorage() uses ~/.pi/agent/auth.json // Default: AuthStorage uses ~/.pi/agent/auth.json
// discoverModels() loads built-in + custom models from ~/.pi/agent/models.json // ModelRegistry loads built-in + custom models from ~/.pi/agent/models.json
const authStorage = discoverAuthStorage(); const authStorage = new AuthStorage();
const modelRegistry = discoverModels(authStorage); const modelRegistry = new ModelRegistry(authStorage);
await createAgentSession({ await createAgentSession({
sessionManager: SessionManager.inMemory(), sessionManager: SessionManager.inMemory(),

View file

@ -4,11 +4,11 @@
* Override settings using SettingsManager. * Override settings using SettingsManager.
*/ */
import { createAgentSession, loadSettings, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent"; import { createAgentSession, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent";
// Load current settings (merged global + project) // Load current settings (merged global + project)
const settings = loadSettings(); const settingsManagerFromDisk = SettingsManager.create();
console.log("Current settings:", JSON.stringify(settings, null, 2)); console.log("Current settings:", JSON.stringify(settingsManagerFromDisk.getGlobalSettings(), null, 2));
// Override specific settings // Override specific settings
const settingsManager = SettingsManager.create(); const settingsManager = SettingsManager.create();

View file

@ -13,8 +13,10 @@ import {
AuthStorage, AuthStorage,
createAgentSession, createAgentSession,
createBashTool, createBashTool,
createExtensionRuntime,
createReadTool, createReadTool,
ModelRegistry, ModelRegistry,
type ResourceLoader,
SessionManager, SessionManager,
SettingsManager, SettingsManager,
} from "@mariozechner/pi-coding-agent"; } from "@mariozechner/pi-coding-agent";
@ -42,6 +44,18 @@ const settingsManager = SettingsManager.inMemory({
// When using a custom cwd with explicit tools, use the factory functions // When using a custom cwd with explicit tools, use the factory functions
const cwd = process.cwd(); const cwd = process.cwd();
const resourceLoader: ResourceLoader = {
getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
getSkills: () => ({ skills: [], diagnostics: [] }),
getPrompts: () => ({ prompts: [], diagnostics: [] }),
getThemes: () => ({ themes: [], diagnostics: [] }),
getAgentsFiles: () => ({ agentsFiles: [] }),
getSystemPrompt: () => `You are a minimal assistant.
Available: read, bash. Be concise.`,
getAppendSystemPrompt: () => [],
reload: async () => {},
};
const { session } = await createAgentSession({ const { session } = await createAgentSession({
cwd, cwd,
agentDir: "/tmp/my-agent", agentDir: "/tmp/my-agent",
@ -49,15 +63,9 @@ const { session } = await createAgentSession({
thinkingLevel: "off", thinkingLevel: "off",
authStorage, authStorage,
modelRegistry, modelRegistry,
systemPrompt: `You are a minimal assistant. resourceLoader,
Available: read, bash. Be concise.`,
// Use factory functions with the same cwd to ensure path resolution works correctly // Use factory functions with the same cwd to ensure path resolution works correctly
tools: [createReadTool(cwd), createBashTool(cwd)], tools: [createReadTool(cwd), createBashTool(cwd)],
// Pass empty array to disable extension discovery, or provide inline factories
extensions: [],
skills: [],
contextFiles: [],
promptTemplates: [],
sessionManager: SessionManager.inMemory(), sessionManager: SessionManager.inMemory(),
settingsManager, settingsManager,
}); });

View file

@ -33,24 +33,18 @@ import { getModel } from "@mariozechner/pi-ai";
import { import {
AuthStorage, AuthStorage,
createAgentSession, createAgentSession,
discoverAuthStorage, DefaultResourceLoader,
discoverModels,
discoverSkills,
discoverExtensions,
discoverContextFiles,
discoverPromptTemplates,
loadSettings,
buildSystemPrompt,
ModelRegistry, ModelRegistry,
SessionManager, SessionManager,
SettingsManager,
codingTools, codingTools,
readOnlyTools, readOnlyTools,
readTool, bashTool, editTool, writeTool, readTool, bashTool, editTool, writeTool,
} from "@mariozechner/pi-coding-agent"; } from "@mariozechner/pi-coding-agent";
// Auth and models setup // Auth and models setup
const authStorage = discoverAuthStorage(); const authStorage = new AuthStorage();
const modelRegistry = discoverModels(authStorage); const modelRegistry = new ModelRegistry(authStorage);
// Minimal // Minimal
const { session } = await createAgentSession({ authStorage, modelRegistry }); const { session } = await createAgentSession({ authStorage, modelRegistry });
@ -60,11 +54,11 @@ const model = getModel("anthropic", "claude-opus-4-5");
const { session } = await createAgentSession({ model, thinkingLevel: "high", authStorage, modelRegistry }); const { session } = await createAgentSession({ model, thinkingLevel: "high", authStorage, modelRegistry });
// Modify prompt // Modify prompt
const { session } = await createAgentSession({ const loader = new DefaultResourceLoader({
systemPrompt: (defaultPrompt) => defaultPrompt + "\n\nBe concise.", systemPromptOverride: (base) => `${base}\n\nBe concise.`,
authStorage,
modelRegistry,
}); });
await loader.reload();
const { session } = await createAgentSession({ resourceLoader: loader, authStorage, modelRegistry });
// Read-only // Read-only
const { session } = await createAgentSession({ tools: readOnlyTools, authStorage, modelRegistry }); const { session } = await createAgentSession({ tools: readOnlyTools, authStorage, modelRegistry });
@ -81,18 +75,24 @@ const customAuth = new AuthStorage("/my/app/auth.json");
customAuth.setRuntimeApiKey("anthropic", process.env.MY_KEY!); customAuth.setRuntimeApiKey("anthropic", process.env.MY_KEY!);
const customRegistry = new ModelRegistry(customAuth); const customRegistry = new ModelRegistry(customAuth);
const resourceLoader = new DefaultResourceLoader({
systemPromptOverride: () => "You are helpful.",
extensionFactories: [myExtension],
skillsOverride: () => ({ skills: [], diagnostics: [] }),
agentsFilesOverride: () => ({ agentsFiles: [] }),
promptsOverride: () => ({ prompts: [], diagnostics: [] }),
});
await resourceLoader.reload();
const { session } = await createAgentSession({ const { session } = await createAgentSession({
model, model,
authStorage: customAuth, authStorage: customAuth,
modelRegistry: customRegistry, modelRegistry: customRegistry,
systemPrompt: "You are helpful.", resourceLoader,
tools: [readTool, bashTool], tools: [readTool, bashTool],
customTools: [{ tool: myTool }], customTools: [{ tool: myTool }],
extensions: [{ factory: myExtension }],
skills: [],
contextFiles: [],
promptTemplates: [],
sessionManager: SessionManager.inMemory(), sessionManager: SessionManager.inMemory(),
settingsManager: SettingsManager.inMemory(),
}); });
// Run prompts // Run prompts
@ -108,23 +108,17 @@ await session.prompt("Hello");
| Option | Default | Description | | Option | Default | Description |
|--------|---------|-------------| |--------|---------|-------------|
| `authStorage` | `discoverAuthStorage()` | Credential storage | | `authStorage` | `new AuthStorage()` | Credential storage |
| `modelRegistry` | `discoverModels(authStorage)` | Model registry | | `modelRegistry` | `new ModelRegistry(authStorage)` | Model registry |
| `cwd` | `process.cwd()` | Working directory | | `cwd` | `process.cwd()` | Working directory |
| `agentDir` | `~/.pi/agent` | Config directory | | `agentDir` | `~/.pi/agent` | Config directory |
| `model` | From settings/first available | Model to use | | `model` | From settings/first available | Model to use |
| `thinkingLevel` | From settings/"off" | off, low, medium, high | | `thinkingLevel` | From settings/"off" | off, low, medium, high |
| `systemPrompt` | Discovered | String or `(default) => modified` |
| `tools` | `codingTools` | Built-in tools | | `tools` | `codingTools` | Built-in tools |
| `customTools` | Discovered | Replaces discovery | | `customTools` | `[]` | Additional tool definitions |
| `additionalCustomToolPaths` | `[]` | Merge with discovery | | `resourceLoader` | DefaultResourceLoader | Resource loader for extensions, skills, prompts, themes |
| `extensions` | Discovered | Replaces discovery |
| `additionalExtensionPaths` | `[]` | Merge with discovery |
| `skills` | Discovered | Skills for prompt |
| `contextFiles` | Discovered | AGENTS.md files |
| `promptTemplates` | Discovered | Prompt templates (slash commands) |
| `sessionManager` | `SessionManager.create(cwd)` | Persistence | | `sessionManager` | `SessionManager.create(cwd)` | Persistence |
| `settingsManager` | From agentDir | Settings overrides | | `settingsManager` | `SettingsManager.create(cwd, agentDir)` | Settings overrides |
## Events ## Events

View file

@ -33,6 +33,10 @@ export interface Args {
export?: string; export?: string;
noSkills?: boolean; noSkills?: boolean;
skills?: string[]; skills?: string[];
promptTemplates?: string[];
noPromptTemplates?: boolean;
themes?: string[];
noThemes?: boolean;
listModels?: string | true; listModels?: string | true;
messages: string[]; messages: string[];
fileArgs: string[]; fileArgs: string[];
@ -122,11 +126,21 @@ export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "
result.extensions.push(args[++i]); result.extensions.push(args[++i]);
} else if (arg === "--no-extensions") { } else if (arg === "--no-extensions") {
result.noExtensions = true; result.noExtensions = true;
} else if (arg === "--skill" && i + 1 < args.length) {
result.skills = result.skills ?? [];
result.skills.push(args[++i]);
} else if (arg === "--prompt-template" && i + 1 < args.length) {
result.promptTemplates = result.promptTemplates ?? [];
result.promptTemplates.push(args[++i]);
} else if (arg === "--theme" && i + 1 < args.length) {
result.themes = result.themes ?? [];
result.themes.push(args[++i]);
} else if (arg === "--no-skills") { } else if (arg === "--no-skills") {
result.noSkills = true; result.noSkills = true;
} else if (arg === "--skills" && i + 1 < args.length) { } else if (arg === "--no-prompt-templates") {
// Comma-separated glob patterns for skill filtering result.noPromptTemplates = true;
result.skills = args[++i].split(",").map((s) => s.trim()); } else if (arg === "--no-themes") {
result.noThemes = true;
} else if (arg === "--list-models") { } else if (arg === "--list-models") {
// Check if next arg is a search pattern (not a flag or file arg) // Check if next arg is a search pattern (not a flag or file arg)
if (i + 1 < args.length && !args[i + 1].startsWith("-") && !args[i + 1].startsWith("@")) { if (i + 1 < args.length && !args[i + 1].startsWith("-") && !args[i + 1].startsWith("@")) {
@ -162,6 +176,11 @@ export function printHelp(): void {
${chalk.bold("Usage:")} ${chalk.bold("Usage:")}
${APP_NAME} [options] [@files...] [messages...] ${APP_NAME} [options] [@files...] [messages...]
${chalk.bold("Commands:")}
${APP_NAME} install <source> [-l] Install extension source and add to settings
${APP_NAME} remove <source> [-l] Remove extension source from settings
${APP_NAME} update [source] Update installed extensions (skips pinned sources)
${chalk.bold("Options:")} ${chalk.bold("Options:")}
--provider <name> Provider name (default: google) --provider <name> Provider name (default: google)
--model <id> Model ID (default: gemini-2.5-flash) --model <id> Model ID (default: gemini-2.5-flash)
@ -183,8 +202,12 @@ ${chalk.bold("Options:")}
--thinking <level> Set thinking level: off, minimal, low, medium, high, xhigh --thinking <level> Set thinking level: off, minimal, low, medium, high, xhigh
--extension, -e <path> Load an extension file (can be used multiple times) --extension, -e <path> Load an extension file (can be used multiple times)
--no-extensions Disable extension discovery (explicit -e paths still work) --no-extensions Disable extension discovery (explicit -e paths still work)
--skill <path> Load a skill file or directory (can be used multiple times)
--no-skills Disable skills discovery and loading --no-skills Disable skills discovery and loading
--skills <patterns> Comma-separated glob patterns to filter skills (e.g., git-*,docker) --prompt-template <path> Load a prompt template file or directory (can be used multiple times)
--no-prompt-templates Disable prompt template discovery and loading
--theme <path> Load a theme file or directory (can be used multiple times)
--no-themes Disable theme discovery and loading
--export <file> Export session file to HTML and exit --export <file> Export session file to HTML and exit
--list-models [search] List available models (with optional fuzzy search) --list-models [search] List available models (with optional fuzzy search)
--help, -h Show this help --help, -h Show this help

View file

@ -40,25 +40,35 @@ import {
} from "./compaction/index.js"; } from "./compaction/index.js";
import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.js"; import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.js";
import { createToolHtmlRenderer } from "./export-html/tool-renderer.js"; import { createToolHtmlRenderer } from "./export-html/tool-renderer.js";
import type { import {
ContextUsage, type ContextUsage,
type ExtensionCommandContextActions,
type ExtensionErrorListener,
ExtensionRunner, ExtensionRunner,
InputSource, type ExtensionUIContext,
SessionBeforeCompactResult, type InputSource,
SessionBeforeForkResult, type SessionBeforeCompactResult,
SessionBeforeSwitchResult, type SessionBeforeForkResult,
SessionBeforeTreeResult, type SessionBeforeSwitchResult,
TreePreparation, type SessionBeforeTreeResult,
TurnEndEvent, type ShutdownHandler,
TurnStartEvent, type ToolDefinition,
type TreePreparation,
type TurnEndEvent,
type TurnStartEvent,
wrapRegisteredTools,
wrapToolsWithExtensions,
} from "./extensions/index.js"; } from "./extensions/index.js";
import type { BashExecutionMessage, CustomMessage } from "./messages.js"; import type { BashExecutionMessage, CustomMessage } from "./messages.js";
import type { ModelRegistry } from "./model-registry.js"; import type { ModelRegistry } from "./model-registry.js";
import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.js"; import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.js";
import type { ResourceLoader } from "./resource-loader.js";
import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager.js"; import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager.js";
import type { SettingsManager, SkillsSettings } from "./settings-manager.js"; import type { SettingsManager } from "./settings-manager.js";
import type { Skill, SkillWarning } from "./skills.js"; import type { Skill, SkillWarning } from "./skills.js";
import { buildSystemPrompt } from "./system-prompt.js";
import type { BashOperations } from "./tools/bash.js"; import type { BashOperations } from "./tools/bash.js";
import { createAllTools } from "./tools/index.js";
/** Session-specific events that extend the core AgentEvent */ /** Session-specific events that extend the core AgentEvent */
export type AgentSessionEvent = export type AgentSessionEvent =
@ -85,23 +95,28 @@ export interface AgentSessionConfig {
agent: Agent; agent: Agent;
sessionManager: SessionManager; sessionManager: SessionManager;
settingsManager: SettingsManager; settingsManager: SettingsManager;
cwd: string;
/** Models to cycle through with Ctrl+P (from --models flag) */ /** Models to cycle through with Ctrl+P (from --models flag) */
scopedModels?: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>; scopedModels?: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
/** File-based prompt templates for expansion */ /** Resource loader for skills, prompts, themes, context files, system prompt */
promptTemplates?: PromptTemplate[]; resourceLoader: ResourceLoader;
/** Extension runner (created in sdk.ts with wrapped tools) */ /** SDK custom tools registered outside extensions */
extensionRunner?: ExtensionRunner; customTools?: ToolDefinition[];
/** Loaded skills (already discovered by SDK) */
skills?: Skill[];
/** Skill loading warnings (already captured by SDK) */
skillWarnings?: SkillWarning[];
skillsSettings?: Required<SkillsSettings>;
/** Model registry for API key resolution and model discovery */ /** Model registry for API key resolution and model discovery */
modelRegistry: ModelRegistry; modelRegistry: ModelRegistry;
/** Tool registry for extension getTools/setTools - maps name to tool */ /** Initial active built-in tool names. Default: [read, bash, edit, write] */
toolRegistry?: Map<string, AgentTool>; initialActiveToolNames?: string[];
/** Function to rebuild system prompt when tools change */ /** Override base tools (useful for custom runtimes). */
rebuildSystemPrompt?: (toolNames: string[]) => string; baseToolsOverride?: Record<string, AgentTool>;
/** Mutable ref used by Agent to access the current ExtensionRunner */
extensionRunnerRef?: { current?: ExtensionRunner };
}
export interface ExtensionBindings {
uiContext?: ExtensionUIContext;
commandContextActions?: ExtensionCommandContextActions;
shutdownHandler?: ShutdownHandler;
onError?: ExtensionErrorListener;
} }
/** Options for AgentSession.prompt() */ /** Options for AgentSession.prompt() */
@ -163,7 +178,6 @@ export class AgentSession {
readonly settingsManager: SettingsManager; readonly settingsManager: SettingsManager;
private _scopedModels: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>; private _scopedModels: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
private _promptTemplates: PromptTemplate[];
// Event subscription state // Event subscription state
private _unsubscribeAgent?: () => void; private _unsubscribeAgent?: () => void;
@ -197,40 +211,49 @@ export class AgentSession {
private _extensionRunner: ExtensionRunner | undefined = undefined; private _extensionRunner: ExtensionRunner | undefined = undefined;
private _turnIndex = 0; private _turnIndex = 0;
private _skills: Skill[]; private _resourceLoader: ResourceLoader;
private _skillWarnings: SkillWarning[]; private _customTools: ToolDefinition[];
private _skillsSettings: Required<SkillsSettings> | undefined; private _baseToolRegistry: Map<string, AgentTool> = new Map();
private _cwd: string;
private _extensionRunnerRef?: { current?: ExtensionRunner };
private _initialActiveToolNames?: string[];
private _baseToolsOverride?: Record<string, AgentTool>;
private _extensionUIContext?: ExtensionUIContext;
private _extensionCommandContextActions?: ExtensionCommandContextActions;
private _extensionShutdownHandler?: ShutdownHandler;
private _extensionErrorListeners = new Set<ExtensionErrorListener>();
private _extensionErrorUnsubscribers: Array<() => void> = [];
// Model registry for API key resolution // Model registry for API key resolution
private _modelRegistry: ModelRegistry; private _modelRegistry: ModelRegistry;
// Tool registry for extension getTools/setTools // Tool registry for extension getTools/setTools
private _toolRegistry: Map<string, AgentTool>; private _toolRegistry: Map<string, AgentTool> = new Map();
// Function to rebuild system prompt when tools change
private _rebuildSystemPrompt?: (toolNames: string[]) => string;
// Base system prompt (without extension appends) - used to apply fresh appends each turn // Base system prompt (without extension appends) - used to apply fresh appends each turn
private _baseSystemPrompt: string; private _baseSystemPrompt = "";
constructor(config: AgentSessionConfig) { constructor(config: AgentSessionConfig) {
this.agent = config.agent; this.agent = config.agent;
this.sessionManager = config.sessionManager; this.sessionManager = config.sessionManager;
this.settingsManager = config.settingsManager; this.settingsManager = config.settingsManager;
this._scopedModels = config.scopedModels ?? []; this._scopedModels = config.scopedModels ?? [];
this._promptTemplates = config.promptTemplates ?? []; this._resourceLoader = config.resourceLoader;
this._extensionRunner = config.extensionRunner; this._customTools = config.customTools ?? [];
this._skills = config.skills ?? []; this._cwd = config.cwd;
this._skillWarnings = config.skillWarnings ?? [];
this._skillsSettings = config.skillsSettings;
this._modelRegistry = config.modelRegistry; this._modelRegistry = config.modelRegistry;
this._toolRegistry = config.toolRegistry ?? new Map(); this._extensionRunnerRef = config.extensionRunnerRef;
this._rebuildSystemPrompt = config.rebuildSystemPrompt; this._initialActiveToolNames = config.initialActiveToolNames;
this._baseSystemPrompt = config.agent.state.systemPrompt; this._baseToolsOverride = config.baseToolsOverride;
// Always subscribe to agent events for internal handling // Always subscribe to agent events for internal handling
// (session persistence, extensions, auto-compaction, retry logic) // (session persistence, extensions, auto-compaction, retry logic)
this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent); this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
this._buildRuntime({
activeToolNames: this._initialActiveToolNames,
includeAllExtensionTools: true,
});
} }
/** Model registry for API key resolution and model discovery */ /** Model registry for API key resolution and model discovery */
@ -502,10 +525,8 @@ export class AgentSession {
this.agent.setTools(tools); this.agent.setTools(tools);
// Rebuild base system prompt with new tool set // Rebuild base system prompt with new tool set
if (this._rebuildSystemPrompt) { this._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames);
this._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames); this.agent.setSystemPrompt(this._baseSystemPrompt);
this.agent.setSystemPrompt(this._baseSystemPrompt);
}
} }
/** Whether auto-compaction is currently running */ /** Whether auto-compaction is currently running */
@ -550,7 +571,26 @@ export class AgentSession {
/** File-based prompt templates */ /** File-based prompt templates */
get promptTemplates(): ReadonlyArray<PromptTemplate> { get promptTemplates(): ReadonlyArray<PromptTemplate> {
return this._promptTemplates; return this._resourceLoader.getPrompts().prompts;
}
private _rebuildSystemPrompt(toolNames: string[]): string {
const validToolNames = toolNames.filter((name) => this._baseToolRegistry.has(name));
const loaderSystemPrompt = this._resourceLoader.getSystemPrompt();
const loaderAppendSystemPrompt = this._resourceLoader.getAppendSystemPrompt();
const appendSystemPrompt =
loaderAppendSystemPrompt.length > 0 ? loaderAppendSystemPrompt.join("\n\n") : undefined;
const loadedSkills = this._resourceLoader.getSkills().skills;
const loadedContextFiles = this._resourceLoader.getAgentsFiles().agentsFiles;
return buildSystemPrompt({
cwd: this._cwd,
skills: loadedSkills,
contextFiles: loadedContextFiles,
customPrompt: loaderSystemPrompt,
appendSystemPrompt,
selectedTools: validToolNames,
});
} }
// ========================================================================= // =========================================================================
@ -601,7 +641,7 @@ export class AgentSession {
let expandedText = currentText; let expandedText = currentText;
if (expandPromptTemplates) { if (expandPromptTemplates) {
expandedText = this._expandSkillCommand(expandedText); expandedText = this._expandSkillCommand(expandedText);
expandedText = expandPromptTemplate(expandedText, [...this._promptTemplates]); expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);
} }
// If streaming, queue via steer() or followUp() based on option // If streaming, queue via steer() or followUp() based on option
@ -750,7 +790,7 @@ export class AgentSession {
const skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex); const skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex);
const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim(); const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
const skill = this._skills.find((s) => s.name === skillName); const skill = this.skills.find((s) => s.name === skillName);
if (!skill) return text; // Unknown skill, pass through if (!skill) return text; // Unknown skill, pass through
try { try {
@ -784,7 +824,7 @@ export class AgentSession {
// Expand skill commands and prompt templates // Expand skill commands and prompt templates
let expandedText = this._expandSkillCommand(text); let expandedText = this._expandSkillCommand(text);
expandedText = expandPromptTemplate(expandedText, [...this._promptTemplates]); expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);
await this._queueSteer(expandedText); await this._queueSteer(expandedText);
} }
@ -803,7 +843,7 @@ export class AgentSession {
// Expand skill commands and prompt templates // Expand skill commands and prompt templates
let expandedText = this._expandSkillCommand(text); let expandedText = this._expandSkillCommand(text);
expandedText = expandPromptTemplate(expandedText, [...this._promptTemplates]); expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);
await this._queueFollowUp(expandedText); await this._queueFollowUp(expandedText);
} }
@ -891,6 +931,8 @@ export class AgentSession {
message.display, message.display,
message.details, message.details,
); );
this._emit({ type: "message_start", message: appMessage });
this._emit({ type: "message_end", message: appMessage });
} }
} }
@ -963,18 +1005,21 @@ export class AgentSession {
return this._followUpMessages; return this._followUpMessages;
} }
get skillsSettings(): Required<SkillsSettings> | undefined { /** Skills loaded by resource loader */
return this._skillsSettings;
}
/** Skills loaded by SDK (empty if --no-skills or skills: [] was passed) */
get skills(): readonly Skill[] { get skills(): readonly Skill[] {
return this._skills; return this._resourceLoader.getSkills().skills;
} }
/** Skill loading warnings captured by SDK */ /** Skill loading warnings captured by resource loader */
get skillWarnings(): readonly SkillWarning[] { get skillWarnings(): readonly SkillWarning[] {
return this._skillWarnings; return this._resourceLoader.getSkills().diagnostics.map((diagnostic) => ({
skillPath: diagnostic.path ?? "<unknown>",
message: diagnostic.message,
}));
}
get resourceLoader(): ResourceLoader {
return this._resourceLoader;
} }
/** /**
@ -1588,6 +1633,219 @@ export class AgentSession {
return this.settingsManager.getCompactionEnabled(); return this.settingsManager.getCompactionEnabled();
} }
async bindExtensions(bindings: ExtensionBindings): Promise<void> {
if (bindings.uiContext !== undefined) {
this._extensionUIContext = bindings.uiContext;
}
if (bindings.commandContextActions !== undefined) {
this._extensionCommandContextActions = bindings.commandContextActions;
}
if (bindings.shutdownHandler !== undefined) {
this._extensionShutdownHandler = bindings.shutdownHandler;
}
if (bindings.onError) {
this._extensionErrorListeners.add(bindings.onError);
}
if (this._extensionRunner) {
this._applyExtensionBindings(this._extensionRunner);
await this._extensionRunner.emit({ type: "session_start" });
}
}
private _applyExtensionBindings(runner: ExtensionRunner): void {
runner.setUIContext(this._extensionUIContext);
runner.bindCommandContext(this._extensionCommandContextActions);
for (const unsubscribe of this._extensionErrorUnsubscribers) {
unsubscribe();
}
this._extensionErrorUnsubscribers = [];
for (const listener of this._extensionErrorListeners) {
this._extensionErrorUnsubscribers.push(runner.onError(listener));
}
}
private _bindExtensionCore(runner: ExtensionRunner): void {
runner.bindCore(
{
sendMessage: (message, options) => {
this.sendCustomMessage(message, options).catch((err) => {
runner.emitError({
extensionPath: "<runtime>",
event: "send_message",
error: err instanceof Error ? err.message : String(err),
});
});
},
sendUserMessage: (content, options) => {
this.sendUserMessage(content, options).catch((err) => {
runner.emitError({
extensionPath: "<runtime>",
event: "send_user_message",
error: err instanceof Error ? err.message : String(err),
});
});
},
appendEntry: (customType, data) => {
this.sessionManager.appendCustomEntry(customType, data);
},
setSessionName: (name) => {
this.sessionManager.appendSessionInfo(name);
},
getSessionName: () => {
return this.sessionManager.getSessionName();
},
setLabel: (entryId, label) => {
this.sessionManager.appendLabelChange(entryId, label);
},
getActiveTools: () => this.getActiveToolNames(),
getAllTools: () => this.getAllTools(),
setActiveTools: (toolNames) => this.setActiveToolsByName(toolNames),
setModel: async (model) => {
const key = await this.modelRegistry.getApiKey(model);
if (!key) return false;
await this.setModel(model);
return true;
},
getThinkingLevel: () => this.thinkingLevel,
setThinkingLevel: (level) => this.setThinkingLevel(level),
},
{
getModel: () => this.model,
isIdle: () => !this.isStreaming,
abort: () => this.abort(),
hasPendingMessages: () => this.pendingMessageCount > 0,
shutdown: () => {
this._extensionShutdownHandler?.();
},
getContextUsage: () => this.getContextUsage(),
compact: (options) => {
void (async () => {
try {
const result = await this.compact(options?.customInstructions);
options?.onComplete?.(result);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
options?.onError?.(err);
}
})();
},
},
);
}
private _buildRuntime(options: {
activeToolNames?: string[];
flagValues?: Map<string, boolean | string>;
includeAllExtensionTools?: boolean;
}): void {
const autoResizeImages = this.settingsManager.getImageAutoResize();
const shellCommandPrefix = this.settingsManager.getShellCommandPrefix();
const baseTools = this._baseToolsOverride
? this._baseToolsOverride
: createAllTools(this._cwd, {
read: { autoResizeImages },
bash: { commandPrefix: shellCommandPrefix },
});
this._baseToolRegistry = new Map(Object.entries(baseTools).map(([name, tool]) => [name, tool as AgentTool]));
const extensionsResult = this._resourceLoader.getExtensions();
if (options.flagValues) {
for (const [name, value] of options.flagValues) {
extensionsResult.runtime.flagValues.set(name, value);
}
}
const hasExtensions = extensionsResult.extensions.length > 0;
const hasCustomTools = this._customTools.length > 0;
this._extensionRunner =
hasExtensions || hasCustomTools
? new ExtensionRunner(
extensionsResult.extensions,
extensionsResult.runtime,
this._cwd,
this.sessionManager,
this._modelRegistry,
)
: undefined;
if (this._extensionRunnerRef) {
this._extensionRunnerRef.current = this._extensionRunner;
}
if (this._extensionRunner) {
this._bindExtensionCore(this._extensionRunner);
this._applyExtensionBindings(this._extensionRunner);
}
const registeredTools = this._extensionRunner?.getAllRegisteredTools() ?? [];
const allCustomTools = [
...registeredTools,
...this._customTools.map((def) => ({ definition: def, extensionPath: "<sdk>" })),
];
const wrappedExtensionTools = this._extensionRunner
? wrapRegisteredTools(allCustomTools, this._extensionRunner)
: [];
const toolRegistry = new Map(this._baseToolRegistry);
for (const tool of wrappedExtensionTools as AgentTool[]) {
toolRegistry.set(tool.name, tool);
}
const defaultActiveToolNames = this._baseToolsOverride
? Object.keys(this._baseToolsOverride)
: ["read", "bash", "edit", "write"];
const baseActiveToolNames = options.activeToolNames ?? defaultActiveToolNames;
const activeToolNameSet = new Set<string>(baseActiveToolNames);
if (options.includeAllExtensionTools) {
for (const tool of wrappedExtensionTools as AgentTool[]) {
activeToolNameSet.add(tool.name);
}
}
const extensionToolNames = new Set(wrappedExtensionTools.map((tool) => tool.name));
const activeBaseTools = Array.from(activeToolNameSet)
.filter((name) => this._baseToolRegistry.has(name) && !extensionToolNames.has(name))
.map((name) => this._baseToolRegistry.get(name) as AgentTool);
const activeExtensionTools = wrappedExtensionTools.filter((tool) => activeToolNameSet.has(tool.name));
const activeToolsArray: AgentTool[] = [...activeBaseTools, ...activeExtensionTools];
if (this._extensionRunner) {
const wrappedActiveTools = wrapToolsWithExtensions(activeToolsArray, this._extensionRunner);
this.agent.setTools(wrappedActiveTools as AgentTool[]);
const wrappedAllTools = wrapToolsWithExtensions(Array.from(toolRegistry.values()), this._extensionRunner);
this._toolRegistry = new Map(wrappedAllTools.map((tool) => [tool.name, tool]));
} else {
this.agent.setTools(activeToolsArray);
this._toolRegistry = toolRegistry;
}
const systemPromptToolNames = Array.from(activeToolNameSet).filter((name) => this._baseToolRegistry.has(name));
this._baseSystemPrompt = this._rebuildSystemPrompt(systemPromptToolNames);
this.agent.setSystemPrompt(this._baseSystemPrompt);
}
async reload(): Promise<void> {
const previousFlagValues = this._extensionRunner?.getFlagValues();
await this._extensionRunner?.emit({ type: "session_shutdown" });
await this._resourceLoader.reload();
this._buildRuntime({
activeToolNames: this.getActiveToolNames(),
flagValues: previousFlagValues,
includeAllExtensionTools: true,
});
const hasBindings =
this._extensionUIContext ||
this._extensionCommandContextActions ||
this._extensionShutdownHandler ||
this._extensionErrorListeners.size > 0;
if (this._extensionRunner && hasBindings) {
await this._extensionRunner.emit({ type: "session_start" });
}
}
// ========================================================================= // =========================================================================
// Auto-Retry // Auto-Retry
// ========================================================================= // =========================================================================

View file

@ -18,8 +18,9 @@ import {
type OAuthProvider, type OAuthProvider,
} from "@mariozechner/pi-ai"; } from "@mariozechner/pi-ai";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { dirname } from "path"; import { dirname, join } from "path";
import lockfile from "proper-lockfile"; import lockfile from "proper-lockfile";
import { getAgentDir } from "../config.js";
export type ApiKeyCredential = { export type ApiKeyCredential = {
type: "api_key"; type: "api_key";
@ -42,7 +43,7 @@ export class AuthStorage {
private runtimeOverrides: Map<string, string> = new Map(); private runtimeOverrides: Map<string, string> = new Map();
private fallbackResolver?: (provider: string) => string | undefined; private fallbackResolver?: (provider: string) => string | undefined;
constructor(private authPath: string) { constructor(private authPath: string = join(getAgentDir(), "auth.json")) {
this.reload(); this.reload();
} }

View file

@ -12,7 +12,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { type ChildProcess, spawn } from "child_process"; import { type ChildProcess, spawn } from "child_process";
import stripAnsi from "strip-ansi"; import stripAnsi from "strip-ansi";
import { getShellConfig, killProcessTree, sanitizeBinaryOutput } from "../utils/shell.js"; import { getShellConfig, getShellEnv, killProcessTree, sanitizeBinaryOutput } from "../utils/shell.js";
import type { BashOperations } from "./tools/bash.js"; import type { BashOperations } from "./tools/bash.js";
import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.js"; import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.js";
@ -63,6 +63,7 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
const { shell, args } = getShellConfig(); const { shell, args } = getShellConfig();
const child: ChildProcess = spawn(shell, [...args, command], { const child: ChildProcess = spawn(shell, [...args, command], {
detached: true, detached: true,
env: getShellEnv(),
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
}); });

View file

@ -101,7 +101,7 @@ type HandlerFn = (...args: unknown[]) => Promise<unknown>;
/** /**
* Create a runtime with throwing stubs for action methods. * Create a runtime with throwing stubs for action methods.
* Runner.initialize() replaces these with real implementations. * Runner.bindCore() replaces these with real implementations.
*/ */
export function createExtensionRuntime(): ExtensionRuntime { export function createExtensionRuntime(): ExtensionRuntime {
const notInitialized = () => { const notInitialized = () => {
@ -246,6 +246,7 @@ function createExtensionAPI(
async function loadExtensionModule(extensionPath: string) { async function loadExtensionModule(extensionPath: string) {
const jiti = createJiti(import.meta.url, { const jiti = createJiti(import.meta.url, {
moduleCache: false,
// In Bun binary: use virtualModules for bundled packages (no filesystem resolution) // In Bun binary: use virtualModules for bundled packages (no filesystem resolution)
// Also disable tryNative so jiti handles ALL imports (not just the entry point) // Also disable tryNative so jiti handles ALL imports (not just the entry point)
// In Node.js/dev: use aliases to resolve to node_modules paths // In Node.js/dev: use aliases to resolve to node_modules paths
@ -347,6 +348,7 @@ interface PiManifest {
extensions?: string[]; extensions?: string[];
themes?: string[]; themes?: string[];
skills?: string[]; skills?: string[];
prompts?: string[];
} }
function readPiManifest(packageJsonPath: string): PiManifest | null { function readPiManifest(packageJsonPath: string): PiManifest | null {

View file

@ -178,12 +178,7 @@ export class ExtensionRunner {
this.modelRegistry = modelRegistry; this.modelRegistry = modelRegistry;
} }
initialize( bindCore(actions: ExtensionActions, contextActions: ExtensionContextActions): void {
actions: ExtensionActions,
contextActions: ExtensionContextActions,
commandContextActions?: ExtensionCommandContextActions,
uiContext?: ExtensionUIContext,
): void {
// Copy actions into the shared runtime (all extension APIs reference this) // Copy actions into the shared runtime (all extension APIs reference this)
this.runtime.sendMessage = actions.sendMessage; this.runtime.sendMessage = actions.sendMessage;
this.runtime.sendUserMessage = actions.sendUserMessage; this.runtime.sendUserMessage = actions.sendUserMessage;
@ -206,14 +201,24 @@ export class ExtensionRunner {
this.shutdownHandler = contextActions.shutdown; this.shutdownHandler = contextActions.shutdown;
this.getContextUsageFn = contextActions.getContextUsage; this.getContextUsageFn = contextActions.getContextUsage;
this.compactFn = contextActions.compact; this.compactFn = contextActions.compact;
}
// Command context actions (optional, only for interactive mode) bindCommandContext(actions?: ExtensionCommandContextActions): void {
if (commandContextActions) { if (actions) {
this.waitForIdleFn = commandContextActions.waitForIdle; this.waitForIdleFn = actions.waitForIdle;
this.newSessionHandler = commandContextActions.newSession; this.newSessionHandler = actions.newSession;
this.forkHandler = commandContextActions.fork; this.forkHandler = actions.fork;
this.navigateTreeHandler = commandContextActions.navigateTree; this.navigateTreeHandler = actions.navigateTree;
return;
} }
this.waitForIdleFn = async () => {};
this.newSessionHandler = async () => ({ cancelled: false });
this.forkHandler = async () => ({ cancelled: false });
this.navigateTreeHandler = async () => ({ cancelled: false });
}
setUIContext(uiContext?: ExtensionUIContext): void {
this.uiContext = uiContext ?? noOpUIContext; this.uiContext = uiContext ?? noOpUIContext;
} }
@ -265,6 +270,10 @@ export class ExtensionRunner {
this.runtime.flagValues.set(name, value); this.runtime.flagValues.set(name, value);
} }
getFlagValues(): Map<string, boolean | string> {
return new Map(this.runtime.flagValues);
}
getShortcuts(effectiveKeybindings: Required<KeybindingsConfig>): Map<KeyId, ExtensionShortcut> { getShortcuts(effectiveKeybindings: Required<KeybindingsConfig>): Map<KeyId, ExtensionShortcut> {
const builtinKeybindings = buildBuiltinKeybindings(effectiveKeybindings); const builtinKeybindings = buildBuiltinKeybindings(effectiveKeybindings);
const extensionShortcuts = new Map<KeyId, ExtensionShortcut>(); const extensionShortcuts = new Map<KeyId, ExtensionShortcut>();
@ -351,7 +360,7 @@ export class ExtensionRunner {
/** /**
* Request a graceful shutdown. Called by extension tools and event handlers. * Request a graceful shutdown. Called by extension tools and event handlers.
* The actual shutdown behavior is provided by the mode via initialize(). * The actual shutdown behavior is provided by the mode via bindExtensions().
*/ */
shutdown(): void { shutdown(): void {
this.shutdownHandler(); this.shutdownHandler();
@ -359,7 +368,7 @@ export class ExtensionRunner {
/** /**
* Create an ExtensionContext for use in event handlers and tool execution. * Create an ExtensionContext for use in event handlers and tool execution.
* Context values are resolved at call time, so changes via initialize() are reflected. * Context values are resolved at call time, so changes via bindCore/bindUI are reflected.
*/ */
createContext(): ExtensionContext { createContext(): ExtensionContext {
const getModel = this.getModel; const getModel = this.getModel;

View file

@ -85,6 +85,11 @@ export class FooterDataProvider {
} }
} }
/** Internal: clear extension statuses */
clearExtensionStatuses(): void {
this.extensionStatuses.clear();
}
/** Internal: cleanup */ /** Internal: cleanup */
dispose(): void { dispose(): void {
if (this.gitWatcher) { if (this.gitWatcher) {

View file

@ -15,6 +15,8 @@ import { type Static, Type } from "@sinclair/typebox";
import AjvModule from "ajv"; import AjvModule from "ajv";
import { execSync } from "child_process"; import { execSync } from "child_process";
import { existsSync, readFileSync } from "fs"; import { existsSync, readFileSync } from "fs";
import { join } from "path";
import { getAgentDir } from "../config.js";
import type { AuthStorage } from "./auth-storage.js"; import type { AuthStorage } from "./auth-storage.js";
const Ajv = (AjvModule as any).default || AjvModule; const Ajv = (AjvModule as any).default || AjvModule;
@ -159,7 +161,7 @@ export class ModelRegistry {
constructor( constructor(
readonly authStorage: AuthStorage, readonly authStorage: AuthStorage,
private modelsJsonPath: string | undefined = undefined, private modelsJsonPath: string | undefined = join(getAgentDir(), "models.json"),
) { ) {
// Set up fallback resolver for custom provider API keys // Set up fallback resolver for custom provider API keys
this.authStorage.setFallbackResolver((provider) => { this.authStorage.setFallbackResolver((provider) => {

View file

@ -0,0 +1,536 @@
import { spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { CONFIG_DIR_NAME } from "../config.js";
import type { SettingsManager } from "./settings-manager.js";
export interface ResolvedPaths {
extensions: string[];
skills: string[];
prompts: string[];
themes: string[];
}
export type MissingSourceAction = "install" | "skip" | "error";
export interface PackageManager {
resolve(onMissing?: (source: string) => Promise<MissingSourceAction>): Promise<ResolvedPaths>;
install(source: string, options?: { local?: boolean }): Promise<void>;
remove(source: string, options?: { local?: boolean }): Promise<void>;
update(source?: string): Promise<void>;
resolveExtensionSources(
sources: string[],
options?: { local?: boolean; temporary?: boolean },
): Promise<ResolvedPaths>;
}
interface PackageManagerOptions {
cwd: string;
agentDir: string;
settingsManager: SettingsManager;
}
type SourceScope = "global" | "project" | "temporary";
type NpmSource = {
type: "npm";
spec: string;
name: string;
pinned: boolean;
};
type GitSource = {
type: "git";
repo: string;
host: string;
path: string;
ref?: string;
pinned: boolean;
};
type LocalSource = {
type: "local";
path: string;
};
type ParsedSource = NpmSource | GitSource | LocalSource;
interface PiManifest {
extensions?: string[];
skills?: string[];
prompts?: string[];
themes?: string[];
}
interface ResourceAccumulator {
extensions: Set<string>;
skills: Set<string>;
prompts: Set<string>;
themes: Set<string>;
}
export class DefaultPackageManager implements PackageManager {
private cwd: string;
private agentDir: string;
private settingsManager: SettingsManager;
private globalNpmRoot: string | undefined;
constructor(options: PackageManagerOptions) {
this.cwd = options.cwd;
this.agentDir = options.agentDir;
this.settingsManager = options.settingsManager;
}
async resolve(onMissing?: (source: string) => Promise<MissingSourceAction>): Promise<ResolvedPaths> {
const accumulator = this.createAccumulator();
const globalSettings = this.settingsManager.getGlobalSettings();
const projectSettings = this.settingsManager.getProjectSettings();
const extensionSources: Array<{ source: string; scope: SourceScope }> = [];
for (const source of globalSettings.extensions ?? []) {
extensionSources.push({ source, scope: "global" });
}
for (const source of projectSettings.extensions ?? []) {
extensionSources.push({ source, scope: "project" });
}
await this.resolveExtensionSourcesInternal(extensionSources, accumulator, onMissing);
for (const skill of projectSettings.skills ?? []) {
this.addPath(accumulator.skills, this.resolvePath(skill));
}
for (const skill of globalSettings.skills ?? []) {
this.addPath(accumulator.skills, this.resolvePath(skill));
}
for (const prompt of projectSettings.prompts ?? []) {
this.addPath(accumulator.prompts, this.resolvePath(prompt));
}
for (const prompt of globalSettings.prompts ?? []) {
this.addPath(accumulator.prompts, this.resolvePath(prompt));
}
for (const theme of projectSettings.themes ?? []) {
this.addPath(accumulator.themes, this.resolvePath(theme));
}
for (const theme of globalSettings.themes ?? []) {
this.addPath(accumulator.themes, this.resolvePath(theme));
}
return this.toResolvedPaths(accumulator);
}
async resolveExtensionSources(
sources: string[],
options?: { local?: boolean; temporary?: boolean },
): Promise<ResolvedPaths> {
const accumulator = this.createAccumulator();
const scope: SourceScope = options?.temporary ? "temporary" : options?.local ? "project" : "global";
const extensionSources = sources.map((source) => ({ source, scope }));
await this.resolveExtensionSourcesInternal(extensionSources, accumulator);
return this.toResolvedPaths(accumulator);
}
async install(source: string, options?: { local?: boolean }): Promise<void> {
const parsed = this.parseSource(source);
const scope: SourceScope = options?.local ? "project" : "global";
if (parsed.type === "npm") {
await this.installNpm(parsed, scope, false);
return;
}
if (parsed.type === "git") {
await this.installGit(parsed, scope, false);
return;
}
throw new Error(`Unsupported install source: ${source}`);
}
async remove(source: string, options?: { local?: boolean }): Promise<void> {
const parsed = this.parseSource(source);
const scope: SourceScope = options?.local ? "project" : "global";
if (parsed.type === "npm") {
await this.uninstallNpm(parsed, scope);
return;
}
if (parsed.type === "git") {
await this.removeGit(parsed, scope, false);
return;
}
throw new Error(`Unsupported remove source: ${source}`);
}
async update(source?: string): Promise<void> {
if (source) {
await this.updateSourceForScope(source, "global");
await this.updateSourceForScope(source, "project");
return;
}
const globalSettings = this.settingsManager.getGlobalSettings();
const projectSettings = this.settingsManager.getProjectSettings();
for (const extension of globalSettings.extensions ?? []) {
await this.updateSourceForScope(extension, "global");
}
for (const extension of projectSettings.extensions ?? []) {
await this.updateSourceForScope(extension, "project");
}
}
private async updateSourceForScope(source: string, scope: SourceScope): Promise<void> {
const parsed = this.parseSource(source);
if (parsed.type === "npm") {
if (parsed.pinned) return;
await this.installNpm(parsed, scope, false);
return;
}
if (parsed.type === "git") {
if (parsed.pinned) return;
await this.updateGit(parsed, scope, false);
return;
}
}
private async resolveExtensionSourcesInternal(
sources: Array<{ source: string; scope: SourceScope }>,
accumulator: ResourceAccumulator,
onMissing?: (source: string) => Promise<MissingSourceAction>,
): Promise<void> {
for (const { source, scope } of sources) {
const parsed = this.parseSource(source);
if (parsed.type === "local") {
this.resolveLocalExtensionSource(parsed, accumulator);
continue;
}
const installMissing = async (): Promise<boolean> => {
if (!onMissing) {
await this.installParsedSource(parsed, scope);
return true;
}
const action = await onMissing(source);
if (action === "skip") return false;
if (action === "error") throw new Error(`Missing source: ${source}`);
await this.installParsedSource(parsed, scope);
return true;
};
if (parsed.type === "npm") {
const installedPath = this.getNpmInstallPath(parsed, scope);
if (!existsSync(installedPath)) {
const installed = await installMissing();
if (!installed) continue;
}
this.collectPackageResources(installedPath, accumulator);
continue;
}
if (parsed.type === "git") {
const installedPath = this.getGitInstallPath(parsed, scope);
if (!existsSync(installedPath)) {
const installed = await installMissing();
if (!installed) continue;
}
this.collectPackageResources(installedPath, accumulator);
}
}
}
private resolveLocalExtensionSource(source: LocalSource, accumulator: ResourceAccumulator): void {
const resolved = this.resolvePath(source.path);
if (!existsSync(resolved)) {
return;
}
try {
const stats = statSync(resolved);
if (stats.isFile()) {
this.addPath(accumulator.extensions, resolved);
return;
}
if (stats.isDirectory()) {
const resources = this.collectPackageResources(resolved, accumulator);
if (!resources) {
this.addPath(accumulator.extensions, resolved);
}
}
} catch {
return;
}
}
private async installParsedSource(parsed: ParsedSource, scope: SourceScope): Promise<void> {
if (parsed.type === "npm") {
await this.installNpm(parsed, scope, scope === "temporary");
return;
}
if (parsed.type === "git") {
await this.installGit(parsed, scope, scope === "temporary");
return;
}
}
private parseSource(source: string): ParsedSource {
if (source.startsWith("npm:")) {
const spec = source.slice("npm:".length).trim();
const { name, version } = this.parseNpmSpec(spec);
return {
type: "npm",
spec,
name,
pinned: Boolean(version),
};
}
if (source.startsWith("git:")) {
const repoSpec = source.slice("git:".length).trim();
const [repo, ref] = repoSpec.split("@");
const normalized = repo.replace(/^https?:\/\//, "");
const parts = normalized.split("/");
const host = parts.shift() ?? "";
const repoPath = parts.join("/");
return {
type: "git",
repo: normalized,
host,
path: repoPath,
ref,
pinned: Boolean(ref),
};
}
return { type: "local", path: source };
}
private parseNpmSpec(spec: string): { name: string; version?: string } {
const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@(.+))?$/);
if (!match) {
return { name: spec };
}
const name = match[1] ?? spec;
const version = match[2];
return { name, version };
}
private async installNpm(source: NpmSource, scope: SourceScope, temporary: boolean): Promise<void> {
if (scope === "global" && !temporary) {
await this.runCommand("npm", ["install", "-g", source.spec]);
return;
}
const installRoot = this.getNpmInstallRoot(scope, temporary);
this.ensureNpmProject(installRoot);
await this.runCommand("npm", ["install", source.spec, "--prefix", installRoot]);
}
private async uninstallNpm(source: NpmSource, scope: SourceScope): Promise<void> {
if (scope === "global") {
await this.runCommand("npm", ["uninstall", "-g", source.name]);
return;
}
const installRoot = this.getNpmInstallRoot(scope, false);
if (!existsSync(installRoot)) {
return;
}
await this.runCommand("npm", ["uninstall", source.name, "--prefix", installRoot]);
}
private async installGit(source: GitSource, scope: SourceScope, temporary: boolean): Promise<void> {
const targetDir = this.getGitInstallPath(source, scope, temporary);
if (existsSync(targetDir)) {
return;
}
mkdirSync(dirname(targetDir), { recursive: true });
const cloneUrl = source.repo.startsWith("http") ? source.repo : `https://${source.repo}`;
await this.runCommand("git", ["clone", cloneUrl, targetDir]);
if (source.ref) {
await this.runCommand("git", ["checkout", source.ref], { cwd: targetDir });
}
}
private async updateGit(source: GitSource, scope: SourceScope, temporary: boolean): Promise<void> {
const targetDir = this.getGitInstallPath(source, scope, temporary);
if (!existsSync(targetDir)) {
await this.installGit(source, scope, temporary);
return;
}
await this.runCommand("git", ["pull"], { cwd: targetDir });
}
private async removeGit(source: GitSource, scope: SourceScope, temporary: boolean): Promise<void> {
const targetDir = this.getGitInstallPath(source, scope, temporary);
if (!existsSync(targetDir)) return;
rmSync(targetDir, { recursive: true, force: true });
}
private ensureNpmProject(installRoot: string): void {
if (!existsSync(installRoot)) {
mkdirSync(installRoot, { recursive: true });
}
const packageJsonPath = join(installRoot, "package.json");
if (!existsSync(packageJsonPath)) {
const pkgJson = { name: "pi-extensions", private: true };
writeFileSync(packageJsonPath, JSON.stringify(pkgJson, null, 2), "utf-8");
}
}
private getNpmInstallRoot(scope: SourceScope, temporary: boolean): string {
if (temporary) {
return this.getTemporaryDir("npm");
}
if (scope === "project") {
return join(this.cwd, CONFIG_DIR_NAME, "npm");
}
return join(this.getGlobalNpmRoot(), "..");
}
private getGlobalNpmRoot(): string {
if (this.globalNpmRoot) {
return this.globalNpmRoot;
}
const result = this.runCommandSync("npm", ["root", "-g"]);
this.globalNpmRoot = result.trim();
return this.globalNpmRoot;
}
private getNpmInstallPath(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);
}
private getGitInstallPath(source: GitSource, scope: SourceScope, temporary?: boolean): string {
if (temporary) {
return this.getTemporaryDir(`git-${source.host}`, source.path);
}
if (scope === "project") {
return join(this.cwd, CONFIG_DIR_NAME, "git", source.host, source.path);
}
return join(this.agentDir, "git", source.host, source.path);
}
private getTemporaryDir(prefix: string, suffix?: string): string {
const hash = createHash("sha256")
.update(`${prefix}-${suffix ?? ""}`)
.digest("hex")
.slice(0, 8);
return join(tmpdir(), "pi-extensions", prefix, hash, suffix ?? "");
}
private resolvePath(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 resolve(this.cwd, trimmed);
}
private collectPackageResources(packageRoot: string, accumulator: ResourceAccumulator): boolean {
const manifest = this.readPiManifest(packageRoot);
if (manifest) {
this.addManifestEntries(manifest.extensions, packageRoot, accumulator.extensions);
this.addManifestEntries(manifest.skills, packageRoot, accumulator.skills);
this.addManifestEntries(manifest.prompts, packageRoot, accumulator.prompts);
this.addManifestEntries(manifest.themes, packageRoot, accumulator.themes);
return true;
}
const extensionsDir = join(packageRoot, "extensions");
const skillsDir = join(packageRoot, "skills");
const promptsDir = join(packageRoot, "prompts");
const themesDir = join(packageRoot, "themes");
const hasAnyDir =
existsSync(extensionsDir) || existsSync(skillsDir) || existsSync(promptsDir) || existsSync(themesDir);
if (!hasAnyDir) {
return false;
}
if (existsSync(extensionsDir)) {
this.addPath(accumulator.extensions, extensionsDir);
}
if (existsSync(skillsDir)) {
this.addPath(accumulator.skills, skillsDir);
}
if (existsSync(promptsDir)) {
this.addPath(accumulator.prompts, promptsDir);
}
if (existsSync(themesDir)) {
this.addPath(accumulator.themes, themesDir);
}
return true;
}
private readPiManifest(packageRoot: string): PiManifest | null {
const packageJsonPath = join(packageRoot, "package.json");
if (!existsSync(packageJsonPath)) {
return null;
}
try {
const content = readFileSync(packageJsonPath, "utf-8");
const pkg = JSON.parse(content) as { pi?: PiManifest };
return pkg.pi ?? null;
} catch {
return null;
}
}
private addManifestEntries(entries: string[] | undefined, root: string, target: Set<string>): void {
if (!entries) return;
for (const entry of entries) {
const resolved = resolve(root, entry);
this.addPath(target, resolved);
}
}
private addPath(set: Set<string>, value: string): void {
if (!value) return;
set.add(value);
}
private createAccumulator(): ResourceAccumulator {
return {
extensions: new Set<string>(),
skills: new Set<string>(),
prompts: new Set<string>(),
themes: new Set<string>(),
};
}
private toResolvedPaths(accumulator: ResourceAccumulator): ResolvedPaths {
return {
extensions: Array.from(accumulator.extensions),
skills: Array.from(accumulator.skills),
prompts: Array.from(accumulator.prompts),
themes: Array.from(accumulator.themes),
};
}
private runCommand(command: string, args: string[], options?: { cwd?: string }): Promise<void> {
return new Promise((resolvePromise, reject) => {
const child = spawn(command, args, {
cwd: options?.cwd,
stdio: "inherit",
});
child.on("error", reject);
child.on("exit", (code) => {
if (code === 0) {
resolvePromise();
} else {
reject(new Error(`${command} ${args.join(" ")} failed with code ${code}`));
}
});
});
}
private runCommandSync(command: string, args: string[]): string {
const result = spawnSync(command, args, { stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" });
if (result.status !== 0) {
throw new Error(`Failed to run ${command} ${args.join(" ")}: ${result.stderr || result.stdout}`);
}
return (result.stdout || result.stderr || "").trim();
}
}

View file

@ -1,5 +1,6 @@
import { existsSync, readdirSync, readFileSync, statSync } from "fs"; import { existsSync, readdirSync, readFileSync, statSync } from "fs";
import { join, resolve } from "path"; import { homedir } from "os";
import { basename, isAbsolute, join, resolve } from "path";
import { CONFIG_DIR_NAME, getPromptsDir } from "../config.js"; import { CONFIG_DIR_NAME, getPromptsDir } from "../config.js";
import { parseFrontmatter } from "../utils/frontmatter.js"; import { parseFrontmatter } from "../utils/frontmatter.js";
@ -10,7 +11,7 @@ export interface PromptTemplate {
name: string; name: string;
description: string; description: string;
content: string; content: string;
source: string; // e.g., "(user)", "(project)", "(project:frontend)" source: string; // e.g., "(user)", "(project)", "(custom:my-dir)"
} }
/** /**
@ -97,10 +98,42 @@ export function substituteArgs(content: string, args: string[]): string {
return result; return result;
} }
function loadTemplateFromFile(filePath: string, sourceLabel: string): PromptTemplate | null {
try {
const rawContent = readFileSync(filePath, "utf-8");
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(rawContent);
const name = basename(filePath).replace(/\.md$/, "");
// Get description from frontmatter or first non-empty line
let description = frontmatter.description || "";
if (!description) {
const firstLine = body.split("\n").find((line) => line.trim());
if (firstLine) {
// Truncate if too long
description = firstLine.slice(0, 60);
if (firstLine.length > 60) description += "...";
}
}
// Append source to description
description = description ? `${description} ${sourceLabel}` : sourceLabel;
return {
name,
description,
content: body,
source: sourceLabel,
};
} catch {
return null;
}
}
/** /**
* Recursively scan a directory for .md files (and symlinks to .md files) and load them as prompt templates * Scan a directory for .md files (non-recursive) and load them as prompt templates.
*/ */
function loadTemplatesFromDir(dir: string, source: "user" | "project", subdir: string = ""): PromptTemplate[] { function loadTemplatesFromDir(dir: string, sourceLabel: string): PromptTemplate[] {
const templates: PromptTemplate[] = []; const templates: PromptTemplate[] = [];
if (!existsSync(dir)) { if (!existsSync(dir)) {
@ -113,13 +146,11 @@ function loadTemplatesFromDir(dir: string, source: "user" | "project", subdir: s
for (const entry of entries) { for (const entry of entries) {
const fullPath = join(dir, entry.name); const fullPath = join(dir, entry.name);
// For symlinks, check if they point to a directory and follow them // For symlinks, check if they point to a file
let isDirectory = entry.isDirectory();
let isFile = entry.isFile(); let isFile = entry.isFile();
if (entry.isSymbolicLink()) { if (entry.isSymbolicLink()) {
try { try {
const stats = statSync(fullPath); const stats = statSync(fullPath);
isDirectory = stats.isDirectory();
isFile = stats.isFile(); isFile = stats.isFile();
} catch { } catch {
// Broken symlink, skip it // Broken symlink, skip it
@ -127,52 +158,15 @@ function loadTemplatesFromDir(dir: string, source: "user" | "project", subdir: s
} }
} }
if (isDirectory) { if (isFile && entry.name.endsWith(".md")) {
// Recurse into subdirectory const template = loadTemplateFromFile(fullPath, sourceLabel);
const newSubdir = subdir ? `${subdir}:${entry.name}` : entry.name; if (template) {
templates.push(...loadTemplatesFromDir(fullPath, source, newSubdir)); templates.push(template);
} else if (isFile && entry.name.endsWith(".md")) {
try {
const rawContent = readFileSync(fullPath, "utf-8");
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(rawContent);
const name = entry.name.slice(0, -3); // Remove .md extension
// Build source string
let sourceStr: string;
if (source === "user") {
sourceStr = subdir ? `(user:${subdir})` : "(user)";
} else {
sourceStr = subdir ? `(project:${subdir})` : "(project)";
}
// Get description from frontmatter or first non-empty line
let description = frontmatter.description || "";
if (!description) {
const firstLine = body.split("\n").find((line) => line.trim());
if (firstLine) {
// Truncate if too long
description = firstLine.slice(0, 60);
if (firstLine.length > 60) description += "...";
}
}
// Append source to description
description = description ? `${description} ${sourceStr}` : sourceStr;
templates.push({
name,
description,
content: body,
source: sourceStr,
});
} catch (_error) {
// Silently skip files that can't be read
} }
} }
} }
} catch (_error) { } catch {
// Silently skip directories that can't be read return templates;
} }
return templates; return templates;
@ -183,27 +177,71 @@ export interface LoadPromptTemplatesOptions {
cwd?: string; cwd?: string;
/** Agent config directory for global templates. Default: from getPromptsDir() */ /** Agent config directory for global templates. Default: from getPromptsDir() */
agentDir?: string; agentDir?: string;
/** Explicit prompt template paths (files or directories) */
promptPaths?: string[];
}
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);
}
function buildCustomSourceLabel(p: string): string {
const base = basename(p).replace(/\.md$/, "") || "custom";
return `(custom:${base})`;
} }
/** /**
* Load all prompt templates from: * Load all prompt templates from:
* 1. Global: agentDir/prompts/ * 1. Global: agentDir/prompts/
* 2. Project: cwd/{CONFIG_DIR_NAME}/prompts/ * 2. Project: cwd/{CONFIG_DIR_NAME}/prompts/
* 3. Explicit prompt paths
*/ */
export function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): PromptTemplate[] { export function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): PromptTemplate[] {
const resolvedCwd = options.cwd ?? process.cwd(); const resolvedCwd = options.cwd ?? process.cwd();
const resolvedAgentDir = options.agentDir ?? getPromptsDir(); const resolvedAgentDir = options.agentDir ?? getPromptsDir();
const promptPaths = options.promptPaths ?? [];
const templates: PromptTemplate[] = []; const templates: PromptTemplate[] = [];
// 1. Load global templates from agentDir/prompts/ // 1. Load global templates from agentDir/prompts/
// Note: if agentDir is provided, it should be the agent dir, not the prompts dir // Note: if agentDir is provided, it should be the agent dir, not the prompts dir
const globalPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir; const globalPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir;
templates.push(...loadTemplatesFromDir(globalPromptsDir, "user")); templates.push(...loadTemplatesFromDir(globalPromptsDir, "(user)"));
// 2. Load project templates from cwd/{CONFIG_DIR_NAME}/prompts/ // 2. Load project templates from cwd/{CONFIG_DIR_NAME}/prompts/
const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts"); const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts");
templates.push(...loadTemplatesFromDir(projectPromptsDir, "project")); templates.push(...loadTemplatesFromDir(projectPromptsDir, "(project)"));
// 3. Load explicit prompt paths
for (const rawPath of promptPaths) {
const resolvedPath = resolvePromptPath(rawPath, resolvedCwd);
if (!existsSync(resolvedPath)) {
continue;
}
try {
const stats = statSync(resolvedPath);
if (stats.isDirectory()) {
templates.push(...loadTemplatesFromDir(resolvedPath, buildCustomSourceLabel(resolvedPath)));
} else if (stats.isFile() && resolvedPath.endsWith(".md")) {
const template = loadTemplateFromFile(resolvedPath, buildCustomSourceLabel(resolvedPath));
if (template) {
templates.push(template);
}
}
} catch {
// Ignore read failures
}
}
return templates; return templates;
} }

View file

@ -0,0 +1,516 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import chalk from "chalk";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.js";
import { loadThemeFromPath, type Theme } from "../modes/interactive/theme/theme.js";
import { createEventBus, type EventBus } from "./event-bus.js";
import {
createExtensionRuntime,
discoverAndLoadExtensions,
loadExtensionFromFactory,
loadExtensions,
} from "./extensions/loader.js";
import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.js";
import { DefaultPackageManager } 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, SkillWarning } from "./skills.js";
import { loadSkills } from "./skills.js";
export interface ResourceDiagnostic {
type: "warning" | "error";
message: string;
path?: string;
}
export interface ResourceLoader {
getExtensions(): LoadExtensionsResult;
getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] };
getPrompts(): { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] };
getThemes(): { themes: Theme[]; diagnostics: ResourceDiagnostic[] };
getAgentsFiles(): { agentsFiles: Array<{ path: string; content: string }> };
getSystemPrompt(): string | undefined;
getAppendSystemPrompt(): string[];
reload(): Promise<void>;
}
function resolvePromptInput(input: string | undefined, description: string): string | undefined {
if (!input) {
return undefined;
}
if (existsSync(input)) {
try {
return readFileSync(input, "utf-8");
} catch (error) {
console.error(chalk.yellow(`Warning: Could not read ${description} file ${input}: ${error}`));
return input;
}
}
return input;
}
function loadContextFileFromDir(dir: string): { path: string; content: string } | null {
const candidates = ["AGENTS.md", "CLAUDE.md"];
for (const filename of candidates) {
const filePath = join(dir, filename);
if (existsSync(filePath)) {
try {
return {
path: filePath,
content: readFileSync(filePath, "utf-8"),
};
} catch (error) {
console.error(chalk.yellow(`Warning: Could not read ${filePath}: ${error}`));
}
}
}
return null;
}
function loadProjectContextFiles(
options: { cwd?: string; agentDir?: string } = {},
): Array<{ path: string; content: string }> {
const resolvedCwd = options.cwd ?? process.cwd();
const resolvedAgentDir = options.agentDir ?? getAgentDir();
const contextFiles: Array<{ path: string; content: string }> = [];
const seenPaths = new Set<string>();
const globalContext = loadContextFileFromDir(resolvedAgentDir);
if (globalContext) {
contextFiles.push(globalContext);
seenPaths.add(globalContext.path);
}
const ancestorContextFiles: Array<{ path: string; content: string }> = [];
let currentDir = resolvedCwd;
const root = resolve("/");
while (true) {
const contextFile = loadContextFileFromDir(currentDir);
if (contextFile && !seenPaths.has(contextFile.path)) {
ancestorContextFiles.unshift(contextFile);
seenPaths.add(contextFile.path);
}
if (currentDir === root) break;
const parentDir = resolve(currentDir, "..");
if (parentDir === currentDir) break;
currentDir = parentDir;
}
contextFiles.push(...ancestorContextFiles);
return contextFiles;
}
export interface DefaultResourceLoaderOptions {
cwd?: string;
agentDir?: string;
settingsManager?: SettingsManager;
eventBus?: EventBus;
additionalExtensionPaths?: string[];
additionalSkillPaths?: string[];
additionalPromptTemplatePaths?: string[];
additionalThemePaths?: string[];
extensionFactories?: ExtensionFactory[];
noExtensions?: boolean;
noSkills?: boolean;
noPromptTemplates?: boolean;
noThemes?: boolean;
systemPrompt?: string;
appendSystemPrompt?: string;
extensionsOverride?: (base: LoadExtensionsResult) => LoadExtensionsResult;
skillsOverride?: (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => {
skills: Skill[];
diagnostics: ResourceDiagnostic[];
};
promptsOverride?: (base: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] }) => {
prompts: PromptTemplate[];
diagnostics: ResourceDiagnostic[];
};
themesOverride?: (base: { themes: Theme[]; diagnostics: ResourceDiagnostic[] }) => {
themes: Theme[];
diagnostics: ResourceDiagnostic[];
};
agentsFilesOverride?: (base: { agentsFiles: Array<{ path: string; content: string }> }) => {
agentsFiles: Array<{ path: string; content: string }>;
};
systemPromptOverride?: (base: string | undefined) => string | undefined;
appendSystemPromptOverride?: (base: string[]) => string[];
}
export class DefaultResourceLoader implements ResourceLoader {
private cwd: string;
private agentDir: string;
private settingsManager: SettingsManager;
private eventBus: EventBus;
private packageManager: DefaultPackageManager;
private additionalExtensionPaths: string[];
private additionalSkillPaths: string[];
private additionalPromptTemplatePaths: string[];
private additionalThemePaths: string[];
private extensionFactories: ExtensionFactory[];
private noExtensions: boolean;
private noSkills: boolean;
private noPromptTemplates: boolean;
private noThemes: boolean;
private systemPromptSource?: string;
private appendSystemPromptSource?: string;
private extensionsOverride?: (base: LoadExtensionsResult) => LoadExtensionsResult;
private skillsOverride?: (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => {
skills: Skill[];
diagnostics: ResourceDiagnostic[];
};
private promptsOverride?: (base: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] }) => {
prompts: PromptTemplate[];
diagnostics: ResourceDiagnostic[];
};
private themesOverride?: (base: { themes: Theme[]; diagnostics: ResourceDiagnostic[] }) => {
themes: Theme[];
diagnostics: ResourceDiagnostic[];
};
private agentsFilesOverride?: (base: { agentsFiles: Array<{ path: string; content: string }> }) => {
agentsFiles: Array<{ path: string; content: string }>;
};
private systemPromptOverride?: (base: string | undefined) => string | undefined;
private appendSystemPromptOverride?: (base: string[]) => string[];
private extensionsResult: LoadExtensionsResult;
private skills: Skill[];
private skillDiagnostics: ResourceDiagnostic[];
private prompts: PromptTemplate[];
private promptDiagnostics: ResourceDiagnostic[];
private themes: Theme[];
private themeDiagnostics: ResourceDiagnostic[];
private agentsFiles: Array<{ path: string; content: string }>;
private systemPrompt?: string;
private appendSystemPrompt: string[];
constructor(options: DefaultResourceLoaderOptions) {
this.cwd = options.cwd ?? process.cwd();
this.agentDir = options.agentDir ?? getAgentDir();
this.settingsManager = options.settingsManager ?? SettingsManager.create(this.cwd, this.agentDir);
this.eventBus = options.eventBus ?? createEventBus();
this.packageManager = new DefaultPackageManager({
cwd: this.cwd,
agentDir: this.agentDir,
settingsManager: this.settingsManager,
});
this.additionalExtensionPaths = options.additionalExtensionPaths ?? [];
this.additionalSkillPaths = options.additionalSkillPaths ?? [];
this.additionalPromptTemplatePaths = options.additionalPromptTemplatePaths ?? [];
this.additionalThemePaths = options.additionalThemePaths ?? [];
this.extensionFactories = options.extensionFactories ?? [];
this.noExtensions = options.noExtensions ?? false;
this.noSkills = options.noSkills ?? false;
this.noPromptTemplates = options.noPromptTemplates ?? false;
this.noThemes = options.noThemes ?? false;
this.systemPromptSource = options.systemPrompt;
this.appendSystemPromptSource = options.appendSystemPrompt;
this.extensionsOverride = options.extensionsOverride;
this.skillsOverride = options.skillsOverride;
this.promptsOverride = options.promptsOverride;
this.themesOverride = options.themesOverride;
this.agentsFilesOverride = options.agentsFilesOverride;
this.systemPromptOverride = options.systemPromptOverride;
this.appendSystemPromptOverride = options.appendSystemPromptOverride;
this.extensionsResult = { extensions: [], errors: [], runtime: createExtensionRuntime() };
this.skills = [];
this.skillDiagnostics = [];
this.prompts = [];
this.promptDiagnostics = [];
this.themes = [];
this.themeDiagnostics = [];
this.agentsFiles = [];
this.appendSystemPrompt = [];
}
getExtensions(): LoadExtensionsResult {
return this.extensionsResult;
}
getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] } {
return { skills: this.skills, diagnostics: this.skillDiagnostics };
}
getPrompts(): { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] } {
return { prompts: this.prompts, diagnostics: this.promptDiagnostics };
}
getThemes(): { themes: Theme[]; diagnostics: ResourceDiagnostic[] } {
return { themes: this.themes, diagnostics: this.themeDiagnostics };
}
getAgentsFiles(): { agentsFiles: Array<{ path: string; content: string }> } {
return { agentsFiles: this.agentsFiles };
}
getSystemPrompt(): string | undefined {
return this.systemPrompt;
}
getAppendSystemPrompt(): string[] {
return this.appendSystemPrompt;
}
async reload(): Promise<void> {
const resolvedPaths = await this.packageManager.resolve();
const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, {
temporary: true,
});
const extensionPaths = this.noExtensions
? cliExtensionPaths.extensions
: this.mergePaths(resolvedPaths.extensions, cliExtensionPaths.extensions);
let extensionsResult: LoadExtensionsResult;
if (this.noExtensions) {
extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus);
} else {
extensionsResult = await discoverAndLoadExtensions(extensionPaths, this.cwd, this.agentDir, this.eventBus);
}
const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime);
extensionsResult.extensions.push(...inlineExtensions.extensions);
extensionsResult.errors.push(...inlineExtensions.errors);
this.extensionsResult = this.extensionsOverride ? this.extensionsOverride(extensionsResult) : extensionsResult;
const skillPaths = this.noSkills
? this.mergePaths(cliExtensionPaths.skills, this.additionalSkillPaths)
: this.mergePaths([...resolvedPaths.skills, ...cliExtensionPaths.skills], this.additionalSkillPaths);
let skillsResult: { skills: Skill[]; diagnostics: ResourceDiagnostic[] };
if (this.noSkills && skillPaths.length === 0) {
skillsResult = { skills: [], diagnostics: [] };
} else {
const result = loadSkills({
cwd: this.cwd,
agentDir: this.agentDir,
skillPaths,
});
skillsResult = { skills: result.skills, diagnostics: this.toDiagnostics(result.warnings) };
}
const resolvedSkills = this.skillsOverride ? this.skillsOverride(skillsResult) : skillsResult;
this.skills = resolvedSkills.skills;
this.skillDiagnostics = resolvedSkills.diagnostics;
const promptPaths = this.noPromptTemplates
? this.mergePaths(cliExtensionPaths.prompts, this.additionalPromptTemplatePaths)
: this.mergePaths(
[...resolvedPaths.prompts, ...cliExtensionPaths.prompts],
this.additionalPromptTemplatePaths,
);
let promptsResult: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] };
if (this.noPromptTemplates && promptPaths.length === 0) {
promptsResult = { prompts: [], diagnostics: [] };
} else {
promptsResult = {
prompts: loadPromptTemplates({
cwd: this.cwd,
agentDir: this.agentDir,
promptPaths,
}),
diagnostics: [],
};
}
const resolvedPrompts = this.promptsOverride ? this.promptsOverride(promptsResult) : promptsResult;
this.prompts = resolvedPrompts.prompts;
this.promptDiagnostics = resolvedPrompts.diagnostics;
const themePaths = this.noThemes
? this.mergePaths(cliExtensionPaths.themes, this.additionalThemePaths)
: this.mergePaths([...resolvedPaths.themes, ...cliExtensionPaths.themes], this.additionalThemePaths);
let themesResult: { themes: Theme[]; diagnostics: ResourceDiagnostic[] };
if (this.noThemes && themePaths.length === 0) {
themesResult = { themes: [], diagnostics: [] };
} else {
themesResult = this.loadThemes(themePaths);
}
const resolvedThemes = this.themesOverride ? this.themesOverride(themesResult) : themesResult;
this.themes = resolvedThemes.themes;
this.themeDiagnostics = resolvedThemes.diagnostics;
const agentsFiles = { agentsFiles: loadProjectContextFiles({ cwd: this.cwd, agentDir: this.agentDir }) };
const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles;
this.agentsFiles = resolvedAgentsFiles.agentsFiles;
const baseSystemPrompt = resolvePromptInput(
this.systemPromptSource ?? this.discoverSystemPromptFile(),
"system prompt",
);
this.systemPrompt = this.systemPromptOverride ? this.systemPromptOverride(baseSystemPrompt) : baseSystemPrompt;
const appendSource = this.appendSystemPromptSource ?? this.discoverAppendSystemPromptFile();
const resolvedAppend = resolvePromptInput(appendSource, "append system prompt");
const baseAppend = resolvedAppend ? [resolvedAppend] : [];
this.appendSystemPrompt = this.appendSystemPromptOverride
? this.appendSystemPromptOverride(baseAppend)
: baseAppend;
}
private mergePaths(primary: string[], additional: string[]): string[] {
const merged: string[] = [];
const seen = new Set<string>();
for (const p of [...primary, ...additional]) {
const resolved = this.resolveResourcePath(p);
if (seen.has(resolved)) continue;
seen.add(resolved);
merged.push(resolved);
}
return merged;
}
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);
}
private loadThemes(paths: string[]): { themes: Theme[]; diagnostics: ResourceDiagnostic[] } {
const themes: Theme[] = [];
const diagnostics: ResourceDiagnostic[] = [];
const defaultDirs = [join(this.agentDir, "themes"), join(this.cwd, CONFIG_DIR_NAME, "themes")];
for (const dir of defaultDirs) {
this.loadThemesFromDir(dir, themes, diagnostics);
}
for (const p of paths) {
const resolved = resolve(this.cwd, p);
if (!existsSync(resolved)) {
diagnostics.push({ type: "warning", message: "theme path does not exist", path: resolved });
continue;
}
try {
const stats = statSync(resolved);
if (stats.isDirectory()) {
this.loadThemesFromDir(resolved, themes, diagnostics);
} else if (stats.isFile() && resolved.endsWith(".json")) {
this.loadThemeFromFile(resolved, themes, diagnostics);
} else {
diagnostics.push({ type: "warning", message: "theme path is not a json file", path: resolved });
}
} catch (error) {
const message = error instanceof Error ? error.message : "failed to read theme path";
diagnostics.push({ type: "warning", message, path: resolved });
}
}
return { themes, diagnostics };
}
private loadThemesFromDir(dir: string, themes: Theme[], diagnostics: ResourceDiagnostic[]): void {
if (!existsSync(dir)) {
return;
}
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
let isFile = entry.isFile();
if (entry.isSymbolicLink()) {
try {
isFile = statSync(join(dir, entry.name)).isFile();
} catch {
continue;
}
}
if (!isFile) {
continue;
}
if (!entry.name.endsWith(".json")) {
continue;
}
this.loadThemeFromFile(join(dir, entry.name), themes, diagnostics);
}
} catch (error) {
const message = error instanceof Error ? error.message : "failed to read theme directory";
diagnostics.push({ type: "warning", message, path: dir });
}
}
private loadThemeFromFile(filePath: string, themes: Theme[], diagnostics: ResourceDiagnostic[]): void {
try {
themes.push(loadThemeFromPath(filePath));
} catch (error) {
const message = error instanceof Error ? error.message : "failed to load theme";
diagnostics.push({ type: "warning", message, path: filePath });
}
}
private async loadExtensionFactories(runtime: ExtensionRuntime): Promise<{
extensions: Extension[];
errors: Array<{ path: string; error: string }>;
}> {
const extensions: Extension[] = [];
const errors: Array<{ path: string; error: string }> = [];
for (const [index, factory] of this.extensionFactories.entries()) {
const extensionPath = `<inline:${index + 1}>`;
try {
const extension = await loadExtensionFromFactory(factory, this.cwd, this.eventBus, runtime, extensionPath);
extensions.push(extension);
} catch (error) {
const message = error instanceof Error ? error.message : "failed to load extension";
errors.push({ path: extensionPath, error: message });
}
}
return { extensions, errors };
}
private toDiagnostics(warnings: SkillWarning[]): ResourceDiagnostic[] {
return warnings.map((warning) => ({
type: "warning",
message: warning.message,
path: warning.skillPath,
}));
}
private discoverSystemPromptFile(): string | undefined {
const projectPath = join(this.cwd, CONFIG_DIR_NAME, "SYSTEM.md");
if (existsSync(projectPath)) {
return projectPath;
}
const globalPath = join(this.agentDir, "SYSTEM.md");
if (existsSync(globalPath)) {
return globalPath;
}
return undefined;
}
private discoverAppendSystemPromptFile(): string | undefined {
const projectPath = join(this.cwd, CONFIG_DIR_NAME, "APPEND_SYSTEM.md");
if (existsSync(projectPath)) {
return projectPath;
}
const globalPath = join(this.agentDir, "APPEND_SYSTEM.md");
if (existsSync(globalPath)) {
return globalPath;
}
return undefined;
}
}

View file

@ -1,59 +1,21 @@
/** import { join } from "node:path";
* SDK for programmatic usage of AgentSession. import { Agent, type AgentMessage, type ThinkingLevel } from "@mariozechner/pi-agent-core";
*
* Provides a factory function and discovery helpers that allow full control
* over agent configuration, or sensible defaults that match CLI behavior.
*
* @example
* ```typescript
* // Minimal - everything auto-discovered
* const session = await createAgentSession();
*
* // Full control
* const session = await createAgentSession({
* model: myModel,
* getApiKey: async () => process.env.MY_KEY,
* tools: [readTool, bashTool],
* skills: [],
* sessionFile: false,
* });
* ```
*/
import { Agent, type AgentMessage, type AgentTool, type ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { Message, Model } from "@mariozechner/pi-ai"; import type { Message, Model } from "@mariozechner/pi-ai";
import { join } from "path";
import { getAgentDir, getAuthPath } from "../config.js"; import { getAgentDir, getAuthPath } from "../config.js";
import { AgentSession } from "./agent-session.js"; import { AgentSession } from "./agent-session.js";
import { AuthStorage } from "./auth-storage.js"; import { AuthStorage } from "./auth-storage.js";
import { createEventBus, type EventBus } from "./event-bus.js"; import type { ExtensionRunner, LoadExtensionsResult, ToolDefinition } from "./extensions/index.js";
import {
createExtensionRuntime,
discoverAndLoadExtensions,
type ExtensionFactory,
ExtensionRunner,
type LoadExtensionsResult,
loadExtensionFromFactory,
type ToolDefinition,
wrapRegisteredTools,
wrapToolsWithExtensions,
} from "./extensions/index.js";
import { convertToLlm } from "./messages.js"; import { convertToLlm } from "./messages.js";
import { ModelRegistry } from "./model-registry.js"; import { ModelRegistry } from "./model-registry.js";
import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./prompt-templates.js"; import type { ResourceLoader } from "./resource-loader.js";
import { DefaultResourceLoader } from "./resource-loader.js";
import { SessionManager } from "./session-manager.js"; import { SessionManager } from "./session-manager.js";
import { type Settings, SettingsManager, type SkillsSettings } from "./settings-manager.js"; import { SettingsManager } from "./settings-manager.js";
import { loadSkills as loadSkillsInternal, type Skill, type SkillWarning } from "./skills.js";
import {
buildSystemPrompt as buildSystemPromptInternal,
loadProjectContextFiles as loadContextFilesInternal,
} from "./system-prompt.js";
import { time } from "./timings.js"; import { time } from "./timings.js";
import { import {
allTools, allTools,
bashTool, bashTool,
codingTools, codingTools,
createAllTools,
createBashTool, createBashTool,
createCodingTools, createCodingTools,
createEditTool, createEditTool,
@ -74,17 +36,15 @@ import {
writeTool, writeTool,
} from "./tools/index.js"; } from "./tools/index.js";
// Types
export interface CreateAgentSessionOptions { export interface CreateAgentSessionOptions {
/** Working directory for project-local discovery. Default: process.cwd() */ /** Working directory for project-local discovery. Default: process.cwd() */
cwd?: string; cwd?: string;
/** Global config directory. Default: ~/.pi/agent */ /** Global config directory. Default: ~/.pi/agent */
agentDir?: string; agentDir?: string;
/** Auth storage for credentials. Default: discoverAuthStorage(agentDir) */ /** Auth storage for credentials. Default: new AuthStorage(agentDir/auth.json) */
authStorage?: AuthStorage; authStorage?: AuthStorage;
/** Model registry. Default: discoverModels(authStorage, agentDir) */ /** Model registry. Default: new ModelRegistry(authStorage, agentDir/models.json) */
modelRegistry?: ModelRegistry; modelRegistry?: ModelRegistry;
/** Model to use. Default: from settings, else first available */ /** Model to use. Default: from settings, else first available */
@ -94,32 +54,13 @@ export interface CreateAgentSessionOptions {
/** Models available for cycling (Ctrl+P in interactive mode) */ /** Models available for cycling (Ctrl+P in interactive mode) */
scopedModels?: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>; scopedModels?: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
/** System prompt. String replaces default, function receives default and returns final. */
systemPrompt?: string | ((defaultPrompt: string) => string);
/** Built-in tools to use. Default: codingTools [read, bash, edit, write] */ /** Built-in tools to use. Default: codingTools [read, bash, edit, write] */
tools?: Tool[]; tools?: Tool[];
/** Custom tools to register (in addition to built-in tools). */ /** Custom tools to register (in addition to built-in tools). */
customTools?: ToolDefinition[]; customTools?: ToolDefinition[];
/** Inline extensions. When provided (even if empty), skips file discovery. */
extensions?: ExtensionFactory[];
/** Additional extension paths to load (merged with discovery). */
additionalExtensionPaths?: string[];
/**
* Pre-loaded extensions result (skips file discovery).
* @internal Used by CLI when extensions are loaded early to parse custom flags.
*/
preloadedExtensions?: LoadExtensionsResult;
/** Shared event bus for tool/extension communication. Default: creates new bus. */ /** Resource loader. When omitted, DefaultResourceLoader is used. */
eventBus?: EventBus; resourceLoader?: ResourceLoader;
/** Skills. Default: discovered from multiple locations */
skills?: Skill[];
/** Context files (AGENTS.md content). Default: discovered walking up from cwd */
contextFiles?: Array<{ path: string; content: string }>;
/** Prompt templates. Default: discovered from cwd/.pi/prompts/ + agentDir/prompts/ */
promptTemplates?: PromptTemplate[];
/** Session manager. Default: SessionManager.create(cwd) */ /** Session manager. Default: SessionManager.create(cwd) */
sessionManager?: SessionManager; sessionManager?: SessionManager;
@ -148,7 +89,6 @@ export type {
ToolDefinition, ToolDefinition,
} from "./extensions/index.js"; } from "./extensions/index.js";
export type { PromptTemplate } from "./prompt-templates.js"; export type { PromptTemplate } from "./prompt-templates.js";
export type { Settings, SkillsSettings } from "./settings-manager.js";
export type { Skill } from "./skills.js"; export type { Skill } from "./skills.js";
export type { Tool } from "./tools/index.js"; export type { Tool } from "./tools/index.js";
@ -182,133 +122,6 @@ function getDefaultAgentDir(): string {
return getAgentDir(); return getAgentDir();
} }
// Discovery Functions
/**
* Create an AuthStorage instance for the given agent directory.
*/
export function discoverAuthStorage(agentDir: string = getDefaultAgentDir()): AuthStorage {
return new AuthStorage(join(agentDir, "auth.json"));
}
/**
* Create a ModelRegistry for the given agent directory.
*/
export function discoverModels(authStorage: AuthStorage, agentDir: string = getDefaultAgentDir()): ModelRegistry {
return new ModelRegistry(authStorage, join(agentDir, "models.json"));
}
/**
* Discover extensions from cwd and agentDir.
* @param eventBus - Shared event bus for extension communication. Pass to createAgentSession too.
* @param cwd - Current working directory
* @param agentDir - Agent configuration directory
*/
export async function discoverExtensions(
eventBus: EventBus,
cwd?: string,
agentDir?: string,
): Promise<LoadExtensionsResult> {
const resolvedCwd = cwd ?? process.cwd();
const resolvedAgentDir = agentDir ?? getDefaultAgentDir();
const result = await discoverAndLoadExtensions([], resolvedCwd, resolvedAgentDir, eventBus);
// Log errors but don't fail
for (const { path, error } of result.errors) {
console.error(`Failed to load extension "${path}": ${error}`);
}
return result;
}
/**
* Discover skills from cwd and agentDir.
*/
export function discoverSkills(
cwd?: string,
agentDir?: string,
settings?: SkillsSettings,
): { skills: Skill[]; warnings: SkillWarning[] } {
return loadSkillsInternal({
...settings,
cwd: cwd ?? process.cwd(),
agentDir: agentDir ?? getDefaultAgentDir(),
});
}
/**
* Discover context files (AGENTS.md) walking up from cwd.
*/
export function discoverContextFiles(cwd?: string, agentDir?: string): Array<{ path: string; content: string }> {
return loadContextFilesInternal({
cwd: cwd ?? process.cwd(),
agentDir: agentDir ?? getDefaultAgentDir(),
});
}
/**
* Discover prompt templates from cwd and agentDir.
*/
export function discoverPromptTemplates(cwd?: string, agentDir?: string): PromptTemplate[] {
return loadPromptTemplatesInternal({
cwd: cwd ?? process.cwd(),
agentDir: agentDir ?? getDefaultAgentDir(),
});
}
// API Key Helpers
// System Prompt
export interface BuildSystemPromptOptions {
tools?: Tool[];
skills?: Skill[];
contextFiles?: Array<{ path: string; content: string }>;
cwd?: string;
appendPrompt?: string;
}
/**
* Build the default system prompt.
*/
export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): string {
return buildSystemPromptInternal({
cwd: options.cwd,
skills: options.skills,
contextFiles: options.contextFiles,
appendSystemPrompt: options.appendPrompt,
});
}
// Settings
/**
* Load settings from agentDir/settings.json merged with cwd/.pi/settings.json.
*/
export function loadSettings(cwd?: string, agentDir?: string): Settings {
const manager = SettingsManager.create(cwd ?? process.cwd(), agentDir ?? getDefaultAgentDir());
return {
defaultProvider: manager.getDefaultProvider(),
defaultModel: manager.getDefaultModel(),
defaultThinkingLevel: manager.getDefaultThinkingLevel(),
steeringMode: manager.getSteeringMode(),
followUpMode: manager.getFollowUpMode(),
theme: manager.getTheme(),
compaction: manager.getCompactionSettings(),
retry: manager.getRetrySettings(),
hideThinkingBlock: manager.getHideThinkingBlock(),
shellPath: manager.getShellPath(),
collapseChangelog: manager.getCollapseChangelog(),
extensions: manager.getExtensionPaths(),
skills: manager.getSkillsSettings(),
terminal: { showImages: manager.getShowImages() },
images: { autoResize: manager.getImageAutoResize(), blockImages: manager.getBlockImages() },
};
}
// Factory
/** /**
* Create an AgentSession with the specified options. * Create an AgentSession with the specified options.
* *
@ -330,12 +143,16 @@ export function loadSettings(cwd?: string, agentDir?: string): Settings {
* }); * });
* *
* // Full control * // Full control
* const loader = new DefaultResourceLoader({
* cwd: process.cwd(),
* agentDir: getAgentDir(),
* settingsManager: SettingsManager.create(),
* });
* await loader.reload();
* const { session } = await createAgentSession({ * const { session } = await createAgentSession({
* model: myModel, * model: myModel,
* getApiKey: async () => process.env.MY_KEY,
* systemPrompt: 'You are helpful.',
* tools: [readTool, bashTool], * tools: [readTool, bashTool],
* skills: [], * resourceLoader: loader,
* sessionManager: SessionManager.inMemory(), * sessionManager: SessionManager.inMemory(),
* }); * });
* ``` * ```
@ -343,21 +160,25 @@ export function loadSettings(cwd?: string, agentDir?: string): Settings {
export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise<CreateAgentSessionResult> { export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise<CreateAgentSessionResult> {
const cwd = options.cwd ?? process.cwd(); const cwd = options.cwd ?? process.cwd();
const agentDir = options.agentDir ?? getDefaultAgentDir(); const agentDir = options.agentDir ?? getDefaultAgentDir();
const eventBus = options.eventBus ?? createEventBus(); let resourceLoader = options.resourceLoader;
// Use provided or create AuthStorage and ModelRegistry // Use provided or create AuthStorage and ModelRegistry
const authStorage = options.authStorage ?? discoverAuthStorage(agentDir); const authPath = options.agentDir ? join(agentDir, "auth.json") : undefined;
const modelRegistry = options.modelRegistry ?? discoverModels(authStorage, agentDir); const modelsPath = options.agentDir ? join(agentDir, "models.json") : undefined;
time("discoverModels"); const authStorage = options.authStorage ?? new AuthStorage(authPath);
const modelRegistry = options.modelRegistry ?? new ModelRegistry(authStorage, modelsPath);
const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir); const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
time("settingsManager");
const sessionManager = options.sessionManager ?? SessionManager.create(cwd); const sessionManager = options.sessionManager ?? SessionManager.create(cwd);
time("sessionManager");
if (!resourceLoader) {
resourceLoader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });
await resourceLoader.reload();
time("resourceLoader.reload");
}
// Check if session has existing data to restore // Check if session has existing data to restore
const existingSession = sessionManager.buildSessionContext(); const existingSession = sessionManager.buildSessionContext();
time("loadSession");
const hasExistingSession = existingSession.messages.length > 0; const hasExistingSession = existingSession.messages.length > 0;
let model = options.model; let model = options.model;
@ -394,7 +215,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
break; break;
} }
} }
time("findAvailableModel");
if (model) { if (model) {
if (modelFallbackMessage) { if (modelFallbackMessage) {
modelFallbackMessage += `. Using ${model.provider}/${model.id}`; modelFallbackMessage += `. Using ${model.provider}/${model.id}`;
@ -422,161 +242,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
thinkingLevel = "off"; thinkingLevel = "off";
} }
let skills: Skill[];
let skillWarnings: SkillWarning[];
if (options.skills !== undefined) {
skills = options.skills;
skillWarnings = [];
} else {
const discovered = discoverSkills(cwd, agentDir, settingsManager.getSkillsSettings());
skills = discovered.skills;
skillWarnings = discovered.warnings;
}
time("discoverSkills");
const contextFiles = options.contextFiles ?? discoverContextFiles(cwd, agentDir);
time("discoverContextFiles");
const autoResizeImages = settingsManager.getImageAutoResize();
const shellCommandPrefix = settingsManager.getShellCommandPrefix();
// Create ALL built-in tools for the registry (extensions can enable any of them)
const allBuiltInToolsMap = createAllTools(cwd, {
read: { autoResizeImages },
bash: { commandPrefix: shellCommandPrefix },
});
// Determine initially active built-in tools (default: read, bash, edit, write)
const defaultActiveToolNames: ToolName[] = ["read", "bash", "edit", "write"]; const defaultActiveToolNames: ToolName[] = ["read", "bash", "edit", "write"];
const initialActiveToolNames: ToolName[] = options.tools const initialActiveToolNames: ToolName[] = options.tools
? options.tools.map((t) => t.name).filter((n): n is ToolName => n in allBuiltInToolsMap) ? options.tools.map((t) => t.name).filter((n): n is ToolName => n in allTools)
: defaultActiveToolNames; : defaultActiveToolNames;
const initialActiveBuiltInTools = initialActiveToolNames.map((name) => allBuiltInToolsMap[name]);
time("createAllTools");
// Load extensions (discovers from standard locations + configured paths)
let extensionsResult: LoadExtensionsResult;
if (options.preloadedExtensions !== undefined) {
// Use pre-loaded extensions (from early CLI flag discovery)
extensionsResult = options.preloadedExtensions;
} else if (options.extensions !== undefined) {
// User explicitly provided extensions array (even if empty) - skip discovery
// Create runtime for inline extensions
const runtime = createExtensionRuntime();
extensionsResult = {
extensions: [],
errors: [],
runtime,
};
} else {
// Discover extensions, merging with additional paths
const configuredPaths = [...settingsManager.getExtensionPaths(), ...(options.additionalExtensionPaths ?? [])];
extensionsResult = await discoverAndLoadExtensions(configuredPaths, cwd, agentDir, eventBus);
time("discoverAndLoadExtensions");
for (const { path, error } of extensionsResult.errors) {
console.error(`Failed to load extension "${path}": ${error}`);
}
}
// Load inline extensions from factories
if (options.extensions && options.extensions.length > 0) {
for (let i = 0; i < options.extensions.length; i++) {
const factory = options.extensions[i];
const loaded = await loadExtensionFromFactory(
factory,
cwd,
eventBus,
extensionsResult.runtime,
`<inline-${i}>`,
);
extensionsResult.extensions.push(loaded);
}
}
// Create extension runner if we have extensions or SDK custom tools
// The runner provides consistent context for tool execution (shutdown, abort, etc.)
let extensionRunner: ExtensionRunner | undefined;
const hasExtensions = extensionsResult.extensions.length > 0;
const hasCustomTools = options.customTools && options.customTools.length > 0;
if (hasExtensions || hasCustomTools) {
extensionRunner = new ExtensionRunner(
extensionsResult.extensions,
extensionsResult.runtime,
cwd,
sessionManager,
modelRegistry,
);
}
// Wrap extension-registered tools and SDK-provided custom tools
// Tools use runner.createContext() for consistent context with event handlers
let agent: Agent; let agent: Agent;
const registeredTools = extensionRunner?.getAllRegisteredTools() ?? [];
// Combine extension-registered tools with SDK-provided custom tools
const allCustomTools = [
...registeredTools,
...(options.customTools?.map((def) => ({ definition: def, extensionPath: "<sdk>" })) ?? []),
];
// Wrap tools using runner's context (ensures shutdown, abort, etc. work correctly)
const wrappedExtensionTools = extensionRunner ? wrapRegisteredTools(allCustomTools, extensionRunner) : [];
// Create tool registry mapping name -> tool (for extension getTools/setTools)
// Registry contains ALL built-in tools so extensions can enable any of them
const toolRegistry = new Map<string, AgentTool>();
for (const [name, tool] of Object.entries(allBuiltInToolsMap)) {
toolRegistry.set(name, tool as AgentTool);
}
for (const tool of wrappedExtensionTools as AgentTool[]) {
toolRegistry.set(tool.name, tool);
}
// Initially active tools = active built-in + extension tools
// Extension tools can override built-in tools with the same name
const extensionToolNames = new Set(wrappedExtensionTools.map((t) => t.name));
const nonOverriddenBuiltInTools = initialActiveBuiltInTools.filter((t) => !extensionToolNames.has(t.name));
let activeToolsArray: Tool[] = [...nonOverriddenBuiltInTools, ...wrappedExtensionTools];
time("combineTools");
// Wrap tools with extensions if available
let wrappedToolRegistry: Map<string, AgentTool> | undefined;
if (extensionRunner) {
activeToolsArray = wrapToolsWithExtensions(activeToolsArray as AgentTool[], extensionRunner);
// Wrap ALL registry tools (not just active) so extensions can enable any
const allRegistryTools = Array.from(toolRegistry.values());
const wrappedAllTools = wrapToolsWithExtensions(allRegistryTools, extensionRunner);
wrappedToolRegistry = new Map<string, AgentTool>();
for (const tool of wrappedAllTools) {
wrappedToolRegistry.set(tool.name, tool);
}
}
// Function to rebuild system prompt when tools change
// Captures static options (cwd, agentDir, skills, contextFiles, customPrompt)
const rebuildSystemPrompt = (toolNames: string[]): string => {
// Filter to valid tool names
const validToolNames = toolNames.filter((n): n is ToolName => n in allBuiltInToolsMap);
const defaultPrompt = buildSystemPromptInternal({
cwd,
agentDir,
skills,
contextFiles,
selectedTools: validToolNames,
});
if (options.systemPrompt === undefined) {
return defaultPrompt;
} else if (typeof options.systemPrompt === "string") {
// String is a full replacement - use as-is without appending context/skills
return options.systemPrompt;
} else {
return options.systemPrompt(defaultPrompt);
}
};
const systemPrompt = rebuildSystemPrompt(initialActiveToolNames);
time("buildSystemPrompt");
const promptTemplates = options.promptTemplates ?? discoverPromptTemplates(cwd, agentDir);
time("discoverPromptTemplates");
// Create convertToLlm wrapper that filters images if blockImages is enabled (defense-in-depth) // Create convertToLlm wrapper that filters images if blockImages is enabled (defense-in-depth)
const convertToLlmWithBlockImages = (messages: AgentMessage[]): Message[] => { const convertToLlmWithBlockImages = (messages: AgentMessage[]): Message[] => {
@ -615,20 +286,22 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
}); });
}; };
const extensionRunnerRef: { current?: ExtensionRunner } = {};
agent = new Agent({ agent = new Agent({
initialState: { initialState: {
systemPrompt, systemPrompt: "",
model, model,
thinkingLevel, thinkingLevel,
tools: activeToolsArray, tools: [],
}, },
convertToLlm: convertToLlmWithBlockImages, convertToLlm: convertToLlmWithBlockImages,
sessionId: sessionManager.getSessionId(), sessionId: sessionManager.getSessionId(),
transformContext: extensionRunner transformContext: async (messages) => {
? async (messages) => { const runner = extensionRunnerRef.current;
return extensionRunner.emitContext(messages); if (!runner) return messages;
} return runner.emitContext(messages);
: undefined, },
steeringMode: settingsManager.getSteeringMode(), steeringMode: settingsManager.getSteeringMode(),
followUpMode: settingsManager.getFollowUpMode(), followUpMode: settingsManager.getFollowUpMode(),
thinkingBudgets: settingsManager.getThinkingBudgets(), thinkingBudgets: settingsManager.getThinkingBudgets(),
@ -658,7 +331,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
return key; return key;
}, },
}); });
time("createAgent");
// Restore messages if session has existing data // Restore messages if session has existing data
if (hasExistingSession) { if (hasExistingSession) {
@ -675,17 +347,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
agent, agent,
sessionManager, sessionManager,
settingsManager, settingsManager,
cwd,
scopedModels: options.scopedModels, scopedModels: options.scopedModels,
promptTemplates: promptTemplates, resourceLoader,
extensionRunner, customTools: options.customTools,
skills,
skillWarnings,
skillsSettings: settingsManager.getSkillsSettings(),
modelRegistry, modelRegistry,
toolRegistry: wrappedToolRegistry ?? toolRegistry, initialActiveToolNames,
rebuildSystemPrompt, extensionRunnerRef,
}); });
time("createAgentSession"); const extensionsResult = resourceLoader.getExtensions();
return { return {
session, session,

View file

@ -18,19 +18,6 @@ export interface RetrySettings {
baseDelayMs?: number; // default: 2000 (exponential backoff: 2s, 4s, 8s) baseDelayMs?: number; // default: 2000 (exponential backoff: 2s, 4s, 8s)
} }
export interface SkillsSettings {
enabled?: boolean; // default: true
enableCodexUser?: boolean; // default: true
enableClaudeUser?: boolean; // default: true
enableClaudeProject?: boolean; // default: true
enablePiUser?: boolean; // default: true
enablePiProject?: boolean; // default: true
enableSkillCommands?: boolean; // default: true - register skills as /skill:name commands
customDirectories?: string[]; // default: []
ignoredSkills?: string[]; // default: [] (glob patterns to exclude; takes precedence over includeSkills)
includeSkills?: string[]; // default: [] (empty = include all; glob patterns to filter)
}
export interface TerminalSettings { export interface TerminalSettings {
showImages?: boolean; // default: true (only relevant if terminal supports images) showImages?: boolean; // default: true (only relevant if terminal supports images)
} }
@ -67,8 +54,11 @@ export interface Settings {
quietStartup?: boolean; quietStartup?: boolean;
shellCommandPrefix?: string; // Prefix prepended to every bash command (e.g., "shopt -s expand_aliases" for alias support) shellCommandPrefix?: string; // Prefix prepended to every bash command (e.g., "shopt -s expand_aliases" for alias support)
collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full) collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
extensions?: string[]; // Array of extension file paths extensions?: string[]; // Array of extension file paths or directories
skills?: SkillsSettings; skills?: string[]; // Array of skill file paths or directories
prompts?: string[]; // Array of prompt template paths or directories
themes?: string[]; // Array of theme file paths or directories
enableSkillCommands?: boolean; // default: true - register skills as /skill:name commands
terminal?: TerminalSettings; terminal?: TerminalSettings;
images?: ImageSettings; images?: ImageSettings;
enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag) enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag)
@ -165,6 +155,27 @@ export class SettingsManager {
settings.steeringMode = settings.queueMode; settings.steeringMode = settings.queueMode;
delete settings.queueMode; delete settings.queueMode;
} }
if (
"skills" in settings &&
typeof settings.skills === "object" &&
settings.skills !== null &&
!Array.isArray(settings.skills)
) {
const skillsSettings = settings.skills as {
enableSkillCommands?: boolean;
customDirectories?: unknown;
};
if (skillsSettings.enableSkillCommands !== undefined && settings.enableSkillCommands === undefined) {
settings.enableSkillCommands = skillsSettings.enableSkillCommands;
}
if (Array.isArray(skillsSettings.customDirectories) && skillsSettings.customDirectories.length > 0) {
settings.skills = skillsSettings.customDirectories;
} else {
delete settings.skills;
}
}
return settings as Settings; return settings as Settings;
} }
@ -183,6 +194,14 @@ export class SettingsManager {
} }
} }
getGlobalSettings(): Settings {
return structuredClone(this.globalSettings);
}
getProjectSettings(): Settings {
return this.loadProjectSettings();
}
/** Apply additional overrides on top of current settings */ /** Apply additional overrides on top of current settings */
applyOverrides(overrides: Partial<Settings>): void { applyOverrides(overrides: Partial<Settings>): void {
this.settings = deepMergeSettings(this.settings, overrides); this.settings = deepMergeSettings(this.settings, overrides);
@ -214,6 +233,21 @@ export class SettingsManager {
this.settings = deepMergeSettings(this.globalSettings, projectSettings); this.settings = deepMergeSettings(this.globalSettings, projectSettings);
} }
private saveProjectSettings(settings: Settings): void {
if (!this.persist || !this.projectSettingsPath) {
return;
}
try {
const dir = dirname(this.projectSettingsPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(this.projectSettingsPath, JSON.stringify(settings, null, 2), "utf-8");
} catch (error) {
console.error(`Warning: Could not save project settings file: ${error}`);
}
}
getLastChangelogVersion(): string | undefined { getLastChangelogVersion(): string | undefined {
return this.settings.lastChangelogVersion; return this.settings.lastChangelogVersion;
} }
@ -391,42 +425,46 @@ export class SettingsManager {
this.save(); this.save();
} }
getSkillsEnabled(): boolean { setProjectExtensionPaths(paths: string[]): void {
return this.settings.skills?.enabled ?? true; const projectSettings = this.loadProjectSettings();
projectSettings.extensions = paths;
this.saveProjectSettings(projectSettings);
this.settings = deepMergeSettings(this.globalSettings, projectSettings);
} }
setSkillsEnabled(enabled: boolean): void { getSkillPaths(): string[] {
if (!this.globalSettings.skills) { return [...(this.settings.skills ?? [])];
this.globalSettings.skills = {}; }
}
this.globalSettings.skills.enabled = enabled; setSkillPaths(paths: string[]): void {
this.globalSettings.skills = paths;
this.save(); this.save();
} }
getSkillsSettings(): Required<SkillsSettings> { getPromptTemplatePaths(): string[] {
return { return [...(this.settings.prompts ?? [])];
enabled: this.settings.skills?.enabled ?? true, }
enableCodexUser: this.settings.skills?.enableCodexUser ?? true,
enableClaudeUser: this.settings.skills?.enableClaudeUser ?? true, setPromptTemplatePaths(paths: string[]): void {
enableClaudeProject: this.settings.skills?.enableClaudeProject ?? true, this.globalSettings.prompts = paths;
enablePiUser: this.settings.skills?.enablePiUser ?? true, this.save();
enablePiProject: this.settings.skills?.enablePiProject ?? true, }
enableSkillCommands: this.settings.skills?.enableSkillCommands ?? true,
customDirectories: [...(this.settings.skills?.customDirectories ?? [])], getThemePaths(): string[] {
ignoredSkills: [...(this.settings.skills?.ignoredSkills ?? [])], return [...(this.settings.themes ?? [])];
includeSkills: [...(this.settings.skills?.includeSkills ?? [])], }
};
setThemePaths(paths: string[]): void {
this.globalSettings.themes = paths;
this.save();
} }
getEnableSkillCommands(): boolean { getEnableSkillCommands(): boolean {
return this.settings.skills?.enableSkillCommands ?? true; return this.settings.enableSkillCommands ?? true;
} }
setEnableSkillCommands(enabled: boolean): void { setEnableSkillCommands(enabled: boolean): void {
if (!this.globalSettings.skills) { this.globalSettings.enableSkillCommands = enabled;
this.globalSettings.skills = {};
}
this.globalSettings.skills.enableSkillCommands = enabled;
this.save(); this.save();
} }

View file

@ -1,10 +1,8 @@
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "fs"; import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "fs";
import { minimatch } from "minimatch";
import { homedir } from "os"; import { homedir } from "os";
import { basename, dirname, join, resolve } from "path"; import { basename, dirname, isAbsolute, join, resolve } from "path";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.js"; import { CONFIG_DIR_NAME, getAgentDir } from "../config.js";
import { parseFrontmatter } from "../utils/frontmatter.js"; import { parseFrontmatter } from "../utils/frontmatter.js";
import type { SkillsSettings } from "./settings-manager.js";
/** /**
* Standard frontmatter fields per Agent Skills spec. * Standard frontmatter fields per Agent Skills spec.
@ -49,8 +47,6 @@ export interface LoadSkillsResult {
warnings: SkillWarning[]; warnings: SkillWarning[];
} }
type SkillFormat = "recursive" | "claude";
/** /**
* Validate skill name per Agent Skills spec. * Validate skill name per Agent Skills spec.
* Returns array of validation error messages (empty if valid). * Returns array of validation error messages (empty if valid).
@ -88,7 +84,7 @@ function validateDescription(description: string | undefined): string[] {
const errors: string[] = []; const errors: string[] = [];
if (!description || description.trim() === "") { if (!description || description.trim() === "") {
errors.push(`description is required`); errors.push("description is required");
} else if (description.length > MAX_DESCRIPTION_LENGTH) { } else if (description.length > MAX_DESCRIPTION_LENGTH) {
errors.push(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`); errors.push(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`);
} }
@ -117,15 +113,18 @@ export interface LoadSkillsFromDirOptions {
} }
/** /**
* Load skills from a directory recursively. * Load skills from a directory.
* Skills are directories containing a SKILL.md file with frontmatter including a description. *
* Discovery rules:
* - direct .md children in the root
* - recursive SKILL.md under subdirectories
*/ */
export function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkillsResult { export function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkillsResult {
const { dir, source } = options; const { dir, source } = options;
return loadSkillsFromDirInternal(dir, source, "recursive"); return loadSkillsFromDirInternal(dir, source, true);
} }
function loadSkillsFromDirInternal(dir: string, source: string, format: SkillFormat): LoadSkillsResult { function loadSkillsFromDirInternal(dir: string, source: string, includeRootFiles: boolean): LoadSkillsResult {
const skills: Skill[] = []; const skills: Skill[] = [];
const warnings: SkillWarning[] = []; const warnings: SkillWarning[] = [];
@ -162,36 +161,28 @@ function loadSkillsFromDirInternal(dir: string, source: string, format: SkillFor
} }
} }
if (format === "recursive") { if (isDirectory) {
// Recursive format: scan directories, look for SKILL.md files const subResult = loadSkillsFromDirInternal(fullPath, source, false);
if (isDirectory) { skills.push(...subResult.skills);
const subResult = loadSkillsFromDirInternal(fullPath, source, format); warnings.push(...subResult.warnings);
skills.push(...subResult.skills); continue;
warnings.push(...subResult.warnings);
} else if (isFile && entry.name === "SKILL.md") {
const result = loadSkillFromFile(fullPath, source);
if (result.skill) {
skills.push(result.skill);
}
warnings.push(...result.warnings);
}
} else if (format === "claude") {
// Claude format: only one level deep, each directory must contain SKILL.md
if (!isDirectory) {
continue;
}
const skillFile = join(fullPath, "SKILL.md");
if (!existsSync(skillFile)) {
continue;
}
const result = loadSkillFromFile(skillFile, source);
if (result.skill) {
skills.push(result.skill);
}
warnings.push(...result.warnings);
} }
if (!isFile) {
continue;
}
const isRootMd = includeRootFiles && entry.name.endsWith(".md");
const isSkillMd = !includeRootFiles && entry.name === "SKILL.md";
if (!isRootMd && !isSkillMd) {
continue;
}
const result = loadSkillFromFile(fullPath, source);
if (result.skill) {
skills.push(result.skill);
}
warnings.push(...result.warnings);
} }
} catch {} } catch {}
@ -290,11 +281,26 @@ function escapeXml(str: string): string {
.replace(/'/g, "&apos;"); .replace(/'/g, "&apos;");
} }
export interface LoadSkillsOptions extends SkillsSettings { export interface LoadSkillsOptions {
/** Working directory for project-local skills. Default: process.cwd() */ /** Working directory for project-local skills. Default: process.cwd() */
cwd?: string; cwd?: string;
/** Agent config directory for global skills. Default: ~/.pi/agent */ /** Agent config directory for global skills. Default: ~/.pi/agent */
agentDir?: string; agentDir?: string;
/** Explicit skill paths (files or directories) */
skillPaths?: string[];
}
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);
} }
/** /**
@ -302,18 +308,7 @@ export interface LoadSkillsOptions extends SkillsSettings {
* Returns skills and any validation warnings. * Returns skills and any validation warnings.
*/ */
export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult { export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
const { const { cwd = process.cwd(), agentDir, skillPaths = [] } = options;
cwd = process.cwd(),
agentDir,
enableCodexUser = true,
enableClaudeUser = true,
enableClaudeProject = true,
enablePiUser = true,
enablePiProject = true,
customDirectories = [],
ignoredSkills = [],
includeSkills = [],
} = options;
// Resolve agentDir - if not provided, use default from config // Resolve agentDir - if not provided, use default from config
const resolvedAgentDir = agentDir ?? getAgentDir(); const resolvedAgentDir = agentDir ?? getAgentDir();
@ -323,30 +318,9 @@ export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
const allWarnings: SkillWarning[] = []; const allWarnings: SkillWarning[] = [];
const collisionWarnings: SkillWarning[] = []; const collisionWarnings: SkillWarning[] = [];
// Check if skill name matches any of the include patterns
function matchesIncludePatterns(name: string): boolean {
if (includeSkills.length === 0) return true; // No filter = include all
return includeSkills.some((pattern) => minimatch(name, pattern));
}
// Check if skill name matches any of the ignore patterns
function matchesIgnorePatterns(name: string): boolean {
if (ignoredSkills.length === 0) return false;
return ignoredSkills.some((pattern) => minimatch(name, pattern));
}
function addSkills(result: LoadSkillsResult) { function addSkills(result: LoadSkillsResult) {
allWarnings.push(...result.warnings); allWarnings.push(...result.warnings);
for (const skill of result.skills) { for (const skill of result.skills) {
// Apply ignore filter (glob patterns) - takes precedence over include
if (matchesIgnorePatterns(skill.name)) {
continue;
}
// Apply include filter (glob patterns)
if (!matchesIncludePatterns(skill.name)) {
continue;
}
// Resolve symlinks to detect duplicate files // Resolve symlinks to detect duplicate files
let realPath: string; let realPath: string;
try { try {
@ -373,23 +347,34 @@ export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
} }
} }
if (enableCodexUser) { addSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", true));
addSkills(loadSkillsFromDirInternal(join(homedir(), ".codex", "skills"), "codex-user", "recursive")); addSkills(loadSkillsFromDirInternal(resolve(cwd, CONFIG_DIR_NAME, "skills"), "project", true));
}
if (enableClaudeUser) { for (const rawPath of skillPaths) {
addSkills(loadSkillsFromDirInternal(join(homedir(), ".claude", "skills"), "claude-user", "claude")); const resolvedPath = resolveSkillPath(rawPath, cwd);
} if (!existsSync(resolvedPath)) {
if (enableClaudeProject) { allWarnings.push({ skillPath: resolvedPath, message: "skill path does not exist" });
addSkills(loadSkillsFromDirInternal(resolve(cwd, ".claude", "skills"), "claude-project", "claude")); continue;
} }
if (enablePiUser) {
addSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", "recursive")); try {
} const stats = statSync(resolvedPath);
if (enablePiProject) { if (stats.isDirectory()) {
addSkills(loadSkillsFromDirInternal(resolve(cwd, CONFIG_DIR_NAME, "skills"), "project", "recursive")); addSkills(loadSkillsFromDirInternal(resolvedPath, "custom", true));
} } else if (stats.isFile() && resolvedPath.endsWith(".md")) {
for (const customDir of customDirectories) { const result = loadSkillFromFile(resolvedPath, "custom");
addSkills(loadSkillsFromDirInternal(customDir.replace(/^~(?=$|[\\/])/, homedir()), "custom", "recursive")); if (result.skill) {
addSkills({ skills: [result.skill], warnings: result.warnings });
} else {
allWarnings.push(...result.warnings);
}
} else {
allWarnings.push({ skillPath: resolvedPath, message: "skill path is not a markdown file" });
}
} catch (error) {
const message = error instanceof Error ? error.message : "failed to read skill path";
allWarnings.push({ skillPath: resolvedPath, message });
}
} }
return { return {

View file

@ -2,16 +2,11 @@
* System prompt construction and project context loading * System prompt construction and project context loading
*/ */
import chalk from "chalk"; import { getDocsPath, getExamplesPath, getReadmePath } from "../config.js";
import { existsSync, readFileSync } from "fs"; import { formatSkillsForPrompt, type Skill } from "./skills.js";
import { join, resolve } from "path";
import { getAgentDir, getDocsPath, getExamplesPath, getReadmePath } from "../config.js";
import type { SkillsSettings } from "./settings-manager.js";
import { formatSkillsForPrompt, loadSkills, type Skill } from "./skills.js";
import type { ToolName } from "./tools/index.js";
/** Tool descriptions for system prompt */ /** Tool descriptions for system prompt */
const toolDescriptions: Record<ToolName, string> = { const toolDescriptions: Record<string, string> = {
read: "Read file contents", read: "Read file contents",
bash: "Execute bash commands (ls, grep, find, etc.)", bash: "Execute bash commands (ls, grep, find, etc.)",
edit: "Make surgical edits to files (find exact text and replace)", edit: "Make surgical edits to files (find exact text and replace)",
@ -21,117 +16,18 @@ const toolDescriptions: Record<ToolName, string> = {
ls: "List directory contents", ls: "List directory contents",
}; };
/** Resolve input as file path or literal string */
export function resolvePromptInput(input: string | undefined, description: string): string | undefined {
if (!input) {
return undefined;
}
if (existsSync(input)) {
try {
return readFileSync(input, "utf-8");
} catch (error) {
console.error(chalk.yellow(`Warning: Could not read ${description} file ${input}: ${error}`));
return input;
}
}
return input;
}
/** Look for AGENTS.md or CLAUDE.md in a directory (prefers AGENTS.md) */
function loadContextFileFromDir(dir: string): { path: string; content: string } | null {
const candidates = ["AGENTS.md", "CLAUDE.md"];
for (const filename of candidates) {
const filePath = join(dir, filename);
if (existsSync(filePath)) {
try {
return {
path: filePath,
content: readFileSync(filePath, "utf-8"),
};
} catch (error) {
console.error(chalk.yellow(`Warning: Could not read ${filePath}: ${error}`));
}
}
}
return null;
}
export interface LoadContextFilesOptions {
/** Working directory to start walking up from. Default: process.cwd() */
cwd?: string;
/** Agent config directory for global context. Default: from getAgentDir() */
agentDir?: string;
}
/**
* Load all project context files in order:
* 1. Global: agentDir/AGENTS.md or CLAUDE.md
* 2. Parent directories (top-most first) down to cwd
* Each returns {path, content} for separate messages
*/
export function loadProjectContextFiles(
options: LoadContextFilesOptions = {},
): Array<{ path: string; content: string }> {
const resolvedCwd = options.cwd ?? process.cwd();
const resolvedAgentDir = options.agentDir ?? getAgentDir();
const contextFiles: Array<{ path: string; content: string }> = [];
const seenPaths = new Set<string>();
// 1. Load global context from agentDir
const globalContext = loadContextFileFromDir(resolvedAgentDir);
if (globalContext) {
contextFiles.push(globalContext);
seenPaths.add(globalContext.path);
}
// 2. Walk up from cwd to root, collecting all context files
const ancestorContextFiles: Array<{ path: string; content: string }> = [];
let currentDir = resolvedCwd;
const root = resolve("/");
while (true) {
const contextFile = loadContextFileFromDir(currentDir);
if (contextFile && !seenPaths.has(contextFile.path)) {
// Add to beginning so we get top-most parent first
ancestorContextFiles.unshift(contextFile);
seenPaths.add(contextFile.path);
}
// Stop if we've reached root
if (currentDir === root) break;
// Move up one directory
const parentDir = resolve(currentDir, "..");
if (parentDir === currentDir) break; // Safety check
currentDir = parentDir;
}
// Add ancestor files in order (top-most → cwd)
contextFiles.push(...ancestorContextFiles);
return contextFiles;
}
export interface BuildSystemPromptOptions { export interface BuildSystemPromptOptions {
/** Custom system prompt (replaces default). */ /** Custom system prompt (replaces default). */
customPrompt?: string; customPrompt?: string;
/** Tools to include in prompt. Default: [read, bash, edit, write] */ /** Tools to include in prompt. Default: [read, bash, edit, write] */
selectedTools?: ToolName[]; selectedTools?: string[];
/** Text to append to system prompt. */ /** Text to append to system prompt. */
appendSystemPrompt?: string; appendSystemPrompt?: string;
/** Skills settings for discovery. */
skillsSettings?: SkillsSettings;
/** Working directory. Default: process.cwd() */ /** Working directory. Default: process.cwd() */
cwd?: string; cwd?: string;
/** Agent config directory. Default: from getAgentDir() */ /** Pre-loaded context files. */
agentDir?: string;
/** Pre-loaded context files (skips discovery if provided). */
contextFiles?: Array<{ path: string; content: string }>; contextFiles?: Array<{ path: string; content: string }>;
/** Pre-loaded skills (skips discovery if provided). */ /** Pre-loaded skills. */
skills?: Skill[]; skills?: Skill[];
} }
@ -141,15 +37,11 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
customPrompt, customPrompt,
selectedTools, selectedTools,
appendSystemPrompt, appendSystemPrompt,
skillsSettings,
cwd, cwd,
agentDir,
contextFiles: providedContextFiles, contextFiles: providedContextFiles,
skills: providedSkills, skills: providedSkills,
} = options; } = options;
const resolvedCwd = cwd ?? process.cwd(); const resolvedCwd = cwd ?? process.cwd();
const resolvedCustomPrompt = resolvePromptInput(customPrompt, "system prompt");
const resolvedAppendPrompt = resolvePromptInput(appendSystemPrompt, "append system prompt");
const now = new Date(); const now = new Date();
const dateTime = now.toLocaleString("en-US", { const dateTime = now.toLocaleString("en-US", {
@ -163,18 +55,13 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
timeZoneName: "short", timeZoneName: "short",
}); });
const appendSection = resolvedAppendPrompt ? `\n\n${resolvedAppendPrompt}` : ""; const appendSection = appendSystemPrompt ? `\n\n${appendSystemPrompt}` : "";
// Resolve context files: use provided or discover const contextFiles = providedContextFiles ?? [];
const contextFiles = providedContextFiles ?? loadProjectContextFiles({ cwd: resolvedCwd, agentDir }); const skills = providedSkills ?? [];
// Resolve skills: use provided or discover if (customPrompt) {
const skills = let prompt = customPrompt;
providedSkills ??
(skillsSettings?.enabled !== false ? loadSkills({ ...skillsSettings, cwd: resolvedCwd, agentDir }).skills : []);
if (resolvedCustomPrompt) {
let prompt = resolvedCustomPrompt;
if (appendSection) { if (appendSection) {
prompt += appendSection; prompt += appendSection;
@ -208,8 +95,9 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
const examplesPath = getExamplesPath(); const examplesPath = getExamplesPath();
// Build tools list based on selected tools // Build tools list based on selected tools
const tools = selectedTools || (["read", "bash", "edit", "write"] as ToolName[]); const tools = selectedTools || ["read", "bash", "edit", "write"];
const toolsList = tools.length > 0 ? tools.map((t) => `- ${t}: ${toolDescriptions[t]}`).join("\n") : "(none)"; const toolsList =
tools.length > 0 ? tools.map((t) => `- ${t}: ${toolDescriptions[t] ?? "Custom tool"}`).join("\n") : "(none)";
// Build guidelines based on which tools are actually available // Build guidelines based on which tools are actually available
const guidelinesList: string[] = []; const guidelinesList: string[] = [];
@ -222,16 +110,9 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
const hasLs = tools.includes("ls"); const hasLs = tools.includes("ls");
const hasRead = tools.includes("read"); const hasRead = tools.includes("read");
// Bash without edit/write = read-only bash mode
if (hasBash && !hasEdit && !hasWrite) {
guidelinesList.push(
"Use bash ONLY for read-only operations (git log, gh issue view, curl, etc.) - do NOT modify any files",
);
}
// File exploration guidelines // File exploration guidelines
if (hasBash && !hasGrep && !hasFind && !hasLs) { if (hasBash && !hasGrep && !hasFind && !hasLs) {
guidelinesList.push("Use bash for file operations like ls, grep, find"); guidelinesList.push("Use bash for file operations like ls, rg, find");
} else if (hasBash && (hasGrep || hasFind || hasLs)) { } else if (hasBash && (hasGrep || hasFind || hasLs)) {
guidelinesList.push("Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)"); guidelinesList.push("Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)");
} }
@ -251,7 +132,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
guidelinesList.push("Use write only for new files or complete rewrites"); guidelinesList.push("Use write only for new files or complete rewrites");
} }
// Output guideline (only when actually writing/executing) // Output guideline (only when actually writing or executing)
if (hasEdit || hasWrite) { if (hasEdit || hasWrite) {
guidelinesList.push( guidelinesList.push(
"When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did", "When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did",
@ -274,7 +155,7 @@ In addition to the tools above, you may have access to other custom tools depend
Guidelines: Guidelines:
${guidelines} ${guidelines}
Pi documentation (only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI): Pi documentation (read only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI):
- Main documentation: ${readmePath} - Main documentation: ${readmePath}
- Additional docs: ${docsPath} - Additional docs: ${docsPath}
- Examples: ${examplesPath} (extensions, custom tools, SDK) - Examples: ${examplesPath} (extensions, custom tools, SDK)

View file

@ -5,7 +5,7 @@ import { join } from "node:path";
import type { AgentTool } from "@mariozechner/pi-agent-core"; import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox";
import { spawn } from "child_process"; import { spawn } from "child_process";
import { getShellConfig, killProcessTree } from "../../utils/shell.js"; import { getShellConfig, getShellEnv, killProcessTree } from "../../utils/shell.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js";
/** /**
@ -65,6 +65,7 @@ const defaultBashOperations: BashOperations = {
const child = spawn(shell, [...args, command], { const child = spawn(shell, [...args, command], {
cwd, cwd,
detached: true, detached: true,
env: getShellEnv(),
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
}); });

View file

@ -98,6 +98,7 @@ export type {
WidgetPlacement, WidgetPlacement,
} from "./core/extensions/index.js"; } from "./core/extensions/index.js";
export { export {
createExtensionRuntime,
ExtensionRunner, ExtensionRunner,
isBashToolResult, isBashToolResult,
isEditToolResult, isEditToolResult,
@ -115,10 +116,10 @@ export {
export type { ReadonlyFooterDataProvider } from "./core/footer-data-provider.js"; export type { ReadonlyFooterDataProvider } from "./core/footer-data-provider.js";
export { convertToLlm } from "./core/messages.js"; export { convertToLlm } from "./core/messages.js";
export { ModelRegistry } from "./core/model-registry.js"; export { ModelRegistry } from "./core/model-registry.js";
export type { ResourceDiagnostic, ResourceLoader } from "./core/resource-loader.js";
export { DefaultResourceLoader } from "./core/resource-loader.js";
// SDK for programmatic usage // SDK for programmatic usage
export { export {
type BuildSystemPromptOptions,
buildSystemPrompt,
type CreateAgentSessionOptions, type CreateAgentSessionOptions,
type CreateAgentSessionResult, type CreateAgentSessionResult,
// Factory // Factory
@ -133,14 +134,6 @@ export {
createReadOnlyTools, createReadOnlyTools,
createReadTool, createReadTool,
createWriteTool, createWriteTool,
// Discovery
discoverAuthStorage,
discoverContextFiles,
discoverExtensions,
discoverModels,
discoverPromptTemplates,
discoverSkills,
loadSettings,
type PromptTemplate, type PromptTemplate,
// Pre-built tools (use process.cwd()) // Pre-built tools (use process.cwd())
readOnlyTools, readOnlyTools,
@ -172,9 +165,7 @@ export {
type CompactionSettings, type CompactionSettings,
type ImageSettings, type ImageSettings,
type RetrySettings, type RetrySettings,
type Settings,
SettingsManager, SettingsManager,
type SkillsSettings,
} from "./core/settings-manager.js"; } from "./core/settings-manager.js";
// Skills // Skills
export { export {

View file

@ -7,24 +7,23 @@
import { type ImageContent, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai"; import { type ImageContent, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai";
import chalk from "chalk"; import chalk from "chalk";
import { existsSync } from "fs";
import { join } from "path";
import { createInterface } from "readline"; import { createInterface } from "readline";
import { type Args, parseArgs, printHelp } from "./cli/args.js"; import { type Args, parseArgs, printHelp } from "./cli/args.js";
import { processFileArguments } from "./cli/file-processor.js"; import { processFileArguments } from "./cli/file-processor.js";
import { listModels } from "./cli/list-models.js"; import { listModels } from "./cli/list-models.js";
import { selectSession } from "./cli/session-picker.js"; import { selectSession } from "./cli/session-picker.js";
import { CONFIG_DIR_NAME, getAgentDir, getModelsPath, VERSION } from "./config.js"; import { getAgentDir, getModelsPath, VERSION } from "./config.js";
import { createEventBus } from "./core/event-bus.js"; import { AuthStorage } from "./core/auth-storage.js";
import { exportFromFile } from "./core/export-html/index.js"; import { exportFromFile } from "./core/export-html/index.js";
import { discoverAndLoadExtensions, type LoadExtensionsResult, loadExtensions } from "./core/extensions/index.js"; import type { LoadExtensionsResult } from "./core/extensions/index.js";
import { KeybindingsManager } from "./core/keybindings.js"; import { KeybindingsManager } from "./core/keybindings.js";
import type { ModelRegistry } from "./core/model-registry.js"; import { ModelRegistry } from "./core/model-registry.js";
import { resolveModelScope, type ScopedModel } from "./core/model-resolver.js"; import { resolveModelScope, type ScopedModel } from "./core/model-resolver.js";
import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage, discoverModels } from "./core/sdk.js"; import { DefaultPackageManager } from "./core/package-manager.js";
import { DefaultResourceLoader } from "./core/resource-loader.js";
import { type CreateAgentSessionOptions, createAgentSession } from "./core/sdk.js";
import { SessionManager } from "./core/session-manager.js"; import { SessionManager } from "./core/session-manager.js";
import { SettingsManager } from "./core/settings-manager.js"; import { SettingsManager } from "./core/settings-manager.js";
import { resolvePromptInput } from "./core/system-prompt.js";
import { printTimings, time } from "./core/timings.js"; import { printTimings, time } from "./core/timings.js";
import { allTools } from "./core/tools/index.js"; import { allTools } from "./core/tools/index.js";
import { runMigrations, showDeprecationWarnings } from "./migrations.js"; import { runMigrations, showDeprecationWarnings } from "./migrations.js";
@ -54,6 +53,118 @@ async function readPipedStdin(): Promise<string | undefined> {
}); });
} }
type PackageCommand = "install" | "remove" | "update";
interface PackageCommandOptions {
command: PackageCommand;
source?: string;
local: boolean;
}
function parsePackageCommand(args: string[]): PackageCommandOptions | undefined {
const [command, ...rest] = args;
if (command !== "install" && command !== "remove" && command !== "update") {
return undefined;
}
let local = false;
const sources: string[] = [];
for (const arg of rest) {
if (arg === "-l" || arg === "--local") {
local = true;
continue;
}
sources.push(arg);
}
return { command, source: sources[0], local };
}
function normalizeExtensionSource(source: string): { type: "npm" | "git" | "local"; key: string } {
if (source.startsWith("npm:")) {
const spec = source.slice("npm:".length).trim();
const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@.+)?$/);
return { type: "npm", key: match?.[1] ?? spec };
}
if (source.startsWith("git:")) {
const repo = source.slice("git:".length).trim().split("@")[0] ?? "";
return { type: "git", key: repo.replace(/^https?:\/\//, "") };
}
return { type: "local", key: source };
}
function sourcesMatch(a: string, b: string): boolean {
const left = normalizeExtensionSource(a);
const right = normalizeExtensionSource(b);
return left.type === right.type && left.key === right.key;
}
function updateExtensionSources(
settingsManager: SettingsManager,
source: string,
local: boolean,
action: "add" | "remove",
): void {
const currentSettings = local ? settingsManager.getProjectSettings() : settingsManager.getGlobalSettings();
const currentSources = currentSettings.extensions ?? [];
let nextSources: string[];
if (action === "add") {
const exists = currentSources.some((existing) => sourcesMatch(existing, source));
nextSources = exists ? currentSources : [...currentSources, source];
} else {
nextSources = currentSources.filter((existing) => !sourcesMatch(existing, source));
}
if (local) {
settingsManager.setProjectExtensionPaths(nextSources);
} else {
settingsManager.setExtensionPaths(nextSources);
}
}
async function handlePackageCommand(args: string[]): Promise<boolean> {
const options = parsePackageCommand(args);
if (!options) {
return false;
}
const cwd = process.cwd();
const agentDir = getAgentDir();
const settingsManager = SettingsManager.create(cwd, agentDir);
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
if (options.command === "install") {
if (!options.source) {
console.error(chalk.red("Missing install source."));
process.exit(1);
}
await packageManager.install(options.source, { local: options.local });
updateExtensionSources(settingsManager, options.source, options.local, "add");
console.log(`Installed ${options.source}`);
return true;
}
if (options.command === "remove") {
if (!options.source) {
console.error(chalk.red("Missing remove source."));
process.exit(1);
}
await packageManager.remove(options.source, { local: options.local });
updateExtensionSources(settingsManager, options.source, options.local, "remove");
console.log(`Removed ${options.source}`);
return true;
}
await packageManager.update(options.source);
if (options.source) {
console.log(`Updated ${options.source}`);
} else {
console.log("Updated extensions");
}
return true;
}
async function prepareInitialMessage( async function prepareInitialMessage(
parsed: Args, parsed: Args,
autoResizeImages: boolean, autoResizeImages: boolean,
@ -173,58 +284,15 @@ async function createSessionManager(parsed: Args, cwd: string): Promise<SessionM
return undefined; return undefined;
} }
/** Discover SYSTEM.md file if no CLI system prompt was provided */
function discoverSystemPromptFile(): string | undefined {
// Check project-local first: .pi/SYSTEM.md
const projectPath = join(process.cwd(), CONFIG_DIR_NAME, "SYSTEM.md");
if (existsSync(projectPath)) {
return projectPath;
}
// Fall back to global: ~/.pi/agent/SYSTEM.md
const globalPath = join(getAgentDir(), "SYSTEM.md");
if (existsSync(globalPath)) {
return globalPath;
}
return undefined;
}
/** Discover APPEND_SYSTEM.md file if no CLI append system prompt was provided */
function discoverAppendSystemPromptFile(): string | undefined {
// Check project-local first: .pi/APPEND_SYSTEM.md
const projectPath = join(process.cwd(), CONFIG_DIR_NAME, "APPEND_SYSTEM.md");
if (existsSync(projectPath)) {
return projectPath;
}
// Fall back to global: ~/.pi/agent/APPEND_SYSTEM.md
const globalPath = join(getAgentDir(), "APPEND_SYSTEM.md");
if (existsSync(globalPath)) {
return globalPath;
}
return undefined;
}
function buildSessionOptions( function buildSessionOptions(
parsed: Args, parsed: Args,
scopedModels: ScopedModel[], scopedModels: ScopedModel[],
sessionManager: SessionManager | undefined, sessionManager: SessionManager | undefined,
modelRegistry: ModelRegistry, modelRegistry: ModelRegistry,
settingsManager: SettingsManager, settingsManager: SettingsManager,
extensionsResult?: LoadExtensionsResult,
): CreateAgentSessionOptions { ): CreateAgentSessionOptions {
const options: CreateAgentSessionOptions = {}; const options: CreateAgentSessionOptions = {};
// Auto-discover SYSTEM.md if no CLI system prompt provided
const systemPromptSource = parsed.systemPrompt ?? discoverSystemPromptFile();
// Auto-discover APPEND_SYSTEM.md if no CLI append system prompt provided
const appendSystemPromptSource = parsed.appendSystemPrompt ?? discoverAppendSystemPromptFile();
const resolvedSystemPrompt = resolvePromptInput(systemPromptSource, "system prompt");
const resolvedAppendPrompt = resolvePromptInput(appendSystemPromptSource, "append system prompt");
if (sessionManager) { if (sessionManager) {
options.sessionManager = sessionManager; options.sessionManager = sessionManager;
} }
@ -276,15 +344,6 @@ function buildSessionOptions(
// API key from CLI - set in authStorage // API key from CLI - set in authStorage
// (handled by caller before createAgentSession) // (handled by caller before createAgentSession)
// System prompt
if (resolvedSystemPrompt && resolvedAppendPrompt) {
options.systemPrompt = `${resolvedSystemPrompt}\n\n${resolvedAppendPrompt}`;
} else if (resolvedSystemPrompt) {
options.systemPrompt = resolvedSystemPrompt;
} else if (resolvedAppendPrompt) {
options.systemPrompt = (defaultPrompt) => `${defaultPrompt}\n\n${resolvedAppendPrompt}`;
}
// Tools // Tools
if (parsed.noTools) { if (parsed.noTools) {
// --no-tools: start with no built-in tools // --no-tools: start with no built-in tools
@ -298,60 +357,52 @@ function buildSessionOptions(
options.tools = parsed.tools.map((name) => allTools[name]); options.tools = parsed.tools.map((name) => allTools[name]);
} }
// Skills
if (parsed.noSkills) {
options.skills = [];
}
// Pre-loaded extensions (from early CLI flag discovery)
if (extensionsResult) {
options.preloadedExtensions = extensionsResult;
}
return options; return options;
} }
export async function main(args: string[]) { export async function main(args: string[]) {
time("start"); if (await handlePackageCommand(args)) {
return;
}
// Run migrations (pass cwd for project-local migrations) // Run migrations (pass cwd for project-local migrations)
const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(process.cwd()); const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(process.cwd());
// Create AuthStorage and ModelRegistry upfront // Create AuthStorage and ModelRegistry upfront
const authStorage = discoverAuthStorage(); const authStorage = new AuthStorage();
const modelRegistry = discoverModels(authStorage); const modelRegistry = new ModelRegistry(authStorage);
time("discoverModels");
// First pass: parse args to get --extension paths // First pass: parse args to get --extension paths
const firstPass = parseArgs(args); const firstPass = parseArgs(args);
time("parseArgs-firstPass");
// Early load extensions to discover their CLI flags (unless --no-extensions) // Early load extensions to discover their CLI flags
const cwd = process.cwd(); const cwd = process.cwd();
const agentDir = getAgentDir(); const agentDir = getAgentDir();
const eventBus = createEventBus(); const settingsManager = SettingsManager.create(cwd, agentDir);
const settingsManager = SettingsManager.create(cwd);
time("SettingsManager.create");
let extensionsResult: LoadExtensionsResult; const resourceLoader = new DefaultResourceLoader({
if (firstPass.noExtensions) { cwd,
// --no-extensions disables discovery, but explicit -e flags still work agentDir,
const explicitPaths = firstPass.extensions ?? []; settingsManager,
extensionsResult = await loadExtensions(explicitPaths, cwd, eventBus); additionalExtensionPaths: firstPass.extensions,
time("loadExtensions"); additionalSkillPaths: firstPass.skills,
} else { additionalPromptTemplatePaths: firstPass.promptTemplates,
// Merge CLI --extension args with settings.json extensions additionalThemePaths: firstPass.themes,
const extensionPaths = [...settingsManager.getExtensionPaths(), ...(firstPass.extensions ?? [])]; noExtensions: firstPass.noExtensions,
extensionsResult = await discoverAndLoadExtensions(extensionPaths, cwd, agentDir, eventBus); noSkills: firstPass.noSkills,
time("discoverExtensionFlags"); noPromptTemplates: firstPass.noPromptTemplates,
} noThemes: firstPass.noThemes,
systemPrompt: firstPass.systemPrompt,
appendSystemPrompt: firstPass.appendSystemPrompt,
});
await resourceLoader.reload();
time("resourceLoader.reload");
// Log extension loading errors const extensionsResult: LoadExtensionsResult = resourceLoader.getExtensions();
for (const { path, error } of extensionsResult.errors) { for (const { path, error } of extensionsResult.errors) {
console.error(chalk.red(`Failed to load extension "${path}": ${error}`)); console.error(chalk.red(`Failed to load extension "${path}": ${error}`));
} }
// Collect all extension flags
const extensionFlags = new Map<string, { type: "boolean" | "string" }>(); const extensionFlags = new Map<string, { type: "boolean" | "string" }>();
for (const ext of extensionsResult.extensions) { for (const ext of extensionsResult.extensions) {
for (const [name, flag] of ext.flags) { for (const [name, flag] of ext.flags) {
@ -361,7 +412,6 @@ export async function main(args: string[]) {
// Second pass: parse args with extension flags // Second pass: parse args with extension flags
const parsed = parseArgs(args, extensionFlags); const parsed = parseArgs(args, extensionFlags);
time("parseArgs");
// Pass flag values to extensions via runtime // Pass flag values to extensions via runtime
for (const [name, value] of parsed.unknownFlags) { for (const [name, value] of parsed.unknownFlags) {
@ -393,7 +443,6 @@ export async function main(args: string[]) {
// Prepend stdin content to messages // Prepend stdin content to messages
parsed.messages.unshift(stdinContent); parsed.messages.unshift(stdinContent);
} }
time("readPipedStdin");
} }
if (parsed.export) { if (parsed.export) {
@ -415,11 +464,9 @@ export async function main(args: string[]) {
} }
const { initialMessage, initialImages } = await prepareInitialMessage(parsed, settingsManager.getImageAutoResize()); const { initialMessage, initialImages } = await prepareInitialMessage(parsed, settingsManager.getImageAutoResize());
time("prepareInitialMessage");
const isInteractive = !parsed.print && parsed.mode === undefined; const isInteractive = !parsed.print && parsed.mode === undefined;
const mode = parsed.mode || "text"; const mode = parsed.mode || "text";
initTheme(settingsManager.getTheme(), isInteractive); initTheme(settingsManager.getTheme(), isInteractive);
time("initTheme");
// Show deprecation warnings in interactive mode // Show deprecation warnings in interactive mode
if (isInteractive && deprecationWarnings.length > 0) { if (isInteractive && deprecationWarnings.length > 0) {
@ -430,12 +477,10 @@ export async function main(args: string[]) {
const modelPatterns = parsed.models ?? settingsManager.getEnabledModels(); const modelPatterns = parsed.models ?? settingsManager.getEnabledModels();
if (modelPatterns && modelPatterns.length > 0) { if (modelPatterns && modelPatterns.length > 0) {
scopedModels = await resolveModelScope(modelPatterns, modelRegistry); scopedModels = await resolveModelScope(modelPatterns, modelRegistry);
time("resolveModelScope");
} }
// Create session manager based on CLI flags // Create session manager based on CLI flags
let sessionManager = await createSessionManager(parsed, cwd); let sessionManager = await createSessionManager(parsed, cwd);
time("createSessionManager");
// Handle --resume: show session picker // Handle --resume: show session picker
if (parsed.resume) { if (parsed.resume) {
@ -446,7 +491,6 @@ export async function main(args: string[]) {
(onProgress) => SessionManager.list(cwd, parsed.sessionDir, onProgress), (onProgress) => SessionManager.list(cwd, parsed.sessionDir, onProgress),
SessionManager.listAll, SessionManager.listAll,
); );
time("selectSession");
if (!selectedPath) { if (!selectedPath) {
console.log(chalk.dim("No session selected")); console.log(chalk.dim("No session selected"));
stopThemeWatcher(); stopThemeWatcher();
@ -455,17 +499,10 @@ export async function main(args: string[]) {
sessionManager = SessionManager.open(selectedPath); sessionManager = SessionManager.open(selectedPath);
} }
const sessionOptions = buildSessionOptions( const sessionOptions = buildSessionOptions(parsed, scopedModels, sessionManager, modelRegistry, settingsManager);
parsed,
scopedModels,
sessionManager,
modelRegistry,
settingsManager,
extensionsResult,
);
sessionOptions.authStorage = authStorage; sessionOptions.authStorage = authStorage;
sessionOptions.modelRegistry = modelRegistry; sessionOptions.modelRegistry = modelRegistry;
sessionOptions.eventBus = eventBus; sessionOptions.resourceLoader = resourceLoader;
// Handle CLI --api-key as runtime override (not persisted) // Handle CLI --api-key as runtime override (not persisted)
if (parsed.apiKey) { if (parsed.apiKey) {
@ -476,9 +513,7 @@ export async function main(args: string[]) {
authStorage.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey); authStorage.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey);
} }
time("buildSessionOptions");
const { session, modelFallbackMessage } = await createAgentSession(sessionOptions); const { session, modelFallbackMessage } = await createAgentSession(sessionOptions);
time("createAgentSession");
if (!isInteractive && !session.model) { if (!isInteractive && !session.model) {
console.error(chalk.red("No models available.")); console.error(chalk.red("No models available."));

View file

@ -1,42 +1,66 @@
import { CancellableLoader, Container, Spacer, Text, type TUI } from "@mariozechner/pi-tui"; import { CancellableLoader, Container, Loader, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
import type { Theme } from "../theme/theme.js"; import type { Theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js"; import { DynamicBorder } from "./dynamic-border.js";
import { keyHint } from "./keybinding-hints.js"; import { keyHint } from "./keybinding-hints.js";
/** Loader wrapped with borders for extension UI */ /** Loader wrapped with borders for extension UI */
export class BorderedLoader extends Container { export class BorderedLoader extends Container {
private loader: CancellableLoader; private loader: CancellableLoader | Loader;
private cancellable: boolean;
private signalController?: AbortController;
constructor(tui: TUI, theme: Theme, message: string) { constructor(tui: TUI, theme: Theme, message: string, options?: { cancellable?: boolean }) {
super(); super();
this.cancellable = options?.cancellable ?? true;
const borderColor = (s: string) => theme.fg("border", s); const borderColor = (s: string) => theme.fg("border", s);
this.addChild(new DynamicBorder(borderColor)); this.addChild(new DynamicBorder(borderColor));
this.loader = new CancellableLoader( if (this.cancellable) {
tui, this.loader = new CancellableLoader(
(s) => theme.fg("accent", s), tui,
(s) => theme.fg("muted", s), (s) => theme.fg("accent", s),
message, (s) => theme.fg("muted", s),
); message,
);
} else {
this.signalController = new AbortController();
this.loader = new Loader(
tui,
(s) => theme.fg("accent", s),
(s) => theme.fg("muted", s),
message,
);
}
this.addChild(this.loader); this.addChild(this.loader);
this.addChild(new Spacer(1)); if (this.cancellable) {
this.addChild(new Text(keyHint("selectCancel", "cancel"), 1, 0)); this.addChild(new Spacer(1));
this.addChild(new Text(keyHint("selectCancel", "cancel"), 1, 0));
}
this.addChild(new Spacer(1)); this.addChild(new Spacer(1));
this.addChild(new DynamicBorder(borderColor)); this.addChild(new DynamicBorder(borderColor));
} }
get signal(): AbortSignal { get signal(): AbortSignal {
return this.loader.signal; if (this.cancellable) {
return (this.loader as CancellableLoader).signal;
}
return this.signalController?.signal ?? new AbortController().signal;
} }
set onAbort(fn: (() => void) | undefined) { set onAbort(fn: (() => void) | undefined) {
this.loader.onAbort = fn; if (this.cancellable) {
(this.loader as CancellableLoader).onAbort = fn;
}
} }
handleInput(data: string): void { handleInput(data: string): void {
this.loader.handleInput(data); if (this.cancellable) {
(this.loader as CancellableLoader).handleInput(data);
}
} }
dispose(): void { dispose(): void {
this.loader.dispose(); if ("dispose" in this.loader && typeof this.loader.dispose === "function") {
this.loader.dispose();
}
} }
} }

View file

@ -66,7 +66,6 @@ import { type AppAction, KeybindingsManager } from "../../core/keybindings.js";
import { createCompactionSummaryMessage } from "../../core/messages.js"; import { createCompactionSummaryMessage } from "../../core/messages.js";
import { resolveModelScope } from "../../core/model-resolver.js"; import { resolveModelScope } from "../../core/model-resolver.js";
import { type SessionContext, SessionManager } from "../../core/session-manager.js"; import { type SessionContext, SessionManager } from "../../core/session-manager.js";
import { loadProjectContextFiles } from "../../core/system-prompt.js";
import type { TruncationResult } from "../../core/tools/truncate.js"; import type { TruncationResult } from "../../core/tools/truncate.js";
import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js"; import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js";
import { copyToClipboard } from "../../utils/clipboard.js"; import { copyToClipboard } from "../../utils/clipboard.js";
@ -156,7 +155,6 @@ export class InteractiveMode {
private keybindings: KeybindingsManager; private keybindings: KeybindingsManager;
private version: string; private version: string;
private isInitialized = false; private isInitialized = false;
private hasRenderedInitialMessages = false;
private onInputCallback?: (text: string) => void; private onInputCallback?: (text: string) => void;
private loadingAnimation: Loader | undefined = undefined; private loadingAnimation: Loader | undefined = undefined;
private readonly defaultWorkingMessage = "Working..."; private readonly defaultWorkingMessage = "Working...";
@ -321,6 +319,7 @@ export class InteractiveMode {
{ name: "new", description: "Start a new session" }, { name: "new", description: "Start a new session" },
{ name: "compact", description: "Manually compact the session context" }, { name: "compact", description: "Manually compact the session context" },
{ name: "resume", description: "Resume a different session" }, { name: "resume", description: "Resume a different session" },
{ name: "reload", description: "Reload extensions, skills, prompts, and themes" },
]; ];
// Convert prompt templates to SlashCommand format for autocomplete // Convert prompt templates to SlashCommand format for autocomplete
@ -617,133 +616,63 @@ export class InteractiveMode {
// Extension System // Extension System
// ========================================================================= // =========================================================================
private showLoadedResources(options?: { extensionPaths?: string[]; force?: boolean }): void {
const shouldShow = options?.force || !this.settingsManager.getQuietStartup();
if (!shouldShow) {
return;
}
const contextFiles = this.session.resourceLoader.getAgentsFiles().agentsFiles;
if (contextFiles.length > 0) {
const contextList = contextFiles.map((f) => theme.fg("dim", ` ${f.path}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded context:\n") + contextList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
const skills = this.session.skills;
if (skills.length > 0) {
const skillList = skills.map((s) => theme.fg("dim", ` ${s.filePath}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded skills:\n") + skillList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
const skillWarnings = this.session.skillWarnings;
if (skillWarnings.length > 0) {
const warningList = skillWarnings.map((w) => theme.fg("warning", ` ${w.skillPath}: ${w.message}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("warning", "Skill warnings:\n") + warningList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
const templates = this.session.promptTemplates;
if (templates.length > 0) {
const templateList = templates.map((t) => theme.fg("dim", ` /${t.name} ${t.source}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded prompt templates:\n") + templateList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
const extensionPaths = options?.extensionPaths ?? [];
if (extensionPaths.length > 0) {
const extList = extensionPaths.map((p) => theme.fg("dim", ` ${p}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded extensions:\n") + extList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
}
/** /**
* Initialize the extension system with TUI-based UI context. * Initialize the extension system with TUI-based UI context.
*/ */
private async initExtensions(): Promise<void> { private async initExtensions(): Promise<void> {
// Show discovery info unless silenced
if (!this.settingsManager.getQuietStartup()) {
// Show loaded project context files
const contextFiles = loadProjectContextFiles();
if (contextFiles.length > 0) {
const contextList = contextFiles.map((f) => theme.fg("dim", ` ${f.path}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded context:\n") + contextList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
// Show loaded skills (already discovered by SDK)
const skills = this.session.skills;
if (skills.length > 0) {
const skillList = skills.map((s) => theme.fg("dim", ` ${s.filePath}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded skills:\n") + skillList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
// Show skill warnings if any
const skillWarnings = this.session.skillWarnings;
if (skillWarnings.length > 0) {
const warningList = skillWarnings
.map((w) => theme.fg("warning", ` ${w.skillPath}: ${w.message}`))
.join("\n");
this.chatContainer.addChild(new Text(theme.fg("warning", "Skill warnings:\n") + warningList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
// Show loaded prompt templates
const templates = this.session.promptTemplates;
if (templates.length > 0) {
const templateList = templates.map((t) => theme.fg("dim", ` /${t.name} ${t.source}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded prompt templates:\n") + templateList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
}
const extensionRunner = this.session.extensionRunner; const extensionRunner = this.session.extensionRunner;
if (!extensionRunner) { if (!extensionRunner) {
return; // No extensions loaded this.showLoadedResources({ extensionPaths: [], force: false });
return;
} }
// Create extension UI context // Create extension UI context
const uiContext = this.createExtensionUIContext(); const uiContext = this.createExtensionUIContext();
await this.session.bindExtensions({
extensionRunner.initialize( uiContext,
// ExtensionActions - for pi.* API commandContextActions: {
{
sendMessage: (message, options) => {
const wasStreaming = this.session.isStreaming;
this.session
.sendCustomMessage(message, options)
.then(() => {
// Don't rebuild if initial render hasn't happened yet
// (renderInitialMessages will handle it)
if (!wasStreaming && message.display && this.hasRenderedInitialMessages) {
this.rebuildChatFromMessages();
}
})
.catch((err) => {
this.showError(
`Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`,
);
});
},
sendUserMessage: (content, options) => {
this.session.sendUserMessage(content, options).catch((err) => {
this.showError(
`Extension sendUserMessage failed: ${err instanceof Error ? err.message : String(err)}`,
);
});
},
appendEntry: (customType, data) => {
this.sessionManager.appendCustomEntry(customType, data);
},
setSessionName: (name) => {
this.sessionManager.appendSessionInfo(name);
this.updateTerminalTitle();
},
getSessionName: () => {
return this.sessionManager.getSessionName();
},
setLabel: (entryId, label) => {
this.sessionManager.appendLabelChange(entryId, label);
},
getActiveTools: () => this.session.getActiveToolNames(),
getAllTools: () => this.session.getAllTools(),
setActiveTools: (toolNames) => this.session.setActiveToolsByName(toolNames),
setModel: async (model) => {
const key = await this.session.modelRegistry.getApiKey(model);
if (!key) return false;
await this.session.setModel(model);
return true;
},
getThinkingLevel: () => this.session.thinkingLevel,
setThinkingLevel: (level) => this.session.setThinkingLevel(level),
},
// ExtensionContextActions - for ctx.* in event handlers
{
getModel: () => this.session.model,
isIdle: () => !this.session.isStreaming,
abort: () => this.session.abort(),
hasPendingMessages: () => this.session.pendingMessageCount > 0,
shutdown: () => {
this.shutdownRequested = true;
},
getContextUsage: () => this.session.getContextUsage(),
compact: (options) => {
void (async () => {
try {
const result = await this.executeCompaction(options?.customInstructions, false);
if (result) {
options?.onComplete?.(result);
}
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
options?.onError?.(err);
}
})();
},
},
// ExtensionCommandContextActions - for ctx.* in command handlers
{
waitForIdle: () => this.session.agent.waitForIdle(), waitForIdle: () => this.session.agent.waitForIdle(),
newSession: async (options) => { newSession: async (options) => {
if (this.loadingAnimation) { if (this.loadingAnimation) {
@ -808,31 +737,16 @@ export class InteractiveMode {
return { cancelled: false }; return { cancelled: false };
}, },
}, },
uiContext, shutdownHandler: () => {
); this.shutdownRequested = true;
},
// Subscribe to extension errors onError: (error) => {
extensionRunner.onError((error) => { this.showExtensionError(error.extensionPath, error.error, error.stack);
this.showExtensionError(error.extensionPath, error.error, error.stack); },
}); });
// Set up extension-registered shortcuts
this.setupExtensionShortcuts(extensionRunner); this.setupExtensionShortcuts(extensionRunner);
this.showLoadedResources({ extensionPaths: extensionRunner.getExtensionPaths(), force: false });
// Show loaded extensions (unless silenced)
if (!this.settingsManager.getQuietStartup()) {
const extensionPaths = extensionRunner.getExtensionPaths();
if (extensionPaths.length > 0) {
const extList = extensionPaths.map((p) => theme.fg("dim", ` ${p}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded extensions:\n") + extList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
}
// Emit session_start event
await extensionRunner.emit({
type: "session_start",
});
} }
/** /**
@ -950,6 +864,38 @@ export class InteractiveMode {
this.renderWidgets(); this.renderWidgets();
} }
private clearExtensionWidgets(): void {
for (const widget of this.extensionWidgetsAbove.values()) {
widget.dispose?.();
}
for (const widget of this.extensionWidgetsBelow.values()) {
widget.dispose?.();
}
this.extensionWidgetsAbove.clear();
this.extensionWidgetsBelow.clear();
this.renderWidgets();
}
private resetExtensionUI(): void {
if (this.extensionSelector) {
this.hideExtensionSelector();
}
if (this.extensionInput) {
this.hideExtensionInput();
}
if (this.extensionEditor) {
this.hideExtensionEditor();
}
this.ui.hideOverlay();
this.setExtensionFooter(undefined);
this.setExtensionHeader(undefined);
this.clearExtensionWidgets();
this.footerDataProvider.clearExtensionStatuses();
this.footer.invalidate();
this.setCustomEditorComponent(undefined);
this.defaultEditor.onExtensionShortcut = undefined;
}
// Maximum total widget lines to prevent viewport overflow // Maximum total widget lines to prevent viewport overflow
private static readonly MAX_WIDGET_LINES = 10; private static readonly MAX_WIDGET_LINES = 10;
@ -1608,6 +1554,11 @@ export class InteractiveMode {
await this.handleCompactCommand(customInstructions); await this.handleCompactCommand(customInstructions);
return; return;
} }
if (text === "/reload") {
this.editor.setText("");
await this.handleReloadCommand();
return;
}
if (text === "/debug") { if (text === "/debug") {
this.handleDebugCommand(); this.handleDebugCommand();
this.editor.setText(""); this.editor.setText("");
@ -2143,7 +2094,6 @@ export class InteractiveMode {
} }
renderInitialMessages(): void { renderInitialMessages(): void {
this.hasRenderedInitialMessages = true;
// Get aligned messages and entries from session context // Get aligned messages and entries from session context
const context = this.sessionManager.buildSessionContext(); const context = this.sessionManager.buildSessionContext();
this.renderSessionContext(context, { this.renderSessionContext(context, {
@ -3276,6 +3226,53 @@ export class InteractiveMode {
// Command handlers // Command handlers
// ========================================================================= // =========================================================================
private async handleReloadCommand(): Promise<void> {
if (this.session.isStreaming) {
this.showWarning("Wait for the current response to finish before reloading.");
return;
}
if (this.session.isCompacting) {
this.showWarning("Wait for compaction to finish before reloading.");
return;
}
this.resetExtensionUI();
const loader = new BorderedLoader(this.ui, theme, "Reloading resources...", { cancellable: false });
const previousEditor = this.editor;
this.editorContainer.clear();
this.editorContainer.addChild(loader);
this.ui.setFocus(loader);
this.ui.requestRender();
const restoreEditor = () => {
loader.dispose();
this.editorContainer.clear();
this.editorContainer.addChild(previousEditor);
this.ui.setFocus(previousEditor as Component);
this.ui.requestRender();
};
try {
await this.session.reload();
this.rebuildAutocomplete();
const runner = this.session.extensionRunner;
if (runner) {
this.setupExtensionShortcuts(runner);
}
restoreEditor();
this.showLoadedResources({ extensionPaths: runner?.getExtensionPaths() ?? [], force: true });
const modelsJsonError = this.session.modelRegistry.getError();
if (modelsJsonError) {
this.showError(`models.json error: ${modelsJsonError}`);
}
this.showStatus("Reloaded resources");
} catch (error) {
restoreEditor();
this.showError(`Reload failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
private async handleExportCommand(text: string): Promise<void> { private async handleExportCommand(text: string): Promise<void> {
const parts = text.split(/\s+/); const parts = text.split(/\s+/);
const outputPath = parts.length > 1 ? parts[1] : undefined; const outputPath = parts.length > 1 ? parts[1] : undefined;
@ -3632,12 +3629,13 @@ export class InteractiveMode {
private handleDebugCommand(): void { private handleDebugCommand(): void {
const width = this.ui.terminal.columns; const width = this.ui.terminal.columns;
const height = this.ui.terminal.rows;
const allLines = this.ui.render(width); const allLines = this.ui.render(width);
const debugLogPath = getDebugLogPath(); const debugLogPath = getDebugLogPath();
const debugData = [ const debugData = [
`Debug output at ${new Date().toISOString()}`, `Debug output at ${new Date().toISOString()}`,
`Terminal width: ${width}`, `Terminal: ${width}x${height}`,
`Total lines: ${allLines.length}`, `Total lines: ${allLines.length}`,
"", "",
"=== All rendered lines with visible widths ===", "=== All rendered lines with visible widths ===",

View file

@ -334,6 +334,8 @@ function resolveThemeColors<T extends Record<string, ColorValue>>(
// ============================================================================ // ============================================================================
export class Theme { export class Theme {
readonly name?: string;
readonly sourcePath?: string;
private fgColors: Map<ThemeColor, string>; private fgColors: Map<ThemeColor, string>;
private bgColors: Map<ThemeBg, string>; private bgColors: Map<ThemeBg, string>;
private mode: ColorMode; private mode: ColorMode;
@ -342,7 +344,10 @@ export class Theme {
fgColors: Record<ThemeColor, string | number>, fgColors: Record<ThemeColor, string | number>,
bgColors: Record<ThemeBg, string | number>, bgColors: Record<ThemeBg, string | number>,
mode: ColorMode, mode: ColorMode,
options: { name?: string; sourcePath?: string } = {},
) { ) {
this.name = options.name;
this.sourcePath = options.sourcePath;
this.mode = mode; this.mode = mode;
this.fgColors = new Map(); this.fgColors = new Map();
for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) { for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) {
@ -457,6 +462,9 @@ export function getAvailableThemes(): string[] {
} }
} }
} }
for (const name of registeredThemes.keys()) {
themes.add(name);
}
return Array.from(themes).sort(); return Array.from(themes).sort();
} }
@ -487,26 +495,16 @@ export function getAvailableThemesWithPaths(): ThemeInfo[] {
} }
} }
for (const [name, theme] of registeredThemes.entries()) {
if (!result.some((t) => t.name === name)) {
result.push({ name, path: theme.sourcePath });
}
}
return result.sort((a, b) => a.name.localeCompare(b.name)); return result.sort((a, b) => a.name.localeCompare(b.name));
} }
function loadThemeJson(name: string): ThemeJson { function parseThemeJson(label: string, json: unknown): ThemeJson {
const builtinThemes = getBuiltinThemes();
if (name in builtinThemes) {
return builtinThemes[name];
}
const customThemesDir = getCustomThemesDir();
const themePath = path.join(customThemesDir, `${name}.json`);
if (!fs.existsSync(themePath)) {
throw new Error(`Theme not found: ${name}`);
}
const content = fs.readFileSync(themePath, "utf-8");
let json: unknown;
try {
json = JSON.parse(content);
} catch (error) {
throw new Error(`Failed to parse theme ${name}: ${error}`);
}
if (!validateThemeJson.Check(json)) { if (!validateThemeJson.Check(json)) {
const errors = Array.from(validateThemeJson.Errors(json)); const errors = Array.from(validateThemeJson.Errors(json));
const missingColors: string[] = []; const missingColors: string[] = [];
@ -522,12 +520,12 @@ function loadThemeJson(name: string): ThemeJson {
} }
} }
let errorMessage = `Invalid theme "${name}":\n`; let errorMessage = `Invalid theme "${label}":\n`;
if (missingColors.length > 0) { if (missingColors.length > 0) {
errorMessage += `\nMissing required color tokens:\n`; errorMessage += "\nMissing required color tokens:\n";
errorMessage += missingColors.map((c) => ` - ${c}`).join("\n"); errorMessage += missingColors.map((c) => ` - ${c}`).join("\n");
errorMessage += `\n\nPlease add these colors to your theme's "colors" object.`; errorMessage += '\n\nPlease add these colors to your theme\'s "colors" object.';
errorMessage += `\nSee the built-in themes (dark.json, light.json) for reference values.`; errorMessage += "\nSee the built-in themes (dark.json, light.json) for reference values.";
} }
if (otherErrors.length > 0) { if (otherErrors.length > 0) {
errorMessage += `\n\nOther errors:\n${otherErrors.join("\n")}`; errorMessage += `\n\nOther errors:\n${otherErrors.join("\n")}`;
@ -535,10 +533,35 @@ function loadThemeJson(name: string): ThemeJson {
throw new Error(errorMessage); throw new Error(errorMessage);
} }
return json as ThemeJson; return json as ThemeJson;
} }
function createTheme(themeJson: ThemeJson, mode?: ColorMode): Theme { function parseThemeJsonContent(label: string, content: string): ThemeJson {
let json: unknown;
try {
json = JSON.parse(content);
} catch (error) {
throw new Error(`Failed to parse theme ${label}: ${error}`);
}
return parseThemeJson(label, json);
}
function loadThemeJson(name: string): ThemeJson {
const builtinThemes = getBuiltinThemes();
if (name in builtinThemes) {
return builtinThemes[name];
}
const customThemesDir = getCustomThemesDir();
const themePath = path.join(customThemesDir, `${name}.json`);
if (!fs.existsSync(themePath)) {
throw new Error(`Theme not found: ${name}`);
}
const content = fs.readFileSync(themePath, "utf-8");
return parseThemeJsonContent(name, content);
}
function createTheme(themeJson: ThemeJson, mode?: ColorMode, sourcePath?: string): Theme {
const colorMode = mode ?? detectColorMode(); const colorMode = mode ?? detectColorMode();
const resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars); const resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars);
const fgColors: Record<ThemeColor, string | number> = {} as Record<ThemeColor, string | number>; const fgColors: Record<ThemeColor, string | number> = {} as Record<ThemeColor, string | number>;
@ -558,10 +581,23 @@ function createTheme(themeJson: ThemeJson, mode?: ColorMode): Theme {
fgColors[key as ThemeColor] = value; fgColors[key as ThemeColor] = value;
} }
} }
return new Theme(fgColors, bgColors, colorMode); return new Theme(fgColors, bgColors, colorMode, {
name: themeJson.name,
sourcePath,
});
}
export function loadThemeFromPath(themePath: string, mode?: ColorMode): Theme {
const content = fs.readFileSync(themePath, "utf-8");
const themeJson = parseThemeJsonContent(themePath, content);
return createTheme(themeJson, mode, themePath);
} }
function loadTheme(name: string, mode?: ColorMode): Theme { function loadTheme(name: string, mode?: ColorMode): Theme {
const registeredTheme = registeredThemes.get(name);
if (registeredTheme) {
return registeredTheme;
}
const themeJson = loadThemeJson(name); const themeJson = loadThemeJson(name);
return createTheme(themeJson, mode); return createTheme(themeJson, mode);
} }
@ -617,6 +653,16 @@ function setGlobalTheme(t: Theme): void {
let currentThemeName: string | undefined; let currentThemeName: string | undefined;
let themeWatcher: fs.FSWatcher | undefined; let themeWatcher: fs.FSWatcher | undefined;
let onThemeChangeCallback: (() => void) | undefined; let onThemeChangeCallback: (() => void) | undefined;
const registeredThemes = new Map<string, Theme>();
export function setRegisteredThemes(themes: Theme[]): void {
registeredThemes.clear();
for (const theme of themes) {
if (theme.name) {
registeredThemes.set(theme.name, theme);
}
}
}
export function initTheme(themeName?: string, enableWatcher: boolean = false): void { export function initTheme(themeName?: string, enableWatcher: boolean = false): void {
const name = themeName ?? getDefaultTheme(); const name = themeName ?? getDefaultTheme();

View file

@ -35,68 +35,11 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
console.log(JSON.stringify(header)); console.log(JSON.stringify(header));
} }
} }
// Set up extensions for print mode (no UI, no command context) // Set up extensions for print mode (no UI)
const extensionRunner = session.extensionRunner; const extensionRunner = session.extensionRunner;
if (extensionRunner) { if (extensionRunner) {
extensionRunner.initialize( await session.bindExtensions({
// ExtensionActions commandContextActions: {
{
sendMessage: (message, options) => {
session.sendCustomMessage(message, options).catch((e) => {
console.error(`Extension sendMessage failed: ${e instanceof Error ? e.message : String(e)}`);
});
},
sendUserMessage: (content, options) => {
session.sendUserMessage(content, options).catch((e) => {
console.error(`Extension sendUserMessage failed: ${e instanceof Error ? e.message : String(e)}`);
});
},
appendEntry: (customType, data) => {
session.sessionManager.appendCustomEntry(customType, data);
},
setSessionName: (name) => {
session.sessionManager.appendSessionInfo(name);
},
getSessionName: () => {
return session.sessionManager.getSessionName();
},
setLabel: (entryId, label) => {
session.sessionManager.appendLabelChange(entryId, label);
},
getActiveTools: () => session.getActiveToolNames(),
getAllTools: () => session.getAllTools(),
setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
setModel: async (model) => {
const key = await session.modelRegistry.getApiKey(model);
if (!key) return false;
await session.setModel(model);
return true;
},
getThinkingLevel: () => session.thinkingLevel,
setThinkingLevel: (level) => session.setThinkingLevel(level),
},
// ExtensionContextActions
{
getModel: () => session.model,
isIdle: () => !session.isStreaming,
abort: () => session.abort(),
hasPendingMessages: () => session.pendingMessageCount > 0,
shutdown: () => {},
getContextUsage: () => session.getContextUsage(),
compact: (options) => {
void (async () => {
try {
const result = await session.compact(options?.customInstructions);
options?.onComplete?.(result);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
options?.onError?.(err);
}
})();
},
},
// ExtensionCommandContextActions - commands invokable via prompt("/command")
{
waitForIdle: () => session.agent.waitForIdle(), waitForIdle: () => session.agent.waitForIdle(),
newSession: async (options) => { newSession: async (options) => {
const success = await session.newSession({ parentSession: options?.parentSession }); const success = await session.newSession({ parentSession: options?.parentSession });
@ -119,14 +62,9 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
return { cancelled: result.cancelled }; return { cancelled: result.cancelled };
}, },
}, },
// No UI context - hasUI will be false onError: (err) => {
); console.error(`Extension error (${err.extensionPath}): ${err.error}`);
extensionRunner.onError((err) => { },
console.error(`Extension error (${err.extensionPath}): ${err.error}`);
});
// Emit session_start event
await extensionRunner.emit({
type: "session_start",
}); });
} }

View file

@ -256,67 +256,9 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
// Set up extensions with RPC-based UI context // Set up extensions with RPC-based UI context
const extensionRunner = session.extensionRunner; const extensionRunner = session.extensionRunner;
if (extensionRunner) { if (extensionRunner) {
extensionRunner.initialize( await session.bindExtensions({
// ExtensionActions uiContext: createExtensionUIContext(),
{ commandContextActions: {
sendMessage: (message, options) => {
session.sendCustomMessage(message, options).catch((e) => {
output(error(undefined, "extension_send", e.message));
});
},
sendUserMessage: (content, options) => {
session.sendUserMessage(content, options).catch((e) => {
output(error(undefined, "extension_send_user", e.message));
});
},
appendEntry: (customType, data) => {
session.sessionManager.appendCustomEntry(customType, data);
},
setSessionName: (name) => {
session.sessionManager.appendSessionInfo(name);
},
getSessionName: () => {
return session.sessionManager.getSessionName();
},
setLabel: (entryId, label) => {
session.sessionManager.appendLabelChange(entryId, label);
},
getActiveTools: () => session.getActiveToolNames(),
getAllTools: () => session.getAllTools(),
setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
setModel: async (model) => {
const key = await session.modelRegistry.getApiKey(model);
if (!key) return false;
await session.setModel(model);
return true;
},
getThinkingLevel: () => session.thinkingLevel,
setThinkingLevel: (level) => session.setThinkingLevel(level),
},
// ExtensionContextActions
{
getModel: () => session.agent.state.model,
isIdle: () => !session.isStreaming,
abort: () => session.abort(),
hasPendingMessages: () => session.pendingMessageCount > 0,
shutdown: () => {
shutdownRequested = true;
},
getContextUsage: () => session.getContextUsage(),
compact: (options) => {
void (async () => {
try {
const result = await session.compact(options?.customInstructions);
options?.onComplete?.(result);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
options?.onError?.(err);
}
})();
},
},
// ExtensionCommandContextActions - commands invokable via prompt("/command")
{
waitForIdle: () => session.agent.waitForIdle(), waitForIdle: () => session.agent.waitForIdle(),
newSession: async (options) => { newSession: async (options) => {
const success = await session.newSession({ parentSession: options?.parentSession }); const success = await session.newSession({ parentSession: options?.parentSession });
@ -340,14 +282,12 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
return { cancelled: result.cancelled }; return { cancelled: result.cancelled };
}, },
}, },
createExtensionUIContext(), shutdownHandler: () => {
); shutdownRequested = true;
extensionRunner.onError((err) => { },
output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error }); onError: (err) => {
}); output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error });
// Emit session_start event },
await extensionRunner.emit({
type: "session_start",
}); });
} }
@ -577,8 +517,9 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
async function checkShutdownRequested(): Promise<void> { async function checkShutdownRequested(): Promise<void> {
if (!shutdownRequested) return; if (!shutdownRequested) return;
if (extensionRunner?.hasHandlers("session_shutdown")) { const currentRunner = session.extensionRunner;
await extensionRunner.emit({ type: "session_shutdown" }); if (currentRunner?.hasHandlers("session_shutdown")) {
await currentRunner.emit({ type: "session_shutdown" });
} }
// Close readline interface to stop waiting for input // Close readline interface to stop waiting for input

View file

@ -1,6 +1,8 @@
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { delimiter } from "node:path";
import { spawn, spawnSync } from "child_process"; import { spawn, spawnSync } from "child_process";
import { getSettingsPath } from "../config.js"; import { getSettingsPath } from "../config.js";
import { getBinDir } from "../config.js";
import { SettingsManager } from "../core/settings-manager.js"; import { SettingsManager } from "../core/settings-manager.js";
let cachedShellConfig: { shell: string; args: string[] } | null = null; let cachedShellConfig: { shell: string; args: string[] } | null = null;
@ -95,6 +97,20 @@ export function getShellConfig(): { shell: string; args: string[] } {
return cachedShellConfig; return cachedShellConfig;
} }
export function getShellEnv(): NodeJS.ProcessEnv {
const binDir = getBinDir();
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH";
const currentPath = process.env[pathKey] ?? "";
const pathEntries = currentPath.split(delimiter).filter(Boolean);
const hasBinDir = pathEntries.includes(binDir);
const updatedPath = hasBinDir ? currentPath : [binDir, currentPath].filter(Boolean).join(delimiter);
return {
...process.env,
[pathKey]: updatedPath,
};
}
/** /**
* Sanitize binary output for display/storage. * Sanitize binary output for display/storage.
* Removes characters that crash string-width or cause display issues: * Removes characters that crash string-width or cause display issues:

View file

@ -19,7 +19,7 @@ import { ModelRegistry } from "../src/core/model-registry.js";
import { SessionManager } from "../src/core/session-manager.js"; import { SessionManager } from "../src/core/session-manager.js";
import { SettingsManager } from "../src/core/settings-manager.js"; import { SettingsManager } from "../src/core/settings-manager.js";
import { codingTools } from "../src/core/tools/index.js"; import { codingTools } from "../src/core/tools/index.js";
import { API_KEY } from "./utilities.js"; import { API_KEY, createTestResourceLoader } from "./utilities.js";
describe.skipIf(!API_KEY)("AgentSession forking", () => { describe.skipIf(!API_KEY)("AgentSession forking", () => {
let session: AgentSession; let session: AgentSession;
@ -61,7 +61,9 @@ describe.skipIf(!API_KEY)("AgentSession forking", () => {
agent, agent,
sessionManager, sessionManager,
settingsManager, settingsManager,
cwd: tempDir,
modelRegistry, modelRegistry,
resourceLoader: createTestResourceLoader(),
}); });
// Must subscribe to enable session persistence // Must subscribe to enable session persistence

View file

@ -19,7 +19,7 @@ import { ModelRegistry } from "../src/core/model-registry.js";
import { SessionManager } from "../src/core/session-manager.js"; import { SessionManager } from "../src/core/session-manager.js";
import { SettingsManager } from "../src/core/settings-manager.js"; import { SettingsManager } from "../src/core/settings-manager.js";
import { codingTools } from "../src/core/tools/index.js"; import { codingTools } from "../src/core/tools/index.js";
import { API_KEY } from "./utilities.js"; import { API_KEY, createTestResourceLoader } from "./utilities.js";
describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => { describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
let session: AgentSession; let session: AgentSession;
@ -67,7 +67,9 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
agent, agent,
sessionManager, sessionManager,
settingsManager, settingsManager,
cwd: tempDir,
modelRegistry, modelRegistry,
resourceLoader: createTestResourceLoader(),
}); });
// Subscribe to track events // Subscribe to track events

View file

@ -13,6 +13,7 @@ import { AuthStorage } from "../src/core/auth-storage.js";
import { ModelRegistry } from "../src/core/model-registry.js"; import { ModelRegistry } from "../src/core/model-registry.js";
import { SessionManager } from "../src/core/session-manager.js"; import { SessionManager } from "../src/core/session-manager.js";
import { SettingsManager } from "../src/core/settings-manager.js"; import { SettingsManager } from "../src/core/settings-manager.js";
import { createTestResourceLoader } from "./utilities.js";
// Mock stream that mimics AssistantMessageEventStream // Mock stream that mimics AssistantMessageEventStream
class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> { class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
@ -107,7 +108,9 @@ describe("AgentSession concurrent prompt guard", () => {
agent, agent,
sessionManager, sessionManager,
settingsManager, settingsManager,
cwd: tempDir,
modelRegistry, modelRegistry,
resourceLoader: createTestResourceLoader(),
}); });
return session; return session;
@ -197,7 +200,9 @@ describe("AgentSession concurrent prompt guard", () => {
agent, agent,
sessionManager, sessionManager,
settingsManager, settingsManager,
cwd: tempDir,
modelRegistry, modelRegistry,
resourceLoader: createTestResourceLoader(),
}); });
// First prompt completes // First prompt completes

View file

@ -163,6 +163,42 @@ describe("parseArgs", () => {
}); });
}); });
describe("--skill flag", () => {
test("parses single --skill", () => {
const result = parseArgs(["--skill", "./skill-dir"]);
expect(result.skills).toEqual(["./skill-dir"]);
});
test("parses multiple --skill flags", () => {
const result = parseArgs(["--skill", "./skill-a", "--skill", "./skill-b"]);
expect(result.skills).toEqual(["./skill-a", "./skill-b"]);
});
});
describe("--prompt-template flag", () => {
test("parses single --prompt-template", () => {
const result = parseArgs(["--prompt-template", "./prompts"]);
expect(result.promptTemplates).toEqual(["./prompts"]);
});
test("parses multiple --prompt-template flags", () => {
const result = parseArgs(["--prompt-template", "./one", "--prompt-template", "./two"]);
expect(result.promptTemplates).toEqual(["./one", "./two"]);
});
});
describe("--theme flag", () => {
test("parses single --theme", () => {
const result = parseArgs(["--theme", "./theme.json"]);
expect(result.themes).toEqual(["./theme.json"]);
});
test("parses multiple --theme flags", () => {
const result = parseArgs(["--theme", "./dark.json", "--theme", "./light.json"]);
expect(result.themes).toEqual(["./dark.json", "./light.json"]);
});
});
describe("--no-skills flag", () => { describe("--no-skills flag", () => {
test("parses --no-skills flag", () => { test("parses --no-skills flag", () => {
const result = parseArgs(["--no-skills"]); const result = parseArgs(["--no-skills"]);
@ -170,6 +206,20 @@ describe("parseArgs", () => {
}); });
}); });
describe("--no-prompt-templates flag", () => {
test("parses --no-prompt-templates flag", () => {
const result = parseArgs(["--no-prompt-templates"]);
expect(result.noPromptTemplates).toBe(true);
});
});
describe("--no-themes flag", () => {
test("parses --no-themes flag", () => {
const result = parseArgs(["--no-themes"]);
expect(result.noThemes).toBe(true);
});
});
describe("--no-tools flag", () => { describe("--no-tools flag", () => {
test("parses --no-tools flag", () => { test("parses --no-tools flag", () => {
const result = parseArgs(["--no-tools"]); const result = parseArgs(["--no-tools"]);

View file

@ -13,7 +13,6 @@ import { AuthStorage } from "../src/core/auth-storage.js";
import { import {
createExtensionRuntime, createExtensionRuntime,
type Extension, type Extension,
ExtensionRunner,
type SessionBeforeCompactEvent, type SessionBeforeCompactEvent,
type SessionCompactEvent, type SessionCompactEvent,
type SessionEvent, type SessionEvent,
@ -22,13 +21,13 @@ import { ModelRegistry } from "../src/core/model-registry.js";
import { SessionManager } from "../src/core/session-manager.js"; import { SessionManager } from "../src/core/session-manager.js";
import { SettingsManager } from "../src/core/settings-manager.js"; import { SettingsManager } from "../src/core/settings-manager.js";
import { codingTools } from "../src/core/tools/index.js"; import { codingTools } from "../src/core/tools/index.js";
import { createTestResourceLoader } from "./utilities.js";
const API_KEY = process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY; const API_KEY = process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY;
describe.skipIf(!API_KEY)("Compaction extensions", () => { describe.skipIf(!API_KEY)("Compaction extensions", () => {
let session: AgentSession; let session: AgentSession;
let tempDir: string; let tempDir: string;
let extensionRunner: ExtensionRunner;
let capturedEvents: SessionEvent[]; let capturedEvents: SessionEvent[];
beforeEach(() => { beforeEach(() => {
@ -101,51 +100,18 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
const modelRegistry = new ModelRegistry(authStorage); const modelRegistry = new ModelRegistry(authStorage);
const runtime = createExtensionRuntime(); const runtime = createExtensionRuntime();
extensionRunner = new ExtensionRunner(extensions, runtime, tempDir, sessionManager, modelRegistry); const resourceLoader = {
extensionRunner.initialize( ...createTestResourceLoader(),
// ExtensionActions getExtensions: () => ({ extensions, errors: [], runtime }),
{ };
sendMessage: async () => {},
sendUserMessage: async () => {},
appendEntry: async () => {},
setSessionName: () => {},
getSessionName: () => undefined,
setLabel: () => {},
getActiveTools: () => [],
getAllTools: () => [],
setActiveTools: () => {},
setModel: async () => false,
getThinkingLevel: () => "off",
setThinkingLevel: () => {},
},
// ExtensionContextActions
{
getModel: () => session.model,
isIdle: () => !session.isStreaming,
abort: () => session.abort(),
hasPendingMessages: () => session.pendingMessageCount > 0,
shutdown: () => {},
getContextUsage: () => session.getContextUsage(),
compact: (options) => {
void (async () => {
try {
const result = await session.compact(options?.customInstructions);
options?.onComplete?.(result);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
options?.onError?.(err);
}
})();
},
},
);
session = new AgentSession({ session = new AgentSession({
agent, agent,
sessionManager, sessionManager,
settingsManager, settingsManager,
extensionRunner, cwd: tempDir,
modelRegistry, modelRegistry,
resourceLoader,
}); });
return session; return session;

View file

@ -19,7 +19,13 @@ import { ModelRegistry } from "../src/core/model-registry.js";
import { SessionManager } from "../src/core/session-manager.js"; import { SessionManager } from "../src/core/session-manager.js";
import { SettingsManager } from "../src/core/settings-manager.js"; import { SettingsManager } from "../src/core/settings-manager.js";
import { codingTools } from "../src/core/tools/index.js"; import { codingTools } from "../src/core/tools/index.js";
import { API_KEY, getRealAuthStorage, hasAuthForProvider, resolveApiKey } from "./utilities.js"; import {
API_KEY,
createTestResourceLoader,
getRealAuthStorage,
hasAuthForProvider,
resolveApiKey,
} from "./utilities.js";
// Check for auth // Check for auth
const HAS_ANTIGRAVITY_AUTH = hasAuthForProvider("google-antigravity"); const HAS_ANTIGRAVITY_AUTH = hasAuthForProvider("google-antigravity");
@ -81,7 +87,9 @@ describe.skipIf(!HAS_ANTIGRAVITY_AUTH)("Compaction with thinking models (Antigra
agent, agent,
sessionManager, sessionManager,
settingsManager, settingsManager,
cwd: tempDir,
modelRegistry, modelRegistry,
resourceLoader: createTestResourceLoader(),
}); });
session.subscribe(() => {}); session.subscribe(() => {});
@ -175,7 +183,9 @@ describe.skipIf(!HAS_ANTHROPIC_AUTH)("Compaction with thinking models (Anthropic
agent, agent,
sessionManager, sessionManager,
settingsManager, settingsManager,
cwd: tempDir,
modelRegistry, modelRegistry,
resourceLoader: createTestResourceLoader(),
}); });
session.subscribe(() => {}); session.subscribe(() => {});

View file

@ -2,6 +2,8 @@ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createExtensionRuntime } from "../src/core/extensions/loader.js";
import type { ResourceLoader } from "../src/core/resource-loader.js";
import { createAgentSession } from "../src/core/sdk.js"; import { createAgentSession } from "../src/core/sdk.js";
import { SessionManager } from "../src/core/session-manager.js"; import { SessionManager } from "../src/core/session-manager.js";
@ -47,21 +49,30 @@ This is a test skill.
expect(session.skills.some((s) => s.name === "test-skill")).toBe(true); expect(session.skills.some((s) => s.name === "test-skill")).toBe(true);
}); });
it("should have empty skills when options.skills is empty array (--no-skills)", async () => { it("should have empty skills when resource loader returns none (--no-skills)", async () => {
const resourceLoader: ResourceLoader = {
getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
getSkills: () => ({ skills: [], diagnostics: [] }),
getPrompts: () => ({ prompts: [], diagnostics: [] }),
getThemes: () => ({ themes: [], diagnostics: [] }),
getAgentsFiles: () => ({ agentsFiles: [] }),
getSystemPrompt: () => undefined,
getAppendSystemPrompt: () => [],
reload: async () => {},
};
const { session } = await createAgentSession({ const { session } = await createAgentSession({
cwd: tempDir, cwd: tempDir,
agentDir: tempDir, agentDir: tempDir,
sessionManager: SessionManager.inMemory(), sessionManager: SessionManager.inMemory(),
skills: [], // Explicitly empty - like --no-skills resourceLoader,
}); });
// session.skills should be empty
expect(session.skills).toEqual([]); expect(session.skills).toEqual([]);
// No warnings since we didn't discover
expect(session.skillWarnings).toEqual([]); expect(session.skillWarnings).toEqual([]);
}); });
it("should use provided skills when options.skills is explicitly set", async () => { it("should use provided skills when resource loader supplies them", async () => {
const customSkill = { const customSkill = {
name: "custom-skill", name: "custom-skill",
description: "A custom skill", description: "A custom skill",
@ -70,16 +81,25 @@ This is a test skill.
source: "custom" as const, source: "custom" as const,
}; };
const resourceLoader: ResourceLoader = {
getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
getSkills: () => ({ skills: [customSkill], diagnostics: [] }),
getPrompts: () => ({ prompts: [], diagnostics: [] }),
getThemes: () => ({ themes: [], diagnostics: [] }),
getAgentsFiles: () => ({ agentsFiles: [] }),
getSystemPrompt: () => undefined,
getAppendSystemPrompt: () => [],
reload: async () => {},
};
const { session } = await createAgentSession({ const { session } = await createAgentSession({
cwd: tempDir, cwd: tempDir,
agentDir: tempDir, agentDir: tempDir,
sessionManager: SessionManager.inMemory(), sessionManager: SessionManager.inMemory(),
skills: [customSkill], resourceLoader,
}); });
// session.skills should contain only the provided skill
expect(session.skills).toEqual([customSkill]); expect(session.skills).toEqual([customSkill]);
// No warnings since we didn't discover
expect(session.skillWarnings).toEqual([]); expect(session.skillWarnings).toEqual([]);
}); });
}); });

View file

@ -254,152 +254,44 @@ describe("skills", () => {
}); });
describe("loadSkills with options", () => { describe("loadSkills with options", () => {
it("should load from customDirectories only when built-ins disabled", () => { const emptyAgentDir = resolve(__dirname, "fixtures/empty-agent");
const { skills } = loadSkills({ const emptyCwd = resolve(__dirname, "fixtures/empty-cwd");
enableCodexUser: false,
enableClaudeUser: false, it("should load from explicit skillPaths", () => {
enableClaudeProject: false, const { skills, warnings } = loadSkills({
enablePiUser: false, agentDir: emptyAgentDir,
enablePiProject: false, cwd: emptyCwd,
customDirectories: [fixturesDir], skillPaths: [join(fixturesDir, "valid-skill")],
}); });
expect(skills.length).toBeGreaterThan(0); expect(skills).toHaveLength(1);
expect(skills.every((s) => s.source === "custom")).toBe(true); expect(skills[0].source).toBe("custom");
expect(warnings).toHaveLength(0);
}); });
it("should filter out ignoredSkills", () => { it("should warn when skill path does not exist", () => {
const { skills } = loadSkills({ const { skills, warnings } = loadSkills({
enableCodexUser: false, agentDir: emptyAgentDir,
enableClaudeUser: false, cwd: emptyCwd,
enableClaudeProject: false, skillPaths: ["/non/existent/path"],
enablePiUser: false,
enablePiProject: false,
customDirectories: [join(fixturesDir, "valid-skill")],
ignoredSkills: ["valid-skill"],
}); });
expect(skills).toHaveLength(0); expect(skills).toHaveLength(0);
expect(warnings.some((w) => w.message.includes("does not exist"))).toBe(true);
}); });
it("should support glob patterns in ignoredSkills", () => { it("should expand ~ in skillPaths", () => {
const { skills } = loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
enablePiUser: false,
enablePiProject: false,
customDirectories: [fixturesDir],
ignoredSkills: ["valid-*"],
});
expect(skills.every((s) => !s.name.startsWith("valid-"))).toBe(true);
});
it("should have ignoredSkills take precedence over includeSkills", () => {
const { skills } = loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
enablePiUser: false,
enablePiProject: false,
customDirectories: [fixturesDir],
includeSkills: ["valid-*"],
ignoredSkills: ["valid-skill"],
});
// valid-skill should be excluded even though it matches includeSkills
expect(skills.every((s) => s.name !== "valid-skill")).toBe(true);
});
it("should expand ~ in customDirectories", () => {
const homeSkillsDir = join(homedir(), ".pi/agent/skills"); const homeSkillsDir = join(homedir(), ".pi/agent/skills");
const { skills: withTilde } = loadSkills({ const { skills: withTilde } = loadSkills({
enableCodexUser: false, agentDir: emptyAgentDir,
enableClaudeUser: false, cwd: emptyCwd,
enableClaudeProject: false, skillPaths: ["~/.pi/agent/skills"],
enablePiUser: false,
enablePiProject: false,
customDirectories: ["~/.pi/agent/skills"],
}); });
const { skills: withoutTilde } = loadSkills({ const { skills: withoutTilde } = loadSkills({
enableCodexUser: false, agentDir: emptyAgentDir,
enableClaudeUser: false, cwd: emptyCwd,
enableClaudeProject: false, skillPaths: [homeSkillsDir],
enablePiUser: false,
enablePiProject: false,
customDirectories: [homeSkillsDir],
}); });
expect(withTilde.length).toBe(withoutTilde.length); expect(withTilde.length).toBe(withoutTilde.length);
}); });
it("should return empty when all sources disabled and no custom dirs", () => {
const { skills } = loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
enablePiUser: false,
enablePiProject: false,
});
expect(skills).toHaveLength(0);
});
it("should filter skills with includeSkills glob patterns", () => {
// Load all skills from fixtures
const { skills: allSkills } = loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
enablePiUser: false,
enablePiProject: false,
customDirectories: [fixturesDir],
});
expect(allSkills.length).toBeGreaterThan(0);
// Filter to only include "valid-skill"
const { skills: filtered } = loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
enablePiUser: false,
enablePiProject: false,
customDirectories: [fixturesDir],
includeSkills: ["valid-skill"],
});
expect(filtered).toHaveLength(1);
expect(filtered[0].name).toBe("valid-skill");
});
it("should support glob patterns in includeSkills", () => {
const { skills } = loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
enablePiUser: false,
enablePiProject: false,
customDirectories: [fixturesDir],
includeSkills: ["valid-*"],
});
expect(skills.length).toBeGreaterThan(0);
expect(skills.every((s) => s.name.startsWith("valid-"))).toBe(true);
});
it("should return all skills when includeSkills is empty", () => {
const { skills: withEmpty } = loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
enablePiUser: false,
enablePiProject: false,
customDirectories: [fixturesDir],
includeSkills: [],
});
const { skills: withoutOption } = loadSkills({
enableCodexUser: false,
enableClaudeUser: false,
enableClaudeProject: false,
enablePiUser: false,
enablePiProject: false,
customDirectories: [fixturesDir],
});
expect(withEmpty.length).toBe(withoutOption.length);
});
}); });
describe("collision handling", () => { describe("collision handling", () => {

View file

@ -9,7 +9,9 @@ import { Agent } from "@mariozechner/pi-agent-core";
import { getModel, getOAuthApiKey, type OAuthCredentials, type OAuthProvider } from "@mariozechner/pi-ai"; import { getModel, getOAuthApiKey, type OAuthCredentials, type OAuthProvider } from "@mariozechner/pi-ai";
import { AgentSession } from "../src/core/agent-session.js"; import { AgentSession } from "../src/core/agent-session.js";
import { AuthStorage } from "../src/core/auth-storage.js"; import { AuthStorage } from "../src/core/auth-storage.js";
import { createExtensionRuntime } from "../src/core/extensions/loader.js";
import { ModelRegistry } from "../src/core/model-registry.js"; import { ModelRegistry } from "../src/core/model-registry.js";
import type { ResourceLoader } from "../src/core/resource-loader.js";
import { SessionManager } from "../src/core/session-manager.js"; import { SessionManager } from "../src/core/session-manager.js";
import { SettingsManager } from "../src/core/settings-manager.js"; import { SettingsManager } from "../src/core/settings-manager.js";
import { codingTools } from "../src/core/tools/index.js"; import { codingTools } from "../src/core/tools/index.js";
@ -172,6 +174,19 @@ export interface TestSessionContext {
cleanup: () => void; cleanup: () => void;
} }
export function createTestResourceLoader(): ResourceLoader {
return {
getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
getSkills: () => ({ skills: [], diagnostics: [] }),
getPrompts: () => ({ prompts: [], diagnostics: [] }),
getThemes: () => ({ themes: [], diagnostics: [] }),
getAgentsFiles: () => ({ agentsFiles: [] }),
getSystemPrompt: () => undefined,
getAppendSystemPrompt: () => [],
reload: async () => {},
};
}
/** /**
* Create an AgentSession for testing with proper setup and cleanup. * Create an AgentSession for testing with proper setup and cleanup.
* Use this for e2e tests that need real LLM calls. * Use this for e2e tests that need real LLM calls.
@ -204,7 +219,9 @@ export function createTestSession(options: TestSessionOptions = {}): TestSession
agent, agent,
sessionManager, sessionManager,
settingsManager, settingsManager,
cwd: tempDir,
modelRegistry, modelRegistry,
resourceLoader: createTestResourceLoader(),
}); });
// Must subscribe to enable session persistence // Must subscribe to enable session persistence

View file

@ -4,9 +4,11 @@ import {
AgentSession, AgentSession,
AuthStorage, AuthStorage,
convertToLlm, convertToLlm,
createExtensionRuntime,
formatSkillsForPrompt, formatSkillsForPrompt,
loadSkillsFromDir, loadSkillsFromDir,
ModelRegistry, ModelRegistry,
type ResourceLoader,
SessionManager, SessionManager,
type Skill, type Skill,
} from "@mariozechner/pi-coding-agent"; } from "@mariozechner/pi-coding-agent";
@ -448,12 +450,28 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi
log.logInfo(`[${channelId}] Loaded ${loadedSession.messages.length} messages from context.jsonl`); log.logInfo(`[${channelId}] Loaded ${loadedSession.messages.length} messages from context.jsonl`);
} }
const resourceLoader: ResourceLoader = {
getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
getSkills: () => ({ skills: [], diagnostics: [] }),
getPrompts: () => ({ prompts: [], diagnostics: [] }),
getThemes: () => ({ themes: [], diagnostics: [] }),
getAgentsFiles: () => ({ agentsFiles: [] }),
getSystemPrompt: () => systemPrompt,
getAppendSystemPrompt: () => [],
reload: async () => {},
};
const baseToolsOverride = Object.fromEntries(tools.map((tool) => [tool.name, tool]));
// Create AgentSession wrapper // Create AgentSession wrapper
const session = new AgentSession({ const session = new AgentSession({
agent, agent,
sessionManager, sessionManager,
settingsManager: settingsManager as any, settingsManager: settingsManager as any,
cwd: process.cwd(),
modelRegistry, modelRegistry,
resourceLoader,
baseToolsOverride,
}); });
// Mutable per-run state - event handler references this // Mutable per-run state - event handler references this

19
test.sh
View file

@ -33,9 +33,28 @@ unset XAI_API_KEY
unset OPENROUTER_API_KEY unset OPENROUTER_API_KEY
unset ZAI_API_KEY unset ZAI_API_KEY
unset MISTRAL_API_KEY unset MISTRAL_API_KEY
unset MINIMAX_API_KEY
unset MINIMAX_CN_API_KEY
unset AI_GATEWAY_API_KEY
unset OPENCODE_API_KEY
unset COPILOT_GITHUB_TOKEN unset COPILOT_GITHUB_TOKEN
unset GH_TOKEN unset GH_TOKEN
unset GITHUB_TOKEN unset GITHUB_TOKEN
unset GOOGLE_APPLICATION_CREDENTIALS
unset GOOGLE_CLOUD_PROJECT
unset GCLOUD_PROJECT
unset GOOGLE_CLOUD_LOCATION
unset AWS_PROFILE
unset AWS_ACCESS_KEY_ID
unset AWS_SECRET_ACCESS_KEY
unset AWS_SESSION_TOKEN
unset AWS_REGION
unset AWS_DEFAULT_REGION
unset AWS_BEARER_TOKEN_BEDROCK
unset AWS_CONTAINER_CREDENTIALS_RELATIVE_URI
unset AWS_CONTAINER_CREDENTIALS_FULL_URI
unset AWS_WEB_IDENTITY_TOKEN_FILE
unset BEDROCK_EXTENSIVE_MODEL_TEST
echo "Running tests without API keys..." echo "Running tests without API keys..."
npm test npm test