@counterposition/skill-pi 0.87.1 → 0.99.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,122 @@
1
+ # MCP Servers
2
+
3
+ Pi 0.99 connects to MCP servers through a built-in extension. There is no package to install. Checked against Pi v0.99.1: [mcp.md](https://github.com/earendil-works/pi/blob/v0.99.1/packages/coding-agent/docs/mcp.md), [cli.md#mcp-commands](https://github.com/earendil-works/pi/blob/v0.99.1/packages/coding-agent/docs/cli.md#mcp-commands), [extensions.md#mcp-servers](https://github.com/earendil-works/pi/blob/v0.99.1/packages/coding-agent/docs/extensions.md#mcp-servers).
4
+
5
+ ## Configure
6
+
7
+ | File | Scope |
8
+ |------|-------|
9
+ | `~/.pi/agent/mcp.json` | Global. Put personal servers and servers with credentials here |
10
+ | `.pi/mcp.json` | Project. Read only after the project is trusted, because stdio servers run commands |
11
+
12
+ A project entry replaces a global entry with the same name. The format is the usual `mcpServers` object, so Claude Desktop, Claude Code, and Cursor entries copy over unchanged:
13
+
14
+ ```json
15
+ {
16
+ "mcpServers": {
17
+ "filesystem": {
18
+ "command": "npx",
19
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "."]
20
+ },
21
+ "docs": {
22
+ "url": "https://example.com/mcp",
23
+ "headers": { "Authorization": "Bearer ${DOCS_TOKEN}" },
24
+ "exposure": "direct"
25
+ }
26
+ }
27
+ }
28
+ ```
29
+
30
+ - stdio: `command` (one executable, not a shell string), `args`, `env`, `cwd`. Relative `cwd` resolves against the session directory; a leading `~/` works in `command`, args, and `cwd`.
31
+ - HTTP: `url`, `headers`, `oauth`. Only streamable HTTP; `"type": "sse"` is rejected (try the server's `/mcp` endpoint instead of `/sse`).
32
+ - `type` is optional (`stdio`, `http`, or `streamable-http`); `command` implies stdio and `url` implies HTTP.
33
+ - `env` and `headers` values accept `${NAME}` and `!command`. A command must produce the whole value: `"Authorization": "!echo Bearer $(gh auth token)"`.
34
+ - `timeout` is seconds per request (default 60); progress notifications reset it. `enabled: false` keeps the entry without connecting.
35
+ - Server names use letters, digits, `_`, `-`. Tools are named `mcp__<server>__<tool>`.
36
+ - Invalid entries are skipped and reported; other servers still connect.
37
+
38
+ Converting other clients: VS Code uses `servers` + `inputs` (move under `mcpServers`, replace `${input:...}` with `${NAME}`); Codex uses TOML `[mcp_servers.<name>]` (same fields as JSON); opencode uses `"type": "local"` with an array `command` (split into `command`/`args`), `environment` for `env`, and `{env:NAME}` for `${NAME}`.
39
+
40
+ ## Set Up From a Shell
41
+
42
+ These commands work outside a session, so an agent can run them through `bash`:
43
+
44
+ ```bash
45
+ pi mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem .
46
+ pi mcp add docs --url https://example.com/mcp --bearer-token-env-var DOCS_TOKEN --exposure direct
47
+ pi mcp add -l tools --env API_KEY='${TOOLS_KEY}' -- uvx tools-mcp # -l writes .pi/mcp.json
48
+ pi mcp list # connects to every enabled server; exits 1 while anything is wrong
49
+ pi mcp login sentry # OAuth: opens the browser and waits for the user to approve
50
+ pi mcp remove docs # keeps stored OAuth credentials
51
+ pi mcp logout sentry
52
+ ```
53
+
54
+ `pi mcp add` does not connect; verify with `pi mcp list`. A running session picks up added or changed servers after `/reload` or a new session.
55
+
56
+ In a session, `/mcp` opens the server manager (state, tools, errors, sign in/out, reconnect, exposure, enable/disable). Changes are written back to the `mcp.json` that defines the server. Outside the TUI, `/mcp` prints status and `/mcp login|logout|reconnect <server>` run directly.
57
+
58
+ Pi connects on session start; the first prompt waits up to 10 seconds for startup connections. OAuth tokens live in `~/.pi/agent/mcp-auth.json`; server log notifications go to `~/.pi/agent/mcp.log`. OAuth is used only for HTTP servers whose config has no `Authorization` header. The optional `oauth: { clientId?, clientSecret?, callbackPort?, callbackUrl?, scope? }` sets a pre-registered client for servers without dynamic client registration, the loopback redirect (`callbackPort` alone gives `http://127.0.0.1:<port>/callback`; a `callbackUrl` without a port gets `callbackPort` or a free one), and `scope`, space-separated scopes to request when the server does not advertise the ones it needs.
59
+
60
+ ## Exposure
61
+
62
+ `exposure` decides how the model reaches a server's tools:
63
+
64
+ | Value | Declared to the model | Callable from codemode | Notes |
65
+ |-------|----------------------|------------------------|-------|
66
+ | `codemode` (default) | No | Yes, listed in the `codemode` description | Pi activates `codemode` when such a server connects |
67
+ | `codemode-deferred` | No | Yes, found with `searchTools()` / `ALL_TOOLS` | Description names only the server and tool count; for large, rarely used servers |
68
+ | `deferred` | After `tool_search` loads them | Yes | Pi activates `tool_search`; for large servers without codemode |
69
+ | `direct` | Yes, like built-in tools | Yes | |
70
+ | `hidden` | No | No | Registered but unreachable |
71
+
72
+ `toolExposure` overrides single tools by name or `*` pattern (exact name wins, then first matching pattern):
73
+
74
+ ```json
75
+ {
76
+ "mcpServers": {
77
+ "github": {
78
+ "url": "https://api.githubcopilot.com/mcp/",
79
+ "exposure": "deferred",
80
+ "toolExposure": { "search_code": "direct", "get_*": "codemode", "delete_*": "hidden" }
81
+ }
82
+ }
83
+ }
84
+ ```
85
+
86
+ Undeclared tools (`codemode`, `codemode-deferred`, `deferred`) are reachable through either tool: codemode scripts can call a `deferred` server's tools, and `tool_search` can load a `codemode` server's tools. Set top-level `"autoEnableCodemode": false` in `mcp.json` to stop Pi from activating `codemode`. Pi warns once when neither `codemode` nor `tool_search` is active, because the tools are then unreachable.
87
+
88
+ ## Results
89
+
90
+ - Text results over 20KB reach the model with the middle cut out; the full text goes to a temp file named in the result.
91
+ - Codemode scripts receive the whole `CallToolResult` (`content`, `structuredContent`, `isError`). A result with `isError` resolves in scripts and is reported to the model as an error for direct calls. `image(result.content[0])` forwards an image block.
92
+ - Servers with resources add `list_mcp_resources`, `list_mcp_resource_templates`, and `read_mcp_resource` (marked read-only).
93
+ - Tool calls are never retried (the server may have run them); resource reads retry once on 408/429/5xx.
94
+
95
+ ## Permissions
96
+
97
+ Every MCP call goes through Pi's tool pipeline, so `tool_call` and `tool_result` handlers (permission gates included) see MCP tools. Calls from codemode scripts are nested calls; see [Nested Calls and Permissions](codemode.md#nested-calls-and-permissions). `pi.getAllTools()` reports the server's `readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint` annotations. They are unverified hints; see [Tool Exposure](extensions.md#tool-exposure--structured-results).
98
+
99
+ ## Servers From Extensions
100
+
101
+ ```typescript
102
+ pi.registerMcpServer("jira", { url: "https://mcp.example.com/jira", exposure: "codemode" });
103
+ pi.unregisterMcpServer("jira");
104
+ ```
105
+
106
+ - Same config shape as an `mcpServers` entry. Registrations are not saved: register on every load.
107
+ - Registered during load: connects on `session_start` with the `mcp.json` servers. Registered later: connects right away.
108
+ - An `mcp.json` server with the same name wins. Another extension's name, an invalid name, or an invalid config throws.
109
+ - `pi mcp` shell commands do not load extensions and only see `mcp.json` servers.
110
+ - Alternative MCP extensions read registrations with `pi.getMcpServers()` on `session_start` and handle `mcp_servers_change`.
111
+
112
+ ## Turning It Off or Replacing It
113
+
114
+ - `"extensions": ["-builtin:mcp"]` in settings, or `pi config` → Built-in, disables the built-in support. `pi mcp` shell commands still work.
115
+ - `--no-extensions` disables built-ins too; `pi -ne -e builtin:mcp` keeps only MCP.
116
+ - An extension that registers the `/mcp` command (for example `pi-mcp-adapter`) replaces the built-in support: `mcp.json` is then not read in sessions.
117
+
118
+ ## SDK
119
+
120
+ SDK sessions do not load built-in extensions. Add `createMcpExtension()`, plus `createCodemodeExtension()` for `codemode`/`codemode-deferred` servers and `createToolSearchExtension()` for `deferred` servers, then call `session.bindExtensions()` so `session_start` connects the servers. See `references/sdk.md`.
121
+
122
+ The standalone client is published as `@earendil-works/pi-mcp` (stdio and streamable HTTP transports, OAuth, an in-memory testing transport). It has no Pi session dependency.
@@ -94,16 +94,21 @@ If there is no `pi` manifest, Pi auto-discovers:
94
94
 
95
95
  ## Dependencies
96
96
 
97
+ Checked against Pi v0.99.1 [packages.md](https://github.com/earendil-works/pi/blob/v0.99.1/packages/coding-agent/docs/packages.md#declare-dependencies).
98
+
97
99
  - Normal runtime dependencies belong in `dependencies`
98
- - Pi core libraries should be peer deps with `"*"` ranges:
100
+ - Pi supplies these to extensions; declare them as peer deps with `"*"` ranges and never bundle them:
99
101
  - `@earendil-works/pi-ai`
100
102
  - `@earendil-works/pi-agent-core`
101
103
  - `@earendil-works/pi-coding-agent`
102
104
  - `@earendil-works/pi-tui`
103
105
  - `typebox`
104
- - Pin dev dependencies to a current Pi line (e.g. `^0.84.2`) so typecheck/tests validate against what users run; keep peers `"*"` so installs stay compatible across Pi versions
105
- - Other Pi packages must be bundled explicitly
106
- - Git package installs run `npm install --omit=dev` — runtime needs go in `dependencies`, not `devDependencies`
106
+ - Do not list those host-provided packages in `dependencies`. A physical copy can bypass Pi's extension module mapping and create duplicate classes and registries; since Pi 0.99.0 Pi reports an extension warning for this manifest.
107
+ - Pi suppresses automatic peer installation for managed npm packages and for git packages installed with npm (`--omit=dev --legacy-peer-deps`), pnpm (`--prod`, no auto-installed peers), or Bun (`--omit=dev --omit=peer`). Local path packages are not installed or modified; their dependency tree is the author's responsibility.
108
+ - Git package installs omit dev dependencies — runtime needs go in `dependencies`, not `devDependencies`
109
+ - Installed packages load with separate module roots. Do not rely on two packages sharing one dependency instance, or on one package resolving another package's undeclared dependency.
110
+ - Other Pi packages used as dependencies must be included in the published tarball and referenced through their `node_modules` resource paths.
111
+ - Pin dev dependencies to the Pi line you test against (e.g. `^0.99.1`) so typecheck/tests validate against what users run; keep peers `"*"` so installs stay compatible across Pi versions. `"*"` does not prove compatibility: document the minimum supported Pi version and feature-check newer APIs (for example `ctx.executeTool`, `pi.registerMcpServer`) if you support older hosts.
107
112
 
108
113
  ## Package Filtering
109
114
 
@@ -134,4 +139,5 @@ Rules:
134
139
 
135
140
  - Project settings override global settings for the same package identity — unless the project entry has `autoload: false`, in which case it is applied as a delta over the global entry (Pi 0.80.4)
136
141
  - Identity is package name for npm, repo URL for git, and resolved absolute path for local sources
137
- - `pi config` can enable or disable package resources after installation; since Pi 0.80.4 it manages global vs project-local scopes (Tab to switch, `pi config -l` to start in project mode with inherited global resources dimmed)
142
+ - `pi config` can enable or disable package resources after installation; since Pi 0.80.4 it manages global vs project-local scopes (Tab to switch, `pi config -l` to start in project mode with inherited global resources dimmed). Since Pi 0.99.0 its Built-in section also disables the built-in `mcp`, `llama.cpp`, `codemode`, and `tool-search` extensions, stored as `-builtin:<name>` in `extensions`
143
+ - The `codemode`, `tool-search`, and `mcp` built-ins are replaceable: an extension that registers one of their tools, commands, or flags (for example `/mcp`, `codemode`, or `tool_search`) replaces that built-in, and Pi warns
@@ -19,6 +19,11 @@ Practical recipes for extending Pi. Each pattern is a self-contained example.
19
19
  13. [File Watcher / Trigger](#file-watcher--trigger)
20
20
  14. [Input Transform](#input-transform)
21
21
  15. [Tool Override](#tool-override)
22
+ 16. [Structured Tool](#structured-tool)
23
+ 17. [Nested Tool Calls](#nested-tool-calls)
24
+ 18. [Classifier Gate](#classifier-gate)
25
+
26
+ Patterns 16–18 are full files under `examples/` that type-check against Pi 0.99.1 in this skill's package; read them rather than retyping.
22
27
 
23
28
  ---
24
29
 
@@ -43,6 +48,8 @@ export default function (pi: ExtensionAPI) {
43
48
  }
44
49
  ```
45
50
 
51
+ The handler also runs for `bash` calls made by codemode scripts; see [Nested Calls and Permissions](codemode.md#nested-calls-and-permissions).
52
+
46
53
  ## Protected Paths
47
54
 
48
55
  Block writes to sensitive files:
@@ -313,3 +320,30 @@ pi.on("tool_call", async (event, ctx) => {
313
320
  }
314
321
  });
315
322
  ```
323
+
324
+ ## Structured Tool
325
+
326
+ `examples/structured-tool.ts` registers a read-only data tool:
327
+
328
+ - `outputSchema` plus `structuredContent` on every result, so codemode scripts get `{ files, missing }` as an object while the model reads a text summary.
329
+ - `annotations: { readOnlyHint: true, openWorldHint: false }` for permission extensions, and a `namespace` for grouping in the `codemode` description.
330
+ - `isError: true` when no file was found: the model sees an error, scripts still get the data.
331
+ - Only `ENOENT` is treated as "missing"; other errors are rethrown.
332
+
333
+ ## Nested Tool Calls
334
+
335
+ `examples/nested-tools.ts` registers `read_many`, which reads several files in parallel through `ctx.executeTool("read", ...)`:
336
+
337
+ - `exposure: "model-only"`: the model can call it, other tools and codemode cannot, so it cannot recurse.
338
+ - Checks `ctx.tools` first: an inactive `direct` tool is not callable.
339
+ - Each nested call runs every `tool_call` gate (see [Nested Calls and Permissions](codemode.md#nested-calls-and-permissions)). `executeTool()` never rejects, and aborted calls come back as `isError`, so the tool calls `signal?.throwIfAborted()` after the batch to report the abort as an abort rather than as failed reads.
340
+ - Streams progress through `onUpdate`.
341
+
342
+ ## Classifier Gate
343
+
344
+ `examples/classifier-gate.ts` asks a classifier model whether a `bash` command is destructive before it runs:
345
+
346
+ - Finds Jev with `ctx.modelRegistry.findOfType("classifier", "typesafe", "jev-latest")` and calls `ctx.modelRegistry.classify(..., { signal: ctx.signal })`.
347
+ - `classify()` never rejects: it checks `stopReason` (`"aborted"` blocks, `"error"` falls through to confirmation) and validates the `bool` answer's probability.
348
+ - Fails closed: a missing model, an error, an out-of-range answer, or a probability above the strict `MAX_ALLOWED_PROBABILITY` (0.2; tune it) asks the user, and blocks when `ctx.hasUI` is false.
349
+ - Uses `satisfies ClassifierContext["questions"]` so the question literal keeps its narrow types.
@@ -7,7 +7,8 @@ Pi supports subscription-based providers via OAuth, API-key providers via env va
7
7
  Use `/login` in interactive mode, then select a provider. The `/login` selector is fuzzy-searchable and shows where each entry's auth comes from (`--api-key`, env var, custom provider) without leaking the secret.
8
8
 
9
9
  - Anthropic Claude Pro / Max — third-party usage draws from extra usage and is billed per token (suppress the warning via `warnings.anthropicExtraUsage`)
10
- - OpenAI ChatGPT Plus / Pro (Codex) — `/login` defaults to browser auth but can use a device-code flow for headless environments
10
+ - OpenAI with a ChatGPT subscription (Pi 0.99.0) — `/login openai` offers **Sign in with ChatGPT**, an OAuth login that uses the subscription with the OpenAI API on the regular `openai` provider. Pi stores a stable `deviceId` for it in global settings. Subscription usage-limit errors are not retried
11
+ - OpenAI Codex (legacy) — the older `openai-codex` provider, renamed in Pi 0.99.0 and superseded by Sign in with ChatGPT. Its `/login` still offers browser auth or a device-code flow; its default model is `gpt-6.1-sol` (Pi 0.99.1). Prefer Sign in with ChatGPT on `openai` for new setups
11
12
  - GitHub Copilot
12
13
  - OpenRouter (Pi 0.82.0) — `/login openrouter` runs a PKCE flow that mints a user-controlled API key billed from OpenRouter credits; on headless/SSH hosts paste the redirect URL or authorization code into the prompt (Pi 0.83.0). `OPENROUTER_API_KEY` still works via **Use an API key**
13
14
  - xAI (Grok/X subscription, Pi 0.80.8) — `/login xai` then **Use a subscription** (device-code OAuth); `XAI_API_KEY` remains available via **Use an API key**
@@ -43,6 +44,7 @@ Since Pi 0.80.8, built-in catalogs are complemented by dynamic ones: `/model` re
43
44
  | OpenCode Go | `OPENCODE_API_KEY` | `opencode-go` |
44
45
  | Amazon Bedrock | `AWS_BEARER_TOKEN_BEDROCK` | `amazon-bedrock` |
45
46
  | Radius | `RADIUS_API_KEY` | `radius` |
47
+ | TypeSafe (classifier models only) | `TYPESAFE_API_KEY` | `typesafe` |
46
48
  | Hugging Face | `HF_TOKEN` | `huggingface` |
47
49
  | Fireworks | `FIREWORKS_API_KEY` | `fireworks` |
48
50
  | Together AI | `TOGETHER_API_KEY` | `together` |
@@ -86,10 +88,12 @@ Entries may include an `env` object (Pi 0.79.5) for provider-scoped environment
86
88
 
87
89
  ## Credential Resolution Order
88
90
 
89
- 1. `--api-key`
91
+ 1. `--api-key` (runtime override)
90
92
  2. `auth.json`
91
- 3. Environment variables
92
- 4. Custom provider keys from `models.json`
93
+ 3. `apiKey` from `models.json`
94
+ 4. Environment variables or ambient cloud credentials
95
+
96
+ Provider extensions can define their own authentication behavior.
93
97
 
94
98
  ## Cloud Providers
95
99
 
@@ -151,12 +155,11 @@ Use `models.json` for OpenAI-compatible, Anthropic-compatible, Google-compatible
151
155
 
152
156
  ```json
153
157
  {
154
- "providers": [
155
- {
156
- "name": "ollama",
158
+ "providers": {
159
+ "ollama": {
157
160
  "baseUrl": "http://localhost:11434/v1",
158
161
  "api": "openai-completions",
159
- "apiKey": "unused",
162
+ "apiKey": "ollama",
160
163
  "models": [
161
164
  {
162
165
  "id": "llama3.2",
@@ -175,10 +178,12 @@ Use `models.json` for OpenAI-compatible, Anthropic-compatible, Google-compatible
175
178
  }
176
179
  ]
177
180
  }
178
- ]
181
+ }
179
182
  }
180
183
  ```
181
184
 
185
+ `providers` is an object keyed by provider ID, not an array; the optional `name` field is only a display label. A `models` entry adds or replaces a model with the same ID on that provider; use `modelOverrides` to change metadata of an existing built-in or extension model.
186
+
182
187
  `apiKey` is optional (Pi 0.80.0): omit it when auth comes from `/login`, `auth.json`, or CLI `--api-key`. Like `auth.json` keys, `apiKey` and `headers` values support `!command` execution and `$ENV_VAR` interpolation; plain uppercase strings are literals. Keyless local servers (e.g. Ollama) should keep a dummy value so their models appear in `/model`.
183
188
 
184
189
  Common `api` values:
@@ -206,7 +211,7 @@ Common `api` values:
206
211
  - `compat.thinkingFormat` supports OpenAI-compatible reasoning variants: `openrouter` sends `reasoning: { effort }`, `together` sends `reasoning: { enabled }` plus `reasoning_effort` when supported, `qwen-chat-template` targets local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking`, `chat-template` (Pi 0.79.9) sends configurable `chat_template_kwargs` via `compat.chatTemplateKwargs` — e.g. `{ "thinking": { "$var": "thinking.enabled" } }` for DeepSeek models behind vLLM/Hugging Face chat templates — and `baseten` (Pi 0.84.0) sends `chat_template_args` via `compat.chatTemplateArgs`.
207
212
  - **Breaking (Pi 0.80.7):** `compat.sendSessionIdHeader` was removed. Session affinity is now controlled by `compat.sessionAffinityFormat` (`"openai"` sends `session_id`/`x-client-request-id`, `"openai-nosession"` omits the underscore-containing `session_id` header, `"openrouter"` sends `x-session-id`; default auto-detected). Replace `sendSessionIdHeader: false` with `sessionAffinityFormat: "openai-nosession"`. `sendSessionAffinityHeaders` still gates the behavior for `openai-completions`.
208
213
  - `compat.deferredToolsMode: "kimi"` (Pi 0.80.9) enables Kimi's deferred tool serialization; `compat.supportsToolReferences` / `compat.supportsToolSearch` enable native dynamic tool loading on verified custom endpoints, and `compat.supportsStrictTools` / `compat.supportsOpenAIGrammarTools` / per-model `constrainedSampling` gate constrained sampling (see `references/extensions.md`). `compat.supportsFinishReason: false` (Pi 0.84.0) tells pi to infer stop/toolUse for OpenAI-compatible streams that omit `finish_reason`.
209
- - Advanced `compat` flags exist for proxy quirks (`cacheControlFormat` — now also covering tool-result text content, `supportsReasoningEffort`, `supportsLongCacheRetention`, `supportsEagerToolInputStreaming`, `supportsStrictMode`). See [Pi `docs/models.md`](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/models.md) when a proxy rejects pi's defaults.
214
+ - Advanced `compat` flags exist for proxy quirks (`cacheControlFormat` — now also covering tool-result text content, `supportsReasoningEffort`, `supportsLongCacheRetention`, `supportsEagerToolInputStreaming`, `supportsStrictMode`). See [Pi `docs/models.md`](https://github.com/earendil-works/pi/blob/v0.99.1/packages/coding-agent/docs/models.md) when a proxy rejects pi's defaults.
210
215
 
211
216
  ### Context Overflow Recovery
212
217
 
@@ -214,12 +219,96 @@ Pi can auto-compact and retry when a provider fails with a recognized context-wi
214
219
 
215
220
  ### llama.cpp
216
221
 
217
- Pi supports the llama.cpp router server (Pi 0.81.0): configure with `/login llama.cpp`, search/download Hugging Face models and load/unload with live progress via `/llama`, then select loaded models in `/model`. The model catalog persists across restarts (fixed in Pi 0.82.1). See [Pi `docs/llama-cpp.md`](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/llama-cpp.md).
222
+ Pi supports the llama.cpp router server (Pi 0.81.0): configure with `/login llama.cpp`, search/download Hugging Face models and load/unload with live progress via `/llama`, then select loaded models in `/model`. The model catalog persists across restarts (fixed in Pi 0.82.1). Every llama.cpp chat model is also listed as a classifier model (see [Classifier Models](#classifier-models)). `"extensions": ["-builtin:llama.cpp"]` removes the provider and `/llama`. See [Pi `docs/llama-cpp.md`](https://github.com/earendil-works/pi/blob/v0.99.1/packages/coding-agent/docs/llama-cpp.md).
223
+
224
+ ## Model Types
225
+
226
+ Since Pi 0.99.0 catalogs hold typed models: `type` is `"chat"` (the default when omitted), `"image"`, or `"classifier"`. Chat-facing reads (`getModels()`, `getAvailable()`, `ctx.modelRegistry.getAll()`, `/model`) stay chat-only. `ModelRuntime`, `ctx.modelRegistry`, and pi-ai `Models` all have `getModelsOfType(type, provider?)`, `getModelOfType(type, provider, id)`, and async `getAvailableOfType(type, provider?)`. Only `ModelRuntime` and `Models` have `getAllModels()` and `getAllAvailable()` for every type; `ctx.modelRegistry` adds `findOfType(type, provider, id)` instead. pi-ai exports `isModelType()` and `getModelType()` for mixed lists. One upstream ID may have separate chat and image entries. Sources: [models.md](https://github.com/earendil-works/pi/blob/v0.99.1/packages/coding-agent/docs/models.md#use-classifier-models), [pi-ai types](https://github.com/earendil-works/pi/blob/v0.99.1/packages/ai/src/types.ts).
227
+
228
+ ### Classifier Models
229
+
230
+ Classifiers do not chat. They answer typed questions about JSON state with probabilities, and do not appear in `/model`.
231
+
232
+ | Provider | Model IDs | Auth |
233
+ |----------|-----------|------|
234
+ | `typesafe` | `jev-latest` | `TYPESAFE_API_KEY` |
235
+ | `openrouter` | `typesafe/jev-1.13`, `~typesafe/jev-latest` | `OPENROUTER_API_KEY` or `/login` |
236
+ | `cloudflare-workers-ai` | `typesafe/jev` | `CLOUDFLARE_API_KEY` + `CLOUDFLARE_ACCOUNT_ID` |
237
+ | `vercel-ai-gateway` | `typesafe-ai/jev` | `AI_GATEWAY_API_KEY` |
238
+ | `opencode` | `jev-1.13`, `jev-1.13-free` | `OPENCODE_API_KEY` |
239
+ | llama.cpp router | every loaded chat model ID | `/login llama.cpp` |
240
+
241
+ ```typescript
242
+ const jev = ctx.modelRegistry.findOfType("classifier", "typesafe", "jev-latest");
243
+ if (jev) {
244
+ const result = await ctx.modelRegistry.classify(
245
+ jev,
246
+ {
247
+ state: { message: "The change works, thanks." },
248
+ questions: {
249
+ approved: {
250
+ type: "bool",
251
+ instructions: "Does the user approve of the result?",
252
+ criteria: { true: "Approval", false: "No approval" },
253
+ },
254
+ },
255
+ },
256
+ { signal: ctx.signal },
257
+ );
258
+ const answer = result.stopReason === "stop" ? result.answers.approved : undefined;
259
+ if (answer?.type === "bool") console.log(answer.probability);
260
+ }
261
+ ```
262
+
263
+ - Question types: `choice` (`criteria` maps labels to descriptions; answer has `choice`, `probabilities`, `confidence`), `bool` (`criteria: { true, false }`; answer has `probability`), and `score` (`criteria` is an ordered list; answer has `score`, `confidence`). Pi translates `bool` to TypeSafe's wire format.
264
+ - `classify()` never rejects. Failures and aborts return `stopReason: "error"` or `"aborted"` with `errorMessage`. Check `stopReason` and each answer's `type` before use, and validate probabilities yourself if a decision depends on them (for example finite and within `[0, 1]`).
265
+ - `result.usage` carries token counts and catalog cost when the service reports them; TypeSafe's direct `jev-latest` has no catalog price.
266
+ - `jev-latest` moves with the service. Pin a versioned ID such as `openrouter`'s `typesafe/jev-1.13` when thresholds were tuned against one model.
267
+ - Codemode scripts reach classifiers through `models.classify()` (see `references/codemode.md`). The SDK equivalent is `modelRuntime.classify()`. A llama.cpp classifier accepts a per-request `temperature` to soften overconfident label probabilities.
268
+
269
+ A gate that fails closed on classifier errors is in `examples/classifier-gate.ts`.
270
+
271
+ ### Image Generation
272
+
273
+ `ModelRuntime.generateImages(model, { input }, options?)` (Pi 0.99.0) generates images with runtime-resolved auth; list image models with `getModelsOfType("image")`. OpenRouter image models appear under the `openrouter` provider with its credential. Like `classify()`, it returns an error result rather than rejecting.
274
+
275
+ In pi-ai, image models live in the same catalog as chat models: `builtinModels()` includes them, `models.getModelOfType("image", provider, id)` finds one (type `ImageModel`), `models.generateImages()` runs it, and `createProvider({ models, images })` registers image implementations. The separate `*Images*` API was removed in pi-ai 0.99.0.
276
+
277
+ ## Virtual Models
278
+
279
+ A virtual model (experimental, Pi 0.99.0) is a selectable entry that routes each request to a physical model. It is for routing by task, cost, or conversation state, not for permission decisions. Source: [virtual-models.md](https://github.com/earendil-works/pi/blob/v0.99.1/packages/coding-agent/docs/virtual-models.md), [`jev-router.ts`](https://github.com/earendil-works/pi/blob/v0.99.1/packages/coding-agent/examples/extensions/jev-router.ts).
280
+
281
+ ```typescript
282
+ pi.registerVirtualModel({
283
+ provider: "router",
284
+ id: "auto",
285
+ name: "Auto",
286
+ thinkingLevels: ["low", "high"],
287
+ route(request, ctx) {
288
+ const sticky = request.failed ?? request.previous;
289
+ if (request.reason !== "user" && sticky) {
290
+ return { model: sticky.model, thinkingLevel: sticky.thinkingLevel ?? "medium" };
291
+ }
292
+ const id = request.thinkingLevel === "high" ? "claude-sonnet-4-5" : "claude-haiku-4-5";
293
+ const model = ctx.modelRegistry.find("anthropic", id);
294
+ if (!model) throw new Error(`anthropic/${id} is not in the catalog`);
295
+ return { model, thinkingLevel: "medium" };
296
+ },
297
+ });
298
+ ```
299
+
300
+ - `route()` runs before every request with `reason` (`user`, `continuation`, `retry`, `direct`), `previous`, `failed`, `state`, `messages`, and `signal`. Returning `previous` for continuations and `failed` for retries keeps prompt caches and thinking signatures valid.
301
+ - Returned `state` (JSON) is stored on the session branch and comes back as `request.state`; `direct` requests (compaction summaries, `ctx.modelRegistry.streamSimple()`) have none.
302
+ - The route must be a physical model with credentials; throwing or returning a virtual model ends the request with an error.
303
+ - Selection (`ctx.model`, `model_change`) names the virtual model; each assistant message records the physical `provider`/`model`/`thinkingLevel`. The footer shows both; `/session` lists cost per physical model.
304
+ - `pi.unregisterVirtualModel(provider, id)` removes one (`unregisterProvider()` does not). SDK code can call `modelRuntime.registerVirtualModel(definition)`.
218
305
 
219
306
  ## Custom Providers via Extensions
220
307
 
221
308
  Use extensions when you need custom streaming, custom headers, OAuth/device-flow logic, or want to override an existing provider's `baseUrl`/`headers`:
222
309
 
310
+ A config-form `models` list replaces the provider's models across chat, image, and classifier operations; image and classifier entries need an explicit `type` and implementations keyed by `api` through `images` and `classifiers`. Registering only `baseUrl` or `headers` keeps the built-in models. See [custom-provider.md](https://github.com/earendil-works/pi/blob/v0.99.1/packages/coding-agent/docs/custom-provider.md).
311
+
223
312
  ```typescript
224
313
  // Full registration
225
314
  pi.registerProvider("my-provider", {
@@ -30,10 +30,15 @@ session.subscribe((event) => {
30
30
  }
31
31
  });
32
32
 
33
- await session.prompt("What files are in the current directory?");
33
+ try {
34
+ await session.prompt("What files are in the current directory?");
35
+ console.log(session.getLastAssistantText());
36
+ } finally {
37
+ session.dispose();
38
+ }
34
39
  ```
35
40
 
36
- Run with `npx tsx my-script.ts`.
41
+ Run with `npx tsx my-script.ts` (or Node's built-in type stripping). `prompt()` resolves when the run finishes, including automatic retries. `session.dispose()` aborts active work, invalidates extension contexts, and removes listeners. Checked against Pi v0.99.1: [sdk.md](https://github.com/earendil-works/pi/blob/v0.99.1/packages/coding-agent/docs/sdk.md), [SDK examples](https://github.com/earendil-works/pi/tree/v0.99.1/packages/coding-agent/examples/sdk).
37
42
 
38
43
  **Breaking (Pi 0.80.8):** `ModelRuntime` replaced the old `AuthStorage`/`ModelRegistry` pair as the SDK model/auth facade. `CreateAgentSessionOptions.authStorage` and `modelRegistry` are gone — pass the async `modelRuntime` instead. `AuthStorage` is no longer exported; use `ModelRuntime` (or a custom pi-ai `CredentialStore`), or `readStoredCredential()` for one-off reads of `auth.json`. `ModelRegistry` still exists only as the synchronous extension-facing compatibility facade, and its `refresh()` is now `Promise<void>`.
39
44
 
@@ -44,6 +49,7 @@ import {
44
49
  createAgentSession,
45
50
  DefaultResourceLoader,
46
51
  defineTool,
52
+ getAgentDir,
47
53
  ModelRuntime,
48
54
  SessionManager,
49
55
  } from "@earendil-works/pi-coding-agent";
@@ -56,11 +62,14 @@ import { getBuiltinModel } from "@earendil-works/pi-ai/providers/all";
56
62
 
57
63
  ```typescript
58
64
  const cwd = "/path/to/project";
65
+ const agentDir = getAgentDir();
59
66
  const modelRuntime = await ModelRuntime.create();
67
+ const resourceLoader = new DefaultResourceLoader({ cwd, agentDir });
68
+ await resourceLoader.reload(); // a loader you pass in is not reloaded for you
60
69
 
61
70
  const { session } = await createAgentSession({
62
71
  cwd,
63
- agentDir: "~/.pi/agent",
72
+ agentDir,
64
73
  modelRuntime,
65
74
  model: getBuiltinModel("anthropic", "claude-opus-4-8"),
66
75
  thinkingLevel: "medium", // off | minimal | low | medium | high | xhigh | max
@@ -69,7 +78,7 @@ const { session } = await createAgentSession({
69
78
  ],
70
79
  tools: ["read", "bash", "edit", "write"],
71
80
  customTools: [/* defineTool(...) entries */],
72
- resourceLoader: new DefaultResourceLoader(),
81
+ resourceLoader,
73
82
  sessionManager: SessionManager.inMemory(),
74
83
  });
75
84
  ```
@@ -80,12 +89,61 @@ Notes:
80
89
  - Pi 0.68.0 changed the SDK `tools` option from `Tool[]` to a `string[]` allowlist of built-in, extension, and custom tool names. Use `noTools: "builtin"` to disable built-ins while keeping extension/custom tools enabled, or `noTools: "all"` for none.
81
90
  - `customTools` accepts `ToolDefinition[]`. Build them with `defineTool({...})` for full TypeScript inference.
82
91
  - The `create*Tool(cwd)` factories still exist for code that needs explicit `AgentTool` instances (e.g. when wiring tools into pi-agent-core directly), but they are no longer the value passed to `createAgentSession({ tools })`.
83
- - `DefaultResourceLoader` loads extensions, skills, prompt templates, themes, and context files. Replace it to drive resource discovery from custom sources (and it must implement `loadProjectContextFiles()` if you want `AGENTS.md`/`CLAUDE.md` discovery; that helper is also exported standalone).
92
+ - `DefaultResourceLoader` requires `{ cwd, agentDir }` and loads extensions, skills, prompt templates, themes, and context files. Replace it to drive resource discovery from custom sources (and it must implement `loadProjectContextFiles()` if you want `AGENTS.md`/`CLAUDE.md` discovery; that helper is also exported standalone).
84
93
  - **Breaking (Pi 0.87.0):** pi-agent-core's `shouldStopAfterTurn` option was removed (it was never a `createAgentSession()` option). On a low-level `Agent`, set `finishTurn` instead: it runs after the assistant message and tool results are finalized but before `turn_end`, also for error/aborted responses. Return `{ action: "end" }` to stop, `{ action: "continue" }` to ensure one more request, or `undefined` for normal scheduling — guard hard exits first: `if (turn.message.stopReason === "error" || turn.message.stopReason === "aborted") return;`. Inside `createAgentSession()`, use an extension's actionable `turn_end` / `agent_before_settle` boundaries (see `references/extensions.md`).
85
94
 
95
+ ## Built-in Extensions: Codemode, Tool Search, MCP
96
+
97
+ The CLI loads `codemode`, `tool_search`, MCP, and llama.cpp as built-in extensions. SDK sessions load none of them. Opt in through the resource loader (Pi 0.99.0, [example](https://github.com/earendil-works/pi/blob/v0.99.1/packages/coding-agent/examples/sdk/14-codemode-mcp.ts)):
98
+
99
+ ```typescript
100
+ import {
101
+ createAgentSession,
102
+ createCodemodeExtension,
103
+ createMcpExtension,
104
+ createToolSearchExtension,
105
+ DefaultResourceLoader,
106
+ getAgentDir,
107
+ SessionManager,
108
+ SettingsManager,
109
+ } from "@earendil-works/pi-coding-agent";
110
+
111
+ const cwd = process.cwd();
112
+ const resourceLoader = new DefaultResourceLoader({
113
+ cwd,
114
+ agentDir: getAgentDir(),
115
+ extensionFactories: [
116
+ createCodemodeExtension({ mode: "on" }), // options override codemode.mode / inlineBudget
117
+ createToolSearchExtension(),
118
+ createMcpExtension(), // reads mcp.json like the CLI (project file only when trusted)
119
+ ],
120
+ });
121
+ await resourceLoader.reload();
122
+
123
+ const settingsManager = SettingsManager.create(cwd);
124
+ settingsManager.applyOverrides({ defaultTools: ["+codemode", "+tool_search"] });
125
+
126
+ const { session } = await createAgentSession({
127
+ resourceLoader,
128
+ settingsManager,
129
+ sessionManager: SessionManager.inMemory(),
130
+ });
131
+
132
+ try {
133
+ await session.bindExtensions({}); // emits session_start; MCP connects here, in the background
134
+ await session.prompt("Use codemode to count the TypeScript files in src/.");
135
+ } finally {
136
+ session.dispose();
137
+ }
138
+ ```
139
+
140
+ - `codemode` and `tool_search` are registered inactive. Enable them with `defaultTools` (`+name` keeps the other defaults), or let MCP activate them for servers that need them. Passing `tools: [...]` instead restricts the session to the named tools, which hides MCP tools.
141
+ - Extension `session_start` handlers run only when the host calls `session.bindExtensions()`. Without it, MCP servers never connect.
142
+ - Inline extensions can be named `{ name, factory, replaceable?: true, builtin?: true }`. `replaceable` drops the extension when another one registers the same tool, command, or flag name. `builtin` supplies the code for `builtin:<name>`: it loads by default, is listed in `pi config`, is disabled by `-builtin:<name>` or `noExtensions`, and loads after project trust (so it cannot handle `project_trust`).
143
+
86
144
  ## Models & Auth (`ModelRuntime`)
87
145
 
88
- `ModelRuntime` (Pi 0.80.8) implements the pi-ai `Models` interface and owns credential storage. Auth resolution priority: runtime overrides (`setRuntimeApiKey`, not persisted) → stored credentials in `auth.json` → environment variables → fallback resolver for `models.json` keys.
146
+ `ModelRuntime` (Pi 0.80.8) implements the pi-ai `Models` interface and owns credential storage. Auth resolution priority: runtime overrides (`setRuntimeApiKey`, not persisted) → stored credentials in `auth.json` → `apiKey` from `models.json` or an extension registration → the provider's environment variables or ambient cloud credentials.
89
147
 
90
148
  ```typescript
91
149
  import { InMemoryCredentialStore } from "@earendil-works/pi-ai";
@@ -115,6 +173,8 @@ const inMemory = await ModelRuntime.create({ credentials: new InMemoryCredential
115
173
 
116
174
  Pi 0.84.x made catalog refresh cancellation-aware: `refresh(options?)` accepts `{ providers?, allowNetwork?, force?, signal? }` and returns a `ModelsRefreshResult` (`{ errors, aborted }`) instead of discarding provider errors; `create({ signal })`, `login()`, `logout()`, and the now-async `setRuntimeApiKey()/removeRuntimeApiKey()` accept optional abort signals too (unbounded when omitted — applications own deadline policy). Credential mutations resolve once local state is consistent, without waiting for remote freshness; if that synchronization fails they reject with the exported `CredentialSynchronizationError` (inspect `providerId`, `operation`, `credential`, `cause` rather than retrying blindly). A failed refresh never undoes a committed credential change, and each `refresh()` starts a fresh generation so stale refreshes cannot publish afterward.
117
175
 
176
+ Non-chat models (Pi 0.99.0): `getModelsOfType(type, provider?)`, `getModelOfType(type, provider, id)`, `getAvailableOfType()`, `getAllModels()`, and `getAllAvailable()` cover `"chat"`, `"image"`, and `"classifier"` entries; `getModels()` stays chat-only. `modelRuntime.classify(model, context, options?)` and `modelRuntime.generateImages(model, context, options?)` resolve auth at request time and return error results (`stopReason: "error" | "aborted"`) instead of rejecting. `modelRuntime.registerVirtualModel(definition)` registers a routing model without an extension. See `references/providers.md#model-types`.
177
+
118
178
  To match CLI model parsing, use the exported resolver helpers (Pi 0.80.4):
119
179
 
120
180
  ```typescript
@@ -138,11 +198,11 @@ await session.prompt("Review the current directory");
138
198
  await session.prompt("Stop and do this instead", { streamingBehavior: "steer" });
139
199
  await session.prompt("After you finish, also check tests", { streamingBehavior: "followUp" });
140
200
 
141
- await session.steer("Use a smaller diff");
201
+ const disposition = await session.steer("Use a smaller diff"); // "queued" | "handled"
142
202
  await session.followUp("Summarize the changes afterward");
143
203
  ```
144
204
 
145
- `prompt()` expands file-based prompt templates. During active streaming, calling it without `streamingBehavior` throws.
205
+ `prompt()` expands file-based prompt templates and resolves after an accepted run finishes. During active streaming, calling it without `streamingBehavior` throws. Since Pi 0.99.0 `steer()` and `followUp()` return a disposition: `"queued"` (also after an extension transformed the input) or `"handled"` (an extension consumed it, so nothing was queued). Both throw for extension commands (`/name`); send those through `prompt()`, which runs them immediately, even while streaming. Read state through `session.messages`, `session.model`, `session.thinkingLevel`, `session.systemPrompt`, and `session.getActiveToolNames()`.
146
206
 
147
207
  ## Session Runtime
148
208
 
@@ -230,7 +290,7 @@ session.subscribe((event) => {
230
290
  process.stdout.write(event.assistantMessageEvent.delta);
231
291
  }
232
292
  break;
233
- case "tool_execution_start":
293
+ case "tool_execution_start": // nested calls (ctx.executeTool, codemode) carry parentToolCallId
234
294
  case "tool_execution_update":
235
295
  case "tool_execution_end":
236
296
  case "agent_start":
@@ -261,8 +321,9 @@ Pi 0.84.0 added transport-neutral remote-session client APIs: `@earendil-works/p
261
321
 
262
322
  ## RPC Mode
263
323
 
264
- `pi --mode rpc` speaks newline-delimited JSON over stdio. Additions since Pi 0.79:
324
+ `pi --mode rpc` speaks newline-delimited JSON over stdio ([rpc.md](https://github.com/earendil-works/pi/blob/v0.99.1/packages/coding-agent/docs/rpc.md), [rpc-commands.md](https://github.com/earendil-works/pi/blob/v0.99.1/packages/coding-agent/docs/rpc-commands.md)). Additions since Pi 0.79:
265
325
 
326
+ - Input dispositions (Pi 0.99.0): a successful `prompt` response carries `data.disposition` — `"started"` (a run started), `"queued"` (queued during a run), or `"handled"` (an extension command or input handler consumed it). `steer` and `follow_up` responses carry `"queued"` or `"handled"`. On `"handled"` no run started for that input: do not wait for `agent_settled`. Otherwise keep consuming events and wait for `agent_settled`, not `agent_end`. Failures after acceptance arrive as events, never as a second response. `RpcClient.prompt(message, images?, streamingBehavior?)`, `steer()`, and `followUp()` return the disposition.
266
327
  - `agent_settled` event (Pi 0.80.4) fires when a run is fully settled — no automatic retry, compaction retry, or queued continuation remains; `agent_end` now carries `willRetry`. `set_thinking_level` accepts `"max"` where the model supports it.
267
328
  - `clear_queue` (Pi 0.84.4) returns and removes queued steering and follow-up messages; direct RPC `steer`/`follow_up` commands pass through extension `input` handlers since Pi 0.86.0.
268
329
  - `get_entries` / `get_tree` (Pi 0.80.3) read session entries and tree snapshots over RPC; `get_available_thinking_levels` (Pi 0.81.0) lists the current model's supported thinking levels.
@@ -285,13 +346,18 @@ Pi 0.84.0 added transport-neutral remote-session client APIs: `@earendil-works/p
285
346
  To name an inline factory in the startup Extensions list (instead of `<inline:1>`), wrap it in an `InlineExtension` (Pi 0.80.4):
286
347
 
287
348
  ```typescript
288
- import type { InlineExtension } from "@earendil-works/pi-coding-agent";
349
+ import { DefaultResourceLoader, getAgentDir, type InlineExtension } from "@earendil-works/pi-coding-agent";
289
350
 
290
351
  const myProvider: InlineExtension = {
291
352
  name: "my-provider",
292
353
  factory: (pi) => { /* ... */ },
293
354
  };
294
- const loader = new DefaultResourceLoader({ extensionFactories: [myProvider] });
355
+ const loader = new DefaultResourceLoader({
356
+ cwd: process.cwd(),
357
+ agentDir: getAgentDir(),
358
+ extensionFactories: [myProvider],
359
+ });
360
+ await loader.reload();
295
361
  ```
296
362
 
297
363
  Bare factory functions are still accepted.