{"locale":"en","source":{"rawBase":"https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/docs","githubBase":"https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs","editBase":"https://github.com/earendil-works/pi/edit/main/packages/coding-agent/docs"},"redirects":[{"from":"/docs/latest/session","to":"/docs/latest/session-format"},{"from":"/docs/latest/tree","to":"/docs/latest/sessions"}],"fileToSlug":{"compaction.md":"compaction","containerization.md":"containerization","custom-provider.md":"custom-provider","development.md":"development","environment-variables.md":"environment-variables","extensions.md":"extensions","index.md":"index","json.md":"json","keybindings.md":"keybindings","llama-cpp.md":"llama-cpp","models.md":"models","packages.md":"packages","prompt-templates.md":"prompt-templates","providers.md":"providers","quickstart.md":"quickstart","rpc.md":"rpc","sdk.md":"sdk","security.md":"security","session-format.md":"session-format","sessions.md":"sessions","settings.md":"settings","shell-aliases.md":"shell-aliases","skills.md":"skills","terminal-setup.md":"terminal-setup","termux.md":"termux","themes.md":"themes","tmux.md":"tmux","tui.md":"tui","usage.md":"usage","windows.md":"windows"},"pages":{"en":{"compaction":{"title":"Compaction & Branch Summarization","markdown":"LLMs have limited context windows. When conversations grow too long, Pi uses compaction to summarize older content while preserving recent work. This page covers both auto-compaction and branch summarization.\n\n**Source files** ([pi-mono](https://github.com/earendil-works/pi-mono)):\n- [`packages/coding-agent/src/core/compaction/compaction.ts`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/compaction/compaction.ts) - Auto-compaction logic\n- [`packages/coding-agent/src/core/compaction/branch-summarization.ts`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/compaction/branch-summarization.ts) - Branch summarization\n- [`packages/coding-agent/src/core/compaction/utils.ts`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/compaction/utils.ts) - Shared utilities (file tracking, serialization)\n- [`packages/coding-agent/src/core/session-manager.ts`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/session-manager.ts) - Entry types (`CompactionEntry`, `BranchSummaryEntry`)\n- [`packages/coding-agent/src/core/extensions/types.ts`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/extensions/types.ts) - Extension event types\n\nFor TypeScript definitions in your project, inspect `node_modules/@earendil-works/pi-coding-agent/dist/`.\n\n## Overview\n\nPi has two summarization mechanisms:\n\n| Mechanism | Trigger | Purpose |\n|-----------|---------|---------|\n| Compaction | Context exceeds threshold, or `/compact` | Summarize old messages to free up context |\n| Branch summarization | `/tree` navigation | Preserve context when switching branches |\n\nBoth use the same structured summary format and track file operations cumulatively. Compaction and branch-summary requests use fresh routing session IDs and, where supported by the provider, disable prompt-cache writes because these one-off prompts are unlikely to be reused.\n\n## Compaction\n\n### When It Triggers\n\nAuto-compaction triggers when:\n\n```\ncontextTokens > contextWindow - reserveTokens\n```\n\nBy default, `reserveTokens` is 16384 tokens (configurable in `~/.pi/agent/settings.json` or `<project-dir>/.pi/settings.json`). This leaves room for the LLM's response.\n\nYou can also trigger manually with `/compact [instructions]`, where optional instructions focus the summary.\n\n### How It Works\n\n1. **Find cut point**: Walk backwards from newest message, accumulating token estimates until `keepRecentTokens` (default 20k, configurable in `~/.pi/agent/settings.json` or `<project-dir>/.pi/settings.json`) is reached\n2. **Extract messages**: Collect messages from the previous kept boundary (or session start) up to the cut point\n3. **Generate summary**: Call LLM to summarize with structured format, passing the previous summary as iterative context when present\n4. **Append entry**: Save `CompactionEntry` with summary and `firstKeptEntryId`\n5. **Reload**: Session reloads, using summary + messages from `firstKeptEntryId` onwards\n\n```\nBefore compaction:\n\n  entry:  0     1     2     3      4     5     6      7      8     9\n        ┌─────┬─────┬─────┬─────┬──────┬─────┬──── ─┬──────┬─────┬─────┐\n        │ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│\n        └─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┘\n                └────────┬───────┘ └──────────────┬──────────────┘\n               messagesToSummarize            kept messages\n                                   ↑\n                          firstKeptEntryId (entry 4)\n\nAfter compaction (new entry appended):\n\n  entry:  0     1     2     3      4     5     6      7      8     9     10\n        ┌─────┬─────┬─────┬─────┬──────┬─────┬──── ─┬──────┬─────┬─────┬─────┐\n        │ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│ cmp │\n        └─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┴─────┘\n               └──────────┬──────┘ └──────────────────────┬───────────────────┘\n                 not sent to LLM                    sent to LLM\n                                                         ↑\n                                              starts from firstKeptEntryId\n\nWhat the LLM sees:\n\n  ┌────────┬─────────┬─────┬─────┬──────┬──────┬─────┬──────┐\n  │ system │ summary │ usr │ ass │ tool │ tool │ ass │ tool │\n  └────────┴─────────┴─────┴─────┴──────┴──────┴─────┴──────┘\n       ↑         ↑      └─────────────────┬────────────────┘\n    prompt   from cmp          messages from firstKeptEntryId\n```\n\nOn repeated compactions, the summarized span starts at the previous compaction's kept boundary (`firstKeptEntryId`), not at the compaction entry itself, falling back to the entry after the previous compaction if that kept entry cannot be found in the path. This preserves messages that survived the earlier compaction by including them in the next summarization pass as well. Pi also recalculates `tokensBefore` from the rebuilt session context before writing the new `CompactionEntry`, so the token count reflects the actual pre-compaction context being replaced.\n\n### Split Turns\n\nA \"turn\" starts with a user message and includes all assistant responses and tool calls until the next user message. Normally, compaction cuts at turn boundaries.\n\nWhen a single turn exceeds `keepRecentTokens`, the cut point lands mid-turn at an assistant message. This is a \"split turn\":\n\n```\nSplit turn (one huge turn exceeds budget):\n\n  entry:  0     1     2      3     4      5      6     7      8\n        ┌─────┬─────┬─────┬──────┬─────┬──────┬──────┬─────┬──────┐\n        │ hdr │ usr │ ass │ tool │ ass │ tool │ tool │ ass │ tool │\n        └─────┴─────┴─────┴──────┴─────┴──────┴──────┴─────┴──────┘\n                ↑                                     ↑\n         turnStartIndex = 1                  firstKeptEntryId = 7\n                │                                     │\n                └──── turnPrefixMessages (1-6) ───────┘\n                                                      └── kept (7-8)\n\n  isSplitTurn = true\n  messagesToSummarize = []  (no complete turns before)\n  turnPrefixMessages = [usr, ass, tool, ass, tool, tool]\n```\n\nFor split turns, Pi generates two summaries and merges them:\n1. **History summary**: Previous context (if any)\n2. **Turn prefix summary**: The early part of the split turn\n\n### Cut Point Rules\n\nValid cut points are:\n- User messages\n- Assistant messages\n- BashExecution messages\n- Custom messages (custom_message, branch_summary)\n\nNever cut at tool results (they must stay with their tool call).\n\n### CompactionEntry Structure\n\nDefined in [`session-manager.ts`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/session-manager.ts):\n\n```typescript\ninterface CompactionEntry<T = unknown> {\n  type: \"compaction\";\n  id: string;\n  parentId: string;\n  timestamp: number;\n  summary: string;\n  firstKeptEntryId: string;\n  tokensBefore: number;\n  usage?: Usage;       // LLM usage that generated the summary\n  fromHook?: boolean;  // true if provided by extension (legacy field name)\n  details?: T;         // implementation-specific data\n}\n\n// Default compaction uses this for details (from compaction.ts):\ninterface CompactionDetails {\n  readFiles: string[];\n  modifiedFiles: string[];\n}\n```\n\nExtensions can store any JSON-serializable data in `details`. The default compaction tracks file operations, but custom extension implementations can use their own structure. Generated and extension-provided summaries store their LLM `usage` when available so session totals include summarization work.\n\nSee [`prepareCompaction()`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/compaction/compaction.ts) and [`compact()`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/compaction/compaction.ts) for the implementation. For direct programmatic summarization, `generateSummary()` returns the summary text and `generateSummaryWithUsage()` returns `{ text, usage }`.\n\n## Branch Summarization\n\n### When It Triggers\n\nWhen you use `/tree` to navigate to a different branch, Pi offers to summarize the work you're leaving. This injects context from the left branch into the new branch.\n\n### How It Works\n\n1. **Find common ancestor**: Deepest node shared by old and new positions\n2. **Collect entries**: Walk from old leaf back to common ancestor\n3. **Prepare with budget**: Include messages up to token budget (newest first)\n4. **Generate summary**: Call LLM with structured format\n5. **Append entry**: Save `BranchSummaryEntry` at navigation point\n\n```\nTree before navigation:\n\n         ┌─ B ─ C ─ D (old leaf, being abandoned)\n    A ───┤\n         └─ E ─ F (target)\n\nCommon ancestor: A\nEntries to summarize: B, C, D\n\nAfter navigation with summary:\n\n         ┌─ B ─ C ─ D\n    A ───┤\n         └─ E ─ F ─ [summary of B,C,D] (new leaf)\n```\n\n### Cumulative File Tracking\n\nBoth compaction and branch summarization track files cumulatively. When generating a summary, pi extracts file operations from:\n- Tool calls in the messages being summarized\n- Previous compaction or branch summary `details` (if any)\n\nThis means file tracking accumulates across multiple compactions or nested branch summaries, preserving the full history of read and modified files.\n\n### BranchSummaryEntry Structure\n\nDefined in [`session-manager.ts`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/session-manager.ts):\n\n```typescript\ninterface BranchSummaryEntry<T = unknown> {\n  type: \"branch_summary\";\n  id: string;\n  parentId: string;\n  timestamp: number;\n  summary: string;\n  fromId: string;      // Entry we navigated from\n  usage?: Usage;       // LLM usage that generated the summary\n  fromHook?: boolean;  // true if provided by extension (legacy field name)\n  details?: T;         // implementation-specific data\n}\n\n// Default branch summarization uses this for details (from branch-summarization.ts):\ninterface BranchSummaryDetails {\n  readFiles: string[];\n  modifiedFiles: string[];\n}\n```\n\nSame as compaction, extensions can store custom data in `details`.\n\nSee [`collectEntriesForBranchSummary()`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/compaction/branch-summarization.ts), [`prepareBranchEntries()`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/compaction/branch-summarization.ts), and [`generateBranchSummary()`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/compaction/branch-summarization.ts) for the implementation.\n\n## Summary Format\n\nBoth compaction and branch summarization use the same structured format:\n\n```markdown\n## Goal\n[What the user is trying to accomplish]\n\n## Constraints & Preferences\n- [Requirements mentioned by user]\n\n## Progress\n### Done\n- [x] [Completed tasks]\n\n### In Progress\n- [ ] [Current work]\n\n### Blocked\n- [Issues, if any]\n\n## Key Decisions\n- **[Decision]**: [Rationale]\n\n## Next Steps\n1. [What should happen next]\n\n## Critical Context\n- [Data needed to continue]\n\n<read-files>\npath/to/file1.ts\npath/to/file2.ts\n</read-files>\n\n<modified-files>\npath/to/changed.ts\n</modified-files>\n```\n\n### Message Serialization\n\nBefore summarization, messages are serialized to text via [`serializeConversation()`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/compaction/utils.ts):\n\n```\n[User]: What they said\n[Assistant thinking]: Internal reasoning\n[Assistant]: Response text\n[Assistant tool calls]: read(path=\"foo.ts\"); edit(path=\"bar.ts\", ...)\n[Tool result]: Output from tool\n```\n\nThis prevents the model from treating it as a conversation to continue.\n\nTool results are truncated to 2000 characters during serialization. Content beyond that limit is replaced with a marker indicating how many characters were truncated. This keeps summarization requests within reasonable token budgets, since tool results (especially from `read` and `bash`) are typically the largest contributors to context size.\n\n## Custom Summarization via Extensions\n\nExtensions can intercept and customize both compaction and branch summarization. See [`extensions/types.ts`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/extensions/types.ts) for event type definitions.\n\n### session_before_compact\n\nFired before auto-compaction or `/compact`. Can cancel or provide custom summary. See `SessionBeforeCompactEvent` and `CompactionPreparation` in the types file.\n\n```typescript\npi.on(\"session_before_compact\", async (event, ctx) => {\n  const { preparation, branchEntries, customInstructions, reason, willRetry, signal } = event;\n\n  // preparation.messagesToSummarize - messages to summarize\n  // preparation.turnPrefixMessages - split turn prefix (if isSplitTurn)\n  // preparation.previousSummary - previous compaction summary\n  // preparation.fileOps - extracted file operations\n  // preparation.tokensBefore - context tokens before compaction\n  // preparation.firstKeptEntryId - where kept messages start\n  // preparation.settings - compaction settings\n\n  // branchEntries - all entries on current branch (for custom state)\n  // reason - \"manual\" (/compact), \"threshold\", or \"overflow\"\n  // willRetry - whether the aborted turn is retried after compaction (overflow recovery)\n  // signal - AbortSignal (pass to LLM calls)\n\n  // Cancel:\n  return { cancel: true };\n\n  // Custom summary:\n  return {\n    compaction: {\n      summary: \"Your summary...\",\n      firstKeptEntryId: preparation.firstKeptEntryId,\n      tokensBefore: preparation.tokensBefore,\n      // usage: summaryResponse.usage, // Optional; included in session totals\n      details: { /* custom data */ },\n    }\n  };\n});\n```\n\n#### Converting Messages to Text\n\nTo generate a summary with your own model, convert messages to text using `serializeConversation`:\n\n```typescript\nimport { convertToLlm, serializeConversation } from \"@earendil-works/pi-coding-agent\";\n\npi.on(\"session_before_compact\", async (event, ctx) => {\n  const { preparation } = event;\n  \n  // Convert AgentMessage[] to Message[], then serialize to text\n  const conversationText = serializeConversation(\n    convertToLlm(preparation.messagesToSummarize)\n  );\n  // Returns:\n  // [User]: message text\n  // [Assistant thinking]: thinking content\n  // [Assistant]: response text\n  // [Assistant tool calls]: read(path=\"...\"); bash(command=\"...\")\n  // [Tool result]: output text\n\n  // Now send to your model for summarization\n  const { summary, usage } = await myModel.summarize(conversationText);\n  \n  return {\n    compaction: {\n      summary,\n      firstKeptEntryId: preparation.firstKeptEntryId,\n      tokensBefore: preparation.tokensBefore,\n      usage,\n    }\n  };\n});\n```\n\nSee [custom-compaction.ts](../examples/extensions/custom-compaction.ts) for a complete example using a different model.\n\n### session_before_tree\n\nFired before `/tree` navigation. Always fires regardless of whether user chose to summarize. Can cancel navigation or provide custom summary.\n\n```typescript\npi.on(\"session_before_tree\", async (event, ctx) => {\n  const { preparation, signal } = event;\n\n  // preparation.targetId - where we're navigating to\n  // preparation.oldLeafId - current position (being abandoned)\n  // preparation.commonAncestorId - shared ancestor\n  // preparation.entriesToSummarize - entries that would be summarized\n  // preparation.userWantsSummary - whether user chose to summarize\n\n  // Cancel navigation entirely:\n  return { cancel: true };\n\n  // Provide custom summary (only used if userWantsSummary is true):\n  if (preparation.userWantsSummary) {\n    return {\n      summary: {\n        summary: \"Your summary...\",\n        // usage: summaryResponse.usage, // Optional; included in session totals\n        details: { /* custom data */ },\n      }\n    };\n  }\n});\n```\n\nSee `SessionBeforeTreeEvent` and `TreePreparation` in the types file.\n\n## Settings\n\nConfigure compaction in `~/.pi/agent/settings.json` or `<project-dir>/.pi/settings.json`:\n\n```json\n{\n  \"compaction\": {\n    \"enabled\": true,\n    \"reserveTokens\": 16384,\n    \"keepRecentTokens\": 20000\n  }\n}\n```\n\n| Setting | Default | Description |\n|---------|---------|-------------|\n| `enabled` | `true` | Enable auto-compaction |\n| `reserveTokens` | `16384` | Tokens to reserve for LLM response |\n| `keepRecentTokens` | `20000` | Recent tokens to keep (not summarized) |\n\nDisable auto-compaction with `\"enabled\": false`. You can still compact manually with `/compact`.","sourceFile":"compaction.md"},"containerization":{"title":"Containerization","markdown":"Pi runs with all permissions by default, but in some cases, you will want to have more control over what directories Pi can write to and which accesses it has.\n\nThere are two general options. You can either\n1. run the whole `pi` process inside an isolated environment, or\n2. run `pi` on the host and route tool execution into an isolated environment.\n\n## Choose a pattern\n\n| Pattern | What is isolated | Best for | Notes |\n| --- | --- | --- | --- |\n| Gondolin extension | Built-in tools and `!` commands | Local micro-VM isolation while keeping auth on host | See [`examples/extensions/gondolin/`](../examples/extensions/gondolin/). |\n| Plain Docker | Whole `pi` process in a local container | Simple local isolation | Provider API keys enter the container. |\n| OpenShell | Whole `pi` process in a policy-controlled sandbox | Local or remote managed sandbox | Requires an OpenShell gateway |\n\nExtensions run wherever the `pi` process runs. If you run host `pi` with a tool-routing extension, other custom extension tools still run on the host unless they also delegate their operations.\n\n## Gondolin\n\n[Gondolin](https://github.com/earendil-works/gondolin) is a local Linux micro-VM.\nUse the [example extension](../examples/extensions/gondolin) when you want `pi` on the host but all built-in tools routed into the VM.\n\nSetup:\n\n```bash\ncp -R packages/coding-agent/examples/extensions/gondolin ~/.pi/agent/extensions/gondolin\ncd ~/.pi/agent/extensions/gondolin\nnpm install --ignore-scripts\n```\n\nRun from the project you want mounted:\n\n```bash\ncd /path/to/project\npi -e ~/.pi/agent/extensions/gondolin\n```\n\nThe extension mounts the host cwd at `/workspace` in the VM and overrides `read`, `write`, `edit`, `bash`, `grep`, `find`, and `ls`.\nUser `!` commands are routed into the VM, as well.\nFile changes under `/workspace` write through to the host.\n\nRequirements: Node.js >= 23.6.0 for `@earendil-works/gondolin`, plus QEMU (requires installation through your package manager).\n\n## Plain Docker\n\nRun the whole `pi` process in Docker when you want the simplest local container boundary.\n\n`Dockerfile.pi`:\n\n```dockerfile\nFROM node:24-bookworm-slim\n\nRUN apt-get update \\\n  && apt-get install -y --no-install-recommends bash ca-certificates git ripgrep \\\n  && rm -rf /var/lib/apt/lists/*\nRUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent\n\nWORKDIR /workspace\nENTRYPOINT [\"pi\"]\n```\n\nBuild and run:\n\n```bash\ndocker build -t pi-sandbox -f Dockerfile.pi .\n\ndocker run --rm -it \\\n  -e ANTHROPIC_API_KEY \\\n  -v \"$PWD:/workspace\" \\\n  -v pi-agent-home:/root/.pi/agent \\\n  pi-sandbox\n```\n\nThe `-v \"$PWD:/workspace\"` mounts your current directory into the container at /workspace such that reads and writes in `/workspace` inside Docker directly affect your host files, like in the Gondolin example.\n\nUse a named volume for `/root/.pi/agent` if you want container-local settings and sessions. Mounting your host `~/.pi/agent` exposes host auth and session files to the container.\n\n## OpenShell\n\nUse [NVIDIA OpenShell](https://docs.nvidia.com/openshell/about/overview) when you want a policy-controlled sandbox with filesystem, process, network, credential, and inference controls.\nOpenShell can run sandboxes through a local gateway backed by Docker, Podman, or a VM runtime, or through a remote Kubernetes gateway.\n\nEvery sandbox requires an active gateway.\nRegister and select one before creating a sandbox:\n\n```bash\nopenshell gateway add <gateway-url> --name <name>\nopenshell gateway select <name>\n```\n\nLaunch `pi` inside an OpenShell sandbox:\n\n```bash\nopenshell sandbox create --name pi-sandbox --from pi -- pi\n```\n\nIn this pattern, the whole `pi` process runs inside the sandbox.\nBuilt-in tools, `!` commands, and extension tools execute inside the OpenShell boundary.\n\nIf the gateway is remote, project files are not bind-mounted from the host, meaning writes in the sandbox are not reflected on your machine.\nClone the repository inside the sandbox or use OpenShell file transfer commands:\n\n```bash\nopenshell sandbox upload pi-sandbox ./repo /workspace\nopenshell sandbox download pi-sandbox /workspace/repo ./repo-out\n```\n\nOpenShell providers can keep raw model API keys outside the sandbox.\nWhen inference routing is configured, code inside the sandbox can call `https://inference.local`, and the gateway injects the configured provider credentials upstream.\nConfigure Pi to use the corresponding OpenAI-compatible or Anthropic-compatible endpoint if you want model traffic to use this route.","sourceFile":"containerization.md"},"custom-provider":{"title":"Custom Providers","markdown":"Extensions can register custom model providers via `pi.registerProvider()`. This enables:\n\n- **Proxies** - Route requests through corporate proxies or API gateways\n- **Custom endpoints** - Use self-hosted or private model deployments\n- **OAuth/SSO** - Add authentication flows for enterprise providers\n- **Custom APIs** - Implement streaming for non-standard LLM APIs\n\n## Example Extensions\n\nSee these complete provider examples:\n\n- [`examples/extensions/custom-provider-anthropic/`](../examples/extensions/custom-provider-anthropic/)\n- [`examples/extensions/custom-provider-gitlab-duo/`](../examples/extensions/custom-provider-gitlab-duo/)\n\n## Table of Contents\n\n- [Example Extensions](#example-extensions)\n- [Quick Reference](#quick-reference)\n- [Override Existing Provider](#override-existing-provider)\n- [Register New Provider](#register-new-provider)\n- [Unregister Provider](#unregister-provider)\n- [OAuth Support](#oauth-support)\n- [Custom Streaming API](#custom-streaming-api)\n- [Context Overflow Errors](#context-overflow-errors)\n- [Testing Your Implementation](#testing-your-implementation)\n- [Config Reference](#config-reference)\n- [Model Definition Reference](#model-definition-reference)\n\n## Quick Reference\n\nExtensions can register either a complete pi-ai `Provider` or use the legacy provider-config form. Prefer a complete provider when custom authentication, filtering, refresh, or streaming behavior is required. Pi composes `models.json` overrides above registered native providers.\n\n```typescript\nimport { createProvider, openAICompletionsApi } from \"@earendil-works/pi-ai\";\nimport type { ExtensionAPI } from \"@earendil-works/pi-coding-agent\";\n\nexport default function (pi: ExtensionAPI) {\n  pi.registerProvider(createProvider({\n    id: \"native-local\",\n    name: \"Native Local\",\n    baseUrl: \"http://localhost:8080/v1\",\n    auth: {\n      apiKey: {\n        name: \"Local server API key\",\n        async login(interaction) {\n          return {\n            type: \"api_key\",\n            key: await interaction.prompt({ type: \"secret\", message: \"API key\" })\n          };\n        },\n        async resolve({ credential }) {\n          return credential?.key\n            ? { auth: { apiKey: credential.key }, source: \"stored API key\" }\n            : undefined;\n        }\n      }\n    },\n    models: [],\n    api: openAICompletionsApi()\n  }));\n\n  // Legacy provider-config form:\n  // Override baseUrl for existing provider\n  pi.registerProvider(\"anthropic\", {\n    baseUrl: \"https://proxy.example.com\"\n  });\n\n  // Register new provider with models\n  pi.registerProvider(\"my-provider\", {\n    name: \"My Provider\",\n    baseUrl: \"https://api.example.com\",\n    apiKey: \"$MY_API_KEY\",\n    api: \"openai-completions\",\n    models: [\n      {\n        id: \"my-model\",\n        name: \"My Model\",\n        reasoning: false,\n        input: [\"text\", \"image\"],\n        cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n        contextWindow: 128000,\n        maxTokens: 4096\n      }\n    ]\n  });\n}\n```\n\nThe extension factory can also be `async`. For dynamic model discovery, fetch and register models in the factory instead of `session_start`. pi waits for the factory before startup continues, so the provider is available during interactive startup and to `pi --list-models`.\n\n## Override Existing Provider\n\nThe simplest use case: redirect an existing provider through a proxy.\n\n```typescript\n// All Anthropic requests now go through your proxy\npi.registerProvider(\"anthropic\", {\n  baseUrl: \"https://proxy.example.com\"\n});\n\n// Add custom headers to OpenAI requests\npi.registerProvider(\"openai\", {\n  headers: {\n    \"X-Custom-Header\": \"value\"\n  }\n});\n\n// Both baseUrl and headers\npi.registerProvider(\"google\", {\n  baseUrl: \"https://ai-gateway.corp.com/google\",\n  headers: {\n    \"X-Corp-Auth\": \"$CORP_AUTH_TOKEN\"  // env var or literal\n  }\n});\n```\n\nWhen only `baseUrl` and/or `headers` are provided (no `models`), all existing models for that provider are preserved with the new endpoint.\n\n## Register New Provider\n\nTo add a completely new provider, specify `models` along with the required configuration.\n\nIf the model list comes from a remote endpoint, use an async extension factory:\n\n```typescript\nimport type { ExtensionAPI } from \"@earendil-works/pi-coding-agent\";\n\nexport default async function (pi: ExtensionAPI) {\n  const response = await fetch(\"http://localhost:1234/v1/models\");\n  const payload = (await response.json()) as {\n    data: Array<{\n      id: string;\n      name?: string;\n      context_window?: number;\n      max_tokens?: number;\n    }>;\n  };\n\n  pi.registerProvider(\"local-openai\", {\n    baseUrl: \"http://localhost:1234/v1\",\n    apiKey: \"$LOCAL_OPENAI_API_KEY\",\n    api: \"openai-completions\",\n    models: payload.data.map((model) => ({\n      id: model.id,\n      name: model.name ?? model.id,\n      reasoning: false,\n      input: [\"text\"],\n      cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n      contextWindow: model.context_window ?? 128000,\n      maxTokens: model.max_tokens ?? 4096,\n    })),\n  });\n}\n```\n\nThis registers the fetched models before startup finishes.\n\n```typescript\npi.registerProvider(\"my-llm\", {\n  baseUrl: \"https://api.my-llm.com/v1\",\n  apiKey: \"$MY_LLM_API_KEY\",  // env var reference\n  api: \"openai-completions\",  // which streaming API to use\n  models: [\n    {\n      id: \"my-llm-large\",\n      name: \"My LLM Large\",\n      reasoning: true,        // supports extended thinking\n      input: [\"text\", \"image\"],\n      cost: {\n        input: 3.0,           // $/million tokens\n        output: 15.0,\n        cacheRead: 0.3,\n        cacheWrite: 3.75\n      },\n      contextWindow: 200000,\n      maxTokens: 16384\n    }\n  ]\n});\n```\n\nWhen `models` is provided, it **replaces** all existing models for that provider.\n\n`apiKey` and custom header values use the same config value syntax as `models.json`: `!command` at the start executes a command for the whole value, `$ENV_VAR` and `${ENV_VAR}` interpolate environment variables, `$$` emits a literal `$`, and `$!` emits a literal `!`.\n\n## Unregister Provider\n\nUse `pi.unregisterProvider(name)` to remove a provider that was previously registered via `pi.registerProvider(name, ...)`:\n\n```typescript\n// Register\npi.registerProvider(\"my-llm\", {\n  baseUrl: \"https://api.my-llm.com/v1\",\n  apiKey: \"$MY_LLM_API_KEY\",\n  api: \"openai-completions\",\n  models: [\n    {\n      id: \"my-llm-large\",\n      name: \"My LLM Large\",\n      reasoning: true,\n      input: [\"text\", \"image\"],\n      cost: { input: 3.0, output: 15.0, cacheRead: 0.3, cacheWrite: 3.75 },\n      contextWindow: 200000,\n      maxTokens: 16384\n    }\n  ]\n});\n\n// Later, remove it\npi.unregisterProvider(\"my-llm\");\n```\n\nUnregistering removes that provider's dynamic models, API key fallback, OAuth provider registration, and custom stream handler registrations. Any built-in models or provider behavior that were overridden are restored.\n\nCalls made after the initial extension load phase are applied immediately, so no `/reload` is required.\n\n### API Types\n\nThe `api` field determines which streaming implementation is used:\n\n| API | Use for |\n|-----|---------|\n| `anthropic-messages` | Anthropic Claude API and compatibles |\n| `openai-completions` | OpenAI Chat Completions API and compatibles |\n| `openai-responses` | OpenAI Responses API |\n| `azure-openai-responses` | Azure OpenAI Responses API |\n| `openai-codex-responses` | OpenAI Codex Responses API |\n| `mistral-conversations` | Native Mistral Chat Completions streaming |\n| `google-generative-ai` | Google Generative AI API |\n| `google-vertex` | Google Vertex AI API |\n| `bedrock-converse-stream` | Amazon Bedrock Converse API |\n\nMost OpenAI-compatible providers work with `openai-completions`. Use model-level `thinkingLevelMap` for model-specific thinking levels, and `compat` for provider quirks. The `xhigh` and `max` levels are opt-in, require non-null map entries, and may be separated by unsupported holes:\n\n```typescript\nmodels: [{\n  id: \"custom-model\",\n  // ...\n  reasoning: true,\n  thinkingLevelMap: {              // map pi levels to provider values; null hides unsupported levels\n    minimal: null,\n    low: null,\n    medium: null,\n    high: \"default\",\n    xhigh: null,\n    max: \"max\"\n  },\n  compat: {\n    supportsDeveloperRole: false,   // use \"system\" instead of \"developer\"\n    supportsReasoningEffort: true,\n    maxTokensField: \"max_tokens\",   // instead of \"max_completion_tokens\"\n    requiresToolResultName: true,   // tool results need name field\n    thinkingFormat: \"qwen\",        // top-level enable_thinking: true\n    cacheControlFormat: \"anthropic\" // Anthropic-style cache_control markers\n  }\n}]\n```\n\nUse `openrouter` for OpenRouter-style `reasoning: { effort }` controls. Use `together` for Together-style `reasoning: { enabled }` controls; with `supportsReasoningEffort`, it also sends `reasoning_effort`. Use `qwen-chat-template` for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking` and need `preserve_thinking`.\nUse `cacheControlFormat: \"anthropic\"` for OpenAI-compatible providers that expose Anthropic-style prompt caching via `cache_control` on the system prompt, last tool definition, and last user, assistant, or tool-result text content.\n\nFor Anthropic-compatible providers using `api: \"anthropic-messages\"`, set `compat.forceAdaptiveThinking: true` on models or providers whose upstream model requires adaptive thinking (`thinking.type: \"adaptive\"` plus `output_config.effort`). Built-in adaptive Claude models set this automatically. Set `compat.allowEmptySignature: true` only for providers that emit empty thinking signatures and expect `signature: \"\"` on replay.\n\n> Migration note: Mistral moved from `openai-completions` to `mistral-conversations`.\n> Use `mistral-conversations` for native Mistral models.\n> If you intentionally route Mistral-compatible/custom endpoints through `openai-completions`, set `compat` flags explicitly as needed.\n\n### Auth Header\n\nIf your provider expects `Authorization: Bearer <key>` but doesn't use a standard API, set `authHeader: true`:\n\n```typescript\npi.registerProvider(\"custom-api\", {\n  baseUrl: \"https://api.example.com\",\n  apiKey: \"$MY_API_KEY\",\n  authHeader: true,  // adds Authorization: Bearer header\n  api: \"openai-completions\",\n  models: [...]\n});\n```\n\nThe key is resolved for each request. An explicit request `Authorization` header takes precedence over the generated value.\n\n## OAuth Support\n\nAdd OAuth/SSO authentication that integrates with `/login`:\n\n```typescript\nimport type { OAuthCredentials, OAuthLoginCallbacks } from \"@earendil-works/pi-ai\";\n\npi.registerProvider(\"corporate-ai\", {\n  baseUrl: \"https://ai.corp.com/v1\",\n  api: \"openai-responses\",\n  models: [...],\n  oauth: {\n    name: \"Corporate AI (SSO)\",\n\n    async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {\n      const method = await callbacks.onSelect({\n        message: \"Select login method:\",\n        options: [\n          { id: \"browser\", label: \"Browser OAuth\" },\n          { id: \"device\", label: \"Device code\" }\n        ]\n      });\n      if (!method) throw new Error(\"Login cancelled\");\n\n      let code: string;\n      if (method === \"device\") {\n        callbacks.onDeviceCode({\n          userCode: \"ABCD-1234\",\n          verificationUri: \"https://sso.corp.com/device\",\n          intervalSeconds: 5,\n          expiresInSeconds: 900\n        });\n        code = await pollDeviceCodeUntilComplete();\n      } else {\n        callbacks.onAuth({ url: \"https://sso.corp.com/authorize?...\" });\n        code = await callbacks.onPrompt({ message: \"Enter SSO code:\" });\n      }\n\n      // Exchange for tokens (your implementation)\n      const tokens = await exchangeCodeForTokens(code);\n\n      return {\n        refresh: tokens.refreshToken,\n        access: tokens.accessToken,\n        expires: Date.now() + tokens.expiresIn * 1000\n      };\n    },\n\n    async refreshToken(credentials: OAuthCredentials, signal: AbortSignal): Promise<OAuthCredentials> {\n      const tokens = await refreshAccessToken(credentials.refresh, signal);\n      return {\n        refresh: tokens.refreshToken ?? credentials.refresh,\n        access: tokens.accessToken,\n        expires: Date.now() + tokens.expiresIn * 1000\n      };\n    },\n\n    getApiKey(credentials: OAuthCredentials): string {\n      return credentials.access;\n    }\n  }\n});\n```\n\nAfter registration, users can authenticate via `/login corporate-ai`.\n\n### OAuthLoginCallbacks\n\nThe `callbacks` object provides UI-neutral interactions for the provider-owned flow:\n\n```typescript\ninterface OAuthLoginCallbacks {\n  // Open URL in browser (for OAuth redirects)\n  onAuth(params: { url: string }): void;\n\n  // Show device code (for device authorization flow)\n  onDeviceCode(params: {\n    userCode: string;\n    verificationUri: string;\n    intervalSeconds?: number;\n    expiresInSeconds?: number;\n  }): void;\n\n  // Show transient progress\n  onProgress?(message: string): void;\n\n  // Prompt user for input (for manual token entry)\n  onPrompt(params: { message: string }): Promise<string>;\n\n  // Show an interactive selector, e.g. to choose browser OAuth vs device code\n  onSelect(params: {\n    message: string;\n    options: { id: string; label: string }[];\n  }): Promise<string | undefined>;\n}\n```\n\n### OAuthCredentials\n\nCredentials are persisted in `~/.pi/agent/auth.json`:\n\n```typescript\ninterface OAuthCredentials {\n  refresh: string;   // Refresh token (for refreshToken())\n  access: string;    // Access token (returned by getApiKey())\n  expires: number;   // Expiration timestamp in milliseconds\n}\n```\n\n## Custom Streaming API\n\nFor providers with non-standard APIs, implement `streamSimple`. Study the existing provider implementations before writing your own:\n\n**Reference implementations:**\n- [anthropic.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/ai/src/providers/anthropic.ts) - Anthropic Messages API\n- [mistral.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/ai/src/providers/mistral.ts) - Mistral Conversations API\n- [openai-completions.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/ai/src/providers/openai-completions.ts) - OpenAI Chat Completions\n- [openai-responses.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/ai/src/providers/openai-responses.ts) - OpenAI Responses API\n- [google.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/ai/src/providers/google.ts) - Google Generative AI\n- [amazon-bedrock.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/ai/src/providers/amazon-bedrock.ts) - AWS Bedrock\n\n### Stream Pattern\n\nAll providers follow the same pattern:\n\n```typescript\nimport {\n  type AssistantMessage,\n  type AssistantMessageEventStream,\n  type Context,\n  type Model,\n  type SimpleStreamOptions,\n  calculateCost,\n  createAssistantMessageEventStream,\n} from \"@earendil-works/pi-ai\";\n\nfunction streamMyProvider(\n  model: Model<any>,\n  context: Context,\n  options?: SimpleStreamOptions\n): AssistantMessageEventStream {\n  const stream = createAssistantMessageEventStream();\n\n  (async () => {\n    // Initialize output message\n    const output: AssistantMessage = {\n      role: \"assistant\",\n      content: [],\n      api: model.api,\n      provider: model.provider,\n      model: model.id,\n      usage: {\n        input: 0,\n        output: 0,\n        cacheRead: 0,\n        cacheWrite: 0,\n        totalTokens: 0,\n        cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n      },\n      stopReason: \"pending\",\n      timestamp: Date.now(),\n    };\n\n    try {\n      // Push start event\n      stream.push({ type: \"start\", partial: output });\n\n      // Make API request and process response...\n      // Push content events as they arrive and set stopReason from the terminal event.\n      if (output.stopReason === \"pending\") {\n        throw new Error(\"Provider stream ended without a stop reason\");\n      }\n      if (output.stopReason === \"error\" || output.stopReason === \"aborted\") {\n        throw new Error(output.errorMessage || \"An unknown error occurred\");\n      }\n\n      // Push done event\n      stream.push({\n        type: \"done\",\n        reason: output.stopReason,\n        message: output\n      });\n      stream.end();\n    } catch (error) {\n      output.stopReason = options?.signal?.aborted ? \"aborted\" : \"error\";\n      output.errorMessage = error instanceof Error ? error.message : String(error);\n      stream.push({ type: \"error\", reason: output.stopReason, error: output });\n      stream.end();\n    }\n  })();\n\n  return stream;\n}\n```\n\n### Event Types\n\nPush events via `stream.push()` in this order:\n\n1. `{ type: \"start\", partial: output }` - Stream started\n\n2. Content events (repeatable, track `contentIndex` for each block):\n   - `{ type: \"text_start\", contentIndex, partial }` - Text block started\n   - `{ type: \"text_delta\", contentIndex, delta, partial }` - Text chunk\n   - `{ type: \"text_end\", contentIndex, content, partial }` - Text block ended\n   - `{ type: \"thinking_start\", contentIndex, partial }` - Thinking started\n   - `{ type: \"thinking_delta\", contentIndex, delta, partial }` - Thinking chunk\n   - `{ type: \"thinking_end\", contentIndex, content, partial }` - Thinking ended\n   - `{ type: \"toolcall_start\", contentIndex, partial }` - Tool call started\n   - `{ type: \"toolcall_delta\", contentIndex, delta, partial }` - Tool call JSON chunk\n   - `{ type: \"toolcall_end\", contentIndex, toolCall, partial }` - Tool call ended\n\n3. `{ type: \"done\", reason, message }` or `{ type: \"error\", reason, error }` - Stream ended\n\nThe `partial` field in each event contains the current `AssistantMessage` state. Update `output.content` as you receive data, then include `output` as the `partial`.\n\n### Content Blocks\n\nAdd content blocks to `output.content` as they arrive:\n\n```typescript\n// Text block\noutput.content.push({ type: \"text\", text: \"\" });\nstream.push({ type: \"text_start\", contentIndex: output.content.length - 1, partial: output });\n\n// As text arrives\nconst block = output.content[contentIndex];\nif (block.type === \"text\") {\n  block.text += delta;\n  stream.push({ type: \"text_delta\", contentIndex, delta, partial: output });\n}\n\n// When block completes\nstream.push({ type: \"text_end\", contentIndex, content: block.text, partial: output });\n```\n\n### Tool Calls\n\nTool calls require accumulating JSON and parsing:\n\n```typescript\n// Start tool call\noutput.content.push({\n  type: \"toolCall\",\n  id: toolCallId,\n  name: toolName,\n  arguments: {}\n});\nstream.push({ type: \"toolcall_start\", contentIndex: output.content.length - 1, partial: output });\n\n// Accumulate JSON\nlet partialJson = \"\";\npartialJson += jsonDelta;\ntry {\n  block.arguments = JSON.parse(partialJson);\n} catch {}\nstream.push({ type: \"toolcall_delta\", contentIndex, delta: jsonDelta, partial: output });\n\n// Complete\nstream.push({\n  type: \"toolcall_end\",\n  contentIndex,\n  toolCall: { type: \"toolCall\", id, name, arguments: block.arguments },\n  partial: output\n});\n```\n\n### Usage and Cost\n\nUpdate usage from API response and calculate cost:\n\n```typescript\noutput.usage.input = response.usage.input_tokens;\noutput.usage.output = response.usage.output_tokens;\noutput.usage.cacheRead = response.usage.cache_read_tokens ?? 0;\noutput.usage.cacheWrite = response.usage.cache_write_tokens ?? 0;\noutput.usage.totalTokens = output.usage.input + output.usage.output +\n                           output.usage.cacheRead + output.usage.cacheWrite;\ncalculateCost(model, output.usage);\n```\n\n### Context Overflow Errors\n\nWhen a request exceeds the model's context window, pi can recover automatically by compacting the conversation and retrying. This recovery only kicks in if pi recognizes the failure as an overflow.\n\nDetection runs on the finalized assistant message:\n\n- `stopReason === \"error\"`\n- `errorMessage` matches one of pi's known overflow patterns (see [`packages/ai/src/utils/overflow.ts`](https://github.com/earendil-works/pi-mono/blob/main/packages/ai/src/utils/overflow.ts))\n\nIf your provider returns overflow errors with a message pi does not recognize, normalize the error from the same extension that registers the provider. Use a `message_end` handler to rewrite the assistant message so its `errorMessage` starts with a phrase pi recognizes. The generic fallback `context_length_exceeded` is the safest choice.\n\n```typescript\nconst MY_PROVIDER_OVERFLOW_PATTERN = /your provider's overflow phrase/i;\n\nexport default function (pi: ExtensionAPI) {\n  pi.registerProvider(\"my-provider\", { /* ... */ });\n\n  pi.on(\"message_end\", (event, ctx) => {\n    const message = event.message;\n    if (message.role !== \"assistant\") return;\n    if (message.stopReason !== \"error\") return;\n    if (\n      message.provider !== \"my-provider\" &&\n      ctx.model?.provider !== \"my-provider\"\n    )\n      return;\n\n    const errorMessage = message.errorMessage ?? \"\";\n    if (errorMessage.includes(\"context_length_exceeded\")) return;\n    if (!MY_PROVIDER_OVERFLOW_PATTERN.test(errorMessage)) return;\n\n    return {\n      message: {\n        ...message,\n        errorMessage: `context_length_exceeded: ${errorMessage}`,\n      },\n    };\n  });\n}\n```\n\n`message_end` runs before pi tracks the assistant message for auto-compaction, so the rewritten `errorMessage` is what pi checks. With this in place, pi will:\n\n1. Detect the overflow from `errorMessage`.\n2. Drop the failed assistant message from live context.\n3. Run compaction.\n4. Retry the request once.\n\nGuard the rewrite carefully:\n\n- Scope it to your provider (`message.provider` and `ctx.model?.provider`) so unrelated errors from other providers are untouched.\n- Match a provider-specific pattern, not pi's generic overflow patterns. Rewriting rate-limit or throttling errors (`rate limit`, `too many requests`) would falsely trigger compaction instead of pi's normal retry-with-backoff path.\n- Skip when `errorMessage` already includes `context_length_exceeded` so the handler is idempotent.\n\n### Registration\n\nRegister your stream function:\n\n```typescript\npi.registerProvider(\"my-provider\", {\n  baseUrl: \"https://api.example.com\",\n  apiKey: \"$MY_API_KEY\",\n  api: \"my-custom-api\",\n  models: [...],\n  streamSimple: streamMyProvider\n});\n```\n\n## Testing Your Implementation\n\nTest your provider against the same test suites used by built-in providers. Copy and adapt these test files from [packages/ai/test/](https://github.com/earendil-works/pi-mono/tree/main/packages/ai/test):\n\n| Test | Purpose |\n|------|---------|\n| `stream.test.ts` | Basic streaming, text output |\n| `tokens.test.ts` | Token counting and usage |\n| `abort.test.ts` | AbortSignal handling |\n| `empty.test.ts` | Empty/minimal responses |\n| `context-overflow.test.ts` | Context window limits |\n| `image-limits.test.ts` | Image input handling |\n| `unicode-surrogate.test.ts` | Unicode edge cases |\n| `tool-call-without-result.test.ts` | Tool call edge cases |\n| `image-tool-result.test.ts` | Images in tool results |\n| `total-tokens.test.ts` | Total token calculation |\n| `cross-provider-handoff.test.ts` | Context handoff between providers |\n\nRun tests with your provider/model pairs to verify compatibility.\n\n## Config Reference\n\n```typescript\ninterface ProviderConfig {\n  /** Display name for the provider in UI such as /login. */\n  name?: string;\n\n  /** API endpoint URL. Required when defining models. */\n  baseUrl?: string;\n\n  /** API key literal, env interpolation ($ENV_VAR or ${ENV_VAR}), or !command. Required when defining models (unless oauth). */\n  apiKey?: string;\n\n  /** API type for streaming. Required at provider or model level when defining models. */\n  api?: Api;\n\n  /** Custom streaming implementation for non-standard APIs. */\n  streamSimple?: (\n    model: Model<Api>,\n    context: Context,\n    options?: SimpleStreamOptions\n  ) => AssistantMessageEventStream;\n\n  /** Custom headers to include in requests. Values use the same resolution syntax as apiKey. */\n  headers?: Record<string, string>;\n\n  /** If true, adds Authorization: Bearer header with the resolved API key. */\n  authHeader?: boolean;\n\n  /** Models to register. If provided, replaces all existing models for this provider. */\n  models?: ProviderModelConfig[];\n\n  /** OAuth provider for /login support. */\n  oauth?: {\n    name: string;\n    login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials>;\n    refreshToken(credentials: OAuthCredentials, signal: AbortSignal): Promise<OAuthCredentials>;\n    getApiKey(credentials: OAuthCredentials): string;\n  };\n}\n```\n\n## Model Definition Reference\n\n```typescript\ninterface ProviderModelConfig {\n  /** Model ID (e.g., \"claude-sonnet-4-20250514\"). */\n  id: string;\n\n  /** Display name (e.g., \"Claude 4 Sonnet\"). */\n  name: string;\n\n  /** API type override for this specific model. */\n  api?: Api;\n\n  /** API endpoint URL override for this specific model. */\n  baseUrl?: string;\n\n  /** Whether the model supports extended thinking. */\n  reasoning: boolean;\n\n  /** Maps pi thinking levels to provider/model-specific values; null marks a level unsupported. */\n  thinkingLevelMap?: Partial<Record<\"off\" | \"minimal\" | \"low\" | \"medium\" | \"high\" | \"xhigh\" | \"max\", string | null>>;\n\n  /** Supported input types. */\n  input: (\"text\" | \"image\")[];\n\n  /** Cost per million tokens (for usage tracking). */\n  cost: {\n    input: number;\n    output: number;\n    cacheRead: number;\n    cacheWrite: number;\n  };\n\n  /** Maximum context window size in tokens. */\n  contextWindow: number;\n\n  /** Maximum output tokens. */\n  maxTokens: number;\n\n  /** Custom headers for this specific model. */\n  headers?: Record<string, string>;\n\n  /** Compatibility settings for the selected API. */\n  compat?: {\n    // openai-completions\n    supportsStore?: boolean;\n    supportsDeveloperRole?: boolean;\n    supportsReasoningEffort?: boolean;\n    supportsUsageInStreaming?: boolean;\n    supportsFinishReason?: boolean;\n    supportsStrictMode?: boolean;\n    supportsOpenAIGrammarTools?: boolean; // openai-completions/openai-responses; false falls back to normal function tools\n    maxTokensField?: \"max_completion_tokens\" | \"max_tokens\";\n    requiresToolResultName?: boolean;\n    requiresAssistantAfterToolResult?: boolean;\n    requiresThinkingAsText?: boolean;\n    requiresReasoningContentOnAssistantMessages?: boolean;\n    thinkingFormat?: \"openai\" | \"openrouter\" | \"deepseek\" | \"together\" | \"baseten\" | \"zai\" | \"qwen\" | \"chat-template\" | \"qwen-chat-template\" | \"string-thinking\" | \"ant-ling\";\n    chatTemplateKwargs?: Record<string, string | number | boolean | null | { \"$var\": \"thinking.enabled\" | \"thinking.effort\"; omitWhenOff?: boolean }>;\n    chatTemplateArgs?: Record<string, string | number | boolean | null | { \"$var\": \"thinking.enabled\" | \"thinking.effort\"; omitWhenOff?: boolean }>;\n    cacheControlFormat?: \"anthropic\";\n    sessionAffinityFormat?: \"openai\" | \"openai-nosession\" | \"openrouter\";\n    sendSessionAffinityHeaders?: boolean;\n\n    // anthropic-messages\n    supportsEagerToolInputStreaming?: boolean;\n    supportsLongCacheRetention?: boolean;\n    sendSessionAffinityHeaders?: boolean;\n    supportsCacheControlOnTools?: boolean;\n    forceAdaptiveThinking?: boolean;\n    allowEmptySignature?: boolean;\n    supportsStrictTools?: boolean;\n  };\n}\n```\n\n`openrouter` sends `reasoning: { effort }`. `deepseek` sends `thinking: { type: \"enabled\" | \"disabled\" }` and `reasoning_effort` when enabled. `together` sends `reasoning: { enabled }` and also `reasoning_effort` when `supportsReasoningEffort` is enabled. `qwen` is for DashScope-style top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking` and need `preserve_thinking`. Use `chat-template` for configurable `chat_template_kwargs`, for example DeepSeek V3.x behind vLLM with `chatTemplateKwargs: { \"thinking\": { \"$var\": \"thinking.enabled\" } }`. Use `thinkingFormat: \"baseten\"` with `chatTemplateArgs` when the provider expects toggle values under `chat_template_args` and optionally supports top-level `reasoning_effort`.\n`cacheControlFormat: \"anthropic\"` applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user, assistant, or tool-result text content.","sourceFile":"custom-provider.md"},"development":{"title":"Development","markdown":"See [AGENTS.md](https://github.com/earendil-works/pi-mono/blob/main/AGENTS.md) for additional guidelines.\n\n## Setup\n\n```bash\ngit clone https://github.com/earendil-works/pi-mono\ncd pi-mono\nnpm install\nnpm run build\n```\n\nRun from source:\n\n```bash\n/path/to/pi-mono/pi-test.sh\n```\n\nThe script can be run from any directory. Pi keeps the caller's current working directory.\n\n## Forking / Rebranding\n\nConfigure via `package.json`:\n\n```json\n{\n  \"piConfig\": {\n    \"name\": \"pi\",\n    \"configDir\": \".pi\"\n  }\n}\n```\n\nChange `name`, `configDir`, and `bin` field for your fork. Affects CLI banner, config paths, and environment variable names.\n\n## Path Resolution\n\nThree execution modes: npm install, standalone binary, tsx from source.\n\n**Always use `src/config.ts`** for package assets:\n\n```typescript\nimport { getPackageDir, getThemeDir } from \"./config.js\";\n```\n\nNever use `__dirname` directly for package assets.\n\n## Debug Command\n\n`/debug` (hidden) writes to `~/.pi/agent/pi-debug.log`:\n- Rendered TUI lines with ANSI codes\n- Last messages sent to the LLM\n\n## Testing\n\n```bash\n./test.sh                         # Run non-LLM tests (no API keys needed)\nnpm test                          # Run all tests\nnpm test -- test/specific.test.ts # Run specific test\n```\n\n## Project Structure\n\n```\npackages/\n  ai/           # LLM provider abstraction\n  agent/        # Agent loop and message types  \n  tui/          # Terminal UI components\n  coding-agent/ # CLI and interactive mode\n```","sourceFile":"development.md"},"environment-variables":{"title":"Environment Variables","markdown":"Pi uses environment variables in three ways:\n\n- Variables such as `PI_OFFLINE` configure the Pi process.\n- Pi sets `PI_CODING_AGENT` so child processes can detect that they run inside Pi.\n- Commands run by the LLM-callable bash tool receive `PI_*` variables describing the current session.\n\nProvider API-key variables are documented separately in [Providers](providers.md#environment-variables-or-auth-file).\n\n## Process Marker\n\nThe CLI and RPC entry points set `PI_CODING_AGENT=true`. Child processes inherit it and can use it to detect that they run inside Pi. It is not session-specific and is not set automatically when Pi is embedded through the SDK.\n\n## Bash Tool Session Environment\n\nCommands run by the bash tool receive the current Pi session state:\n\n| Variable | Description |\n|----------|-------------|\n| `PI_SESSION_ID` | Current session ID |\n| `PI_SESSION_FILE` | Absolute path to the current session JSONL file; unset for ephemeral sessions |\n| `PI_PROVIDER` | Currently selected model provider |\n| `PI_MODEL` | Currently selected model ID |\n| `PI_REASONING_LEVEL` | Current effective reasoning level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max` |\n\nThe values are resolved when each command starts. Switching models or changing the reasoning level therefore affects the next bash command without restarting Pi. `PI_PROVIDER` and `PI_MODEL` identify the selected Pi model, not a different upstream model that a router may choose internally.\n\nWhen asked which model or provider is running, inspect these variables instead of inferring the answer from the system prompt:\n\n```bash\nprintf '%s/%s\\n' \"$PI_PROVIDER\" \"$PI_MODEL\"\nprintf 'reasoning=%s session=%s\\n' \"$PI_REASONING_LEVEL\" \"$PI_SESSION_ID\"\n```\n\nThe session file can be inspected directly when the session is persistent:\n\n```bash\nif [ -n \"$PI_SESSION_FILE\" ]; then\n  tail -n 1 \"$PI_SESSION_FILE\"\nfi\n```\n\nThese variables are injected into the LLM-callable bash tool. They are not injected into user-entered `!` or `!!` commands.\n\n### Custom Bash Tools\n\nBash tools created with `createBashTool()` expose the session environment by default when registered with Pi. Injection happens before `spawnHook`, so a hook receives the variables in `ctx.env`:\n\n```typescript\nconst bashTool = createBashTool(cwd, {\n  spawnHook: (ctx) => ({\n    ...ctx,\n    env: { ...ctx.env, CI: \"1\" },\n  }),\n});\n```\n\nDisable session metadata independently of the spawn hook:\n\n```typescript\nconst bashTool = createBashTool(cwd, {\n  exposeSessionEnvironment: false,\n  spawnHook: (ctx) => ctx,\n});\n```\n\nWhen disabled, Pi removes inherited values for these variables so nested Pi processes do not expose stale parent-session metadata.\n\n## Pi Process Configuration\n\nThese variables are read by Pi itself:\n\n| Variable | Description |\n|----------|-------------|\n| `PI_CODING_AGENT_DIR` | Override the config directory; default is `~/.pi/agent` |\n| `PI_CODING_AGENT_SESSION_DIR` | Override session storage; overridden by `--session-dir` |\n| `PI_PACKAGE_DIR` | Override the package directory, useful for Nix/Guix store paths |\n| `PI_OFFLINE` | Disable startup network operations, including update checks, package updates, and install/update telemetry |\n| `PI_SKIP_VERSION_CHECK` | Disable the `pi.dev` latest-version request |\n| `PI_TELEMETRY` | Override install/update telemetry and provider attribution headers: `1`/`true`/`yes` or `0`/`false`/`no` |\n| `PI_CACHE_RETENTION` | Set to `long` for extended provider prompt caching where supported |\n| `PI_SHARE_VIEWER_URL` | Override the base URL used by `/share` |\n| `PI_HARDWARE_CURSOR` | Set to `1` to show the hardware cursor; see [Terminal setup](terminal-setup.md) |\n| `VISUAL`, `EDITOR` | External editor fallback when `externalEditor` is unset |\n| `HTTP_PROXY`, `HTTPS_PROXY` | Proxy outbound HTTP requests |\n\nProvider credentials such as `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and cloud-provider configuration are listed in [Providers](providers.md#environment-variables-or-auth-file).","sourceFile":"environment-variables.md"},"extensions":{"title":"Extensions","markdown":"> pi can create extensions. Ask it to build one for your use case.\n\n\nExtensions are TypeScript modules that extend pi's behavior. They can subscribe to lifecycle events, register custom tools callable by the LLM, add commands, and more.\n\n> **Placement for /reload:** Put extensions in `~/.pi/agent/extensions/` (global) or `.pi/extensions/` (project-local) for auto-discovery. Use `pi -e ./path.ts` only for quick tests. Extensions in auto-discovered locations can be hot-reloaded with `/reload`.\n\n**Key capabilities:**\n- **Custom tools** - Register tools the LLM can call via `pi.registerTool()`\n- **Event interception** - Block or modify tool calls, inject context, customize compaction\n- **User interaction** - Prompt users via `ctx.ui` (select, confirm, input, notify)\n- **Custom UI components** - Full TUI components with keyboard input via `ctx.ui.custom()` for complex interactions\n- **Custom commands** - Register commands like `/mycommand` via `pi.registerCommand()`\n- **Session persistence** - Store state that survives restarts via `pi.appendEntry()`\n- **Custom rendering** - Control how tool calls/results and messages appear in TUI\n\n**Example use cases:**\n- Permission gates (confirm before `rm -rf`, `sudo`, etc.)\n- Git checkpointing (stash at each turn, restore on branch)\n- Path protection (block writes to `.env`, `node_modules/`)\n- Custom compaction (summarize conversation your way)\n- Conversation summaries (see `summarize.ts` example)\n- Interactive tools (questions, wizards, custom dialogs)\n- Stateful tools (todo lists, connection pools)\n- External integrations (file watchers, webhooks, CI triggers)\n- Games while you wait (see `snake.ts` example)\n\nSee [examples/extensions/](../examples/extensions/) for working implementations.\n\n## Table of Contents\n\n- [Quick Start](#quick-start)\n- [Extension Locations](#extension-locations)\n- [Available Imports](#available-imports)\n- [Writing an Extension](#writing-an-extension)\n  - [Extension Styles](#extension-styles)\n- [Events](#events)\n  - [Lifecycle Overview](#lifecycle-overview)\n  - [Resource Events](#resource-events)\n  - [Session Events](#session-events)\n  - [Agent Events](#agent-events)\n  - [Model Events](#model-events)\n  - [Tool Events](#tool-events)\n- [ExtensionContext](#extensioncontext)\n- [ExtensionCommandContext](#extensioncommandcontext)\n- [ExtensionAPI Methods](#extensionapi-methods)\n- [State Management](#state-management)\n- [Custom Tools](#custom-tools)\n  - [Dynamic Tool Loading](#dynamic-tool-loading)\n- [Custom UI](#custom-ui)\n- [Error Handling](#error-handling)\n- [Mode Behavior](#mode-behavior)\n- [Examples Reference](#examples-reference)\n\n## Quick Start\n\nCreate `~/.pi/agent/extensions/my-extension.ts`:\n\n```typescript\nimport type { ExtensionAPI } from \"@earendil-works/pi-coding-agent\";\nimport { Type } from \"typebox\";\n\nexport default function (pi: ExtensionAPI) {\n  // React to events\n  pi.on(\"session_start\", async (_event, ctx) => {\n    ctx.ui.notify(\"Extension loaded!\", \"info\");\n  });\n\n  pi.on(\"tool_call\", async (event, ctx) => {\n    if (event.toolName === \"bash\" && event.input.command?.includes(\"rm -rf\")) {\n      const ok = await ctx.ui.confirm(\"Dangerous!\", \"Allow rm -rf?\");\n      if (!ok) return { block: true, reason: \"Blocked by user\" };\n    }\n  });\n\n  // Register a custom tool\n  pi.registerTool({\n    name: \"greet\",\n    label: \"Greet\",\n    description: \"Greet someone by name\",\n    parameters: Type.Object({\n      name: Type.String({ description: \"Name to greet\" }),\n    }),\n    async execute(toolCallId, params, signal, onUpdate, ctx) {\n      return {\n        content: [{ type: \"text\", text: `Hello, ${params.name}!` }],\n        details: {},\n      };\n    },\n  });\n\n  // Register a command\n  pi.registerCommand(\"hello\", {\n    description: \"Say hello\",\n    handler: async (args, ctx) => {\n      ctx.ui.notify(`Hello ${args || \"world\"}!`, \"info\");\n    },\n  });\n}\n```\n\nTest with `--extension` (or `-e`) flag:\n\n```bash\npi -e ./my-extension.ts\n```\n\n## Extension Locations\n\n> **Security:** Extensions run with your full system permissions and can execute arbitrary code. Only install from sources you trust.\n\nExtensions are auto-discovered from trusted locations. Project-local `.pi/extensions` entries load only after the project is trusted.\n\n| Location | Scope |\n|----------|-------|\n| `~/.pi/agent/extensions/*.ts` | Global (all projects) |\n| `~/.pi/agent/extensions/*/index.ts` | Global (subdirectory) |\n| `.pi/extensions/*.ts` | Project-local |\n| `.pi/extensions/*/index.ts` | Project-local (subdirectory) |\n\nAdditional paths via `settings.json`:\n\n```json\n{\n  \"packages\": [\n    \"npm:@foo/bar@1.0.0\",\n    \"git:github.com/user/repo@v1\"\n  ],\n  \"extensions\": [\n    \"/path/to/local/extension.ts\",\n    \"/path/to/local/extension/dir\"\n  ]\n}\n```\n\nTo share extensions via npm or git as pi packages, see [packages.md](packages.md).\n\n## Available Imports\n\n| Package | Purpose |\n|---------|---------|\n| `@earendil-works/pi-coding-agent` | Extension types (`ExtensionAPI`, `ExtensionContext`, events) |\n| `typebox` | Schema definitions for tool parameters |\n| `@earendil-works/pi-ai` | AI utilities (`StringEnum` for Google-compatible enums) |\n| `@earendil-works/pi-tui` | TUI components for custom rendering |\n\nnpm dependencies work too. Add a `package.json` next to your extension (or in a parent directory), run `npm install`, and imports from `node_modules/` are resolved automatically.\n\nFor distributed pi packages installed with `pi install` (npm or git), runtime deps must be in `dependencies`. Package installation uses production installs (`npm install --omit=dev`) by default, so `devDependencies` are not available at runtime; when `npmCommand` is configured, git packages use plain `install` for compatibility with wrappers.\n\nNode.js built-ins (`node:fs`, `node:path`, etc.) are also available.\n\n## Writing an Extension\n\nAn extension exports a default factory function that receives `ExtensionAPI`. The factory can be synchronous or asynchronous:\n\n```typescript\nimport type { ExtensionAPI } from \"@earendil-works/pi-coding-agent\";\n\nexport default function (pi: ExtensionAPI) {\n  // Subscribe to events\n  pi.on(\"event_name\", async (event, ctx) => {\n    // ctx.ui for user interaction\n    const ok = await ctx.ui.confirm(\"Title\", \"Are you sure?\");\n    ctx.ui.notify(\"Done!\", \"info\");\n    ctx.ui.setStatus(\"my-ext\", \"Processing...\");  // Footer status\n    ctx.ui.setWidget(\"my-ext\", [\"Line 1\", \"Line 2\"]);  // Widget above editor (default)\n  });\n\n  // Register tools, commands, shortcuts, flags\n  pi.registerTool({ ... });\n  pi.registerCommand(\"name\", { ... });\n  pi.registerShortcut(\"ctrl+x\", { ... });\n  pi.registerFlag(\"my-flag\", { ... });\n}\n```\n\nExtensions are loaded via [jiti](https://github.com/unjs/jiti), so TypeScript works without compilation.\n\nIf the factory returns a `Promise`, pi awaits it before continuing startup. That means async initialization completes before `session_start`, before `resources_discover`, and before provider registrations queued via `pi.registerProvider()` are flushed.\n\n### Async factory functions\n\nUse an async factory for one-time startup work such as fetching remote configuration or dynamically discovering available models.\n\n```typescript\nimport type { ExtensionAPI } from \"@earendil-works/pi-coding-agent\";\n\nexport default async function (pi: ExtensionAPI) {\n  const response = await fetch(\"http://localhost:1234/v1/models\");\n  const payload = (await response.json()) as {\n    data: Array<{\n      id: string;\n      name?: string;\n      context_window?: number;\n      max_tokens?: number;\n    }>;\n  };\n\n  pi.registerProvider(\"local-openai\", {\n    baseUrl: \"http://localhost:1234/v1\",\n    apiKey: \"$LOCAL_OPENAI_API_KEY\",\n    api: \"openai-completions\",\n    models: payload.data.map((model) => ({\n      id: model.id,\n      name: model.name ?? model.id,\n      reasoning: false,\n      input: [\"text\"],\n      cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n      contextWindow: model.context_window ?? 128000,\n      maxTokens: model.max_tokens ?? 4096,\n    })),\n  });\n}\n```\n\nThis pattern makes the fetched models available during normal startup and to `pi --list-models`.\n\n### Long-lived resources and shutdown\n\nExtension factories may run in invocations that never start a session. Do not start background resources such as processes, sockets, file watchers, or timers from the factory.\n\nDefer background resource startup until `session_start` or the command/tool/event that needs the resource. Register an idempotent `session_shutdown` handler to close any session-scoped resources you start.\n\n### Extension Styles\n\n**Single file** - simplest, for small extensions:\n\n```\n~/.pi/agent/extensions/\n└── my-extension.ts\n```\n\n**Directory with index.ts** - for multi-file extensions:\n\n```\n~/.pi/agent/extensions/\n└── my-extension/\n    ├── index.ts        # Entry point (exports default function)\n    ├── tools.ts        # Helper module\n    └── utils.ts        # Helper module\n```\n\n**Package with dependencies** - for extensions that need npm packages:\n\n```\n~/.pi/agent/extensions/\n└── my-extension/\n    ├── package.json    # Declares dependencies and entry points\n    ├── package-lock.json\n    ├── node_modules/   # After npm install\n    └── src/\n        └── index.ts\n```\n\n```json\n// package.json\n{\n  \"name\": \"my-extension\",\n  \"dependencies\": {\n    \"zod\": \"^3.0.0\",\n    \"chalk\": \"^5.0.0\"\n  },\n  \"pi\": {\n    \"extensions\": [\"./src/index.ts\"]\n  }\n}\n```\n\nRun `npm install` in the extension directory, then imports from `node_modules/` work automatically.\n\n## Events\n\n### Lifecycle Overview\n\n```\npi starts\n  │\n  ├─► project_trust (user/global and CLI extensions only, before project resources load)\n  ├─► session_start { reason: \"startup\" }\n  └─► resources_discover { reason: \"startup\" }\n      │\n      ▼\nuser sends prompt ─────────────────────────────────────────┐\n  │                                                        │\n  ├─► (extension commands checked first, bypass if found)  │\n  ├─► input (can intercept, transform, or handle)          │\n  ├─► (skill/template expansion if not handled)            │\n  ├─► before_agent_start (can inject message, modify system prompt)\n  ├─► agent_start                                          │\n  ├─► message_start / message_update / message_end         │\n  │                                                        │\n  │   ┌─── turn (repeats while LLM calls tools) ───┐       │\n  │   │                                            │       │\n  │   ├─► turn_start                               │       │\n  │   ├─► context (can modify messages)            │       │\n  │   ├─► before_provider_headers (can mutate headers)     |\n  │   ├─► before_provider_request (can inspect or replace payload)\n  │   ├─► after_provider_response (status + headers, before stream consume)\n  │   │                                            │       │\n  │   │   LLM responds, may call tools:            │       │\n  │   │     ├─► tool_execution_start               │       │\n  │   │     ├─► tool_call (can block)              │       │\n  │   │     ├─► tool_execution_update              │       │\n  │   │     ├─► tool_result (can modify)           │       │\n  │   │     └─► tool_execution_end                 │       │\n  │   │                                            │       │\n  │   └─► turn_end                                 │       │\n  │                                                        │\n  ├─► agent_end                                            │\n  └─► agent_settled (no retry/compaction/follow-up left)   │\n                                                           │\nuser sends another prompt ◄────────────────────────────────┘\n\n/new (new session) or /resume (switch session)\n  ├─► session_before_switch (can cancel)\n  ├─► session_shutdown\n  ├─► session_start { reason: \"new\" | \"resume\", previousSessionFile? }\n  └─► resources_discover { reason: \"startup\" }\n\n/fork or /clone\n  ├─► session_before_fork (can cancel)\n  ├─► session_shutdown\n  ├─► session_start { reason: \"fork\", previousSessionFile }\n  └─► resources_discover { reason: \"startup\" }\n\n/name or pi.setSessionName()\n  └─► session_info_changed\n\n/compact or auto-compaction\n  ├─► session_before_compact (can cancel or customize)\n  └─► session_compact\n\n/tree navigation\n  ├─► session_before_tree (can cancel or customize)\n  └─► session_tree\n\n/model or Ctrl+P (model selection/cycling)\n  ├─► thinking_level_select (if model change changes/clamps thinking level)\n  └─► model_select\n\nthinking level changes (settings, keybinding, pi.setThinkingLevel())\n  └─► thinking_level_select\n\nexit (Ctrl+C, Ctrl+D, SIGHUP, SIGTERM)\n  └─► session_shutdown\n```\n\n### Startup Events\n\n#### project_trust\n\nFired before pi decides whether to trust a project with dynamic configs (`.pi` or `.agents/skills`). It runs during startup and when session replacement (for example `/resume`) enters a cwd whose trust has not been resolved in the current process. Only user/global extensions and CLI `-e` extensions participate; project-local extensions are not loaded until after trust is resolved.\n\n```typescript\npi.on(\"project_trust\", async (event, ctx) => {\n  // event.cwd - current working directory\n  // ctx has a limited trust context: cwd, mode, hasUI, and select/confirm/input/notify UI helpers\n  if (await ctx.ui.confirm(\"Trust project?\", event.cwd)) {\n    return { trusted: \"yes\", remember: true };\n  }\n  return { trusted: \"undecided\" };\n});\n```\n\nA `project_trust` handler must return `{ trusted: \"yes\" | \"no\" | \"undecided\" }`. A user/global or CLI extension that returns `\"yes\"` or `\"no\"` owns the decision; the first yes/no decision wins and suppresses the built-in trust prompt. Use `remember: true` to persist a yes/no decision; otherwise it applies only to the current process. Return `\"undecided\"` to let later handlers or the built-in trust flow decide. Check `ctx.hasUI` before prompting. If no handler returns yes/no, normal trust resolution continues: saved `trust.json` decisions apply first, then `defaultProjectTrust` controls whether pi asks, trusts, or declines by default.\n\n### Resource Events\n\n#### resources_discover\n\nFired after `session_start` so extensions can contribute additional skill, prompt, and theme paths.\nThe startup path uses `reason: \"startup\"`. Reload uses `reason: \"reload\"`.\n\n```typescript\npi.on(\"resources_discover\", async (event, _ctx) => {\n  // event.cwd - current working directory\n  // event.reason - \"startup\" | \"reload\"\n  return {\n    skillPaths: [\"/path/to/skills\"],\n    promptPaths: [\"/path/to/prompts\"],\n    themePaths: [\"/path/to/themes\"],\n  };\n});\n```\n\n### Session Events\n\nSee [Session Format](session-format.md) for session storage internals and the SessionManager API.\n\n#### session_start\n\nFired when a session is started, loaded, or reloaded.\n\n```typescript\npi.on(\"session_start\", async (event, ctx) => {\n  // event.reason - \"startup\" | \"reload\" | \"new\" | \"resume\" | \"fork\"\n  // event.previousSessionFile - present for \"new\", \"resume\", and \"fork\"\n  ctx.ui.notify(`Session: ${ctx.sessionManager.getSessionFile() ?? \"ephemeral\"}`, \"info\");\n});\n```\n\n#### session_info_changed\n\nFired when the current session display name is set via `/name`, RPC, or `pi.setSessionName()`.\n\n```typescript\npi.on(\"session_info_changed\", async (event, ctx) => {\n  // event.name - current normalized name, or undefined if cleared\n  ctx.ui.notify(`Session renamed: ${event.name ?? \"(none)\"}`, \"info\");\n});\n```\n\n#### session_before_switch\n\nFired before starting a new session (`/new`) or switching sessions (`/resume`).\n\n```typescript\npi.on(\"session_before_switch\", async (event, ctx) => {\n  // event.reason - \"new\" or \"resume\"\n  // event.targetSessionFile - session we're switching to (only for \"resume\")\n\n  if (event.reason === \"new\") {\n    const ok = await ctx.ui.confirm(\"Clear?\", \"Delete all messages?\");\n    if (!ok) return { cancel: true };\n  }\n});\n```\n\nAfter a successful switch or new-session action, pi emits `session_shutdown` for the old extension instance, reloads and rebinds extensions for the new session, then emits `session_start` with `reason: \"new\" | \"resume\"` and `previousSessionFile`.\nDo cleanup work in `session_shutdown`, then reestablish any in-memory state in `session_start`.\n\n#### session_before_fork\n\nFired when forking via `/fork` or cloning via `/clone`.\n\n```typescript\npi.on(\"session_before_fork\", async (event, ctx) => {\n  // event.entryId - ID of the selected entry\n  // event.position - \"before\" for /fork, \"at\" for /clone\n  return { cancel: true }; // Cancel fork/clone\n  // OR\n  return { skipConversationRestore: true }; // Reserved for future conversation restore control\n});\n```\n\nAfter a successful fork or clone, pi emits `session_shutdown` for the old extension instance, reloads and rebinds extensions for the new session, then emits `session_start` with `reason: \"fork\"` and `previousSessionFile`.\nDo cleanup work in `session_shutdown`, then reestablish any in-memory state in `session_start`.\n\n#### session_before_compact / session_compact\n\nFired on compaction. See [compaction.md](compaction.md) for details.\n\n```typescript\npi.on(\"session_before_compact\", async (event, ctx) => {\n  const { preparation, branchEntries, customInstructions, reason, willRetry, signal } = event;\n\n  // reason - \"manual\" (/compact), \"threshold\", or \"overflow\"\n  // willRetry - whether the aborted turn is retried after compaction (overflow recovery)\n\n  // Cancel:\n  return { cancel: true };\n\n  // Custom summary:\n  return {\n    compaction: {\n      summary: \"...\",\n      firstKeptEntryId: preparation.firstKeptEntryId,\n      tokensBefore: preparation.tokensBefore,\n      // usage: summaryResponse.usage, // Optional; included in session totals\n    }\n  };\n});\n\npi.on(\"session_compact\", async (event, ctx) => {\n  // event.compactionEntry - the saved compaction\n  // event.fromExtension - whether extension provided it\n  // event.reason - \"manual\" (/compact), \"threshold\", or \"overflow\"\n  // event.willRetry - whether the aborted turn is retried after compaction (overflow recovery)\n});\n```\n\n#### session_before_tree / session_tree\n\nFired on `/tree` navigation. See [Sessions](sessions.md) for tree navigation concepts.\n\n```typescript\npi.on(\"session_before_tree\", async (event, ctx) => {\n  const { preparation, signal } = event;\n  return { cancel: true };\n  // OR provide custom summary:\n  return {\n    summary: {\n      summary: \"...\",\n      // usage: summaryResponse.usage, // Optional; included in session totals\n      details: {},\n    },\n  };\n});\n\npi.on(\"session_tree\", async (event, ctx) => {\n  // event.newLeafId, oldLeafId, summaryEntry, fromExtension\n});\n```\n\n#### session_shutdown\n\nFired before a started session runtime is torn down. Use this to clean up resources opened from `session_start` or other session-scoped hooks.\n\n```typescript\npi.on(\"session_shutdown\", async (event, ctx) => {\n  // event.reason - \"quit\" | \"reload\" | \"new\" | \"resume\" | \"fork\"\n  // event.targetSessionFile - destination session for session replacement flows\n  // Cleanup, save state, etc.\n});\n```\n\n### Agent Events\n\n#### before_agent_start\n\nFired after user submits prompt, before agent loop. Can inject a message and/or modify the system prompt.\n\n```typescript\npi.on(\"before_agent_start\", async (event, ctx) => {\n  // event.prompt - user's prompt text\n  // event.images - attached images (if any)\n  // event.systemPrompt - current chained system prompt for this handler\n  //   (includes changes from earlier before_agent_start handlers)\n  // event.systemPromptOptions - structured options used to build the system prompt\n  //   .customPrompt - any custom system prompt (from --system-prompt, SYSTEM.md, or custom templates)\n  //   .selectedTools - tools currently active in the prompt\n  //   .toolSnippets - one-line descriptions for each tool\n  //   .promptGuidelines - custom guideline bullets\n  //   .appendSystemPrompt - text from --append-system-prompt flags\n  //   .cwd - working directory\n  //   .contextFiles - AGENTS.md files and other loaded context files\n  //   .skills - loaded skills\n\n  return {\n    // Inject a persistent message (stored in session, sent to LLM)\n    message: {\n      customType: \"my-extension\",\n      content: \"Additional context for the LLM\",\n      display: true,\n    },\n    // Replace the system prompt for this turn (chained across extensions)\n    systemPrompt: event.systemPrompt + \"\\n\\nExtra instructions for this turn...\",\n  };\n});\n```\n\nThe `systemPromptOptions` field gives extensions access to the same structured data Pi uses to build the system prompt. This lets you inspect what Pi has loaded — custom prompts, guidelines, tool snippets, context files, skills — without re-discovering resources or re-parsing flags. Use it when your extension needs to make deep, informed changes to the system prompt while respecting user-provided configuration.\n\nInside `before_agent_start`, `event.systemPrompt` and `ctx.getSystemPrompt()` both reflect the chained system prompt as of the current handler. Later `before_agent_start` handlers can still modify it again.\n\n#### agent_start / agent_end / agent_settled\n\n`agent_start` fires when a low-level agent run begins. `agent_end` fires when that run ends, but Pi may still auto-retry, auto-compact and retry, or continue with queued follow-up messages. Use `agent_settled` for status integrations that need to know Pi will not continue running automatically.\n\n```typescript\npi.on(\"agent_start\", async (_event, ctx) => {});\n\npi.on(\"agent_end\", async (event, ctx) => {\n  // event.messages - messages from this low-level run\n});\n\npi.on(\"agent_settled\", async (_event, ctx) => {\n  // ctx.isIdle() is true here unless another extension started a new run.\n});\n```\n\n#### turn_start / turn_end\n\nFired for each turn (one LLM response + tool calls).\n\n```typescript\npi.on(\"turn_start\", async (event, ctx) => {\n  // event.turnIndex, event.timestamp\n});\n\npi.on(\"turn_end\", async (event, ctx) => {\n  // event.turnIndex, event.message, event.toolResults\n});\n```\n\n#### message_start / message_update / message_end\n\nFired for message lifecycle updates.\n\n- `message_start` and `message_end` fire for user, assistant, and toolResult messages.\n- `message_update` fires for assistant streaming updates.\n- `message_end` handlers can return `{ message }` to replace the finalized message. The replacement must keep the same `role`.\n\n```typescript\npi.on(\"message_start\", async (event, ctx) => {\n  // event.message\n});\n\npi.on(\"message_update\", async (event, ctx) => {\n  // event.message\n  // event.assistantMessageEvent (token-by-token stream event)\n});\n\npi.on(\"message_end\", async (event, ctx) => {\n  if (event.message.role !== \"assistant\") return;\n\n  return {\n    message: {\n      ...event.message,\n      usage: {\n        ...event.message.usage,\n        cost: {\n          ...event.message.usage.cost,\n          total: 0.123,\n        },\n      },\n    },\n  };\n});\n```\n\n#### tool_execution_start / tool_execution_update / tool_execution_end\n\nFired for tool execution lifecycle updates.\n\nIn parallel tool mode:\n- `tool_execution_start` is emitted in assistant source order during the preflight phase\n- `tool_execution_update` events may interleave across tools\n- `tool_execution_end` is emitted in tool completion order after each tool is finalized\n- final `toolResult` message events are still emitted later in assistant source order\n\n```typescript\npi.on(\"tool_execution_start\", async (event, ctx) => {\n  // event.toolCallId, event.toolName, event.args\n});\n\npi.on(\"tool_execution_update\", async (event, ctx) => {\n  // event.toolCallId, event.toolName, event.args, event.partialResult\n});\n\npi.on(\"tool_execution_end\", async (event, ctx) => {\n  // event.toolCallId, event.toolName, event.result, event.isError\n});\n```\n\n#### context\n\nFired before each LLM call. Modify messages non-destructively. See [Session Format](session-format.md) for message types.\n\n```typescript\npi.on(\"context\", async (event, ctx) => {\n  // event.messages - deep copy, safe to modify\n  const filtered = event.messages.filter(m => !shouldPrune(m));\n  return { messages: filtered };\n});\n```\n\n#### before_provider_headers\n\nFired after the outgoing HTTP headers are assembled. Use it to add, override, or remove request headers.\n\nHandlers mutate `event.headers` in place. Set a key to a string to add or override it, or to `null` to delete it.\n\n```typescript\npi.on(\"before_provider_headers\", (event, ctx) => {\n  // Add or override — e.g. a session id for gateway tracing/attribution\n  event.headers[\"x-session-id\"] = ctx.sessionManager.getSessionId();\n\n  // Drop a tracking header pi adds for this call\n  event.headers[\"X-OpenRouter-Title\"] = null;\n});\n```\n\nRuns once per provider request; retries reuse the same headers rather than re-firing the hook.\n\n#### before_provider_request\n\nFired after the provider-specific payload is built, right before the request is sent. Handlers run in extension load order. Returning `undefined` keeps the payload unchanged. Returning any other value replaces the payload for later handlers and for the actual request.\n\nThis hook can rewrite provider-level system instructions or remove them entirely. Those payload-level changes are not reflected by `ctx.getSystemPrompt()`, which reports Pi's system prompt string rather than the final serialized provider payload.\n\n```typescript\npi.on(\"before_provider_request\", (event, ctx) => {\n  console.log(JSON.stringify(event.payload, null, 2));\n\n  // Optional: replace payload\n  // return { ...event.payload, temperature: 0 };\n});\n```\n\nThis is mainly useful for debugging provider serialization and cache behavior.\n\n#### after_provider_response\n\nFired after an HTTP response is received and before its stream body is consumed. Handlers run in extension load order.\n\n```typescript\npi.on(\"after_provider_response\", (event, ctx) => {\n  // event.status - HTTP status code\n  // event.headers - normalized response headers\n  if (event.status === 429) {\n    console.log(\"rate limited\", event.headers[\"retry-after\"]);\n  }\n});\n```\n\nHeader availability depends on provider and transport. Providers that abstract HTTP responses may not expose headers.\n\n### Model Events\n\n#### model_select\n\nFired when the model changes via `/model` command, model cycling (`Ctrl+P`), or session restore.\n\n```typescript\npi.on(\"model_select\", async (event, ctx) => {\n  // event.model - newly selected model\n  // event.previousModel - previous model (undefined if first selection)\n  // event.source - \"set\" | \"cycle\" | \"restore\"\n\n  const prev = event.previousModel\n    ? `${event.previousModel.provider}/${event.previousModel.id}`\n    : \"none\";\n  const next = `${event.model.provider}/${event.model.id}`;\n\n  ctx.ui.notify(`Model changed (${event.source}): ${prev} -> ${next}`, \"info\");\n});\n```\n\nUse this to update UI elements (status bars, footers) or perform model-specific initialization when the active model changes.\n\n#### thinking_level_select\n\nFired when the thinking level changes. This is notification-only; handler return values are ignored.\n\n```typescript\npi.on(\"thinking_level_select\", async (event, ctx) => {\n  // event.level - newly selected thinking level\n  // event.previousLevel - previous thinking level\n\n  ctx.ui.setStatus(\"thinking\", `thinking: ${event.level}`);\n});\n```\n\nUse this to update extension UI when `pi.setThinkingLevel()`, model changes, or built-in thinking-level controls change the active thinking level.\n\n### Tool Events\n\n#### tool_call\n\nFired after `tool_execution_start`, before the tool executes. **Can block.** Use `isToolCallEventType` to narrow and get typed inputs.\n\nBefore `tool_call` runs, pi waits for previously emitted Agent events to finish draining through `AgentSession`. This means `ctx.sessionManager` is up to date through the current assistant tool-calling message.\n\nIn the default parallel tool execution mode, sibling tool calls from the same assistant message are preflighted sequentially, then executed concurrently. `tool_call` is not guaranteed to see sibling tool results from that same assistant message in `ctx.sessionManager`.\n\n`event.input` is mutable. Mutate it in place to patch tool arguments before execution.\n\nBehavior guarantees:\n- Mutations to `event.input` affect the actual tool execution\n- Later `tool_call` handlers see mutations made by earlier handlers\n- No re-validation is performed after your mutation\n- Return values from `tool_call` control blocking via `{ block: true, reason?: string, terminate?: boolean }`\n- `terminate` only applies to a blocked call; the agent stops early only when every finalized result in the batch is terminating\n\n```typescript\nimport { isToolCallEventType } from \"@earendil-works/pi-coding-agent\";\n\npi.on(\"tool_call\", async (event, ctx) => {\n  // event.toolName - \"bash\", \"read\", \"write\", \"edit\", etc.\n  // event.toolCallId\n  // event.input - tool parameters (mutable)\n\n  // Built-in tools: no type params needed\n  if (isToolCallEventType(\"bash\", event)) {\n    // event.input is { command: string; timeout?: number }\n    event.input.command = `source ~/.profile\\n${event.input.command}`;\n\n    if (event.input.command.includes(\"rm -rf\")) {\n      return { block: true, reason: \"Dangerous command\", terminate: true };\n    }\n  }\n\n  if (isToolCallEventType(\"read\", event)) {\n    // event.input is { path: string; offset?: number; limit?: number }\n    console.log(`Reading: ${event.input.path}`);\n  }\n});\n```\n\n#### Typing custom tool input\n\nCustom tools should export their input type:\n\n```typescript\n// my-extension.ts\nexport type MyToolInput = Static<typeof myToolSchema>;\n```\n\nUse `isToolCallEventType` with explicit type parameters:\n\n```typescript\nimport { isToolCallEventType } from \"@earendil-works/pi-coding-agent\";\nimport type { MyToolInput } from \"my-extension\";\n\npi.on(\"tool_call\", (event) => {\n  if (isToolCallEventType<\"my_tool\", MyToolInput>(\"my_tool\", event)) {\n    event.input.action;  // typed\n  }\n});\n```\n\n#### tool_result\n\nFired after tool execution finishes and before `tool_execution_end` plus the final tool result message events are emitted. **Can modify result.**\n\nIn parallel tool mode, `tool_result` and `tool_execution_end` may interleave in tool completion order, while final `toolResult` message events are still emitted later in assistant source order.\n\n`tool_result` handlers chain like middleware:\n- Handlers run in extension load order\n- Each handler sees the latest result after previous handler changes\n- Handlers can return partial patches (`content`, `details`, `isError`, or `usage`); omitted fields keep their current values\n\nUse `ctx.signal` for nested async work inside the handler. This lets Esc cancel model calls, `fetch()`, and other abort-aware operations started by the extension.\n\n```typescript\nimport { isBashToolResult } from \"@earendil-works/pi-coding-agent\";\n\npi.on(\"tool_result\", async (event, ctx) => {\n  // event.toolName, event.toolCallId, event.input\n  // event.content, event.details, event.isError, event.usage\n\n  if (isBashToolResult(event)) {\n    // event.details is typed as BashToolDetails\n  }\n\n  const response = await fetch(\"https://example.com/summarize\", {\n    method: \"POST\",\n    body: JSON.stringify({ content: event.content }),\n    signal: ctx.signal,\n  });\n\n  // Modify result:\n  return { content: [...], details: {...}, isError: false, usage: nestedModelUsage };\n});\n```\n\n### User Bash Events\n\n#### user_bash\n\nFired when user executes `!` or `!!` commands. **Can intercept.**\n\n```typescript\nimport { createLocalBashOperations } from \"@earendil-works/pi-coding-agent\";\n\npi.on(\"user_bash\", (event, ctx) => {\n  // event.command - the bash command\n  // event.excludeFromContext - true if !! prefix\n  // event.cwd - working directory\n\n  // Option 1: Provide custom operations (e.g., SSH)\n  return { operations: remoteBashOps };\n\n  // Option 2: Wrap pi's built-in local bash backend\n  const local = createLocalBashOperations();\n  return {\n    operations: {\n      exec(command, cwd, options) {\n        return local.exec(`source ~/.profile\\n${command}`, cwd, options);\n      }\n    }\n  };\n\n  // Option 3: Full replacement - return result directly\n  return { result: { output: \"...\", exitCode: 0, cancelled: false, truncated: false } };\n});\n```\n\n### Input Events\n\n#### input\n\nFired when user input is received, after extension commands are checked but before skill and template expansion. The event sees the raw input text, so `/skill:foo` and `/template` are not yet expanded.\n\n**Processing order:**\n1. Extension commands (`/cmd`) checked first - if found, handler runs and input event is skipped\n2. `input` event fires - can intercept, transform, or handle\n3. If not handled: skill commands (`/skill:name`) expanded to skill content\n4. If not handled: prompt templates (`/template`) expanded to template content\n5. Agent processing begins (`before_agent_start`, etc.)\n\n```typescript\npi.on(\"input\", async (event, ctx) => {\n  // event.text - raw input (before skill/template expansion)\n  // event.images - attached images, if any\n  // event.source - \"interactive\" (typed), \"rpc\" (API), or \"extension\" (via sendUserMessage)\n  // event.streamingBehavior - \"steer\" | \"followUp\" | undefined\n  //   undefined when idle, \"steer\" for mid-stream interrupts,\n  //   \"followUp\" for messages queued until the agent finishes\n\n  // Transform: rewrite input before expansion\n  if (event.text.startsWith(\"?quick \"))\n    return { action: \"transform\", text: `Respond briefly: ${event.text.slice(7)}` };\n\n  // Handle: respond without LLM (extension shows its own feedback)\n  if (event.text === \"ping\") {\n    ctx.ui.notify(\"pong\", \"info\");\n    return { action: \"handled\" };\n  }\n\n  // Route by source: skip processing for extension-injected messages\n  if (event.source === \"extension\") return { action: \"continue\" };\n\n  // Intercept skill commands before expansion\n  if (event.text.startsWith(\"/skill:\")) {\n    // Could transform, block, or let pass through\n  }\n\n  return { action: \"continue\" };  // Default: pass through to expansion\n});\n```\n\n**Results:**\n- `continue` - pass through unchanged (default if handler returns nothing)\n- `transform` - modify text/images, then continue to expansion\n- `handled` - skip agent entirely (first handler to return this wins)\n\nTransforms chain across handlers. See [input-transform.ts](../examples/extensions/input-transform.ts) and [input-transform-streaming.ts](../examples/extensions/input-transform-streaming.ts) for `streamingBehavior`-aware routing.\n\n## ExtensionContext\n\nAll handlers receive `ctx: ExtensionContext`.\n\n### ctx.ui\n\nUI methods for user interaction. See [Custom UI](#custom-ui) for full details.\n\n### ctx.mode\n\nCurrent run mode: `\"tui\"`, `\"rpc\"`, `\"json\"`, or `\"print\"`. Use `ctx.mode === \"tui\"` to guard terminal-only features such as `custom()`, component factories, terminal input, and direct TUI rendering.\n\n### ctx.hasUI\n\n`true` in TUI and RPC modes. `false` in print mode (`-p`) and JSON mode. Use this to guard dialog methods (`select`, `confirm`, `input`, `editor`) and fire-and-forget methods (`notify`, `setStatus`, `setWidget`, `setTitle`, `setEditorText`) that work in both TUI and RPC modes. In RPC mode, some TUI-specific methods are no-ops or return defaults (see [rpc.md](rpc.md#extension-ui-protocol)).\n\n### ctx.cwd\n\nCurrent working directory.\n\nUse `CONFIG_DIR_NAME` instead of hardcoding `.pi` when constructing project-local config paths. Rebranded distributions can use a different config directory name.\n\n```typescript\nimport { CONFIG_DIR_NAME, type ExtensionAPI } from \"@earendil-works/pi-coding-agent\";\nimport { join } from \"node:path\";\n\nexport default function (pi: ExtensionAPI) {\n  pi.on(\"session_start\", (_event, ctx) => {\n    const projectConfigPath = join(ctx.cwd, CONFIG_DIR_NAME, \"my-extension.json\");\n    // ...\n  });\n}\n```\n\n### ctx.isProjectTrusted()\n\nReturns whether project-local trust is active for the current session context. This includes temporary trust decisions and CLI trust overrides, not just saved decisions in the global trust store.\n\nUse this before reading project-local extension configuration that should only be honored for trusted projects.\n\n### ctx.sessionManager\n\nRead-only access to session state. See [Session Format](session-format.md) for the full SessionManager API and entry types.\n\nFor `tool_call`, this state is synchronized through the current assistant message before handlers run. In parallel tool execution mode it is still not guaranteed to include sibling tool results from the same assistant message.\n\n```typescript\nctx.sessionManager.getEntries()             // All entries\nctx.sessionManager.getBranch()              // Current branch\nctx.sessionManager.buildContextEntries()    // Active branch entries with compaction applied\nctx.sessionManager.getLeafId()              // Current leaf entry ID\n```\n\n### ctx.modelRegistry / ctx.model / ctx.thinkingLevel / ctx.scopedModels\n\nAccess to models, providers, and resolved authentication. `ctx.modelRegistry.getProvider(id)` returns the effective pi-ai provider, while `getProviderAuth(id)` resolves its current API key, headers, base URL, and provider-scoped environment without requiring a loaded model. `ctx.model` is the active model, and `ctx.thinkingLevel` is its current effective thinking level.\n\n`ctx.scopedModels` is the read-only list of models scoped to the current session — the same set the `/scoped-models` command shows. It is resolved at session start from the `--models` CLI flag and the `enabledModels` setting (matched against the available catalogue with minimatch on `provider/modelId` or a bare `modelId`). It is empty when no scoping is configured, meaning every available model is usable. Each entry is `{ model, thinkingLevel? }`, where `thinkingLevel` is set only when a pattern pinned it (e.g. `anthropic/*:high`). Use it to populate a model picker that mirrors the built-in one instead of enumerating the whole catalogue via `ctx.modelRegistry.getAvailable()`.\n\n### ctx.signal\n\nThe current agent abort signal, or `undefined` when no agent turn is active.\n\nUse this for abort-aware nested work started by extension handlers, for example:\n- `fetch(..., { signal: ctx.signal })`\n- model calls that accept `signal`\n- file or process helpers that accept `AbortSignal`\n\n`ctx.signal` is typically defined during active turn events such as `tool_call`, `tool_result`, `message_update`, and `turn_end`.\nIt is usually `undefined` in idle or non-turn contexts such as session events, extension commands, and shortcuts fired while pi is idle.\n\n```typescript\npi.on(\"tool_result\", async (event, ctx) => {\n  const response = await fetch(\"https://example.com/api\", {\n    method: \"POST\",\n    body: JSON.stringify(event),\n    signal: ctx.signal,\n  });\n\n  const data = await response.json();\n  return { details: data };\n});\n```\n\n### ctx.isIdle() / ctx.abort() / ctx.hasPendingMessages()\n\nControl flow helpers. `ctx.isIdle()` is false while Pi is processing an agent run, automatic retry, auto-compaction retry, or queued continuation.\n\n### ctx.shutdown()\n\nRequest a graceful shutdown of pi.\n\n- **Interactive mode:** Deferred until the agent becomes idle (after processing all queued steering and follow-up messages).\n- **RPC mode:** Deferred until the next idle state (after completing the current command response, when waiting for the next command).\n- **Print mode:** No-op. The process exits automatically when all prompts are processed.\n\nEmits `session_shutdown` event to all extensions before exiting. Available in all contexts (event handlers, tools, commands, shortcuts).\n\n```typescript\npi.on(\"tool_call\", (event, ctx) => {\n  if (isFatal(event.input)) {\n    ctx.shutdown();\n  }\n});\n```\n\n### ctx.getContextUsage()\n\nReturns current context usage for the active model. Uses last assistant usage when available, then estimates tokens for trailing messages.\n\n```typescript\nconst usage = ctx.getContextUsage();\nif (usage && usage.tokens > 100_000) {\n  // ...\n}\n```\n\n### ctx.compact()\n\nTrigger compaction without awaiting completion. Use `onComplete` and `onError` for follow-up actions.\n\n```typescript\nctx.compact({\n  customInstructions: \"Focus on recent changes\",\n  onComplete: (result) => {\n    ctx.ui.notify(\"Compaction completed\", \"info\");\n  },\n  onError: (error) => {\n    ctx.ui.notify(`Compaction failed: ${error.message}`, \"error\");\n  },\n});\n```\n\n### ctx.getSystemPrompt()\n\nReturns Pi's current system prompt string.\n\n- During `before_agent_start`, this reflects chained system-prompt changes made so far for the current turn.\n- It does not include later `context` message mutations.\n- It does not include `before_provider_request` payload rewrites.\n- If later-loaded extensions run after yours, they can still change what is ultimately sent.\n\n```typescript\npi.on(\"before_agent_start\", (event, ctx) => {\n  const prompt = ctx.getSystemPrompt();\n  console.log(`System prompt length: ${prompt.length}`);\n});\n```\n\n## ExtensionCommandContext\n\nCommand handlers receive `ExtensionCommandContext`, which extends `ExtensionContext` with session control methods. These are only available in commands because they can deadlock if called from event handlers.\n\n### ctx.getSystemPromptOptions()\n\nReturns the base inputs Pi currently uses to build the system prompt.\n\n```typescript\nconst options = ctx.getSystemPromptOptions();\nconst contextPaths = options.contextFiles?.map((file) => file.path) ?? [];\n```\n\nThis has the same shape and mutability as `before_agent_start` `event.systemPromptOptions`: custom prompt, active tools, tool snippets, prompt guidelines, appended system prompt text, cwd, loaded context files, and loaded skills. It may include full context file contents, so treat it as sensitive extension-local data and avoid exposing it through command lists, logs, or autocomplete metadata.\n\nThis reports the current base prompt inputs. It does not include per-turn `before_agent_start` chained system-prompt changes, later `context` event message mutations, or `before_provider_request` payload rewrites.\n\n### ctx.waitForIdle()\n\nWait for the agent to fully settle, including automatic retries, auto-compaction retries, and queued continuations:\n\n```typescript\npi.registerCommand(\"my-cmd\", {\n  handler: async (args, ctx) => {\n    await ctx.waitForIdle();\n    // Agent is now idle, safe to modify session\n  },\n});\n```\n\n### ctx.newSession(options?)\n\nCreate a new session:\n\n```typescript\nconst parentSession = ctx.sessionManager.getSessionFile();\nconst kickoff = \"Continue in the replacement session\";\n\nconst result = await ctx.newSession({\n  parentSession,\n  setup: async (sm) => {\n    sm.appendMessage({\n      role: \"user\",\n      content: [{ type: \"text\", text: \"Context from previous session...\" }],\n      timestamp: Date.now(),\n    });\n  },\n  withSession: async (ctx) => {\n    // Use only the replacement-session ctx here.\n    await ctx.sendUserMessage(kickoff);\n  },\n});\n\nif (result.cancelled) {\n  // An extension cancelled the new session\n}\n```\n\nOptions:\n- `parentSession`: parent session file to record in the new session header\n- `setup`: mutate the new session's `SessionManager` before `withSession` runs\n- `withSession`: run post-switch work against a fresh replacement-session context. Do not use captured old `pi` / command `ctx`; see [Session replacement lifecycle and footguns](#session-replacement-lifecycle-and-footguns).\n\n### ctx.fork(entryId, options?)\n\nFork from a specific entry, creating a new session file:\n\n```typescript\nconst result = await ctx.fork(\"entry-id-123\", {\n  withSession: async (ctx) => {\n    // Use only the replacement-session ctx here.\n    ctx.ui.notify(\"Now in the forked session\", \"info\");\n  },\n});\nif (result.cancelled) {\n  // An extension cancelled the fork\n}\n\nconst cloneResult = await ctx.fork(\"entry-id-456\", { position: \"at\" });\nif (cloneResult.cancelled) {\n  // An extension cancelled the clone\n}\n```\n\nOptions:\n- `position`: `\"before\"` (default) forks before the selected user message, restoring that prompt into the editor\n- `position`: `\"at\"` duplicates the active path through the selected entry without restoring editor text\n- `withSession`: run post-switch work against a fresh replacement-session context. Do not use captured old `pi` / command `ctx`; see [Session replacement lifecycle and footguns](#session-replacement-lifecycle-and-footguns).\n\n### ctx.navigateTree(targetId, options?)\n\nNavigate to a different point in the session tree:\n\n```typescript\nconst result = await ctx.navigateTree(\"entry-id-456\", {\n  summarize: true,\n  customInstructions: \"Focus on error handling changes\",\n  replaceInstructions: false, // true = replace default prompt entirely\n  label: \"review-checkpoint\",\n});\n```\n\nOptions:\n- `summarize`: Whether to generate a summary of the abandoned branch\n- `customInstructions`: Custom instructions for the summarizer\n- `replaceInstructions`: If true, `customInstructions` replaces the default prompt instead of being appended\n- `label`: Label to attach to the branch summary entry (or target entry if not summarizing)\n\n### ctx.switchSession(sessionPath, options?)\n\nSwitch to a different session file:\n\n```typescript\nconst result = await ctx.switchSession(\"/path/to/session.jsonl\", {\n  withSession: async (ctx) => {\n    await ctx.sendUserMessage(\"Resume work in the replacement session\");\n  },\n});\nif (result.cancelled) {\n  // An extension cancelled the switch via session_before_switch\n}\n```\n\nOptions:\n- `withSession`: run post-switch work against a fresh replacement-session context. Do not use captured old `pi` / command `ctx`; see [Session replacement lifecycle and footguns](#session-replacement-lifecycle-and-footguns).\n\nTo discover available sessions, use the static `SessionManager.list()` or `SessionManager.listAll()` methods:\n\n```typescript\nimport { SessionManager } from \"@earendil-works/pi-coding-agent\";\n\npi.registerCommand(\"switch\", {\n  description: \"Switch to another session\",\n  handler: async (args, ctx) => {\n    const sessions = await SessionManager.list(ctx.cwd);\n    if (sessions.length === 0) return;\n    const choice = await ctx.ui.select(\n      \"Pick session:\",\n      sessions.map(s => s.file),\n    );\n    if (choice) {\n      await ctx.switchSession(choice, {\n        withSession: async (ctx) => {\n          ctx.ui.notify(\"Switched session\", \"info\");\n        },\n      });\n    }\n  },\n});\n```\n\n### Session replacement lifecycle and footguns\n\n`withSession` receives a fresh `ReplacedSessionContext`, which extends `ExtensionCommandContext` with async `sendMessage()` and `sendUserMessage()` helpers bound to the replacement session.\n\nLifecycle and footguns:\n- `withSession` runs only after the old session has emitted `session_shutdown`, the old runtime has been torn down, the replacement session has been rebound, and the new extension instance has already received `session_start`.\n- The callback still executes in the original closure, not inside the new extension instance. That means your old extension instance may already have run its shutdown cleanup before `withSession` starts.\n- Captured old `pi` / old command `ctx` session-bound objects are stale after replacement and will throw if used. Use only the `ctx` passed to `withSession` for session-bound work.\n- Previously extracted raw objects are still your responsibility. For example, if you capture `const sm = ctx.sessionManager` before replacement, `sm` is still the old `SessionManager` object. Do not reuse it after replacement.\n- Code in `withSession` should assume any state invalidated by your `session_shutdown` handler is already gone. Only capture plain data that survives shutdown cleanly, such as strings, ids, and serialized config.\n\nSafe pattern:\n\n```typescript\npi.registerCommand(\"handoff\", {\n  handler: async (_args, ctx) => {\n    const kickoff = \"Continue from the replacement session\";\n    await ctx.newSession({\n      withSession: async (ctx) => {\n        await ctx.sendUserMessage(kickoff);\n      },\n    });\n  },\n});\n```\n\nUnsafe pattern:\n\n```typescript\npi.registerCommand(\"handoff\", {\n  handler: async (_args, ctx) => {\n    const oldSessionManager = ctx.sessionManager;\n    await ctx.newSession({\n      withSession: async (_ctx) => {\n        // stale old objects: do not do this\n        oldSessionManager.getSessionFile();\n        pi.sendUserMessage(\"wrong\");\n      },\n    });\n  },\n});\n```\n\n### ctx.reload()\n\nRun the same reload flow as `/reload`.\n\n```typescript\npi.registerCommand(\"reload-runtime\", {\n  description: \"Reload extensions, skills, prompts, themes, and context files\",\n  handler: async (_args, ctx) => {\n    await ctx.reload();\n    return;\n  },\n});\n```\n\nImportant behavior:\n- `await ctx.reload()` emits `session_shutdown` for the current extension runtime\n- It then reloads resources and emits `session_start` with `reason: \"reload\"` and `resources_discover` with reason `\"reload\"`\n- The currently running command handler still continues in the old call frame\n- Code after `await ctx.reload()` still runs from the pre-reload version\n- Code after `await ctx.reload()` must not assume old in-memory extension state is still valid\n- After the handler returns, future commands/events/tool calls use the new extension version\n\nFor predictable behavior, treat reload as terminal for that handler (`await ctx.reload(); return;`).\n\nTools run with `ExtensionContext`, so they cannot call `ctx.reload()` directly. Use a command as the reload entrypoint, then expose a tool that queues that command as a follow-up user message.\n\nExample tool the LLM can call to trigger reload:\n\n```typescript\nimport type { ExtensionAPI } from \"@earendil-works/pi-coding-agent\";\nimport { Type } from \"typebox\";\n\nexport default function (pi: ExtensionAPI) {\n  pi.registerCommand(\"reload-runtime\", {\n    description: \"Reload extensions, skills, prompts, themes, and context files\",\n    handler: async (_args, ctx) => {\n      await ctx.reload();\n      return;\n    },\n  });\n\n  pi.registerTool({\n    name: \"reload_runtime\",\n    label: \"Reload Runtime\",\n    description: \"Reload extensions, skills, prompts, themes, and context files\",\n    parameters: Type.Object({}),\n    async execute() {\n      pi.sendUserMessage(\"/reload-runtime\", { deliverAs: \"followUp\" });\n      return {\n        content: [{ type: \"text\", text: \"Queued /reload-runtime as a follow-up command.\" }],\n      };\n    },\n  });\n}\n```\n\n## ExtensionAPI Methods\n\n### pi.on(event, handler)\n\nSubscribe to events. See [Events](#events) for event types and return values.\n\n### pi.registerTool(definition)\n\nRegister a custom tool callable by the LLM. See [Custom Tools](#custom-tools) for full details.\n\n`pi.registerTool()` works both during extension load and after startup. You can call it inside `session_start`, command handlers, or other event handlers. New tools are refreshed immediately in the same session, so they appear in `pi.getAllTools()` and are callable by the LLM without `/reload`.\n\nUse `pi.setActiveTools()` to enable or disable tools (including dynamically added tools) at runtime.\n\nUse `promptSnippet` to opt a custom tool into a one-line entry in `Available tools`, and `promptGuidelines` to append tool-specific bullets to the default `Guidelines` section when the tool is active.\n\n**Important:** `promptGuidelines` bullets are appended flat to the `Guidelines` section with no tool name prefix. Each guideline must name the tool it refers to — avoid \"Use this tool when...\" because the LLM cannot tell which tool \"this\" means. Write \"Use my_tool when...\" instead.\n\nSee [dynamic-tools.ts](../examples/extensions/dynamic-tools.ts) for a full example.\n\n```typescript\nimport { Type } from \"typebox\";\nimport { StringEnum } from \"@earendil-works/pi-ai\";\n\npi.registerTool({\n  name: \"my_tool\",\n  label: \"My Tool\",\n  description: \"What this tool does\",\n  promptSnippet: \"Summarize or transform text according to action\",\n  promptGuidelines: [\"Use my_tool when the user asks to summarize previously generated text.\"],\n  parameters: Type.Object({\n    action: StringEnum([\"list\", \"add\"] as const),\n    text: Type.Optional(Type.String()),\n  }),\n  prepareArguments(args) {\n    // Optional compatibility shim. Runs before schema validation.\n    // Return the current schema shape, for example to fold legacy fields\n    // into the modern parameter object.\n    return args;\n  },\n\n  async execute(toolCallId, params, signal, onUpdate, ctx) {\n    // Stream progress\n    onUpdate?.({ content: [{ type: \"text\", text: \"Working...\" }] });\n\n    return {\n      content: [{ type: \"text\", text: \"Done\" }],\n      details: { result: \"...\" },\n    };\n  },\n\n  // Optional: Custom rendering\n  renderCall(args, theme, context) { ... },\n  renderResult(result, options, theme, context) { ... },\n});\n```\n\n### pi.sendMessage(message, options?)\n\nInject a custom message into the session. Custom messages participate in LLM context. For durable TUI-only content that should not be sent to the LLM, use [`pi.appendEntry()`](#piappendentrycustomtype-data) with [`pi.registerEntryRenderer()`](#piregisterentryrenderercustomtype-renderer).\n\n```typescript\npi.sendMessage({\n  customType: \"my-extension\",\n  content: \"Message text\",\n  display: true,\n  details: { ... },\n}, {\n  triggerTurn: true,\n  deliverAs: \"steer\",\n});\n```\n\n**Options:**\n- `deliverAs` - Delivery mode:\n  - `\"steer\"` (default) - Queues the message while streaming. Delivered after the current assistant turn finishes executing its tool calls, before the next LLM call.\n  - `\"followUp\"` - Waits for agent to finish. Delivered only when agent has no more tool calls.\n  - `\"nextTurn\"` - Queued for next user prompt. Does not interrupt or trigger anything.\n- `triggerTurn: true` - If agent is idle, trigger an LLM response immediately. Only applies to `\"steer\"` and `\"followUp\"` modes (ignored for `\"nextTurn\"`).\n\n### pi.sendUserMessage(content, options?)\n\nSend a user message to the agent. Unlike `sendMessage()` which sends custom messages, this sends an actual user message that appears as if typed by the user. Always triggers a turn.\n\n```typescript\n// Simple text message\npi.sendUserMessage(\"What is 2+2?\");\n\n// With content array (text + images)\npi.sendUserMessage([\n  { type: \"text\", text: \"Describe this image:\" },\n  { type: \"image\", source: { type: \"base64\", mediaType: \"image/png\", data: \"...\" } },\n]);\n\n// During streaming - must specify delivery mode\npi.sendUserMessage(\"Focus on error handling\", { deliverAs: \"steer\" });\npi.sendUserMessage(\"And then summarize\", { deliverAs: \"followUp\" });\n```\n\n**Options:**\n- `deliverAs` - Required when agent is streaming:\n  - `\"steer\"` - Queues the message for delivery after the current assistant turn finishes executing its tool calls\n  - `\"followUp\"` - Waits for agent to finish all tools\n\nWhen not streaming, the message is sent immediately and triggers a new turn. When streaming without `deliverAs`, throws an error.\n\nSee [send-user-message.ts](../examples/extensions/send-user-message.ts) for a complete example.\n\n### pi.appendEntry(customType, data?)\n\nPersist extension data. Custom entries do NOT participate in LLM context. In interactive mode, they can also render inside the chat transcript when paired with `pi.registerEntryRenderer()`.\n\n```typescript\npi.appendEntry(\"my-state\", { count: 42 });\npi.appendEntry(\"status-card\", { title: \"Indexed files\", count: 17 });\n\n// Restore on reload\npi.on(\"session_start\", async (_event, ctx) => {\n  for (const entry of ctx.sessionManager.getEntries()) {\n    if (entry.type === \"custom\" && entry.customType === \"my-state\") {\n      // Reconstruct from entry.data\n    }\n  }\n});\n```\n\n### pi.setSessionName(name)\n\nSet the session display name (shown in session selector instead of first message).\n\n```typescript\npi.setSessionName(\"Refactor auth module\");\n```\n\n### pi.getSessionName()\n\nGet the current session name, if set.\n\n```typescript\nconst name = pi.getSessionName();\nif (name) {\n  console.log(`Session: ${name}`);\n}\n```\n\n### pi.setLabel(entryId, label)\n\nSet or clear a label on an entry. Labels are user-defined markers for bookmarking and navigation (shown in `/tree` selector).\n\n```typescript\n// Set a label\npi.setLabel(entryId, \"checkpoint-before-refactor\");\n\n// Clear a label\npi.setLabel(entryId, undefined);\n\n// Read labels via sessionManager\nconst label = ctx.sessionManager.getLabel(entryId);\n```\n\nLabels persist in the session and survive restarts. Use them to mark important points (turns, checkpoints) in the conversation tree.\n\n### pi.registerCommand(name, options)\n\nRegister a command.\n\nIf multiple extensions register the same command name, pi keeps them all and assigns numeric invocation suffixes in load order, for example `/review:1` and `/review:2`.\n\n```typescript\npi.registerCommand(\"stats\", {\n  description: \"Show session statistics\",\n  handler: async (args, ctx) => {\n    const count = ctx.sessionManager.getEntries().length;\n    ctx.ui.notify(`${count} entries`, \"info\");\n  }\n});\n```\n\nOptional: add argument auto-completion for `/command ...`:\n\n```typescript\nimport type { AutocompleteItem } from \"@earendil-works/pi-tui\";\n\npi.registerCommand(\"deploy\", {\n  description: \"Deploy to an environment\",\n  getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {\n    const envs = [\"dev\", \"staging\", \"prod\"];\n    const items = envs.map((e) => ({ value: e, label: e }));\n    const filtered = items.filter((i) => i.value.startsWith(prefix));\n    return filtered.length > 0 ? filtered : null;\n  },\n  handler: async (args, ctx) => {\n    ctx.ui.notify(`Deploying: ${args}`, \"info\");\n  },\n});\n```\n\n### pi.getCommands()\n\nGet the slash commands available for invocation via `prompt` in the current session. Includes extension commands, prompt templates, and skill commands.\nThe list matches the RPC `get_commands` ordering: extensions first, then templates, then skills.\n\n```typescript\nconst commands = pi.getCommands();\nconst bySource = commands.filter((command) => command.source === \"extension\");\nconst userScoped = commands.filter((command) => command.sourceInfo.scope === \"user\");\n```\n\nEach entry has this shape:\n\n```typescript\n{\n  name: string; // Invokable command name without the leading slash. May be suffixed like \"review:1\"\n  description?: string;\n  source: \"extension\" | \"prompt\" | \"skill\";\n  sourceInfo: {\n    path: string;\n    source: string;\n    scope: \"user\" | \"project\" | \"temporary\";\n    origin: \"package\" | \"top-level\";\n    baseDir?: string;\n  };\n}\n```\n\nUse `sourceInfo` as the canonical provenance field. Do not infer ownership from command names or from ad hoc path parsing.\n\nBuilt-in interactive commands (like `/model` and `/settings`) are not included here. They are handled only in interactive\nmode and would not execute if sent via `prompt`.\n\n### pi.registerMessageRenderer(customType, renderer)\n\nRegister a custom TUI renderer for custom messages with your `customType`. Custom messages are created with `pi.sendMessage()` and participate in LLM context. See [Custom UI](#custom-ui).\n\n### pi.registerMarkdownTransformer(transformer)\n\nRegister a transformer for the Markdown in normal user text, assistant text, and thinking blocks. Transformers run in extension load order, and each transformer receives the Markdown returned by the previous transformer. After the chain finishes, Pi renders the transformed content with its built-in renderer.\n\nThe transformer receives the Markdown string and a context with:\n\n- `messageType` — `\"user\"`, `\"assistant\"`, or `\"assistant-thinking\"`\n- `isStreaming` — `true` for partial assistant updates; `false` for user, finalized assistant, and restored messages\n- `availableWidth` — exact terminal columns available for the transformed Markdown content\n\nReturn the transformed Markdown:\n\n```typescript\npi.registerMarkdownTransformer((markdown, { messageType, isStreaming }) => {\n  if (isStreaming || messageType === \"assistant-thinking\") return markdown;\n  return markdown.replaceAll(\"-->\", \"→\");\n});\n```\n\nIf a transformer throws, Pi keeps the Markdown produced so far and continues with the next transformer. The hook is display-only: the original message remains unchanged in the session and model context. It runs for new user messages, assistant streaming updates, restored session messages, and terminal width changes, so transformers should remain synchronous and inexpensive.\n\n### pi.registerEntryRenderer(customType, renderer)\n\nRegister a custom TUI renderer for custom entries with your `customType`. Custom entries are created with `pi.appendEntry()` and do not participate in LLM context.\n\n```typescript\nimport { Box, Text } from \"@earendil-works/pi-tui\";\n\npi.registerEntryRenderer(\"status-card\", (entry, { expanded }, theme) => {\n  const data = entry.data as { title: string; count: number };\n  const box = new Box(1, 1, (text) => theme.bg(\"customMessageBg\", text));\n  box.addChild(new Text(`${theme.bold(data.title)}: ${data.count}`));\n  if (expanded) {\n    box.addChild(new Text(theme.fg(\"dim\", JSON.stringify(data, null, 2))));\n  }\n  return box;\n});\n\npi.appendEntry(\"status-card\", { title: \"Indexed files\", count: 17 });\n```\n\n### pi.registerShortcut(shortcut, options)\n\nRegister a keyboard shortcut. See [keybindings.md](keybindings.md) for the shortcut format and built-in keybindings.\n\n```typescript\npi.registerShortcut(\"ctrl+shift+p\", {\n  description: \"Toggle plan mode\",\n  handler: async (ctx) => {\n    ctx.ui.notify(\"Toggled!\");\n  },\n});\n```\n\n### pi.registerFlag(name, options)\n\nRegister a CLI flag.\n\n```typescript\npi.registerFlag(\"plan\", {\n  description: \"Start in plan mode\",\n  type: \"boolean\",\n  default: false,\n});\n\n// Check value\nif (pi.getFlag(\"plan\")) {\n  // Plan mode enabled\n}\n```\n\n### pi.exec(command, args, options?)\n\nExecute a shell command.\n\n```typescript\nconst result = await pi.exec(\"git\", [\"status\"], { signal, timeout: 5000 });\n// result.stdout, result.stderr, result.code, result.killed\n```\n\n### pi.getActiveTools() / pi.getAllTools() / pi.setActiveTools(names)\n\nManage active tools. This works for both built-in tools and dynamically registered tools. `pi.getActiveTools()` returns the active tool names as `string[]`; `pi.getAllTools()` returns metadata for all configured tools.\n\n```typescript\nconst active = pi.getActiveTools(); // [\"read\", \"bash\", ...]\nconst all = pi.getAllTools();\n// all = [{\n//   name: \"read\",\n//   description: \"Read file contents...\",\n//   parameters: ...,\n//   promptGuidelines: [\"Use read to examine files instead of cat or sed.\"],\n//   sourceInfo: { path: \"<builtin:read>\", source: \"builtin\", scope: \"temporary\", origin: \"top-level\" }\n// }, ...]\nconst builtinTools = all.filter((t) => t.sourceInfo.source === \"builtin\");\nconst extensionTools = all.filter((t) => t.sourceInfo.source !== \"builtin\" && t.sourceInfo.source !== \"sdk\");\npi.setActiveTools([...new Set([...active, \"my_custom_tool\"])]); // Keep current tools and enable my_custom_tool\npi.setActiveTools([\"read\", \"bash\"]); // Switch to read-only\n```\n\n`pi.getAllTools()` returns `name`, `description`, `parameters`, `promptGuidelines`, and `sourceInfo`.\n\nTypical `sourceInfo.source` values:\n- `builtin` for built-in tools\n- `sdk` for tools passed via `createAgentSession({ customTools })`\n- extension source metadata for tools registered by extensions\n\n### pi.setModel(model)\n\nSet the current model. Returns `false` if no API key is available for the model. See [models.md](models.md) for configuring custom models.\n\n```typescript\nconst model = ctx.modelRegistry.find(\"anthropic\", \"claude-sonnet-4-5\");\nif (model) {\n  const success = await pi.setModel(model);\n  if (!success) {\n    ctx.ui.notify(\"No API key for this model\", \"error\");\n  }\n}\n```\n\n### pi.getThinkingLevel() / pi.setThinkingLevel(level)\n\nGet or set the thinking level. Level is clamped to model capabilities (non-reasoning models always use \"off\"). Changes emit `thinking_level_select`.\n\n```typescript\nconst current = pi.getThinkingLevel();  // \"off\" | \"minimal\" | \"low\" | \"medium\" | \"high\" | \"xhigh\" | \"max\"\npi.setThinkingLevel(\"high\");\n```\n\n### pi.events\n\nShared event bus for communication between extensions:\n\n```typescript\npi.events.on(\"my:event\", (data) => { ... });\npi.events.emit(\"my:event\", { ... });\n```\n\n### pi.registerProvider(name, config)\n\nRegister or override a model provider dynamically. Useful for proxies, custom endpoints, or team-wide model configurations.\n\nCalls made during the extension factory function are queued and applied once the runner initialises. Calls made after that — for example from a command handler following a user setup flow — take effect immediately without requiring a `/reload`.\n\nDynamic providers can implement `refreshModels`. Pi calls it during model refresh, publishes the returned list synchronously through the provider, and passes the canonical credential/stored-catalog/network/signal context. The extension decides whether to persist catalog metadata through generation-checked `context.publish({ persist: entry })`; live servers such as llama.cpp can return models without persisting them.\n\n`context.signal` is always a concrete signal and provider callbacks must pass it to blocking I/O. Public `ModelRuntime.refresh()` and `ModelRegistry.refresh()` calls accept an optional signal and are unbounded when it is omitted; extensions and applications choose their own deadlines. Cancellation stops the caller waiting even if a provider ignores the signal, but cooperation is still required to stop the underlying work.\n\nExtensions that need native provider auth, filtering, refresh, or stream behavior can register a complete `Provider` from `@earendil-works/pi-ai`. The provider becomes the composition base and `models.json` overrides still apply above it.\n\n```typescript\nimport { createProvider, openAICompletionsApi } from \"@earendil-works/pi-ai\";\n\nconst provider = createProvider({\n  id: \"local-server\",\n  name: \"Local Server\",\n  baseUrl: \"http://localhost:8080/v1\",\n  auth: {\n    apiKey: {\n      name: \"Local server setup\",\n      async login(interaction) {\n        return {\n          type: \"api_key\",\n          key: await interaction.prompt({ type: \"secret\", message: \"API key\" }),\n        };\n      },\n      async resolve({ credential }) {\n        return credential?.key\n          ? { auth: { apiKey: credential.key }, source: \"stored API key\" }\n          : undefined;\n      },\n    },\n  },\n  models: [],\n  api: openAICompletionsApi(),\n});\n\npi.registerProvider(provider);\n\n// Register a new provider with custom models\npi.registerProvider(\"my-proxy\", {\n  name: \"My Proxy\",\n  baseUrl: \"https://proxy.example.com\",\n  apiKey: \"$PROXY_API_KEY\",  // env var reference\n  api: \"anthropic-messages\",\n  models: [\n    {\n      id: \"claude-sonnet-4-20250514\",\n      name: \"Claude 4 Sonnet (proxy)\",\n      reasoning: false,\n      input: [\"text\", \"image\"],\n      cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n      contextWindow: 200000,\n      maxTokens: 16384\n    }\n  ]\n});\n\n// Register a live llama.cpp catalog without persisting discovered models\npi.registerProvider(\"llama.cpp\", {\n  baseUrl: \"http://localhost:8080/v1\",\n  apiKey: \"local\",\n  api: \"openai-completions\",\n  async refreshModels({ signal }) {\n    const response = await fetch(\"http://localhost:8080/v1/models\", { signal });\n    const { data } = await response.json();\n    return data.map(({ id }) => ({\n      id,\n      name: id,\n      reasoning: false,\n      input: [\"text\"],\n      cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n      contextWindow: 128000,\n      maxTokens: 16384\n    }));\n  }\n});\n\n// Override baseUrl for an existing provider (keeps all models)\npi.registerProvider(\"anthropic\", {\n  baseUrl: \"https://proxy.example.com\"\n});\n\n// Register provider with OAuth support for /login\npi.registerProvider(\"corporate-ai\", {\n  baseUrl: \"https://ai.corp.com\",\n  api: \"openai-responses\",\n  models: [...],\n  oauth: {\n    name: \"Corporate AI (SSO)\",\n    async login(callbacks) {\n      // Custom OAuth flow\n      callbacks.onAuth({ url: \"https://sso.corp.com/...\" });\n      const code = await callbacks.onPrompt({ message: \"Enter code:\" });\n      return { refresh: code, access: code, expires: Date.now() + 3600000 };\n    },\n    async refreshToken(credentials, signal) {\n      signal.throwIfAborted();\n      // Refresh logic\n      return credentials;\n    },\n    getApiKey(credentials) {\n      return credentials.access;\n    }\n  }\n});\n```\n\nThe object form accepts a complete pi-ai `Provider`, including native `auth`, `getModels`, `refreshModels`, `filterModels`, `stream`, and `streamSimple` behavior.\n\n**Legacy config options:**\n- `name` - Display name for the provider in UI such as `/login`.\n- `baseUrl` - API endpoint URL. Required when defining models.\n- `apiKey` - API key literal, environment interpolation (`$ENV_VAR` or `${ENV_VAR}`), or leading `!command`. Required when defining models (unless `oauth` provided). `$$` escapes `$`, and `$!` escapes a literal `!` without triggering command execution.\n- `api` - API type: `\"anthropic-messages\"`, `\"openai-completions\"`, `\"openai-responses\"`, etc.\n- `headers` - Custom headers to include in requests.\n- `authHeader` - If true, adds `Authorization: Bearer` header automatically.\n- `models` - Array of model definitions. If provided, replaces all existing models for this provider. Model definitions can set `baseUrl` to override the provider endpoint for that model.\n- `refreshModels` - Async dynamic discovery callback. Its returned models replace extension-provided models. `context.stored` contains the persisted provider snapshot; use generation-checked `context.publish({ persist: entry })` only when updated catalog data should persist. Use `persist: null` to delete that snapshot.\n- `oauth` - OAuth provider config for `/login` support. When provided, the provider appears in the login menu.\n- `streamSimple` - Custom streaming implementation for non-standard APIs.\n\nSee [custom-provider.md](custom-provider.md) for advanced topics: custom streaming APIs, OAuth details, model definition reference.\n\n### pi.unregisterProvider(name)\n\nRemove a previously registered provider and its models. Built-in models that were overridden by the provider are restored. Has no effect if the provider was not registered.\n\nLike `registerProvider`, this takes effect immediately when called after the initial load phase, so a `/reload` is not required.\n\n```typescript\npi.registerCommand(\"my-setup-teardown\", {\n  description: \"Remove the custom proxy provider\",\n  handler: async (_args, _ctx) => {\n    pi.unregisterProvider(\"my-proxy\");\n  },\n});\n```\n\n## State Management\n\nExtensions with state should store it in tool result `details` for proper branching support:\n\n```typescript\nexport default function (pi: ExtensionAPI) {\n  let items: string[] = [];\n\n  // Reconstruct state from session\n  pi.on(\"session_start\", async (_event, ctx) => {\n    items = [];\n    for (const entry of ctx.sessionManager.getBranch()) {\n      if (entry.type === \"message\" && entry.message.role === \"toolResult\") {\n        if (entry.message.toolName === \"my_tool\") {\n          items = entry.message.details?.items ?? [];\n        }\n      }\n    }\n  });\n\n  pi.registerTool({\n    name: \"my_tool\",\n    // ...\n    async execute(toolCallId, params, signal, onUpdate, ctx) {\n      items.push(\"new item\");\n      return {\n        content: [{ type: \"text\", text: \"Added\" }],\n        details: { items: [...items] },  // Store for reconstruction\n      };\n    },\n  });\n}\n```\n\n## Custom Tools\n\nRegister tools the LLM can call via `pi.registerTool()`. Tools appear in the system prompt and can have custom rendering.\n\nUse `promptSnippet` for a short one-line entry in the `Available tools` section in the default system prompt. If omitted, custom tools are left out of that section.\n\nUse `promptGuidelines` to add tool-specific bullets to the default system prompt `Guidelines` section. These bullets are included only while the tool is active (for example, after `pi.setActiveTools([...])`).\n\n**Important:** `promptGuidelines` bullets are appended flat to the `Guidelines` section with no tool name prefix or grouping. Each guideline must name the tool it refers to — avoid \"Use this tool when...\" because the LLM cannot tell which tool \"this\" means. Write \"Use my_tool when...\" instead.\n\nNote: Some models are idiots and include the @ prefix in tool path arguments. Built-in tools strip a leading @ before resolving paths. If your custom tool accepts a path, normalize a leading @ as well.\n\nIf your custom tool mutates files, use `withFileMutationQueue()` so it participates in the same per-file queue as built-in `edit` and `write`. This matters because tool calls run in parallel by default. Without the queue, two tools can read the same old file contents, compute different updates, and then whichever write lands last overwrites the other.\n\nExample failure case: your custom tool edits `foo.ts` while built-in `edit` also changes `foo.ts` in the same assistant turn. If your tool does not participate in the queue, both can read the original `foo.ts`, apply separate changes, and one of those changes is lost.\n\nPass the real target file path to `withFileMutationQueue()`, not the raw user argument. Resolve it to an absolute path first, relative to `ctx.cwd` or your tool's working directory. For existing files, the helper canonicalizes through `realpath()`, so symlink aliases for the same file share one queue. For new files, it falls back to the resolved absolute path because there is nothing to `realpath()` yet.\n\nQueue the entire mutation window on that target path. That includes read-modify-write logic, not just the final write.\n\n```typescript\nimport { withFileMutationQueue } from \"@earendil-works/pi-coding-agent\";\nimport { mkdir, readFile, writeFile } from \"node:fs/promises\";\nimport { dirname, resolve } from \"node:path\";\n\nasync execute(_toolCallId, params, _signal, _onUpdate, ctx) {\n  const absolutePath = resolve(ctx.cwd, params.path);\n\n  return withFileMutationQueue(absolutePath, async () => {\n    await mkdir(dirname(absolutePath), { recursive: true });\n    const current = await readFile(absolutePath, \"utf8\");\n    const next = current.replace(params.oldText, params.newText);\n    await writeFile(absolutePath, next, \"utf8\");\n\n    return {\n      content: [{ type: \"text\", text: `Updated ${params.path}` }],\n      details: {},\n    };\n  });\n}\n```\n\n### Tool Definition\n\n```typescript\nimport { Type } from \"typebox\";\nimport { StringEnum } from \"@earendil-works/pi-ai\";\nimport { Text } from \"@earendil-works/pi-tui\";\n\npi.registerTool({\n  name: \"my_tool\",\n  label: \"My Tool\",\n  description: \"What this tool does (shown to LLM)\",\n  promptSnippet: \"List or add items in the project todo list\",\n  promptGuidelines: [\n    \"Use my_tool for todo planning instead of direct file edits when the user asks for a task list.\"\n  ],\n  parameters: Type.Object({\n    action: StringEnum([\"list\", \"add\"] as const),  // Use StringEnum for Google compatibility\n    text: Type.Optional(Type.String()),\n  }),\n  prepareArguments(args) {\n    if (!args || typeof args !== \"object\") return args;\n    const input = args as { action?: string; oldAction?: string };\n    if (typeof input.oldAction === \"string\" && input.action === undefined) {\n      return { ...input, action: input.oldAction };\n    }\n    return args;\n  },\n\n  async execute(toolCallId, params, signal, onUpdate, ctx) {\n    // Check for cancellation\n    if (signal?.aborted) {\n      return { content: [{ type: \"text\", text: \"Cancelled\" }] };\n    }\n\n    // Stream progress updates\n    onUpdate?.({\n      content: [{ type: \"text\", text: \"Working...\" }],\n      details: { progress: 50 },\n    });\n\n    // Run commands via pi.exec (captured from extension closure)\n    const result = await pi.exec(\"some-command\", [], { signal });\n\n    // Return result\n    return {\n      content: [{ type: \"text\", text: \"Done\" }],  // Sent to LLM\n      details: { data: result },                   // For rendering & state\n      // usage: nestedModelResponse.usage,          // Optional nested LLM usage\n      // Optional: stop after this tool batch when every finalized tool result\n      // in the batch also returns terminate: true.\n      terminate: true,\n    };\n  },\n\n  // Optional: Custom rendering\n  renderCall(args, theme, context) { ... },\n  renderResult(result, options, theme, context) { ... },\n});\n```\n\n**Usage accounting:** If a tool makes nested LLM calls, return their combined `Usage` as `usage`. Pi persists it on the tool result and includes it in footer, `/session`, and RPC session totals. `tool_result` handlers can inspect or replace this value.\n\n**Signaling errors:** To mark a tool execution as failed (sets `isError: true` on the result and reports it to the LLM), throw an error from `execute`. Returning a value never sets the error flag regardless of what properties you include in the return object.\n\n**Early termination:** Return `terminate: true` from `execute()` to hint that the automatic follow-up LLM call should be skipped after the current tool batch. This only takes effect when every finalized tool result in that batch is terminating. See [examples/extensions/structured-output.ts](../examples/extensions/structured-output.ts) for a minimal example where the agent ends on a final structured-output tool call.\n\n```typescript\n// Correct: throw to signal an error\nasync execute(toolCallId, params) {\n  if (!isValid(params.input)) {\n    throw new Error(`Invalid input: ${params.input}`);\n  }\n  return { content: [{ type: \"text\", text: \"OK\" }], details: {} };\n}\n```\n\n**Important:** Use `StringEnum` from `@earendil-works/pi-ai` for string enums. `Type.Union`/`Type.Literal` doesn't work with Google's API.\n\n**Argument preparation:** `prepareArguments(args)` is optional. If defined, it runs before schema validation and before `execute()`. Use it to mimic an older accepted input shape when pi resumes an older session whose stored tool call arguments no longer match the current schema. Return the object you want validated against `parameters`. Keep the public schema strict. Do not add deprecated compatibility fields to `parameters` just to keep old resumed sessions working.\n\nExample: an older session may contain an `edit` tool call with top-level `oldText` and `newText`, while the current schema only accepts `edits: [{ oldText, newText }]`.\n\n```typescript\npi.registerTool({\n  name: \"edit\",\n  label: \"Edit\",\n  description: \"Edit a single file using exact text replacement\",\n  parameters: Type.Object({\n    path: Type.String(),\n    edits: Type.Array(\n      Type.Object({\n        oldText: Type.String(),\n        newText: Type.String(),\n      }),\n    ),\n  }),\n  prepareArguments(args) {\n    if (!args || typeof args !== \"object\") return args;\n\n    const input = args as {\n      path?: string;\n      edits?: Array<{ oldText: string; newText: string }>;\n      oldText?: unknown;\n      newText?: unknown;\n    };\n\n    if (typeof input.oldText !== \"string\" || typeof input.newText !== \"string\") {\n      return args;\n    }\n\n    return {\n      ...input,\n      edits: [...(input.edits ?? []), { oldText: input.oldText, newText: input.newText }],\n    };\n  },\n  async execute(toolCallId, params, signal, onUpdate, ctx) {\n    // params now matches the current schema\n    return {\n      content: [{ type: \"text\", text: `Applying ${params.edits.length} edit block(s)` }],\n      details: {},\n    };\n  },\n});\n```\n\n### Overriding Built-in Tools\n\nExtensions can override built-in tools (`read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`) by registering a tool with the same name. Interactive mode displays a warning when this happens.\n\n```bash\n# Extension's read tool replaces built-in read\npi -e ./tool-override.ts\n```\n\nAlternatively, use `--no-builtin-tools` to start without any built-in tools while keeping extension tools enabled:\n```bash\n# No built-in tools, only extension tools\npi --no-builtin-tools -e ./my-extension.ts\n```\n\nSee [examples/extensions/tool-override.ts](../examples/extensions/tool-override.ts) for a complete example that overrides `read` with logging and access control.\n\n**Rendering:** Built-in renderer inheritance is resolved per slot. Execution override and rendering override are independent. If your override omits `renderCall`, the built-in `renderCall` is used. If your override omits `renderResult`, the built-in `renderResult` is used. If your override omits both, the built-in renderer is used automatically (syntax highlighting, diffs, etc.). This lets you wrap built-in tools for logging or access control without reimplementing the UI.\n\n**Prompt metadata:** `promptSnippet` and `promptGuidelines` are not inherited from the built-in tool. If your override should keep those prompt instructions, define them on the override explicitly.\n\n**Your implementation must match the exact result shape**, including the `details` type. The UI and session logic depend on these shapes for rendering and state tracking.\n\nBuilt-in tool implementations:\n- [read.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/read.ts) - `ReadToolDetails`\n- [bash.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/bash.ts) - `BashToolDetails`\n- [edit.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/edit.ts)\n- [write.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/write.ts)\n- [grep.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/grep.ts) - `GrepToolDetails`\n- [find.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/find.ts) - `FindToolDetails`\n- [ls.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/ls.ts) - `LsToolDetails`\n\n### Remote Execution\n\nBuilt-in tools support pluggable operations for delegating to remote systems (SSH, containers, etc.):\n\n```typescript\nimport { createReadTool, createBashTool, type ReadOperations } from \"@earendil-works/pi-coding-agent\";\n\n// Create tool with custom operations\nconst remoteRead = createReadTool(cwd, {\n  operations: {\n    readFile: (path) => sshExec(remote, `cat ${path}`),\n    access: (path) => sshExec(remote, `test -r ${path}`).then(() => {}),\n  }\n});\n\n// Register, checking flag at execution time\npi.registerTool({\n  ...remoteRead,\n  async execute(id, params, signal, onUpdate, _ctx) {\n    const ssh = getSshConfig();\n    if (ssh) {\n      const tool = createReadTool(cwd, { operations: createRemoteOps(ssh) });\n      return tool.execute(id, params, signal, onUpdate);\n    }\n    return localRead.execute(id, params, signal, onUpdate);\n  },\n});\n```\n\n**Operations interfaces:** `ReadOperations`, `WriteOperations`, `EditOperations`, `BashOperations`, `LsOperations`, `GrepOperations`, `FindOperations`\n\nFor `user_bash`, extensions can reuse pi's local shell backend via `createLocalBashOperations()` instead of reimplementing local process spawning, shell resolution, and process-tree termination.\n\nThe bash tool also supports a spawn hook to adjust the command, cwd, or env before execution:\n\n```typescript\nimport { createBashTool } from \"@earendil-works/pi-coding-agent\";\n\nconst bashTool = createBashTool(cwd, {\n  spawnHook: ({ command, cwd, env }) => ({\n    command: `source ~/.profile\\n${command}`,\n    cwd: `/mnt/sandbox${cwd}`,\n    env: { ...env, CI: \"1\" },\n  }),\n});\n```\n\n`createBashTool()` exposes the current session to commands through `PI_SESSION_ID`, `PI_SESSION_FILE`, `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL`. Injection happens before `spawnHook`, so hooks receive these values in `env` and preserve them when they spread the existing environment as above. Set `exposeSessionEnvironment: false` to disable them:\n\n```typescript\nconst bashTool = createBashTool(cwd, {\n  exposeSessionEnvironment: false,\n});\n```\n\nSee [Bash tool session environment](environment-variables.md#bash-tool-session-environment) for variable semantics. See [examples/extensions/ssh.ts](../examples/extensions/ssh.ts) for a complete SSH example with `--ssh` flag.\n\n### Output Truncation\n\n**Tools MUST truncate their output** to avoid overwhelming the LLM context. Large outputs can cause:\n- Context overflow errors (prompt too long)\n- Compaction failures\n- Degraded model performance\n\nThe built-in limit is **50KB** (~10k tokens) and **2000 lines**, whichever is hit first. Use the exported truncation utilities:\n\n```typescript\nimport {\n  truncateHead,      // Keep first N lines/bytes (good for file reads, search results)\n  truncateTail,      // Keep last N lines/bytes (good for logs, command output)\n  truncateLine,      // Truncate a single line to maxBytes with ellipsis\n  formatSize,        // Human-readable size (e.g., \"50KB\", \"1.5MB\")\n  DEFAULT_MAX_BYTES, // 50KB\n  DEFAULT_MAX_LINES, // 2000\n} from \"@earendil-works/pi-coding-agent\";\n\nasync execute(toolCallId, params, signal, onUpdate, ctx) {\n  const output = await runCommand();\n\n  // Apply truncation\n  const truncation = truncateHead(output, {\n    maxLines: DEFAULT_MAX_LINES,\n    maxBytes: DEFAULT_MAX_BYTES,\n  });\n\n  let result = truncation.content;\n\n  if (truncation.truncated) {\n    // Write full output to temp file\n    const tempFile = writeTempFile(output);\n\n    // Inform the LLM where to find complete output\n    result += `\\n\\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`;\n    result += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;\n    result += ` Full output saved to: ${tempFile}]`;\n  }\n\n  return { content: [{ type: \"text\", text: result }] };\n}\n```\n\n**Key points:**\n- Use `truncateHead` for content where the beginning matters (search results, file reads)\n- Use `truncateTail` for content where the end matters (logs, command output)\n- Always inform the LLM when output is truncated and where to find the full version\n- Document the truncation limits in your tool's description\n\nSee [examples/extensions/truncated-tool.ts](../examples/extensions/truncated-tool.ts) for a complete example wrapping `rg` (ripgrep) with proper truncation.\n\n### Multiple Tools\n\nOne extension can register multiple tools with shared state:\n\n```typescript\nexport default function (pi: ExtensionAPI) {\n  let connection = null;\n\n  pi.registerTool({ name: \"db_connect\", ... });\n  pi.registerTool({ name: \"db_query\", ... });\n  pi.registerTool({ name: \"db_close\", ... });\n\n  pi.on(\"session_shutdown\", async () => {\n    connection?.close();\n  });\n}\n```\n\n### Custom Rendering\n\nTools can provide `renderCall` and `renderResult` for custom TUI display. See [tui.md](tui.md) for the full component API and [tool-execution.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/modes/interactive/components/tool-execution.ts) for how tool rows are composed.\n\nBy default, tool output is wrapped in a `Box` that handles padding and background. A defined `renderCall` or `renderResult` must return a `Component`. If a slot renderer is not defined, `tool-execution.ts` uses fallback rendering for that slot.\n\nSet `renderShell: \"self\"` when the tool should render its own shell instead of using the default `Box`. This is useful for tools that need complete control over framing or background behavior, for example large previews that must stay visually stable after the tool settles.\n\n```typescript\npi.registerTool({\n  name: \"my_tool\",\n  label: \"My Tool\",\n  description: \"Custom shell example\",\n  parameters: Type.Object({}),\n  renderShell: \"self\",\n  async execute() {\n    return { content: [{ type: \"text\", text: \"ok\" }], details: undefined };\n  },\n  renderCall(args, theme, context) {\n    return new Text(theme.fg(\"accent\", \"my custom shell\"), 0, 0);\n  },\n});\n```\n\n`renderCall` and `renderResult` each receive a `context` object with:\n- `args` - the current tool call arguments\n- `state` - shared row-local state across `renderCall` and `renderResult`\n- `lastComponent` - the previously returned component for that slot, if any\n- `invalidate()` - request a rerender of this tool row\n- `toolCallId`, `cwd`, `executionStarted`, `argsComplete`, `isPartial`, `expanded`, `showImages`, `isError`\n\nUse `context.state` for cross-slot shared state. Keep slot-local caches on the returned component instance when you want to reuse and mutate the same component across renders.\n\n#### renderCall\n\nRenders the tool call or header:\n\n```typescript\nimport { Text } from \"@earendil-works/pi-tui\";\n\nrenderCall(args, theme, context) {\n  const text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n  let content = theme.fg(\"toolTitle\", theme.bold(\"my_tool \"));\n  content += theme.fg(\"muted\", args.action);\n  if (args.text) {\n    content += \" \" + theme.fg(\"dim\", `\"${args.text}\"`);\n  }\n  text.setText(content);\n  return text;\n}\n```\n\n#### renderResult\n\nRenders the tool result or output:\n\n```typescript\nrenderResult(result, { expanded, isPartial }, theme, context) {\n  if (isPartial) {\n    return new Text(theme.fg(\"warning\", \"Processing...\"), 0, 0);\n  }\n\n  if (result.details?.error) {\n    return new Text(theme.fg(\"error\", `Error: ${result.details.error}`), 0, 0);\n  }\n\n  let text = theme.fg(\"success\", \"✓ Done\");\n  if (expanded && result.details?.items) {\n    for (const item of result.details.items) {\n      text += \"\\n  \" + theme.fg(\"dim\", item);\n    }\n  }\n  return new Text(text, 0, 0);\n}\n```\n\nIf a slot intentionally has no visible content, return an empty `Component` such as an empty `Container`.\n\n#### Keybinding Hints\n\nUse `keyHint()` to display keybinding hints that respect the active keybinding configuration:\n\n```typescript\nimport { keyHint } from \"@earendil-works/pi-coding-agent\";\n\nrenderResult(result, { expanded }, theme, context) {\n  let text = theme.fg(\"success\", \"✓ Done\");\n  if (!expanded) {\n    text += ` (${keyHint(\"app.tools.expand\", \"to expand\")})`;\n  }\n  return new Text(text, 0, 0);\n}\n```\n\nAvailable functions:\n- `keyHint(keybinding, description)` - Formats a configured keybinding id such as `\"app.tools.expand\"` or `\"tui.select.confirm\"`\n- `keyText(keybinding)` - Returns the raw configured key text for a keybinding id\n- `rawKeyHint(key, description)` - Format a raw key string\n\nUse namespaced keybinding ids:\n- Coding-agent ids use the `app.*` namespace, for example `app.tools.expand`, `app.editor.external`, `app.session.rename`\n- Shared TUI ids use the `tui.*` namespace, for example `tui.select.confirm`, `tui.select.cancel`, `tui.input.tab`\n\nFor the exhaustive list of keybinding ids and defaults, see [keybindings.md](keybindings.md). `keybindings.json` uses those same namespaced ids.\n\nCustom editors and `ctx.ui.custom()` components receive `keybindings: KeybindingsManager` as an injected argument. They should use that injected manager directly instead of calling `getKeybindings()` or `setKeybindings()`.\n\n#### Best Practices\n\n- Use `Text` with padding `(0, 0)`. The default Box handles padding.\n- Use `\\n` for multi-line content.\n- Handle `isPartial` for streaming progress.\n- Support `expanded` for detail on demand.\n- Keep default view compact.\n- Read `context.args` in `renderResult` instead of copying args into `context.state`.\n- Use `context.state` only for data that must be shared across call and result slots.\n- Reuse `context.lastComponent` when the same component instance can be updated in place.\n- Use `renderShell: \"self\"` only when the default boxed shell gets in the way. In self-shell mode the tool is responsible for its own framing, padding, and background.\n\n#### Fallback\n\nIf a slot renderer is not defined or throws:\n- `renderCall`: Shows the tool name\n- `renderResult`: Shows raw text from `content`\n\n### Dynamic Tool Loading\n\nExtensions can register many tools while keeping only a small initial set active. A tool can then add more tools with `pi.setActiveTools()` during execution. Pi detects purely additive changes, records the newly available tool names on that tool result, and applies the updated active set before the next model request.\n\nThis works with every model. Models with native deferred-loading support preserve the stable prompt prefix and load the new definitions at the tool-result position. Other models use the fallback described below.\n\nThe lifecycle is:\n\n1. Register every tool with `pi.registerTool()` so it appears in `pi.getAllTools()`.\n2. Keep loader tools, such as `search_tools`, active and leave searchable tools inactive.\n3. During loader execution, call `pi.setActiveTools([...currentTools, ...matchingTools])`. The change must be additive: do not remove currently active tools in the same call.\n4. Pi records which tools were added on the loader's tool result.\n5. Before the next model response, Pi exposes the added definitions using native deferred loading when supported, or the normal active tool list otherwise.\n\nYou do not need to return provider-specific tool references or mark the loader as a special search tool. The active-tool change is the signal. Names passed to `pi.setActiveTools()` must already be registered; unknown names are ignored.\n\n#### Models with native deferred loading\n\n- **Anthropic**\n  - **Models:** Sonnet, Opus, Fable version 4.5 or newer (without Haiku)\n  - **Native representation:** Deferred definitions use `defer_loading`; the load point uses `tool_reference` content.\n- **OpenAI**\n  - **Models:** `gpt-5.4` and newer family\n  - **Native representation:** Pi adds completed client `tool_search_call` and `tool_search_output` items at the load point.\n\nFor a verified custom model or proxy, native handling can be enabled with `compat.supportsToolReferences: true` for `anthropic-messages`, or `compat.supportsToolSearch: true` for `openai-responses` and `openai-codex-responses`. Leave these disabled unless the endpoint and model accept the corresponding native protocol.\n\n#### Fallback behavior\n\nFor all other models and providers, dynamic activation still works: Pi sends the complete current active tool list normally on the next request. The model can call the newly activated tools, but adding their definitions may invalidate the provider's cached prompt prefix.\n\nPi also uses this safe fallback when the active set is not purely additive, such as replacing one group of tools with another. Tool removals therefore work, but they do not use deferred loading.\n\nFor the best cache behavior, keep the loader tool active for the whole session and add tools instead of replacing the active set. Also note that activating a tool with `promptSnippet` or `promptGuidelines` rebuilds the system prompt; that system-prompt change can invalidate the prefix even when the provider supports deferred schemas. Lazily loaded tools should usually rely on their tool `description` and omit active-only prompt metadata.\n\n#### Search tool example\n\nThe following extension registers two searchable tools, removes them from the initial active set, and keeps only `search_tools` as their loader. The example uses simple keyword matching, but the search implementation could use BM25, embeddings, a remote catalog, or project-specific routing.\n\n```typescript\nimport type { ExtensionAPI } from \"@earendil-works/pi-coding-agent\";\nimport { Type } from \"typebox\";\n\nconst SEARCHABLE_TOOL_NAMES = new Set([\"lookup_weather\", \"search_issues\"]);\n\nexport default function (pi: ExtensionAPI) {\n  pi.registerTool({\n    name: \"lookup_weather\",\n    label: \"Lookup Weather\",\n    description: \"Look up the current weather for a city\",\n    parameters: Type.Object({ city: Type.String() }),\n    async execute(_toolCallId, params) {\n      return {\n        content: [{ type: \"text\", text: `Weather for ${params.city}: sunny` }],\n        details: {},\n      };\n    },\n  });\n\n  pi.registerTool({\n    name: \"search_issues\",\n    label: \"Search Issues\",\n    description: \"Search project issues by keyword\",\n    parameters: Type.Object({ query: Type.String() }),\n    async execute(_toolCallId, params) {\n      return {\n        content: [{ type: \"text\", text: `No open issues matching ${params.query}` }],\n        details: {},\n      };\n    },\n  });\n\n  pi.registerTool({\n    name: \"search_tools\",\n    label: \"Search Tools\",\n    description: \"Search for and enable tools relevant to a task\",\n    promptSnippet: \"Search for additional tools when the active tools cannot perform the task\",\n    promptGuidelines: [\n      \"Use search_tools when a task requires a capability that is not currently available.\",\n    ],\n    parameters: Type.Object({\n      query: Type.String({ description: \"Capability or task to search for\" }),\n      limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 10 })),\n    }),\n    async execute(_toolCallId, params) {\n      const terms = params.query.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);\n      const matches = pi.getAllTools()\n        .filter((tool) => SEARCHABLE_TOOL_NAMES.has(tool.name))\n        .map((tool) => ({\n          tool,\n          score: terms.reduce(\n            (score, term) =>\n              score + (`${tool.name} ${tool.description}`.toLowerCase().includes(term) ? 1 : 0),\n            0,\n          ),\n        }))\n        .filter((match) => match.score > 0)\n        .sort((a, b) => b.score - a.score)\n        .slice(0, params.limit ?? 3)\n        .map((match) => match.tool.name);\n\n      if (matches.length === 0) {\n        return {\n          content: [{ type: \"text\", text: `No tools found for: ${params.query}` }],\n          details: { matches: [] },\n        };\n      }\n\n      const active = pi.getActiveTools();\n      const added = matches.filter((name) => !active.includes(name));\n      pi.setActiveTools([...new Set([...active, ...added])]);\n\n      return {\n        content: [{\n          type: \"text\",\n          text: added.length > 0\n            ? `Loaded tools: ${added.join(\", \")}`\n            : `Matching tools already active: ${matches.join(\", \")}`,\n        }],\n        details: { matches, added },\n      };\n    },\n  });\n\n  pi.on(\"session_start\", () => {\n    // Keep searchable tools registered but initially inactive. Preserve built-ins\n    // and tools owned by other extensions, and keep the loader itself active.\n    const initialTools = pi.getActiveTools().filter(\n      (name) => !SEARCHABLE_TOOL_NAMES.has(name),\n    );\n    pi.setActiveTools([...new Set([...initialTools, \"search_tools\"])]);\n  });\n}\n```\n\nWhen `search_tools` adds a match, the model receives that definition on the immediately following request. On a native-capable model the definition is anchored after the search result without changing the initial tool-schema prefix. On other models it appears in the normal tool list on that same following request.\n\n## Custom UI\n\nExtensions can interact with users via `ctx.ui` methods and customize how messages/tools render.\n\n**For custom components, see [tui.md](tui.md)** which has copy-paste patterns for:\n- Selection dialogs (SelectList)\n- Async operations with cancel (BorderedLoader)\n- Settings toggles (SettingsList)\n- Status indicators (setStatus)\n- Working message, visibility, and indicator during streaming (`setWorkingMessage`, `setWorkingVisible`, `setWorkingIndicator`)\n- Widgets above/below editor (setWidget)\n- Autocomplete providers layered on top of built-in slash/path completion (addAutocompleteProvider)\n- Custom footers (setFooter)\n\n### Dialogs\n\n```typescript\n// Select from options\nconst choice = await ctx.ui.select(\"Pick one:\", [\"A\", \"B\", \"C\"]);\n\n// Confirm dialog\nconst ok = await ctx.ui.confirm(\"Delete?\", \"This cannot be undone\");\n\n// Text input\nconst name = await ctx.ui.input(\"Name:\", \"placeholder\");\n\n// Multi-line editor\nconst text = await ctx.ui.editor(\"Edit:\", \"prefilled text\");\n\n// Notification (non-blocking)\nctx.ui.notify(\"Done!\", \"info\");  // \"info\" | \"warning\" | \"error\"\n```\n\n#### Timed Dialogs with Countdown\n\nDialogs support a `timeout` option that auto-dismisses with a live countdown display:\n\n```typescript\n// Dialog shows \"Title (5s)\" → \"Title (4s)\" → ... → auto-dismisses at 0\nconst confirmed = await ctx.ui.confirm(\n  \"Timed Confirmation\",\n  \"This dialog will auto-cancel in 5 seconds. Confirm?\",\n  { timeout: 5000 }\n);\n\nif (confirmed) {\n  // User confirmed\n} else {\n  // User cancelled or timed out\n}\n```\n\n**Return values on timeout:**\n- `select()` returns `undefined`\n- `confirm()` returns `false`\n- `input()` returns `undefined`\n\n#### Manual Dismissal with AbortSignal\n\nFor more control (e.g., to distinguish timeout from user cancel), use `AbortSignal`:\n\n```typescript\nconst controller = new AbortController();\nconst timeoutId = setTimeout(() => controller.abort(), 5000);\n\nconst confirmed = await ctx.ui.confirm(\n  \"Timed Confirmation\",\n  \"This dialog will auto-cancel in 5 seconds. Confirm?\",\n  { signal: controller.signal }\n);\n\nclearTimeout(timeoutId);\n\nif (confirmed) {\n  // User confirmed\n} else if (controller.signal.aborted) {\n  // Dialog timed out\n} else {\n  // User cancelled (pressed Escape or selected \"No\")\n}\n```\n\nSee [examples/extensions/timed-confirm.ts](../examples/extensions/timed-confirm.ts) for complete examples.\n\n### Widgets, Status, and Footer\n\n```typescript\n// Status in footer (persistent until cleared)\nctx.ui.setStatus(\"my-ext\", \"Processing...\");\nctx.ui.setStatus(\"my-ext\", undefined);  // Clear\n\n// Working loader (shown during streaming)\nctx.ui.setWorkingMessage(\"Thinking deeply...\");\nctx.ui.setWorkingMessage();  // Restore default\nctx.ui.setWorkingVisible(false);  // Hide the built-in working loader row entirely\nctx.ui.setWorkingVisible(true);   // Show the built-in working loader row\n\n// Working indicator (shown during streaming)\nctx.ui.setWorkingIndicator({ frames: [ctx.ui.theme.fg(\"accent\", \"●\")] });  // Static dot\nctx.ui.setWorkingIndicator({\n  frames: [\n    ctx.ui.theme.fg(\"dim\", \"·\"),\n    ctx.ui.theme.fg(\"muted\", \"•\"),\n    ctx.ui.theme.fg(\"accent\", \"●\"),\n    ctx.ui.theme.fg(\"muted\", \"•\"),\n  ],\n  intervalMs: 120,\n});\nctx.ui.setWorkingIndicator({ frames: [] });  // Hide indicator\nctx.ui.setWorkingIndicator();  // Restore default spinner\n\n// Widget above editor (default)\nctx.ui.setWidget(\"my-widget\", [\"Line 1\", \"Line 2\"]);\n// Widget below editor\nctx.ui.setWidget(\"my-widget\", [\"Line 1\", \"Line 2\"], { placement: \"belowEditor\" });\nctx.ui.setWidget(\"my-widget\", (tui, theme) => new Text(theme.fg(\"accent\", \"Custom\"), 0, 0));\nctx.ui.setWidget(\"my-widget\", undefined);  // Clear\n\n// Custom footer (replaces built-in footer entirely)\nctx.ui.setFooter((tui, theme) => ({\n  render(width) { return [theme.fg(\"dim\", \"Custom footer\")]; },\n  invalidate() {},\n}));\nctx.ui.setFooter(undefined);  // Restore built-in footer\n\n// Terminal title\nctx.ui.setTitle(\"pi - my-project\");\n\n// Editor text\nctx.ui.setEditorText(\"Prefill text\");\nconst current = ctx.ui.getEditorText();\n\n// Paste into editor (triggers paste handling, including collapse for large content)\nctx.ui.pasteToEditor(\"pasted content\");\n\n// Stack custom autocomplete behavior on top of the built-in provider\nctx.ui.addAutocompleteProvider((current) => ({\n  triggerCharacters: [\"#\"],\n  async getSuggestions(lines, line, col, options) {\n    const beforeCursor = (lines[line] ?? \"\").slice(0, col);\n    const match = beforeCursor.match(/(?:^|[ \\t])#([^\\s#]*)$/);\n    if (!match) {\n      return current.getSuggestions(lines, line, col, options);\n    }\n\n    return {\n      prefix: `#${match[1] ?? \"\"}`,\n      items: [{ value: \"#2983\", label: \"#2983\", description: \"Extension API for autocomplete\" }],\n    };\n  },\n  applyCompletion(lines, line, col, item, prefix) {\n    return current.applyCompletion(lines, line, col, item, prefix);\n  },\n  shouldTriggerFileCompletion(lines, line, col) {\n    return current.shouldTriggerFileCompletion?.(lines, line, col) ?? true;\n  },\n}));\n\n// Tool output expansion\nconst wasExpanded = ctx.ui.getToolsExpanded();\nctx.ui.setToolsExpanded(true);\nctx.ui.setToolsExpanded(wasExpanded);\n\n// Custom editor (vim mode, emacs mode, etc.)\nctx.ui.setEditorComponent((tui, theme, keybindings) => new VimEditor(tui, theme, keybindings));\nconst currentEditor = ctx.ui.getEditorComponent();\nctx.ui.setEditorComponent((tui, theme, keybindings) =>\n  new WrappedEditor(tui, theme, keybindings, currentEditor?.(tui, theme, keybindings))\n);\nctx.ui.setEditorComponent(undefined);  // Restore default editor\n\n// Theme management (see themes.md for creating themes)\nconst themes = ctx.ui.getAllThemes();  // [{ name: \"dark\", path: \"/...\" | undefined }, ...]\nconst lightTheme = ctx.ui.getTheme(\"light\");  // Load without switching\nconst result = ctx.ui.setTheme(\"light\");  // Switch by name\nif (!result.success) {\n  ctx.ui.notify(`Failed: ${result.error}`, \"error\");\n}\nctx.ui.setTheme(lightTheme!);  // Or switch by Theme object\nctx.ui.theme.fg(\"accent\", \"styled text\");  // Access current theme\n```\n\nCustom working-indicator frames are rendered verbatim. If you want colors, add them to the frame strings yourself, for example with `ctx.ui.theme.fg(...)`.\n\n### Autocomplete Providers\n\nUse `ctx.ui.addAutocompleteProvider()` to stack custom autocomplete logic on top of the built-in slash-command and path provider. Set `triggerCharacters` for custom natural triggers such as `$`.\n\nTypical pattern:\n\n- inspect the text before the cursor\n- return your own suggestions when your extension-specific syntax matches\n- otherwise delegate to `current.getSuggestions(...)`\n- delegate `applyCompletion(...)` unless you need custom insertion behavior\n\n```typescript\npi.on(\"session_start\", (_event, ctx) => {\n  ctx.ui.addAutocompleteProvider((current) => ({\n    triggerCharacters: [\"#\"],\n    async getSuggestions(lines, cursorLine, cursorCol, options) {\n      const line = lines[cursorLine] ?? \"\";\n      const beforeCursor = line.slice(0, cursorCol);\n      const match = beforeCursor.match(/(?:^|[ \\t])#([^\\s#]*)$/);\n      if (!match) {\n        return current.getSuggestions(lines, cursorLine, cursorCol, options);\n      }\n\n      return {\n        prefix: `#${match[1] ?? \"\"}`,\n        items: [\n          { value: \"#2983\", label: \"#2983\", description: \"Extension API for registering custom @ autocomplete providers\" },\n          { value: \"#2753\", label: \"#2753\", description: \"Reload stale resource settings\" },\n        ],\n      };\n    },\n\n    applyCompletion(lines, cursorLine, cursorCol, item, prefix) {\n      return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);\n    },\n\n    shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {\n      return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;\n    },\n  }));\n});\n```\n\nSee [github-issue-autocomplete.ts](../examples/extensions/github-issue-autocomplete.ts) for a complete example that preloads the latest open GitHub issues with `gh issue list` and filters them locally for fast `#...` completion. It requires GitHub CLI (`gh`) and a GitHub repository checkout.\n\n### Custom Components\n\nFor complex UI, use `ctx.ui.custom()`. This temporarily replaces the editor with your component until `done()` is called:\n\n```typescript\nimport { Text, Component } from \"@earendil-works/pi-tui\";\n\nconst result = await ctx.ui.custom<boolean>((tui, theme, keybindings, done) => {\n  const text = new Text(\"Press Enter to confirm, Escape to cancel\", 1, 1);\n\n  text.onKey = (key) => {\n    if (key === \"return\") done(true);\n    if (key === \"escape\") done(false);\n    return true;\n  };\n\n  return text;\n});\n\nif (result) {\n  // User pressed Enter\n}\n```\n\nThe callback receives:\n- `tui` - TUI instance (for screen dimensions, focus management)\n- `theme` - Current theme for styling\n- `keybindings` - App keybinding manager (for checking shortcuts)\n- `done(value)` - Call to close component and return value\n\nSee [tui.md](tui.md) for the full component API.\n\n#### Overlay Mode (Experimental)\n\nPass `{ overlay: true }` to render the component as a floating modal on top of existing content, without clearing the screen:\n\n```typescript\nconst result = await ctx.ui.custom<string | null>(\n  (tui, theme, keybindings, done) => new MyOverlayComponent({ onClose: done }),\n  { overlay: true }\n);\n```\n\nFor advanced positioning (anchors, margins, percentages, responsive visibility), pass `overlayOptions`. Use `onHandle` to control focus or visibility programmatically:\n\n```typescript\nconst result = await ctx.ui.custom<string | null>(\n  (tui, theme, keybindings, done) => new MyOverlayComponent({ onClose: done }),\n  {\n    overlay: true,\n    overlayOptions: { anchor: \"top-right\", width: \"50%\", margin: 2 },\n    onHandle: (handle) => {\n      handle.focus(); // focus this overlay and bring it to the visual front\n      // handle.unfocus({ target: editorComponent }); // release input to a specific component\n      // handle.setHidden(true/false); // toggle visibility\n      // handle.hide(); // permanently remove\n    }\n  }\n);\n```\n\nA focused visible overlay can reclaim input after temporary non-overlay custom UI closes. If you intentionally want another component to keep input while the overlay stays visible, call `handle.unfocus({ target })`. Passing `{ target: null }` releases the overlay without focusing another component.\n\nSee [tui.md](tui.md) for the full `OverlayOptions` and `OverlayHandle` API and [overlay-qa-tests.ts](../examples/extensions/overlay-qa-tests.ts) for examples.\n\n### Custom Editor\n\nReplace the main input editor with a custom implementation (vim mode, emacs mode, etc.):\n\n```typescript\nimport { CustomEditor, type ExtensionAPI } from \"@earendil-works/pi-coding-agent\";\nimport { matchesKey } from \"@earendil-works/pi-tui\";\n\nclass VimEditor extends CustomEditor {\n  private mode: \"normal\" | \"insert\" = \"insert\";\n\n  handleInput(data: string): void {\n    if (matchesKey(data, \"escape\") && this.mode === \"insert\") {\n      this.mode = \"normal\";\n      return;\n    }\n    if (this.mode === \"normal\" && data === \"i\") {\n      this.mode = \"insert\";\n      return;\n    }\n    super.handleInput(data);  // App keybindings + text editing\n  }\n}\n\nexport default function (pi: ExtensionAPI) {\n  pi.on(\"session_start\", (_event, ctx) => {\n    ctx.ui.setEditorComponent((tui, theme, keybindings) =>\n      new VimEditor(tui, theme, keybindings)\n    );\n  });\n}\n```\n\n**Key points:**\n- Extend `CustomEditor` (not base `Editor`) to get app keybindings (escape to abort, ctrl+d, model switching)\n- Call `super.handleInput(data)` for keys you don't handle\n- Factory receives `tui`, `theme`, and `keybindings` from the app\n- Use `ctx.ui.getEditorComponent()` before `setEditorComponent()` to wrap the previously configured custom editor\n- Pass `undefined` to restore default: `ctx.ui.setEditorComponent(undefined)`\n\nTo compose with another extension that already replaced the editor, capture the previous factory before setting yours:\n\n```typescript\nconst previous = ctx.ui.getEditorComponent();\nctx.ui.setEditorComponent((tui, theme, keybindings) =>\n  new MyEditor(tui, theme, keybindings, { base: previous?.(tui, theme, keybindings) })\n);\n```\n\nSee [tui.md](tui.md) Pattern 7 for a complete example with mode indicator.\n\n### Message and Entry Rendering\n\nRegister a custom renderer for messages with your `customType`. Use message renderers for content that should participate in LLM context:\n\n```typescript\nimport { Text } from \"@earendil-works/pi-tui\";\n\npi.registerMessageRenderer(\"my-extension\", (message, options, theme) => {\n  const { expanded, outputPad } = options;\n  let text = theme.fg(\"accent\", `[${message.customType}] `);\n  text += message.content;\n\n  if (expanded && message.details) {\n    text += \"\\n\" + theme.fg(\"dim\", JSON.stringify(message.details, null, 2));\n  }\n\n  return new Text(text, outputPad, 0);\n});\n```\n\nMessages are sent via `pi.sendMessage()`:\n\n```typescript\npi.sendMessage({\n  customType: \"my-extension\",  // Matches registerMessageRenderer\n  content: \"Status update\",\n  display: true,               // Show in TUI\n  details: { ... },            // Available in renderer\n});\n```\n\nFor TUI-only content that should not be sent to the LLM, render custom entries instead:\n\n```typescript\npi.registerEntryRenderer(\"my-card\", (entry, options, theme) => {\n  return new Text(theme.fg(\"accent\", JSON.stringify(entry.data)));\n});\n\npi.appendEntry(\"my-card\", { status: \"done\" });\n```\n\n### Theme Colors\n\nAll render functions receive a `theme` object. See [themes.md](themes.md) for creating custom themes and the full color palette.\n\n```typescript\n// Foreground colors\ntheme.fg(\"toolTitle\", text)   // Tool names\ntheme.fg(\"accent\", text)      // Highlights\ntheme.fg(\"success\", text)     // Success (green)\ntheme.fg(\"error\", text)       // Errors (red)\ntheme.fg(\"warning\", text)     // Warnings (yellow)\ntheme.fg(\"muted\", text)       // Secondary text\ntheme.fg(\"dim\", text)         // Tertiary text\n\n// Text styles\ntheme.bold(text)\ntheme.italic(text)\ntheme.strikethrough(text)\n```\n\nFor syntax highlighting in custom tool renderers:\n\n```typescript\nimport { highlightCode, getLanguageFromPath } from \"@earendil-works/pi-coding-agent\";\n\n// Highlight code with explicit language\nconst highlighted = highlightCode(\"const x = 1;\", \"typescript\", theme);\n\n// Auto-detect language from file path\nconst lang = getLanguageFromPath(\"/path/to/file.rs\");  // \"rust\"\nconst highlighted = highlightCode(code, lang, theme);\n```\n\n## Error Handling\n\n- Extension errors are logged, agent continues\n- `tool_call` errors block the tool (fail-safe)\n- Tool `execute` errors must be signaled by throwing; the thrown error is caught, reported to the LLM with `isError: true`, and execution continues\n\n## Mode Behavior\n\n| Mode | `ctx.mode` | `ctx.hasUI` | Notes |\n|------|------------|-------------|-------|\n| Interactive | `\"tui\"` | `true` | Full TUI with terminal rendering |\n| RPC (`--mode rpc`) | `\"rpc\"` | `true` | Dialogs and notifications via JSON protocol; `custom()` returns `undefined`. See [rpc.md](rpc.md) |\n| JSON (`--mode json`) | `\"json\"` | `false` | Event stream to stdout; UI methods are no-ops |\n| Print (`-p`) | `\"print\"` | `false` | Extensions run but can't prompt |\n\nUse `ctx.mode === \"tui\"` before TUI-specific features (`custom()`, component factories, terminal input). Use `ctx.hasUI` before dialog and notification methods that work in both TUI and RPC modes.\n\n## Examples Reference\n\nAll examples in [examples/extensions/](../examples/extensions/).\n\n| Example | Description | Key APIs |\n|---------|-------------|----------|\n| **Tools** |||\n| `hello.ts` | Minimal tool registration | `registerTool` |\n| `question.ts` | Tool with user interaction | `registerTool`, `ui.select` |\n| `questionnaire.ts` | Multi-step wizard tool | `registerTool`, `ui.custom` |\n| `todo.ts` | Stateful tool with persistence | `registerTool`, `appendEntry`, `renderResult`, session events |\n| `dynamic-tools.ts` | Register tools after startup and during commands | `registerTool`, `session_start`, `registerCommand` |\n| `structured-output.ts` | Final structured-output tool with `terminate: true` | `registerTool`, terminating tool results |\n| `truncated-tool.ts` | Output truncation example | `registerTool`, `truncateHead` |\n| `tool-override.ts` | Override built-in read tool | `registerTool` (same name as built-in) |\n| **Commands** |||\n| `pirate.ts` | Modify system prompt per-turn | `registerCommand`, `before_agent_start` |\n| `summarize.ts` | Conversation summary command | `registerCommand`, `ui.custom` |\n| `handoff.ts` | Cross-provider model handoff | `registerCommand`, `ui.editor`, `ui.custom` |\n| `qna.ts` | Q&A with custom UI | `registerCommand`, `ui.custom`, `setEditorText` |\n| `send-user-message.ts` | Inject user messages | `registerCommand`, `sendUserMessage` |\n| `reload-runtime.ts` | Reload command and LLM tool handoff | `registerCommand`, `ctx.reload()`, `sendUserMessage` |\n| `shutdown-command.ts` | Graceful shutdown command | `registerCommand`, `shutdown()` |\n| **Events & Gates** |||\n| `permission-gate.ts` | Block dangerous commands | `on(\"tool_call\")`, `ui.confirm` |\n| `project-trust.ts` | Decide or defer project trust from a user/global or CLI extension | `on(\"project_trust\")`, trust UI, required trust result |\n| `protected-paths.ts` | Block writes to specific paths | `on(\"tool_call\")` |\n| `confirm-destructive.ts` | Confirm session changes | `on(\"session_before_switch\")`, `on(\"session_before_fork\")` |\n| `dirty-repo-guard.ts` | Warn on dirty git repo | `on(\"session_before_*\")`, `exec` |\n| `input-transform.ts` | Transform user input | `on(\"input\")` |\n| `input-transform-streaming.ts` | Streaming-aware input transform | `on(\"input\")`, `streamingBehavior` |\n| `model-status.ts` | React to model changes | `on(\"model_select\")`, `setStatus` |\n| `provider-payload.ts` | Inspect payloads and provider response headers | `on(\"before_provider_request\")`, `on(\"after_provider_response\")` |\n| `system-prompt-header.ts` | Display system prompt info | `on(\"agent_start\")`, `getSystemPrompt` |\n| `claude-rules.ts` | Load rules from files | `on(\"session_start\")`, `on(\"before_agent_start\")` |\n| `prompt-customizer.ts` | Add context-aware tool guidance using `systemPromptOptions` | `on(\"before_agent_start\")`, `BuildSystemPromptOptions` |\n| `file-trigger.ts` | File watcher triggers messages | `sendMessage` |\n| **Compaction & Sessions** |||\n| `custom-compaction.ts` | Custom compaction summary | `on(\"session_before_compact\")` |\n| `trigger-compact.ts` | Trigger compaction manually | `compact()` |\n| `git-checkpoint.ts` | Git stash on turns | `on(\"turn_start\")`, `on(\"session_before_fork\")`, `exec` |\n| `git-merge-and-resolve.ts` | Fetch, merge, and resolve conflicts | `on(\"agent_end\")`, `exec`, `sendUserMessage` |\n| `auto-commit-on-exit.ts` | Commit on shutdown | `on(\"session_shutdown\")`, `exec` |\n| **UI Components** |||\n| `status-line.ts` | Footer status indicator | `setStatus`, session events |\n| `working-indicator.ts` | Customize the streaming working indicator | `setWorkingIndicator`, `registerCommand` |\n| `github-issue-autocomplete.ts` | Add `#1234` issue completions on top of built-in autocomplete by preloading recent open issues from `gh issue list` | `addAutocompleteProvider`, `on(\"session_start\")`, `exec` |\n| `custom-footer.ts` | Replace footer entirely | `registerCommand`, `setFooter` |\n| `custom-header.ts` | Replace startup header | `on(\"session_start\")`, `setHeader` |\n| `modal-editor.ts` | Vim-style modal editor | `setEditorComponent`, `CustomEditor` |\n| `rainbow-editor.ts` | Custom editor styling | `setEditorComponent` |\n| `widget-placement.ts` | Widget above/below editor | `setWidget` |\n| `overlay-test.ts` | Overlay components | `ui.custom` with overlay options |\n| `overlay-qa-tests.ts` | Comprehensive overlay tests | `ui.custom`, all overlay options |\n| `notify.ts` | Simple notifications | `ui.notify` |\n| `timed-confirm.ts` | Dialogs with timeout | `ui.confirm` with timeout/signal |\n| `mac-system-theme.ts` | Auto-switch theme | `setTheme`, `exec` |\n| **Complex Extensions** |||\n| `plan-mode/` | Full plan mode implementation | All event types, `registerCommand`, `registerShortcut`, `registerFlag`, `setStatus`, `setWidget`, `sendMessage`, `setActiveTools` |\n| `preset.ts` | Saveable presets (model, tools, thinking) | `registerCommand`, `registerShortcut`, `registerFlag`, `setModel`, `setActiveTools`, `setThinkingLevel`, `appendEntry` |\n| `tools.ts` | Toggle tools on/off UI | `registerCommand`, `setActiveTools`, `SettingsList`, session events |\n| **Remote & Sandbox** |||\n| `ssh.ts` | SSH remote execution | `registerFlag`, `on(\"user_bash\")`, `on(\"before_agent_start\")`, tool operations |\n| `interactive-shell.ts` | Persistent shell session | `on(\"user_bash\")` |\n| `sandbox/` | Sandboxed tool execution | Tool operations |\n| `gondolin/` | Route built-in tools and `!` commands into a Gondolin micro-VM | Tool operations, built-in tool overrides, `on(\"user_bash\")` |\n| `subagent/` | Spawn sub-agents | `registerTool`, `exec` |\n| **Games** |||\n| `snake.ts` | Snake game | `registerCommand`, `ui.custom`, keyboard handling |\n| `space-invaders.ts` | Space Invaders game | `registerCommand`, `ui.custom` |\n| `doom-overlay/` | Doom in overlay | `ui.custom` with overlay |\n| **Providers** |||\n| `custom-provider-anthropic/` | Custom Anthropic proxy | `registerProvider` |\n| `custom-provider-gitlab-duo/` | GitLab Duo integration | `registerProvider` with OAuth |\n| **Messages & Communication** |||\n| `message-renderer.ts` | Custom message rendering | `registerMessageRenderer`, `sendMessage` |\n| `entry-renderer.ts` | TUI-only custom entry rendering | `registerEntryRenderer`, `appendEntry` |\n| `event-bus.ts` | Inter-extension events | `pi.events` |\n| **Session Metadata** |||\n| `session-name.ts` | Name sessions for selector | `setSessionName`, `getSessionName` |\n| `bookmark.ts` | Bookmark entries for /tree | `setLabel` |\n| **Misc** |||\n| `inline-bash.ts` | Inline bash in tool calls | `on(\"tool_call\")` |\n| `bash-spawn-hook.ts` | Adjust bash command, cwd, and env before execution | `createBashTool`, `spawnHook` |\n| `with-deps/` | Extension with npm dependencies | Package structure with `package.json` |","sourceFile":"extensions.md"},"index":{"title":"Pi Documentation","markdown":"Pi is a minimal terminal coding harness. It is designed to stay small at the core while being extended through TypeScript extensions, skills, prompt templates, themes, and pi packages.\n\n## Quick start\n\nInstall Pi with npm:\n\n```bash\nnpm install -g --ignore-scripts @earendil-works/pi-coding-agent\n```\n\n`--ignore-scripts` disables dependency lifecycle scripts during install. Pi does not require install scripts for normal npm installs.\n\nOn Linux or macOS, you can also use the installer:\n\n```bash\ncurl -fsSL https://pi.dev/install.sh | sh\n```\n\nTo uninstall pi itself, use npm for curl and npm installs:\n\n```bash\nnpm uninstall -g @earendil-works/pi-coding-agent\n```\n\nFor pnpm, Yarn, or Bun installs, use the matching global remove command: `pnpm remove -g @earendil-works/pi-coding-agent`, `yarn global remove @earendil-works/pi-coding-agent`, or `bun uninstall -g @earendil-works/pi-coding-agent`.\n\nThen run it in a project directory:\n\n```bash\npi\n```\n\nAuthenticate with `/login` for subscription providers, or set an API key such as `ANTHROPIC_API_KEY` before starting pi.\n\nFor the full first-run flow, see [Quickstart](quickstart.md).\n\n## Start here\n\n- [Quickstart](quickstart.md) - install, authenticate, and run a first session.\n- [Using Pi](usage.md) - interactive mode, slash commands, context files, and CLI reference.\n- [Providers](providers.md) - subscription and API-key setup for built-in providers.\n- [llama.cpp](llama-cpp.md) - run a local router and manage models with `/llama`.\n- [Security](security.md) - project trust, sandbox boundaries, and vulnerability reporting.\n- [Containerization](containerization.md) - sandbox pi with Gondolin, Docker, or OpenShell.\n- [Settings](settings.md) - global and project settings.\n- [Keybindings](keybindings.md) - default shortcuts and custom keybindings.\n- [Sessions](sessions.md) - session management, branching, and tree navigation.\n- [Compaction](compaction.md) - context compaction and branch summarization.\n\n## Customization\n\n- [Extensions](extensions.md) - TypeScript modules for tools, commands, events, and custom UI.\n- [Skills](skills.md) - Agent Skills for reusable on-demand capabilities.\n- [Prompt templates](prompt-templates.md) - reusable prompts that expand from slash commands.\n- [Themes](themes.md) - built-in and custom terminal themes.\n- [Pi packages](packages.md) - bundle and share extensions, skills, prompts, and themes.\n- [Custom models](models.md) - add model entries for supported provider APIs.\n- [Custom providers](custom-provider.md) - implement custom APIs and OAuth flows.\n\n## Programmatic usage\n\n- [SDK](sdk.md) - embed pi in Node.js applications.\n- [RPC mode](rpc.md) - integrate over stdin/stdout JSONL.\n- [JSON event stream mode](json.md) - print mode with structured events.\n- [TUI components](tui.md) - build custom terminal UI for extensions.\n\n## Reference\n\n- [Environment variables](environment-variables.md) - Pi process configuration and session metadata available to bash tools.\n- [Session format](session-format.md) - JSONL session file format, entry types, and SessionManager API.\n\n## Platform setup\n\n- [Windows](windows.md)\n- [Termux on Android](termux.md)\n- [tmux](tmux.md)\n- [Terminal setup](terminal-setup.md)\n- [Shell aliases](shell-aliases.md)\n\n## Development\n\n- [Development](development.md) - local setup, project structure, and debugging.","sourceFile":"index.md"},"json":{"title":"JSON Event Stream Mode","markdown":"```bash\npi --mode json \"Your prompt\"\n```\n\nOutputs all session events as JSON lines to stdout. Useful for integrating pi into other tools or custom UIs.\n\n## Event Types\n\nWire events use `JsonAgentSessionEvent`. It matches\n[`AgentSessionEvent`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/agent-session.ts)\nexcept that streaming message updates omit cumulative snapshots:\n\n```typescript\ntype WithoutPartial<T> = T extends { partial: unknown } ? Omit<T, \"partial\"> : T;\n\ntype JsonAgentSessionEvent =\n  | Exclude<AgentSessionEvent, { type: \"message_update\" }>\n  | {\n      type: \"message_update\";\n      assistantMessageEvent: WithoutPartial<AssistantMessageEvent>;\n    };\n```\n\n`queue_update` emits the full pending steering and follow-up queues whenever they change. `compaction_start` and `compaction_end` cover both manual and automatic compaction.\n\nOther base events come from\n[`AgentEvent`](https://github.com/earendil-works/pi-mono/blob/main/packages/agent/src/types.ts):\n\n```typescript\ntype AgentEvent =\n  // Agent lifecycle\n  | { type: \"agent_start\" }\n  | { type: \"agent_end\"; messages: AgentMessage[] }\n  // Turn lifecycle\n  | { type: \"turn_start\" }\n  | { type: \"turn_end\"; message: AgentMessage; toolResults: ToolResultMessage[] }\n  // Message lifecycle\n  | { type: \"message_start\"; message: AgentMessage }\n  | { type: \"message_update\"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent }\n  | { type: \"message_end\"; message: AgentMessage }\n  // Tool execution\n  | { type: \"tool_execution_start\"; toolCallId: string; toolName: string; args: any }\n  | { type: \"tool_execution_update\"; toolCallId: string; toolName: string; args: any; partialResult: any }\n  | { type: \"tool_execution_end\"; toolCallId: string; toolName: string; result: any; isError: boolean };\n```\n\n## Message Types\n\nBase messages from [`packages/ai/src/types.ts`](https://github.com/earendil-works/pi-mono/blob/main/packages/ai/src/types.ts#L134):\n- `UserMessage` (line 134)\n- `AssistantMessage` (line 140)\n- `ToolResultMessage` (line 152)\n\nExtended messages from [`packages/coding-agent/src/core/messages.ts`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/messages.ts#L29):\n- `BashExecutionMessage` (line 29)\n- `CustomMessage` (line 46)\n- `BranchSummaryMessage` (line 55)\n- `CompactionSummaryMessage` (line 62)\n\n## Output Format\n\nEach line is a JSON object. The first line is the session header:\n\n```json\n{\"type\":\"session\",\"version\":3,\"id\":\"uuid\",\"timestamp\":\"...\",\"cwd\":\"/path\"}\n```\n\nFollowed by events as they occur:\n\n```json\n{\"type\":\"agent_start\"}\n{\"type\":\"turn_start\"}\n{\"type\":\"message_start\",\"message\":{\"role\":\"assistant\",\"content\":[],...}}\n{\"type\":\"message_update\",\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"Hello\"}}\n{\"type\":\"message_end\",\"message\":{...}}\n{\"type\":\"turn_end\",\"message\":{...},\"toolResults\":[]}\n{\"type\":\"agent_end\",\"messages\":[...]}\n```\n\n`message_update` records are delta-only. They omit both the cumulative `message` field and\n`assistantMessageEvent.partial` to keep stream size linear. Use `contentIndex` and `delta`\nto assemble live text, thinking, or tool-call arguments if needed. `message_end` contains\nthe final authoritative message.\n\n## Example\n\n```bash\npi --mode json \"List files\" 2>/dev/null | jq -c 'select(.type == \"message_end\")'\n```","sourceFile":"json.md"},"keybindings":{"title":"Keybindings","markdown":"All keyboard shortcuts can be customized via `~/.pi/agent/keybindings.json`. Each action can be bound to one or more keys.\n\nThe config file uses the same namespaced keybinding ids that pi uses internally and that extension authors use in `keyHint()` and injected `keybindings` managers.\n\nOlder configs using pre-namespaced ids such as `cursorUp` or `expandTools` are migrated automatically to the namespaced ids on startup.\n\nAfter editing `keybindings.json`, run `/reload` in pi to apply the changes without restarting the session.\n\n## Key Format\n\n`modifier+key` where modifiers are `ctrl`, `shift`, `alt`, `super` (combinable) and keys are:\n\n- **Letters:** `a-z`\n- **Digits:** `0-9`\n- **Special:** `escape`, `esc`, `enter`, `return`, `tab`, `space`, `backspace`, `delete`, `insert`, `clear`, `home`, `end`, `pageUp`, `pageDown`, `up`, `down`, `left`, `right`\n- **Function:** `f1`-`f12`\n- **Symbols:** `` ` ``, `-`, `=`, `[`, `]`, `\\`, `;`, `'`, `,`, `.`, `/`, `!`, `@`, `#`, `$`, `%`, `^`, `&`, `*`, `(`, `)`, `_`, `+`, `|`, `~`, `{`, `}`, `:`, `<`, `>`, `?`\n\nModifier combinations: `ctrl+shift+x`, `alt+ctrl+x`, `ctrl+shift+alt+x`, `super+k`, `ctrl+super+k`, `ctrl+1`, etc.\n\n`super` bindings require a terminal that reports the modifier separately, typically through the Kitty keyboard protocol. They may not work in terminals without that support.\n\n## All Actions\n\n### TUI Editor Cursor Movement\n\n| Keybinding id | Default | Description |\n|--------|---------|-------------|\n| `tui.editor.cursorUp` | `up` | Move cursor up, browsing older history at the top |\n| `tui.editor.cursorDown` | `down` | Move cursor down, browsing newer history at the bottom |\n| `tui.editor.historyPrevious` | *(none)* | Select the previous prompt history entry |\n| `tui.editor.historyNext` | *(none)* | Select the next prompt history entry |\n| `tui.editor.cursorLeft` | `left`, `ctrl+b` | Move cursor left |\n| `tui.editor.cursorRight` | `right`, `ctrl+f` | Move cursor right |\n| `tui.editor.cursorWordLeft` | `alt+left`, `ctrl+left`, `alt+b` | Move cursor word left |\n| `tui.editor.cursorWordRight` | `alt+right`, `ctrl+right`, `alt+f` | Move cursor word right |\n| `tui.editor.cursorLineStart` | `home`, `ctrl+home`, `ctrl+a` | Move to line start |\n| `tui.editor.cursorLineEnd` | `end`, `ctrl+end`, `ctrl+e` | Move to line end |\n| `tui.editor.jumpForward` | `ctrl+]` | Jump forward to character |\n| `tui.editor.jumpBackward` | `ctrl+alt+]` | Jump backward to character |\n| `tui.editor.pageUp` | `pageUp`, `ctrl+pageUp` | Scroll up by page |\n| `tui.editor.pageDown` | `pageDown`, `ctrl+pageDown` | Scroll down by page |\n\nThe dedicated history actions always change history entries, regardless of the cursor position in a multiline prompt. Explicit history bindings take precedence over application actions while the main editor is focused, so binding `tui.editor.historyPrevious` to `ctrl+p` overrides model cycling in that context without changing `Ctrl+P` in selectors.\n\n### TUI Editor Deletion\n\n| Keybinding id | Default | Description |\n|--------|---------|-------------|\n| `tui.editor.deleteCharBackward` | `backspace` | Delete character backward |\n| `tui.editor.deleteCharForward` | `delete`, `ctrl+d` | Delete character forward |\n| `tui.editor.deleteWordBackward` | `ctrl+w`, `alt+backspace` | Delete word backward |\n| `tui.editor.deleteWordForward` | `alt+d`, `alt+delete` | Delete word forward |\n| `tui.editor.deleteToLineStart` | `ctrl+u` | Delete to line start |\n| `tui.editor.deleteToLineEnd` | `ctrl+k` | Delete to line end |\n\n### TUI Input\n\n| Keybinding id | Default | Description |\n|--------|---------|-------------|\n| `tui.input.newLine` | `shift+enter`, `ctrl+j` | Insert new line |\n| `tui.input.submit` | `enter` | Submit input |\n| `tui.input.tab` | `tab` | Tab / autocomplete |\n\n### TUI Kill Ring\n\n| Keybinding id | Default | Description |\n|--------|---------|-------------|\n| `tui.editor.yank` | `ctrl+y` | Paste most recently deleted text |\n| `tui.editor.yankPop` | `alt+y` | Cycle through deleted text after yank |\n| `tui.editor.undo` | `ctrl+-` | Undo last edit |\n\n### TUI Clipboard and Selection\n\n| Keybinding id | Default | Description |\n|--------|---------|-------------|\n| `tui.input.copy` | `ctrl+c` | Copy selection |\n| `tui.select.up` | `up` | Move selection up |\n| `tui.select.down` | `down` | Move selection down |\n| `tui.select.pageUp` | `pageUp` | Page up in list |\n| `tui.select.pageDown` | `pageDown` | Page down in list |\n| `tui.select.confirm` | `enter` | Confirm selection |\n| `tui.select.cancel` | `escape`, `ctrl+c` | Cancel selection |\n\n### TUI Fullscreen Viewport\n\nThese actions apply when interactive mode uses `--tui-mode fullscreen` and target the primary transcript scroll region. Two-finger trackpad and mouse-wheel input scroll the region under the pointer, falling back to the transcript over the fixed editor/status/footer dock. Clicking an OSC 8 hyperlink opens it in the default handler. Dragging with the primary mouse button selects text and copies it to the clipboard; holding at the transcript's top or bottom edge auto-scrolls into off-screen content.\n\nFullscreen transcript bindings take precedence over editor bindings. The default unmodified navigation keys therefore control the transcript in fullscreen mode, while their `ctrl` variants continue to control the editor. Outside fullscreen mode, both variants control the editor.\n\n| Key | Default mode | Fullscreen mode |\n|-----|--------------|-----------------|\n| `home`, `end` | Editor | Transcript |\n| `ctrl+home`, `ctrl+end` | Editor | Editor |\n| `pageUp`, `pageDown` | Editor | Transcript |\n| `ctrl+pageUp`, `ctrl+pageDown` | Editor | Editor |\n\nThis routing remains configurable through the ordinary action bindings. For example, `\"tui.altScreen.pageUp\": \"ctrl+pageUp\"` makes `pageUp` control the editor and `ctrl+pageUp` control the transcript in fullscreen mode. Bind `tui.altScreen.halfPageUp` and `tui.altScreen.halfPageDown` for smaller transcript steps while keeping the full-page bindings. Setting `\"tui.altScreen.pageUp\": []` disables that transcript shortcut entirely. User bindings replace the defaults for that action.\n\n| Keybinding id | Default | Description |\n|--------|---------|-------------|\n| `tui.altScreen.pageUp` | `pageUp` | Scroll the transcript up by one page |\n| `tui.altScreen.pageDown` | `pageDown` | Scroll the transcript down by one page |\n| `tui.altScreen.halfPageUp` | *(none)* | Scroll the transcript up by half a page |\n| `tui.altScreen.halfPageDown` | *(none)* | Scroll the transcript down by half a page |\n| `tui.altScreen.previousPrompt` | `ctrl+shift+up` | Jump to the previous marked message |\n| `tui.altScreen.nextPrompt` | `ctrl+shift+down` | Jump to the next marked message |\n| `tui.altScreen.top` | `home` | Scroll to the beginning of the transcript |\n| `tui.altScreen.bottom` | `end` | Scroll to the transcript end and follow new output |\n\n### Application\n\n| Keybinding id | Default | Description |\n|--------|---------|-------------|\n| `app.interrupt` | `escape` | Cancel / abort |\n| `app.clear` | `ctrl+c` | Clear editor (first) / exit (second) |\n| `app.exit` | `ctrl+d` | Exit (when editor empty) |\n| `app.suspend` | `ctrl+z` (none on Windows) | Suspend to background |\n| `app.editor.external` | `ctrl+g` | Open in external editor (`externalEditor`, `$VISUAL`, `$EDITOR`, Notepad on Windows, or `nano` elsewhere) |\n| `app.clipboard.pasteImage` | `ctrl+v` (`alt+v` on Windows) | Paste image or text from clipboard |\n\n### Sessions\n\n| Keybinding id | Default | Description |\n|--------|---------|-------------|\n| `app.session.new` | *(none)* | Start a new session (`/new`) |\n| `app.session.tree` | *(none)* | Open session tree navigator (`/tree`) |\n| `app.session.fork` | *(none)* | Fork current session (`/fork`) |\n| `app.session.resume` | *(none)* | Open session resume picker (`/resume`) |\n| `app.session.togglePath` | `ctrl+p` | Toggle path display |\n| `app.session.toggleSort` | `ctrl+s` | Toggle sort mode |\n| `app.session.toggleNamedFilter` | `ctrl+n` | Toggle named-only filter |\n| `app.session.rename` | `ctrl+r` | Rename session |\n| `app.session.delete` | `ctrl+d` | Delete session |\n| `app.session.deleteNoninvasive` | `ctrl+backspace` | Delete session when query is empty |\n\n### Models and Thinking\n\n| Keybinding id | Default | Description |\n|--------|---------|-------------|\n| `app.model.select` | `ctrl+l` | Open model selector |\n| `app.model.cycleForward` | `ctrl+p` | Cycle to next model |\n| `app.model.cycleBackward` | `shift+ctrl+p` | Cycle to previous model |\n| `app.thinking.cycle` | `shift+tab` | Cycle thinking level |\n| `app.thinking.toggle` | `ctrl+t` | Collapse or expand thinking blocks |\n\n### Display and Message Queue\n\n| Keybinding id | Default | Description |\n|--------|---------|-------------|\n| `app.tools.expand` | `ctrl+o` | Collapse or expand tool output |\n| `app.message.copy` | `ctrl+x` | Copy the last assistant message, or the selected message in `/tree` |\n| `app.message.followUp` | `alt+enter` | Queue follow-up message |\n| `app.message.dequeue` | `alt+up` | Restore queued messages to editor |\n\n### Tree Navigation\n\n| Keybinding id | Default | Description |\n|--------|---------|-------------|\n| `app.tree.foldOrUp` | `ctrl+left`, `alt+left` | Fold current branch segment, or jump to the previous segment start |\n| `app.tree.unfoldOrDown` | `ctrl+right`, `alt+right` | Unfold current branch segment, or jump to the next segment start or branch end |\n| `app.tree.editLabel` | `shift+l` | Edit the label on the selected tree node |\n| `app.tree.toggleLabelTimestamp` | `shift+t` | Toggle label timestamps in the tree |\n| `app.tree.filter.default` | `ctrl+d` | Set tree filter to default view |\n| `app.tree.filter.noTools` | `ctrl+t` | Toggle tree filter that hides tool results |\n| `app.tree.filter.userOnly` | `ctrl+u` | Toggle tree filter that shows only user messages |\n| `app.tree.filter.labeledOnly` | `ctrl+l` | Toggle tree filter that shows only labeled entries |\n| `app.tree.filter.all` | `ctrl+a` | Toggle tree filter that shows all entries |\n| `app.tree.filter.cycleForward` | `ctrl+o` | Cycle tree filter forward |\n| `app.tree.filter.cycleBackward` | `shift+ctrl+o` | Cycle tree filter backward |\n\n### Scoped Models Selector\n\nUsed inside the scoped models selector (opened via `/scoped-models`).\n\n| Keybinding id | Default | Description |\n|--------|---------|-------------|\n| `app.models.save` | `ctrl+s` | Save current model selection to settings |\n| `app.models.enableAll` | `ctrl+a` | Enable all models (or all matching the current search) |\n| `app.models.clearAll` | `ctrl+x` | Clear all models (or all matching the current search) |\n| `app.models.toggleProvider` | `ctrl+p` | Toggle all models for the current provider |\n| `app.models.reorderUp` | `alt+up` | Move the selected model up in the cycle order |\n| `app.models.reorderDown` | `alt+down` | Move the selected model down in the cycle order |\n\n## Custom Configuration\n\nCreate `~/.pi/agent/keybindings.json`:\n\n```json\n{\n  \"tui.editor.historyPrevious\": \"ctrl+p\",\n  \"tui.editor.historyNext\": \"ctrl+n\",\n  \"tui.editor.deleteWordBackward\": [\"ctrl+w\", \"alt+backspace\"]\n}\n```\n\nEach action can have a single key or an array of keys. User config overrides defaults.\n\nOn native Windows, `app.suspend` has no default binding because Windows terminals do not support Unix job control. If you bind it manually, pi shows a status message instead of suspending. In WSL, the normal Linux `ctrl+z`/`fg` behavior still applies.\n\n### Emacs Example\n\n```json\n{\n  \"tui.editor.historyPrevious\": \"ctrl+p\",\n  \"tui.editor.historyNext\": \"ctrl+n\",\n  \"tui.editor.cursorLeft\": [\"left\", \"ctrl+b\"],\n  \"tui.editor.cursorRight\": [\"right\", \"ctrl+f\"],\n  \"tui.editor.cursorWordLeft\": [\"alt+left\", \"alt+b\"],\n  \"tui.editor.cursorWordRight\": [\"alt+right\", \"alt+f\"],\n  \"tui.editor.deleteCharForward\": [\"delete\", \"ctrl+d\"],\n  \"tui.editor.deleteCharBackward\": [\"backspace\", \"ctrl+h\"],\n  \"tui.input.newLine\": [\"shift+enter\", \"ctrl+j\"]\n}\n```\n\n### Vim Example\n\n```json\n{\n  \"tui.editor.cursorUp\": [\"up\", \"alt+k\"],\n  \"tui.editor.cursorDown\": [\"down\", \"alt+j\"],\n  \"tui.editor.cursorLeft\": [\"left\", \"alt+h\"],\n  \"tui.editor.cursorRight\": [\"right\", \"alt+l\"],\n  \"tui.editor.cursorWordLeft\": [\"alt+left\", \"alt+b\"],\n  \"tui.editor.cursorWordRight\": [\"alt+right\", \"alt+w\"]\n}\n```","sourceFile":"keybindings.md"},"llama-cpp":{"title":"llama.cpp","markdown":"Pi supports the [llama.cpp](https://github.com/ggml-org/llama.cpp) router server. The router discovers multiple GGUF models and loads or unloads them on demand.\n\nUse a current llama.cpp build with router support. Follow the [build instructions](https://github.com/ggml-org/llama.cpp/blob/master/docs/build.md) or install a [prebuilt release](https://github.com/ggml-org/llama.cpp/releases) for your platform.\n\n## Start the router\n\nStart `llama-server` without `--model` or `-m`. Passing a model starts single-model mode instead of router mode.\n\n```bash\nllama-server \\\n  --models-dir ~/models \\\n  --no-models-autoload \\\n  --jinja \\\n  --host 127.0.0.1 \\\n  --port 8080 \\\n  -ngl 999 \\\n  -c 32768\n```\n\nImportant options:\n\n- `--models-dir ~/models` discovers local GGUF files.\n- `--no-models-autoload` keeps loading explicit through `/llama`.\n- `--jinja` enables compatible chat templates and tool calling.\n- `-ngl 999` offloads as many layers as possible to the GPU.\n- `-c 32768` sets the context window for each loaded model. Omit it to use the model's native context, which may require substantially more memory.\n\nA single-file model can sit directly in the model directory. Put multimodal and multi-shard models in separate subdirectories:\n\n```text\n~/models/\n├── llama-3.2-1b-Q4_K_M.gguf\n├── gemma-3-4b-it-Q4_K_M/\n│   ├── gemma-3-4b-it-Q4_K_M.gguf\n│   └── mmproj-F16.gguf\n└── large-model-Q4_K_M/\n    ├── large-model-Q4_K_M-00001-of-00003.gguf\n    ├── large-model-Q4_K_M-00002-of-00003.gguf\n    └── large-model-Q4_K_M-00003-of-00003.gguf\n```\n\nRestart the router after manually adding files. For per-model context sizes and other options, use [llama.cpp model presets](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md#model-presets).\n\n## Configure Pi\n\nStart Pi and configure the provider:\n\n```text\n/login llama.cpp\n```\n\nEnter the router URL and optional API key. The default URL is `http://127.0.0.1:8080`.\n\nEnvironment variables can configure the same values without `/login`:\n\n```bash\nexport LLAMA_BASE_URL=http://127.0.0.1:8080\nexport LLAMA_API_KEY=optional-secret\npi\n```\n\nIf the server uses an API key, start `llama-server` with the matching `--api-key` value. Keep `--host 127.0.0.1` for local-only access.\n\n## Manage models\n\nRun:\n\n```text\n/llama\n```\n\n- Select an unloaded model to load it.\n- Select a loaded model to unload it.\n- Select **Download model…**, search Hugging Face, then choose a repository and quantization. Exact `owner/repository[:quant]` values also work.\n- Press Escape during a load or download to confirm cancellation.\n\nHugging Face search uses `HF_TOKEN` when set, then checks `$HF_TOKEN_PATH`, `$HF_HOME/token`, `$XDG_CACHE_HOME/huggingface/token`, and `~/.cache/huggingface/token`. Search also works without authentication, subject to lower rate limits. Pi warns before downloading gated repositories and links to their access page. The llama.cpp server performs the download, so its process must also have `HF_TOKEN` when the selected repository requires access.\n\nIf other models are loaded, Pi asks whether to unload them first or keep them loaded. Pi does not silently unload models and never deletes model files. The router may be shared with other clients, so `/llama` always displays the router's current state.\n\nOnly loaded models appear in `/model`. After loading a model, run `/model` to select it for the current Pi session.\n\nIf the router disconnects, `/llama` shows **Retry** and **Close**. Retry reconnects and refreshes model state without replaying the interrupted operation.\n\n## Troubleshooting\n\nCheck that the router is reachable:\n\n```bash\ncurl http://127.0.0.1:8080/health\ncurl http://127.0.0.1:8080/models\n```\n\n- **No models in `/llama`:** Check `--models-dir`, the directory layout, and restart the router.\n- **Model missing from `/model`:** Load it with `/llama` first.\n- **Load fails or uses too much memory:** Lower `-c` or unload another model.\n- **Server is not in router mode:** Start it without `--model`, `-m`, or `-hf`.","sourceFile":"llama-cpp.md"},"models":{"title":"Custom Models","markdown":"Add custom providers and models (Ollama, vLLM, LM Studio, proxies) via `~/.pi/agent/models.json`.\n\n## Table of Contents\n\n- [Minimal Example](#minimal-example)\n- [Full Example](#full-example)\n- [Supported APIs](#supported-apis)\n- [Provider Configuration](#provider-configuration)\n- [Model Configuration](#model-configuration)\n- [Overriding Built-in Providers](#overriding-built-in-providers)\n- [Per-model Overrides](#per-model-overrides)\n- [Anthropic Messages Compatibility](#anthropic-messages-compatibility)\n- [OpenAI Compatibility](#openai-compatibility)\n\n## Minimal Example\n\nFor local models (Ollama, LM Studio, vLLM), only `id` is required per model:\n\n```json\n{\n  \"providers\": {\n    \"ollama\": {\n      \"baseUrl\": \"http://localhost:11434/v1\",\n      \"api\": \"openai-completions\",\n      \"apiKey\": \"ollama\",\n      \"models\": [\n        { \"id\": \"llama3.1:8b\" },\n        { \"id\": \"qwen2.5-coder:7b\" }\n      ]\n    }\n  }\n}\n```\n\nThe `apiKey` value is a placeholder because Ollama ignores it. pi still treats models as requiring auth before they appear in `/model`, so keyless local servers should keep a dummy value, save a key for that provider with `/login`, or pass `--api-key` when selecting the model.\n\nSome OpenAI-compatible servers do not understand the `developer` role used for reasoning-capable models. For those providers, set `compat.supportsDeveloperRole` to `false` so pi sends the system prompt as a `system` message instead. If the server also does not support `reasoning_effort`, set `compat.supportsReasoningEffort` to `false` too.\n\nYou can set `compat` at the provider level to apply to all models, or at the model level to override a specific model. This commonly applies to Ollama, vLLM, SGLang, and similar OpenAI-compatible servers.\n\n```json\n{\n  \"providers\": {\n    \"ollama\": {\n      \"baseUrl\": \"http://localhost:11434/v1\",\n      \"api\": \"openai-completions\",\n      \"apiKey\": \"ollama\",\n      \"compat\": {\n        \"supportsDeveloperRole\": false,\n        \"supportsReasoningEffort\": false\n      },\n      \"models\": [\n        {\n          \"id\": \"gpt-oss:20b\",\n          \"reasoning\": true\n        }\n      ]\n    }\n  }\n}\n```\n\n## Full Example\n\nOverride defaults when you need specific values:\n\n```json\n{\n  \"providers\": {\n    \"ollama\": {\n      \"baseUrl\": \"http://localhost:11434/v1\",\n      \"api\": \"openai-completions\",\n      \"apiKey\": \"ollama\",\n      \"models\": [\n        {\n          \"id\": \"llama3.1:8b\",\n          \"name\": \"Llama 3.1 8B (Local)\",\n          \"reasoning\": false,\n          \"input\": [\"text\"],\n          \"contextWindow\": 128000,\n          \"maxTokens\": 32000,\n          \"cost\": { \"input\": 0, \"output\": 0, \"cacheRead\": 0, \"cacheWrite\": 0 }\n        }\n      ]\n    }\n  }\n}\n```\n\nThe file reloads each time you open `/model`. Edit during session; no restart needed.\n\n## Google AI Studio Example\n\nUse `google-generative-ai` with a `baseUrl` to add models from Google AI Studio, including custom Gemma 4 entries:\n\n```json\n{\n  \"providers\": {\n    \"my-google\": {\n      \"baseUrl\": \"https://generativelanguage.googleapis.com/v1beta\",\n      \"api\": \"google-generative-ai\",\n      \"apiKey\": \"$GEMINI_API_KEY\",\n      \"models\": [\n        {\n          \"id\": \"gemma-4-31b-it\",\n          \"name\": \"Gemma 4 31B\",\n          \"input\": [\"text\", \"image\"],\n          \"contextWindow\": 262144,\n          \"reasoning\": true\n        }\n      ]\n    }\n  }\n}\n```\n\nThe `baseUrl` is required when adding custom models to the `google-generative-ai` API type.\n\n## Supported APIs\n\n| API | Description |\n|-----|-------------|\n| `openai-completions` | OpenAI Chat Completions (most compatible) |\n| `openai-responses` | OpenAI Responses API |\n| `anthropic-messages` | Anthropic Messages API |\n| `google-generative-ai` | Google Generative AI |\n\nSet `api` at provider level (default for all models) or model level (override per model).\n\n## Provider Configuration\n\n| Field | Description |\n|-------|-------------|\n| `baseUrl` | API endpoint URL |\n| `api` | API type (see above) |\n| `apiKey` | Optional API key config (see value resolution below). Omit it when auth is provided by `/login`/`auth.json` or CLI `--api-key`. |\n| `oauth` | Dynamic OAuth provider type. Currently supports `\"radius\"`; requires the gateway `baseUrl`. |\n| `headers` | Custom headers (see value resolution below) |\n| `authHeader` | Set `true` to add `Authorization: Bearer <apiKey>` automatically |\n| `models` | Array of model configurations |\n| `modelOverrides` | Per-model overrides for built-in or extension-registered models on this provider |\n\nFor providers with `models`, non-built-in provider configs need `baseUrl` and an `api` value at either provider or model level. `apiKey` is not required to load the file: models become available when auth is configured through `/login`/`auth.json`, CLI `--api-key`, or provider `apiKey`. If no auth is configured, the models load but stay unavailable in `/model` and `--list-models`.\n\n### Value Resolution\n\nThe `apiKey` and `headers` fields support command execution, environment interpolation, and literals:\n\n- **Shell command:** `\"!command\"` at the start executes the whole value as a command and uses stdout\n  ```json\n  \"apiKey\": \"!security find-generic-password -ws 'anthropic'\"\n  \"apiKey\": \"!op read 'op://vault/item/credential'\"\n  ```\n- **Environment interpolation:** `\"$ENV_VAR\"` or `\"${ENV_VAR}\"` uses the value of the named variable. Interpolation works inside larger literals.\n  ```json\n  \"apiKey\": \"$MY_API_KEY\"\n  \"apiKey\": \"${KEY_PREFIX}_${KEY_SUFFIX}\"\n  ```\n  `$FOO_BAR` is the variable `FOO_BAR`; use `${FOO}_BAR` when `BAR` is literal text. Missing environment variables make the value unresolved.\n- **Escapes:** `\"$$\"` emits a literal `\"$\"`; `\"$!\"` emits a literal `\"!\"` without triggering command execution.\n  ```json\n  \"apiKey\": \"$$literal-dollar-prefix\"\n  \"apiKey\": \"$!literal-bang-prefix\"\n  ```\n- **Literal value:** Used directly. Plain uppercase strings such as `MY_API_KEY` are literals; use `$MY_API_KEY` for environment variables.\n  ```json\n  \"apiKey\": \"sk-...\"\n  ```\n\nFor `models.json`, shell commands are resolved at request time. pi intentionally does not apply built-in TTL, stale reuse, or recovery logic for arbitrary commands. Different commands need different caching and failure strategies, and pi cannot infer the right one.\n\nIf your command is slow, expensive, rate-limited, or should keep using a previous value on transient failures, wrap it in your own script or command that implements the caching or TTL behavior you want.\n\n`/model` availability checks use configured auth presence and do not execute shell commands.\n\n### Custom Headers\n\n```json\n{\n  \"providers\": {\n    \"custom-proxy\": {\n      \"baseUrl\": \"https://proxy.example.com/v1\",\n      \"apiKey\": \"$MY_API_KEY\",\n      \"api\": \"anthropic-messages\",\n      \"headers\": {\n        \"x-portkey-api-key\": \"$PORTKEY_API_KEY\",\n        \"x-secret\": \"!op read 'op://vault/item/secret'\"\n      },\n      \"models\": [...]\n    }\n  }\n}\n```\n\n## Model Configuration\n\n| Field | Required | Default | Description |\n|-------|----------|---------|-------------|\n| `id` | Yes | — | Model identifier (passed to the API) |\n| `name` | No | `id` | Human-readable model label. Used for matching (`--model` patterns) and shown as secondary model detail text. |\n| `api` | No | provider's `api` | Override provider's API for this model |\n| `reasoning` | No | `false` | Supports extended thinking |\n| `thinkingLevelMap` | No | omitted | Maps pi thinking levels to provider values and marks unsupported levels (see below) |\n| `input` | No | `[\"text\"]` | Input types: `[\"text\"]` or `[\"text\", \"image\"]` |\n| `contextWindow` | No | `128000` | Context window size in tokens |\n| `maxTokens` | No | `16384` | Maximum output tokens |\n| `samplingParams` | No | omitted | Sampling parameters merged verbatim into every request body (see below) |\n| `cost` | No | all zeros | Per-million-token rates with optional request-wide input pricing tiers |\n| `compat` | No | provider `compat` | Provider compatibility overrides. Merged with provider-level `compat` when both are set. |\n\nA cost tier supplies a complete alternate rate set and applies to the full request when total input usage (`input + cacheRead + cacheWrite`) exceeds `inputTokensAbove`. When multiple tiers match, the highest threshold wins.\n\n```json\n{\n  \"cost\": {\n    \"input\": 5,\n    \"output\": 30,\n    \"cacheRead\": 0.5,\n    \"cacheWrite\": 6.25,\n    \"tiers\": [\n      {\n        \"inputTokensAbove\": 272000,\n        \"input\": 10,\n        \"output\": 45,\n        \"cacheRead\": 1,\n        \"cacheWrite\": 12.5\n      }\n    ]\n  }\n}\n```\n\nCurrent behavior:\n- `/model`, `--list-models`, and the interactive footer display entries by model `id`.\n- The configured `name` is used for model matching and secondary model detail text. It does not replace the footer/status-bar model id.\n\n### Sampling Parameters\n\n`samplingParams` is a free-form object merged verbatim into every request body for the model, after the fields pi sets itself, so its keys win. Use it to send sampling parameters pi does not model — including server-specific ones like llama.cpp's `min_p` or vLLM's `top_k`:\n\n```json\n{\n  \"id\": \"deepseek-v4-flash\",\n  \"samplingParams\": {\n    \"temperature\": 1.0,\n    \"top_p\": 0.95,\n    \"top_k\": 0,\n    \"min_p\": 0.0\n  }\n}\n```\n\nOnly OpenAI-compatible APIs apply it (`openai-completions`, `openai-responses`, `azure-openai-responses`); other APIs ignore it. Keys override pi's named request fields (for example a `temperature` key here beats the request-level temperature), so prefer it as the single source of sampling truth for a model. In `modelOverrides`, `samplingParams` merges per key with the base model's value.\n\n### Thinking Level Map\n\nUse `thinkingLevelMap` on a model to describe model-specific thinking controls. Keys are pi thinking levels: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Maps may contain holes; for example, a model can expose `high` and `max` without exposing `xhigh`.\n\nValues are tristate:\n\n| Value | Meaning |\n|-------|---------|\n| omitted | Standard levels through `high` use the provider's default mapping; extended `xhigh` and `max` levels are unsupported |\n| string | Level is supported and this value is sent to the provider |\n| `null` | Level is unsupported and hidden/skipped/clamped away |\n\nExample for a model that only supports off, high, and max reasoning:\n\n```json\n{\n  \"id\": \"deepseek-v4-pro\",\n  \"reasoning\": true,\n  \"thinkingLevelMap\": {\n    \"minimal\": null,\n    \"low\": null,\n    \"medium\": null,\n    \"high\": \"high\",\n    \"xhigh\": null,\n    \"max\": \"max\"\n  }\n}\n```\n\nExample for a model where thinking cannot be disabled:\n\n```json\n{\n  \"id\": \"always-thinking-model\",\n  \"reasoning\": true,\n  \"thinkingLevelMap\": {\n    \"off\": null\n  }\n}\n```\n\nMigration: older configs that used `compat.reasoningEffortMap` should move that mapping to model-level `thinkingLevelMap`. Use `null` for levels that should not appear in the UI.\n\n## Overriding Built-in Providers\n\nRoute a built-in provider through a proxy without redefining models:\n\n```json\n{\n  \"providers\": {\n    \"anthropic\": {\n      \"baseUrl\": \"https://my-proxy.example.com/v1\"\n    }\n  }\n}\n```\n\nAll built-in Anthropic models remain available. Existing OAuth or API key auth continues to work.\n\nTo merge custom models into a built-in provider, include the `models` array:\n\n```json\n{\n  \"providers\": {\n    \"anthropic\": {\n      \"baseUrl\": \"https://my-proxy.example.com/v1\",\n      \"apiKey\": \"$ANTHROPIC_API_KEY\",\n      \"api\": \"anthropic-messages\",\n      \"models\": [...]\n    }\n  }\n}\n```\n\nMerge semantics:\n- Built-in models are kept.\n- Custom models are upserted by `id` within the provider.\n- If a custom model `id` matches a built-in model `id`, the custom model replaces that built-in model.\n- If a custom model `id` is new, it is added alongside built-in models.\n\n## Per-model Overrides\n\nUse `modelOverrides` to customize built-in models and matching extension-registered models without replacing the provider's full model list.\n\n```json\n{\n  \"providers\": {\n    \"openrouter\": {\n      \"modelOverrides\": {\n        \"anthropic/claude-sonnet-4\": {\n          \"name\": \"Claude Sonnet 4 (Bedrock Route)\",\n          \"compat\": {\n            \"openRouterRouting\": {\n              \"only\": [\"amazon-bedrock\"]\n            }\n          }\n        }\n      }\n    }\n  }\n}\n```\n\n`modelOverrides` supports these fields per model: `name`, `reasoning`, `thinkingLevelMap`, `input`, `cost` (partial), `contextWindow`, `maxTokens`, `samplingParams` (merged per key), `headers`, `compat`.\n\nDirect OpenAI GPT-5.6 Sol, Terra, and Luna default to a `272000` context window so requests remain within OpenAI's short-context pricing tier. To opt into OpenAI's 1.05M context window, increase it for each model you use:\n\n```json\n{\n  \"providers\": {\n    \"openai\": {\n      \"modelOverrides\": {\n        \"gpt-5.6-sol\": {\n          \"contextWindow\": 1050000\n        }\n      }\n    }\n  }\n}\n```\n\nThe override preserves the built-in pricing metadata. Requests with more than 272K total input tokens use GPT-5.6's long-context rates for the entire request. Apply the same override to `gpt-5.6-terra` or `gpt-5.6-luna` when needed.\n\nBehavior notes:\n- `modelOverrides` are applied to built-in provider models and matching extension-registered provider models.\n- Unknown model IDs are ignored.\n- You can combine provider-level `baseUrl`/`headers` with `modelOverrides`.\n- Overriding `name` changes model matching and secondary detail text only; the footer and primary model lists continue to show the model `id`.\n- If `models` is also defined for a provider, custom models are merged after built-in overrides. A custom model with the same `id` replaces the overridden built-in model entry.\n\n## Anthropic Messages Compatibility\n\nFor providers or proxies using `api: \"anthropic-messages\"`, use `compat` to control Anthropic-specific request compatibility.\n\nBy default pi sends per-tool `eager_input_streaming: true`. If a proxy or Anthropic-compatible backend rejects that field, set `supportsEagerToolInputStreaming` to `false`. Pi will omit `tools[].eager_input_streaming` and send the legacy `fine-grained-tool-streaming-2025-05-14` beta header for tool-enabled requests instead.\n\nSome Anthropic models require adaptive thinking (`thinking.type: \"adaptive\"` plus `output_config.effort`) instead of the legacy budget-based thinking payload. Built-in models set this automatically. For custom providers or aliases that route to those models, set `forceAdaptiveThinking` to `true`.\n\nSome Anthropic-compatible providers emit thinking blocks with empty signatures and still expect them on replay. Set `allowEmptySignature` to `true` only for those providers; real Anthropic rejects empty thinking signatures.\n\nBuilt-in Anthropic models enable `supportsStrictTools` in their model metadata. Custom Anthropic-compatible models must set it to `true` when their endpoint accepts strict JSON-schema tool definitions.\n\n```json\n{\n  \"providers\": {\n    \"anthropic-proxy\": {\n      \"baseUrl\": \"https://proxy.example.com\",\n      \"api\": \"anthropic-messages\",\n      \"apiKey\": \"$ANTHROPIC_PROXY_KEY\",\n      \"compat\": {\n        \"supportsEagerToolInputStreaming\": false,\n        \"supportsLongCacheRetention\": true,\n        \"forceAdaptiveThinking\": true,\n        \"allowEmptySignature\": true\n      },\n      \"models\": [\n        {\n          \"id\": \"claude-opus-4-7\",\n          \"reasoning\": true,\n          \"input\": [\"text\", \"image\"]\n        }\n      ]\n    }\n  }\n}\n```\n\n| Field | Description |\n|-------|-------------|\n| `supportsEagerToolInputStreaming` | Whether the provider accepts per-tool `eager_input_streaming`. Default: `true`. Set to `false` to omit that field and use the legacy fine-grained tool streaming beta header on tool-enabled requests. |\n| `supportsLongCacheRetention` | Whether the provider accepts Anthropic long cache retention (`cache_control.ttl: \"1h\"`) when cache retention is `long`. Default: `true`. |\n| `sendSessionAffinityHeaders` | Whether to send `x-session-affinity` from the session id when caching is enabled. Default: auto-detected for known providers. |\n| `supportsCacheControlOnTools` | Whether the provider accepts Anthropic-style `cache_control` markers on tool definitions. Default: `true`. |\n| `forceAdaptiveThinking` | Whether to send adaptive thinking (`thinking.type: \"adaptive\"` plus `output_config.effort`) for this model. Built-in adaptive models set this automatically. Default: `false`. |\n| `allowEmptySignature` | Whether to replay empty thinking signatures as `signature: \"\"` instead of converting thinking to text. Default: `false`. |\n| `supportsStrictTools` | Whether the provider accepts strict JSON-schema tool definitions. Default: `false`; built-in Anthropic models enable it in generated metadata. |\n\n## OpenAI Compatibility\n\nFor providers with partial OpenAI compatibility, use the `compat` field.\n\n- Provider-level `compat` applies defaults to all models under that provider.\n- Model-level `compat` overrides provider-level values for that model.\n\n```json\n{\n  \"providers\": {\n    \"local-llm\": {\n      \"baseUrl\": \"http://localhost:8080/v1\",\n      \"api\": \"openai-completions\",\n      \"compat\": {\n        \"supportsUsageInStreaming\": false,\n        \"maxTokensField\": \"max_tokens\"\n      },\n      \"models\": [...]\n    }\n  }\n}\n```\n\n| Field | Description |\n|-------|-------------|\n| `supportsStore` | Provider supports `store` field |\n| `supportsDeveloperRole` | Use `developer` vs `system` role |\n| `supportsReasoningEffort` | Support for `reasoning_effort` parameter |\n| `supportsUsageInStreaming` | Supports `stream_options: { include_usage: true }` (default: `true`) |\n| `supportsFinishReason` | Whether streamed responses include `finish_reason`. When `false`, pi infers `stop` or `toolUse` when the stream ends. Default: `true`. |\n| `maxTokensField` | Use `max_completion_tokens` or `max_tokens` |\n| `requiresToolResultName` | Include `name` on tool result messages |\n| `requiresAssistantAfterToolResult` | Insert an assistant message before a user message after tool results |\n| `requiresThinkingAsText` | Convert thinking blocks to plain text |\n| `requiresReasoningContentOnAssistantMessages` | Include empty `reasoning_content` on all replayed assistant messages when reasoning is enabled |\n| `thinkingFormat` | Use `reasoning_effort`, `openrouter`, `deepseek`, `together`, `baseten`, `zai`, `qwen`, `chat-template`, or `qwen-chat-template` thinking parameters |\n| `chatTemplateKwargs` | `chat_template_kwargs` values for `thinkingFormat: \"chat-template\"`; use `{ \"$var\": \"thinking.enabled\" }` or `{ \"$var\": \"thinking.effort\" }` for pi-controlled thinking values |\n| `chatTemplateArgs` | `chat_template_args` values for `thinkingFormat: \"baseten\"`; use `{ \"$var\": \"thinking.enabled\" }` or `{ \"$var\": \"thinking.effort\" }` for pi-controlled thinking values |\n| `cacheControlFormat` | Use Anthropic-style `cache_control` markers on the system prompt, last tool definition, and last user, assistant, or tool-result text content. Currently only `anthropic` is supported. |\n| `sendSessionAffinityHeaders` | For `openai-completions`, send session-affinity headers from the session id when caching is enabled. Default: `false`. |\n| `sessionAffinityFormat` | For `openai-completions` and `openai-responses`, the session-affinity header format: `openai` sends `session_id`/`x-client-request-id` (completions also `x-session-affinity`), `openai-nosession` omits the underscore-containing `session_id` header, `openrouter` sends `x-session-id`. Does not affect the `prompt_cache_key` body param. Default: auto-detected. |\n| `supportsStrictMode` | Whether the provider accepts strict JSON-schema function tool definitions. Defaults depend on the API; built-in OpenAI models carry explicit capability metadata. |\n| `supportsOpenAIGrammarTools` | Whether OpenAI-compatible APIs emit custom Lark/regex grammar tools. When `false`, grammar-constrained tools fall back to normal function tools. Default: `false`; the built-in model catalog enables it for GPT-5+ models on OpenAI, OpenAI Codex, Azure OpenAI, GitHub Copilot, opencode, and Cloudflare AI Gateway. |\n| `deferredToolsMode` | Use provider-specific deferred tool serialization. Currently only `\"kimi\"` is supported for Kimi's OpenAI-compatible Chat Completions format. |\n| `supportsLongCacheRetention` | Whether the provider accepts long cache retention when cache retention is `long`: `prompt_cache_retention: \"24h\"` for OpenAI prompt caching, or `cache_control.ttl: \"1h\"` when `cacheControlFormat` is `anthropic`. Default: `true`. |\n| `openRouterRouting` | OpenRouter provider routing preferences. This object is sent as-is in the `provider` field of the [OpenRouter API request](https://openrouter.ai/docs/guides/routing/provider-selection). |\n| `vercelGatewayRouting` | Vercel AI Gateway routing config for provider selection (`only`, `order`) |\n\n`openrouter` uses `reasoning: { effort }`. `together` uses `reasoning: { enabled }` and also `reasoning_effort` when `supportsReasoningEffort` is enabled. `qwen` uses top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that require `chat_template_kwargs.enable_thinking` and `preserve_thinking`. Use `chat-template` for vLLM/Hugging Face chat templates that need configurable `chat_template_kwargs`, such as `chatTemplateKwargs: { \"thinking\": { \"$var\": \"thinking.enabled\" } }` for DeepSeek V3.x templates. Use `thinkingFormat: \"baseten\"` with `chatTemplateArgs` for providers that expose toggle controls through `chat_template_args` and optionally support top-level `reasoning_effort`.\n\n`cacheControlFormat: \"anthropic\"` is for OpenAI-compatible providers that expose Anthropic-style prompt caching through `cache_control` markers on text content and tool definitions.\n\nExample:\n\n```json\n{\n  \"providers\": {\n    \"openrouter\": {\n      \"baseUrl\": \"https://openrouter.ai/api/v1\",\n      \"apiKey\": \"$OPENROUTER_API_KEY\",\n      \"api\": \"openai-completions\",\n      \"models\": [\n        {\n          \"id\": \"openrouter/anthropic/claude-3.5-sonnet\",\n          \"name\": \"OpenRouter Claude 3.5 Sonnet\",\n          \"compat\": {\n            \"openRouterRouting\": {\n              \"allow_fallbacks\": true,\n              \"require_parameters\": false,\n              \"data_collection\": \"deny\",\n              \"zdr\": true,\n              \"enforce_distillable_text\": false,\n              \"order\": [\"anthropic\", \"amazon-bedrock\", \"google-vertex\"],\n              \"only\": [\"anthropic\", \"amazon-bedrock\"],\n              \"ignore\": [\"gmicloud\", \"friendli\"],\n              \"quantizations\": [\"fp16\", \"bf16\"],\n              \"sort\": {\n                \"by\": \"price\",\n                \"partition\": \"model\"\n              },\n              \"max_price\": {\n                \"prompt\": 10,\n                \"completion\": 20\n              },\n              \"preferred_min_throughput\": {\n                \"p50\": 100,\n                \"p90\": 50\n              },\n              \"preferred_max_latency\": {\n                \"p50\": 1,\n                \"p90\": 3,\n                \"p99\": 5\n              }\n            }\n          }\n        }\n      ]\n    }\n  }\n}\n```\n\nVercel AI Gateway example:\n\n```json\n{\n  \"providers\": {\n    \"vercel-ai-gateway\": {\n      \"baseUrl\": \"https://ai-gateway.vercel.sh/v1\",\n      \"apiKey\": \"$AI_GATEWAY_API_KEY\",\n      \"api\": \"openai-completions\",\n      \"models\": [\n        {\n          \"id\": \"moonshotai/kimi-k2.5\",\n          \"name\": \"Kimi K2.5 (Fireworks via Vercel)\",\n          \"reasoning\": true,\n          \"input\": [\"text\", \"image\"],\n          \"cost\": { \"input\": 0.6, \"output\": 3, \"cacheRead\": 0, \"cacheWrite\": 0 },\n          \"contextWindow\": 262144,\n          \"maxTokens\": 262144,\n          \"compat\": {\n            \"vercelGatewayRouting\": {\n              \"only\": [\"fireworks\", \"novita\"],\n              \"order\": [\"fireworks\", \"novita\"]\n            }\n          }\n        }\n      ]\n    }\n  }\n}\n```","sourceFile":"models.md"},"packages":{"title":"Pi Packages","markdown":"> pi can help you create pi packages. Ask it to bundle your extensions, skills, prompt templates, or themes.\n\n\nPi packages bundle extensions, skills, prompt templates, and themes so you can share them through npm or git. A package can declare resources in `package.json` under the `pi` key, or use conventional directories.\n\n## Table of Contents\n\n- [Install and Manage](#install-and-manage)\n- [Package Sources](#package-sources)\n- [Creating a Pi Package](#creating-a-pi-package)\n- [Package Structure](#package-structure)\n- [Dependencies](#dependencies)\n- [Package Filtering](#package-filtering)\n- [Enable and Disable Resources](#enable-and-disable-resources)\n- [Scope and Deduplication](#scope-and-deduplication)\n\n## Install and Manage\n\n> **Security:** Pi packages run with full system access. Extensions execute arbitrary code, and skills can instruct the model to perform any action including running executables. Review source code before installing third-party packages.\n\n```bash\npi install npm:@foo/bar@1.0.0\npi install git:github.com/user/repo@v1\npi install https://github.com/user/repo  # raw URLs work too\npi install /absolute/path/to/package\npi install ./relative/path/to/package\n\npi remove npm:@foo/bar\npi list                     # show installed packages from settings\npi update                   # update pi only\npi update --all             # update pi, update packages, and reconcile pinned git refs\npi update --extensions      # update packages and reconcile pinned git refs only\npi update --models          # refresh model catalogs only\npi update --self            # update pi only\npi update --self --force    # reinstall pi even if current\npi update npm:@foo/bar      # update one package\npi update --extension npm:@foo/bar\n```\n\nThese commands manage pi packages and `pi update` can update the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall).\n\nBy default, `install` and `remove` write to user settings (`~/.pi/agent/settings.json`). Use `-l` to write to project settings (`.pi/settings.json`) instead. Project settings can be shared with your team, and pi installs any missing packages automatically on startup after the project is trusted.\n\nTo try a package without installing it, use `--extension` or `-e`. This installs to a temporary directory for the current run only:\n\n```bash\npi -e npm:@foo/bar\npi -e git:github.com/user/repo\n```\n\n## Package Sources\n\nPi accepts three source types in settings and `pi install`.\n\n### npm\n\n```\nnpm:@scope/pkg@1.2.3\nnpm:pkg\n```\n\n- Versioned specs are pinned and skipped by package updates (`pi update --extensions`, `pi update --all`).\n- User installs go under `~/.pi/agent/npm/`.\n- Project installs go under `.pi/npm/`.\n- Set `npmCommand` in `settings.json` to pin npm package lookup and install operations to a specific wrapper command such as `mise` or `asdf`.\n\nExample:\n\n```json\n{\n  \"npmCommand\": [\"mise\", \"exec\", \"node@20\", \"--\", \"npm\"]\n}\n```\n\n### git\n\n```\ngit:github.com/user/repo@v1\ngit:git@github.com:user/repo@v1\nhttps://github.com/user/repo@v1\nssh://git@github.com/user/repo@v1\n```\n\n- Without `git:` prefix, only protocol URLs are accepted (`https://`, `http://`, `ssh://`, `git://`).\n- With `git:` prefix, shorthand formats are accepted, including `github.com/user/repo` and `git@github.com:user/repo`.\n- HTTPS and SSH URLs are both supported.\n- SSH URLs use your configured SSH keys automatically (respects `~/.ssh/config`).\n- For non-interactive runs (for example CI), you can set `GIT_TERMINAL_PROMPT=0` to disable credential prompts and set `GIT_SSH_COMMAND` (for example `ssh -o BatchMode=yes -o ConnectTimeout=5`) to fail fast.\n- Refs are pinned tags or commits. `pi update --extensions` and `pi update --all` do not move them to newer refs, but they do reconcile an existing clone to the configured ref.\n- Use `pi install git:host/user/repo@new-ref` to update settings and move an existing package to a new pinned ref.\n- Cloned to `~/.pi/agent/git/<host>/<path>` (global) or `.pi/git/<host>/<path>` (project).\n- When reconciliation changes the checkout, pi resets and cleans the clone, then runs `npm install` if `package.json` exists.\n\n**SSH examples:**\n```bash\n# git@host:path shorthand (requires git: prefix)\npi install git:git@github.com:user/repo\n\n# ssh:// protocol format\npi install ssh://git@github.com/user/repo\n\n# With version ref\npi install git:git@github.com:user/repo@v1.0.0\n```\n\n### Local Paths\n\n```\n/absolute/path/to/package\n./relative/path/to/package\n```\n\nLocal paths point to files or directories on disk and are added to settings without copying. Relative paths are resolved against the settings file they appear in. If the path is a file, it loads as a single extension. If it is a directory, pi loads resources using package rules.\n\n## Creating a Pi Package\n\nAdd a `pi` manifest to `package.json` or use conventional directories. Include the `pi-package` keyword for discoverability.\n\n```json\n{\n  \"name\": \"my-package\",\n  \"keywords\": [\"pi-package\"],\n  \"pi\": {\n    \"extensions\": [\"./extensions\"],\n    \"skills\": [\"./skills\"],\n    \"prompts\": [\"./prompts\"],\n    \"themes\": [\"./themes\"]\n  }\n}\n```\n\nPaths are relative to the package root. Arrays support glob patterns and `!exclusions`.\n\n### Gallery Metadata\n\nThe [package gallery](https://pi.dev/packages) displays packages tagged with `pi-package`. Add `video` or `image` fields to show a preview:\n\n```json\n{\n  \"name\": \"my-package\",\n  \"keywords\": [\"pi-package\"],\n  \"pi\": {\n    \"extensions\": [\"./extensions\"],\n    \"video\": \"https://example.com/demo.mp4\",\n    \"image\": \"https://example.com/screenshot.png\"\n  }\n}\n```\n\n- **video**: MP4 only. On desktop, autoplays on hover. Clicking opens a fullscreen player.\n- **image**: PNG, JPEG, GIF, or WebP. Displayed as a static preview.\n\nIf both are set, video takes precedence.\n\n## Package Structure\n\n### Convention Directories\n\nIf no `pi` manifest is present, pi auto-discovers resources from these directories:\n\n- `extensions/` loads `.ts` and `.js` files\n- `skills/` recursively finds `SKILL.md` folders and loads top-level `.md` files as skills\n- `prompts/` loads `.md` files\n- `themes/` loads `.json` files\n\n## Dependencies\n\nThird party runtime dependencies belong in `dependencies` in `package.json`. Dependencies that do not register extensions, skills, prompt templates, or themes also belong in `dependencies`. When pi installs a package from npm or git, it runs `npm install`, so those dependencies are installed automatically.\n\nPi bundles core packages for extensions and skills. If you import any of these, list them in `peerDependencies` with a `\"*\"` range and do not bundle them: `@earendil-works/pi-ai`, `@earendil-works/pi-agent-core`, `@earendil-works/pi-coding-agent`, `@earendil-works/pi-tui`, `typebox`.\n\nOther pi packages must be bundled in your tarball. Add them to `dependencies` and `bundledDependencies`, then reference their resources through `node_modules/` paths. Pi loads packages with separate module roots, so separate installs do not collide or share modules.\n\nExample:\n\n```json\n{\n  \"dependencies\": {\n    \"shitty-extensions\": \"^1.0.1\"\n  },\n  \"bundledDependencies\": [\"shitty-extensions\"],\n  \"pi\": {\n    \"extensions\": [\"extensions\", \"node_modules/shitty-extensions/extensions\"],\n    \"skills\": [\"skills\", \"node_modules/shitty-extensions/skills\"]\n  }\n}\n```\n\n## Package Filtering\n\nFilter what a package loads using the object form in settings:\n\n```json\n{\n  \"packages\": [\n    \"npm:simple-pkg\",\n    {\n      \"source\": \"npm:my-package\",\n      \"extensions\": [\"extensions/*.ts\", \"!extensions/legacy.ts\"],\n      \"skills\": [],\n      \"prompts\": [\"prompts/review.md\"],\n      \"themes\": [\"+themes/legacy.json\"]\n    }\n  ]\n}\n```\n\n`+path` and `-path` are exact paths relative to the package root.\n\n- Omit a key to load all of that type.\n- Use `[]` to load none of that type.\n- `!pattern` excludes matches.\n- `+path` force-includes an exact path.\n- `-path` force-excludes an exact path.\n- Filters layer on top of the manifest. They narrow down what is already allowed.\n\n## Enable and Disable Resources\n\nUse `pi config` to enable or disable extensions, skills, prompt templates, and themes from installed packages and local directories. `pi config` starts in global settings (`~/.pi/agent/settings.json`); press Tab to switch between global and project-local modes. Use `pi config -l` to start in project overrides (`.pi/settings.json`) with inherited global resources dimmed.\n\n## Scope and Deduplication\n\nPackages can appear in both global and project settings. If the same package appears in both, the project entry wins unless the project entry has `autoload: false`, in which case it is applied as a delta over the global entry. Identity is determined by:\n\n- npm: package name\n- git: repository URL without ref\n- local: resolved absolute path","sourceFile":"packages.md"},"prompt-templates":{"title":"Prompt Templates","markdown":"> pi can create prompt templates. Ask it to build one for your workflow.\n\n\nPrompt templates are Markdown snippets that expand into full prompts. Type `/name` in the editor to invoke a template, where `name` is the filename without `.md`.\n\n## Locations\n\nPi loads prompt templates from:\n\n- Global: `~/.pi/agent/prompts/*.md`\n- Project: `.pi/prompts/*.md` (only after the project is trusted)\n- Packages: `prompts/` directories or `pi.prompts` entries in `package.json`\n- Settings: `prompts` array with files or directories\n- CLI: `--prompt-template <path>` (repeatable)\n\nDisable discovery with `--no-prompt-templates`.\n\n## Format\n\n```markdown\n---\ndescription: Review staged git changes\n---\nReview the staged changes (`git diff --cached`). Focus on:\n- Bugs and logic errors\n- Security issues\n- Error handling gaps\n```\n\n- The filename becomes the command name. `review.md` becomes `/review`.\n- `description` is optional. If missing, the first non-empty line is used.\n- `argument-hint` is optional. When set, the hint is displayed before the description in the autocomplete dropdown.\n\n### Argument Hints\n\nUse `argument-hint` in frontmatter to show expected arguments in autocomplete. Use `<angle brackets>` for required arguments and `[square brackets]` for optional ones:\n\n```markdown\n---\ndescription: Review PRs from URLs with structured issue and code analysis\nargument-hint: \"<PR-URL>\"\n---\n```\n\nThis renders in the autocomplete dropdown as:\n\n```\n→ pr   <PR-URL>       — Review PRs from URLs with structured issue and code analysis\n  is   <issue>        — Analyze GitHub issues (bugs or feature requests)\n  wr   [instructions] — Finish the current task end-to-end\n  cl   — Audit changelog entries before release\n```\n\n## Usage\n\nType `/` followed by the template name in the editor. Autocomplete shows available templates with descriptions.\n\n```\n/review                           # Expands review.md\n/component Button                 # Expands with argument\n/component Button \"click handler\" # Multiple arguments\n```\n\n## Arguments\n\nTemplates support positional arguments, defaults, and simple slicing:\n\n- `$1`, `$2`, ... positional args\n- `$@` or `$ARGUMENTS` for all args joined\n- `${1:-default}` uses arg 1 when present/non-empty, otherwise `default`\n- `${@:-default}` or `${ARGUMENTS:-default}` uses all arguments when present/non-empty, otherwise `default`\n- `${@:N}` for args from the Nth position (1-indexed)\n- `${@:N:L}` for `L` args starting at N\n\nExample:\n\n```markdown\n---\ndescription: Create a component\n---\nCreate a React component named $1 with features: $@\n```\n\nDefault values are useful for optional arguments:\n\n```markdown\nSummarize the current state in ${1:-7} bullet points.\n```\n\nUsage: `/component Button \"onClick handler\" \"disabled support\"`\n\n## Loading Rules\n\n- Template discovery in `prompts/` is non-recursive.\n- If you want templates in subdirectories, add them explicitly via `prompts` settings or a package manifest.","sourceFile":"prompt-templates.md"},"providers":{"title":"Providers","markdown":"Pi supports subscription-based providers via OAuth and API key providers via environment variables or auth file. Built-in catalogs ship with pi; configured providers may refresh newer catalogs and cache them in `~/.pi/agent/models-store.json` for offline use.\n\n## Table of Contents\n\n- [Subscriptions](#subscriptions)\n- [API Keys](#api-keys)\n- [Auth File](#auth-file)\n- [Cloud Providers](#cloud-providers)\n- [llama.cpp](#llamacpp)\n- [Custom Providers](#custom-providers)\n- [Resolution Order](#resolution-order)\n\n## Subscriptions\n\nUse `/login` in interactive mode, then select a provider:\n\n- ChatGPT Plus/Pro (Codex)\n- Claude Pro/Max\n- GitHub Copilot\n- xAI (Grok/X subscription)\n- OpenRouter (OAuth-minted API key billed from OpenRouter credits)\n- Radius\n\nUse `/logout` to clear credentials. Tokens are stored in `~/.pi/agent/auth.json` and auto-refresh when expired. OpenRouter instead mints a user-controlled API key that does not expire automatically.\n\n### OpenAI Codex\n\n- Requires ChatGPT Plus or Pro subscription\n- Officially endorsed by OpenAI: [Codex for OSS](https://developers.openai.com/community/codex-for-oss)\n\n### Claude Pro/Max\n\nAnthropic subscription auth is active for Claude Pro/Max accounts. Third-party harness usage draws from [extra usage](https://claude.ai/settings/usage) and is billed per token, not against Claude plan limits.\n\n### GitHub Copilot\n\n- Press Enter for github.com, or enter your GitHub Enterprise Server domain\n- If you get \"model not supported\", enable it in VS Code: Copilot Chat → model selector → select model → \"Enable\"\n\n### xAI (Grok/X subscription)\n\n- Run `/login xai`, then select **Use a subscription**\n- `XAI_API_KEY` remains available through **Use an API key**\n\n### OpenRouter\n\n- Run `/login openrouter`, then select **Sign in with OpenRouter** to open the OpenRouter PKCE authorization flow\n- The authorization creates a user-controlled OpenRouter API key billed from your OpenRouter credits\n- On remote/headless machines (e.g. over SSH) the browser cannot reach the loopback callback; paste the final redirect URL (or the authorization code) into the login prompt instead\n- `OPENROUTER_API_KEY` remains available through **Use an API key**\n\n### Radius\n\nRadius is a dynamic `pi-messages` gateway. `/login radius` stores OAuth tokens in `auth.json`; the gateway catalog is refreshed independently and cached in `models-store.json`. Custom Radius gateways can be declared in `models.json` with `\"oauth\": \"radius\"` and a gateway `baseUrl`.\n\n## API Keys\n\n### Environment Variables or Auth File\n\nUse `/login` in interactive mode and select a provider to store an API key in `auth.json`, or set credentials via environment variable:\n\n```bash\nexport ANTHROPIC_API_KEY=sk-ant-...\npi\n```\n\n| Provider | Environment Variable | `auth.json` key |\n|----------|----------------------|------------------|\n| Anthropic | `ANTHROPIC_API_KEY` | `anthropic` |\n| Ant Ling | `ANT_LING_API_KEY` | `ant-ling` |\n| Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` |\n| OpenAI | `OPENAI_API_KEY` | `openai` |\n| DeepSeek | `DEEPSEEK_API_KEY` | `deepseek` |\n| NVIDIA NIM | `NVIDIA_API_KEY` | `nvidia` |\n| Google Gemini | `GEMINI_API_KEY` | `google` |\n| Amazon Bedrock | `AWS_BEARER_TOKEN_BEDROCK` | `amazon-bedrock` |\n| Mistral | `MISTRAL_API_KEY` | `mistral` |\n| Groq | `GROQ_API_KEY` | `groq` |\n| Cerebras | `CEREBRAS_API_KEY` | `cerebras` |\n| Cloudflare AI Gateway | `CLOUDFLARE_API_KEY` (+ `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_GATEWAY_ID`) | `cloudflare-ai-gateway` |\n| Cloudflare Workers AI | `CLOUDFLARE_API_KEY` (+ `CLOUDFLARE_ACCOUNT_ID`) | `cloudflare-workers-ai` |\n| xAI | `XAI_API_KEY` | `xai` |\n| OpenRouter | `OPENROUTER_API_KEY` | `openrouter` |\n| Vercel AI Gateway | `AI_GATEWAY_API_KEY` | `vercel-ai-gateway` |\n| ZAI Coding Plan (Global) | `ZAI_API_KEY` | `zai` |\n| ZAI Coding Plan (China) | `ZAI_CODING_CN_API_KEY` | `zai-coding-cn` |\n| OpenCode Zen | `OPENCODE_API_KEY` | `opencode` |\n| OpenCode Go | `OPENCODE_API_KEY` | `opencode-go` |\n| Radius | `RADIUS_API_KEY` | `radius` |\n| Hugging Face | `HF_TOKEN` | `huggingface` |\n| Fireworks | `FIREWORKS_API_KEY` | `fireworks` |\n| Together AI | `TOGETHER_API_KEY` | `together` |\n| Baseten | `BASETEN_API_KEY` | `baseten` |\n| Kimi For Coding | `KIMI_API_KEY` | `kimi-coding` |\n| MiniMax | `MINIMAX_API_KEY` | `minimax` |\n| MiniMax (China) | `MINIMAX_CN_API_KEY` | `minimax-cn` |\n| Qwen Token Plan (existing catalog) | `QWEN_TOKEN_PLAN_API_KEY` | `qwen-token-plan` |\n| Qwen Token Plan (Individual) | `QWEN_TOKEN_PLAN_API_KEY` | `qwen-token-plan-individual` |\n| Qwen Token Plan (China) | `QWEN_TOKEN_PLAN_CN_API_KEY` | `qwen-token-plan-cn` |\n| Xiaomi MiMo | `XIAOMI_API_KEY` | `xiaomi` |\n| Xiaomi MiMo Token Plan (China) | `XIAOMI_TOKEN_PLAN_CN_API_KEY` | `xiaomi-token-plan-cn` |\n| Xiaomi MiMo Token Plan (Amsterdam) | `XIAOMI_TOKEN_PLAN_AMS_API_KEY` | `xiaomi-token-plan-ams` |\n| Xiaomi MiMo Token Plan (Singapore) | `XIAOMI_TOKEN_PLAN_SGP_API_KEY` | `xiaomi-token-plan-sgp` |\n\nReference for environment variables and `auth.json` keys: [`const envMap`](https://github.com/earendil-works/pi-mono/blob/main/packages/ai/src/env-api-keys.ts) in [`packages/ai/src/env-api-keys.ts`](https://github.com/earendil-works/pi-mono/blob/main/packages/ai/src/env-api-keys.ts).\n\n#### Auth File\n\nStore credentials in `~/.pi/agent/auth.json`:\n\n```json\n{\n  \"anthropic\": { \"type\": \"api_key\", \"key\": \"sk-ant-...\" },\n  \"ant-ling\": { \"type\": \"api_key\", \"key\": \"...\" },\n  \"openai\": { \"type\": \"api_key\", \"key\": \"sk-...\" },\n  \"deepseek\": { \"type\": \"api_key\", \"key\": \"sk-...\" },\n  \"nvidia\": { \"type\": \"api_key\", \"key\": \"nvapi-...\" },\n  \"google\": { \"type\": \"api_key\", \"key\": \"...\" },\n  \"opencode\": { \"type\": \"api_key\", \"key\": \"...\" },\n  \"opencode-go\": { \"type\": \"api_key\", \"key\": \"...\" },\n  \"together\": { \"type\": \"api_key\", \"key\": \"...\" },\n  \"qwen-token-plan\":  { \"type\": \"api_key\", \"key\": \"sk-sp-...\" },\n  \"qwen-token-plan-individual\": { \"type\": \"api_key\", \"key\": \"sk-sp-...\" },\n  \"qwen-token-plan-cn\": { \"type\": \"api_key\", \"key\": \"sk-sp-...\" },\n  \"xiaomi\": { \"type\": \"api_key\", \"key\": \"...\" },\n  \"xiaomi-token-plan-cn\":  { \"type\": \"api_key\", \"key\": \"...\" },\n  \"xiaomi-token-plan-ams\": { \"type\": \"api_key\", \"key\": \"...\" },\n  \"xiaomi-token-plan-sgp\": { \"type\": \"api_key\", \"key\": \"...\" }\n}\n```\n\n`qwen-token-plan-individual` uses the same international endpoint and `QWEN_TOKEN_PLAN_API_KEY` as\n`qwen-token-plan`, but limits the picker to the models documented for Individual subscriptions. The existing\nprovider keeps its broader catalog for backward compatibility. When using `auth.json`, store the\ncredential under the provider you select; an environment variable is shared by both international providers.\n\nThe file is created with `0600` permissions (user read/write only). Auth file credentials take priority over environment variables.\n\nAPI key credentials can also include provider-scoped environment values. These values are used before process environment variables when resolving the credential key, provider/model headers, and provider configuration such as Cloudflare account IDs, Azure OpenAI settings, Vertex project/location, Bedrock settings, `PI_CACHE_RETENTION`, and `HTTP_PROXY`/`HTTPS_PROXY`.\n\n```json\n{\n  \"cloudflare-ai-gateway\": {\n    \"type\": \"api_key\",\n    \"key\": \"$CLOUDFLARE_API_KEY\",\n    \"env\": {\n      \"CLOUDFLARE_API_KEY\": \"...\",\n      \"CLOUDFLARE_ACCOUNT_ID\": \"account-id\",\n      \"CLOUDFLARE_GATEWAY_ID\": \"gateway-id\"\n    }\n  }\n}\n```\n\nUse this when pi should use different provider settings than the project shell environment.\n\n### Key Resolution\n\nThe `key` field supports command execution, environment interpolation, and literals:\n\n- **Shell command:** `\"!command\"` at the start executes the whole value as a command and uses stdout (cached for process lifetime)\n  ```json\n  { \"type\": \"api_key\", \"key\": \"!security find-generic-password -ws 'anthropic'\" }\n  { \"type\": \"api_key\", \"key\": \"!op read 'op://vault/item/credential'\" }\n  ```\n- **Environment interpolation:** `\"$ENV_VAR\"` or `\"${ENV_VAR}\"` uses the value of the named variable. Interpolation works inside larger literals.\n  ```json\n  { \"type\": \"api_key\", \"key\": \"$MY_ANTHROPIC_KEY\" }\n  { \"type\": \"api_key\", \"key\": \"${KEY_PREFIX}_${KEY_SUFFIX}\" }\n  ```\n  `$FOO_BAR` is the variable `FOO_BAR`; use `${FOO}_BAR` when `BAR` is literal text. Missing environment variables make the value unresolved.\n- **Escapes:** `\"$$\"` emits a literal `\"$\"`; `\"$!\"` emits a literal `\"!\"` without triggering command execution.\n  ```json\n  { \"type\": \"api_key\", \"key\": \"$$literal-dollar-prefix\" }\n  { \"type\": \"api_key\", \"key\": \"$!literal-bang-prefix\" }\n  ```\n- **Literal value:** Used directly. Plain uppercase strings such as `MY_API_KEY` are literals; use `$MY_API_KEY` for environment variables.\n  ```json\n  { \"type\": \"api_key\", \"key\": \"sk-ant-...\" }\n  { \"type\": \"api_key\", \"key\": \"public\" }\n  ```\n\nOAuth credentials are also stored here after `/login` and managed automatically.\n\n## Cloud Providers\n\n### Azure OpenAI\n\n```bash\nexport AZURE_OPENAI_API_KEY=...\nexport AZURE_OPENAI_BASE_URL=https://your-resource.ai.azure.com\n# also supported: https://your-resource.cognitiveservices.azure.com\n# also supported: https://your-resource.openai.azure.com\n# root endpoints are auto-normalized to /openai/v1\n# or use resource name instead of base URL\nexport AZURE_OPENAI_RESOURCE_NAME=your-resource\n\n# Optional\nexport AZURE_OPENAI_API_VERSION=2024-02-01\nexport AZURE_OPENAI_DEPLOYMENT_NAME_MAP=gpt-4=my-gpt4,gpt-4o=my-gpt4o\n```\n\n### Amazon Bedrock\n\nUse `/login amazon-bedrock` to store a Bedrock API key, or configure one of the ambient AWS credential sources below:\n\n```bash\n# Option 1: AWS Profile\nexport AWS_PROFILE=your-profile\n\n# Option 2: IAM Keys\nexport AWS_ACCESS_KEY_ID=AKIA...\nexport AWS_SECRET_ACCESS_KEY=...\n\n# Option 3: Bearer Token\nexport AWS_BEARER_TOKEN_BEDROCK=...\n\n# Optional region (defaults to us-east-1)\nexport AWS_REGION=us-west-2\n```\n\nAlso supports ECS task roles (`AWS_CONTAINER_CREDENTIALS_*`) and IRSA (`AWS_WEB_IDENTITY_TOKEN_FILE`).\n\n```bash\npi --provider amazon-bedrock --model us.anthropic.claude-sonnet-4-20250514-v1:0\n```\n\nPrompt caching is enabled automatically for Claude models whose ID contains a recognizable model name (base models and system-defined inference profiles). For application inference profiles (whose ARNs don't contain the model name), set `AWS_BEDROCK_FORCE_CACHE=1` to enable cache points:\n\n```bash\nexport AWS_BEDROCK_FORCE_CACHE=1\npi --provider amazon-bedrock --model arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123\n```\n\nIf you are connecting to a Bedrock API proxy, the following environment variables can be used:\n\n```bash\n# Set the URL for the Bedrock proxy (standard AWS SDK env var)\nexport AWS_ENDPOINT_URL_BEDROCK_RUNTIME=https://my.corp.proxy/bedrock\n\n# Set if your proxy does not require authentication\nexport AWS_BEDROCK_SKIP_AUTH=1\n\n# Set if your proxy only supports HTTP/1.1\nexport AWS_BEDROCK_FORCE_HTTP1=1\n```\n\n### Cloudflare AI Gateway\n\n`CLOUDFLARE_API_KEY` can be set via `/login`. The account ID and gateway slug can be set as environment variables or in the API key credential's `env` object in `auth.json`.\n\n```bash\nexport CLOUDFLARE_API_KEY=...           # or use /login\nexport CLOUDFLARE_ACCOUNT_ID=...\nexport CLOUDFLARE_GATEWAY_ID=...        # create at dash.cloudflare.com → AI → AI Gateway\npi --provider cloudflare-ai-gateway --model \"claude-sonnet-4-5\"\n```\n\nRoutes to OpenAI, Anthropic, and Workers AI through Cloudflare AI Gateway. Workers AI uses the Unified API (`/compat`) and prefixed model IDs (`workers-ai/@cf/...`). OpenAI uses the OpenAI passthrough route (`/openai`) with native OpenAI model IDs such as `gpt-5.1`. Anthropic uses the Anthropic passthrough route (`/anthropic`) with native Anthropic model IDs such as `claude-sonnet-4-5`.\n\nAI Gateway authentication uses `CLOUDFLARE_API_KEY` as `cf-aig-authorization`. Upstream authentication can be one of:\n\n| Mode | Request auth | Upstream auth |\n|------|--------------|---------------|\n| Workers AI | Cloudflare token only | Cloudflare-native |\n| Unified billing | Cloudflare token only | Cloudflare handles upstream auth and deducts credits |\n| Stored BYOK | Cloudflare token only | Cloudflare injects provider keys stored in the AI Gateway dashboard |\n| Inline BYOK | Cloudflare token plus upstream `Authorization` header | The request supplies the upstream provider key |\n\nFor normal pi usage, prefer unified billing or stored BYOK. Inline BYOK requires configuring an additional upstream `Authorization` header for the Cloudflare AI Gateway provider, for example via a `models.json` provider/model override.\n\n### Cloudflare Workers AI\n\n`CLOUDFLARE_API_KEY` can be set via `/login`. `CLOUDFLARE_ACCOUNT_ID` can be set as an environment variable or in the API key credential's `env` object in `auth.json`.\n\n```bash\nexport CLOUDFLARE_API_KEY=...           # or use /login\nexport CLOUDFLARE_ACCOUNT_ID=...\npi --provider cloudflare-workers-ai --model \"@cf/moonshotai/kimi-k2.6\"\n```\n\nPi automatically sets `x-session-affinity` for [prefix caching](https://developers.cloudflare.com/workers-ai/features/prompt-caching/) discounts.\n\n### Google Vertex AI\n\nUses Application Default Credentials:\n\n```bash\ngcloud auth application-default login\nexport GOOGLE_CLOUD_PROJECT=your-project\nexport GOOGLE_CLOUD_LOCATION=us-central1\n```\n\nOr set `GOOGLE_APPLICATION_CREDENTIALS` to a service account key file.\n\n## llama.cpp\n\nPi supports the llama.cpp router server. Configure it with `/login llama.cpp`, manage loaded models with `/llama`, and select a loaded model with `/model`.\n\nSee [llama.cpp](llama-cpp.md) for server setup, model directory layout, environment variables, and command usage.\n\n## Custom Providers\n\n**Via models.json:** Add Ollama, LM Studio, vLLM, or any provider that speaks a supported API (OpenAI Completions, OpenAI Responses, Anthropic Messages, Google Generative AI). See [models.md](models.md).\n\n**Via extensions:** For providers that need custom API implementations or OAuth flows, create an extension. See [custom-provider.md](custom-provider.md) and [examples/extensions/custom-provider-gitlab-duo](../examples/extensions/custom-provider-gitlab-duo/).\n\n## Resolution Order\n\nWhen resolving credentials for a provider:\n\n1. CLI `--api-key` flag\n2. `auth.json` entry (API key or OAuth token)\n3. Environment variable\n4. Custom provider keys from `models.json`","sourceFile":"providers.md"},"quickstart":{"title":"Quickstart","markdown":"This page gets you from install to a useful first pi session.\n\n## Install\n\nPi is distributed as an npm package:\n\n```bash\nnpm install -g --ignore-scripts @earendil-works/pi-coding-agent\n```\n\n`--ignore-scripts` disables dependency lifecycle scripts during install. Pi does not require install scripts for normal npm installs.\n\n### Uninstall\n\nUse the package manager that installed pi. The curl installer uses npm globally, so curl and npm installs are removed with npm:\n\n```bash\n# curl installer or npm install -g\nnpm uninstall -g @earendil-works/pi-coding-agent\n\n# pnpm\npnpm remove -g @earendil-works/pi-coding-agent\n\n# Yarn\nyarn global remove @earendil-works/pi-coding-agent\n\n# Bun\nbun uninstall -g @earendil-works/pi-coding-agent\n```\n\nUninstalling pi leaves settings, credentials, sessions, and installed pi packages in `~/.pi/agent/`.\n\nThen start pi in the project directory you want it to work on:\n\n```bash\ncd /path/to/project\npi\n```\n\n## Authenticate\n\nPi can use subscription providers through `/login`, or API-key providers through environment variables or the auth file.\n\n### Option 1: subscription login\n\nStart pi and run:\n\n```text\n/login\n```\n\nThen select a provider. Built-in subscription logins include Claude Pro/Max, ChatGPT Plus/Pro (Codex), and GitHub Copilot.\n\n### Option 2: API key\n\nSet an API key before launching pi:\n\n```bash\nexport ANTHROPIC_API_KEY=sk-ant-...\npi\n```\n\nYou can also run `/login` and select an API-key provider to store the key in `~/.pi/agent/auth.json`.\n\nSee [Providers](providers.md) for all supported providers, environment variables, and cloud-provider setup.\n\n## First session\n\nOnce pi starts, type a request and press Enter:\n\n```text\nSummarize this repository and tell me how to run its checks.\n```\n\nBy default, pi gives the model four tools:\n\n- `read` - read files\n- `write` - create or overwrite files\n- `edit` - patch files\n- `bash` - run shell commands\n\nAdditional built-in read-only tools (`grep`, `find`, `ls`) are available through tool options. Pi runs in your current working directory and can modify files there. Use git or another checkpointing workflow if you want easy rollback.\n\n## Give pi project instructions\n\nPi loads context files at startup. Add an `AGENTS.md` file to tell it how to work in a project:\n\n```markdown\n# Project Instructions\n\n- Run `npm run check` after code changes.\n- Do not run production migrations locally.\n- Keep responses concise.\n```\n\nPi loads:\n\n- `~/.pi/agent/AGENTS.md` for global instructions\n- `AGENTS.md` or `CLAUDE.md` from parent directories and the current directory\n\nIf a directory contains `AGENTS.override.md`, Pi loads it instead of `AGENTS.md` or `CLAUDE.md` from that directory.\n\nRestart pi, or run `/reload`, after changing context files.\n\n## Common things to try\n\n### Reference files\n\nType `@` in the editor to fuzzy-search files, or pass files on the command line:\n\n```bash\npi @README.md \"Summarize this\"\npi @src/app.ts @src/app.test.ts \"Review these together\"\n```\n\nImages or text can be pasted with Ctrl+V (Alt+V on Windows); images can also be dragged into supported terminals.\n\n### Run shell commands\n\nIn interactive mode:\n\n```text\n!npm run lint\n```\n\nThe command output is sent to the model. Use `!!command` to run a command without adding its output to the model context.\n\n### Switch models\n\nUse `/model` or Ctrl+L to choose a model. Use Shift+Tab to cycle thinking level. Use Ctrl+P / Shift+Ctrl+P to cycle through scoped models.\n\n### Continue later\n\nSessions are saved automatically:\n\n```bash\npi -c                  # Continue most recent session\npi -r                  # Browse previous sessions\npi --name \"my task\"    # Set session display name at startup\npi --session <path|id> # Open a specific session\n```\n\nInside pi, use `/resume`, `/new`, `/tree`, `/fork`, and `/clone` to manage sessions.\n\n### Non-interactive mode\n\nFor one-shot prompts:\n\n```bash\npi -p \"Summarize this codebase\"\ncat README.md | pi -p \"Summarize this text\"\npi -p @screenshot.png \"What's in this image?\"\n```\n\nUse `--mode json` for JSON event output or `--mode rpc` for process integration.\n\n## Next steps\n\n- [Using Pi](usage.md) - interactive mode, slash commands, sessions, context files, and CLI reference.\n- [Providers](providers.md) - authentication and model setup.\n- [Settings](settings.md) - global and project configuration.\n- [Keybindings](keybindings.md) - shortcuts and customization.\n- [Pi Packages](packages.md) - install shared extensions, skills, prompts, and themes.\n\nPlatform notes: [Windows](windows.md), [Termux](termux.md), [tmux](tmux.md), [Terminal setup](terminal-setup.md), [Shell aliases](shell-aliases.md).","sourceFile":"quickstart.md"},"rpc":{"title":"RPC Mode","markdown":"RPC mode enables headless operation of the coding agent via a JSON protocol over stdin/stdout. This is useful for embedding the agent in other applications, IDEs, or custom UIs.\n\n**Note for Node.js/TypeScript users**: If you're building a Node.js application, consider using `AgentSession` directly from `@earendil-works/pi-coding-agent` instead of spawning a subprocess. See [`src/core/agent-session.ts`](../src/core/agent-session.ts) for the API. For a subprocess-based TypeScript client, see [`src/modes/rpc/rpc-client.ts`](../src/modes/rpc/rpc-client.ts).\n\n## Starting RPC Mode\n\n```bash\npi --mode rpc [options]\n```\n\nCommon options:\n- `--provider <name>`: Set the LLM provider (anthropic, openai, google, etc.)\n- `--model <pattern>`: Model pattern or ID (supports `provider/id` and optional `:<thinking>`)\n- `--name <name>` / `-n <name>`: Set the session display name at startup\n- `--no-session`: Disable session persistence\n- `--session-dir <path>`: Custom session storage directory\n\n## Protocol Overview\n\n- **Commands**: JSON objects sent to stdin, one per line\n- **Responses**: JSON objects with `type: \"response\"` indicating command success/failure\n- **Events**: Agent events streamed to stdout as JSON lines\n\nAll commands support an optional `id` field for request/response correlation. If provided, the corresponding response will include the same `id`. `bash_execution_update` events also include the `id` of their originating `bash` command.\n\n### Framing\n\nRPC mode uses strict JSONL semantics with LF (`\\n`) as the only record delimiter.\n\nThis matters for clients:\n- Split records on `\\n` only\n- Accept optional `\\r\\n` input by stripping a trailing `\\r`\n- Do not use generic line readers that treat Unicode separators as newlines\n\nIn particular, Node `readline` is not protocol-compliant for RPC mode because it also splits on `U+2028` and `U+2029`, which are valid inside JSON strings.\n\n## Commands\n\n### Prompting\n\n#### prompt\n\nSend a user prompt to the agent. The command response is emitted after the prompt is accepted, queued, or handled. Events continue streaming asynchronously after acceptance.\n\n```json\n{\"id\": \"req-1\", \"type\": \"prompt\", \"message\": \"Hello, world!\"}\n```\n\nWith images:\n```json\n{\"type\": \"prompt\", \"message\": \"What's in this image?\", \"images\": [{\"type\": \"image\", \"data\": \"base64-encoded-data\", \"mimeType\": \"image/png\"}]}\n```\n\n**During streaming**: If the agent is already streaming, you must specify `streamingBehavior` to queue the message:\n\n```json\n{\"type\": \"prompt\", \"message\": \"New instruction\", \"streamingBehavior\": \"steer\"}\n```\n\n- `\"steer\"`: Queue the message while the agent is running. It is delivered after the current assistant turn finishes executing its tool calls, before the next LLM call.\n- `\"followUp\"`: Wait until the agent finishes. Message is delivered only when agent stops.\n\nIf the agent is streaming and no `streamingBehavior` is specified, the command returns an error.\n\n**Extension commands**: If the message is an extension command (e.g., `/mycommand`), it executes immediately even during streaming. Extension commands manage their own LLM interaction via `pi.sendMessage()`.\n\n**Input expansion**: Skill commands (`/skill:name`) and prompt templates (`/template`) are expanded before sending/queueing.\n\nResponse:\n```json\n{\"id\": \"req-1\", \"type\": \"response\", \"command\": \"prompt\", \"success\": true}\n```\n\n`success: true` means the prompt was accepted, queued, or handled immediately. `success: false` means the prompt was rejected before acceptance. Failures after acceptance are reported through the normal event and message stream, not as a second `response` for the same request id.\n\nThe `images` field is optional. Each image uses `ImageContent` format: `{\"type\": \"image\", \"data\": \"base64-encoded-data\", \"mimeType\": \"image/png\"}`.\n\n#### steer\n\nQueue a steering message while the agent is running. It is delivered after the current assistant turn finishes executing its tool calls, before the next LLM call. Skill commands and prompt templates are expanded. Extension commands are not allowed (use `prompt` instead).\n\n```json\n{\"type\": \"steer\", \"message\": \"Stop and do this instead\"}\n```\n\nWith images:\n```json\n{\"type\": \"steer\", \"message\": \"Look at this instead\", \"images\": [{\"type\": \"image\", \"data\": \"base64-encoded-data\", \"mimeType\": \"image/png\"}]}\n```\n\nThe `images` field is optional. Each image uses `ImageContent` format (same as `prompt`).\n\nResponse:\n```json\n{\"type\": \"response\", \"command\": \"steer\", \"success\": true}\n```\n\nSee [set_steering_mode](#set_steering_mode) for controlling how steering messages are processed.\n\n#### follow_up\n\nQueue a follow-up message to be processed after the agent finishes. Delivered only when agent has no more tool calls or steering messages. Skill commands and prompt templates are expanded. Extension commands are not allowed (use `prompt` instead).\n\n```json\n{\"type\": \"follow_up\", \"message\": \"After you're done, also do this\"}\n```\n\nWith images:\n```json\n{\"type\": \"follow_up\", \"message\": \"Also check this image\", \"images\": [{\"type\": \"image\", \"data\": \"base64-encoded-data\", \"mimeType\": \"image/png\"}]}\n```\n\nThe `images` field is optional. Each image uses `ImageContent` format (same as `prompt`).\n\nResponse:\n```json\n{\"type\": \"response\", \"command\": \"follow_up\", \"success\": true}\n```\n\nSee [set_follow_up_mode](#set_follow_up_mode) for controlling how follow-up messages are processed.\n\n#### abort\n\nAbort the current agent operation.\n\n```json\n{\"type\": \"abort\"}\n```\n\nResponse:\n```json\n{\"type\": \"response\", \"command\": \"abort\", \"success\": true}\n```\n\n#### new_session\n\nStart a fresh session. Can be cancelled by a `session_before_switch` extension event handler.\n\n```json\n{\"type\": \"new_session\"}\n```\n\nWith optional parent session tracking:\n```json\n{\"type\": \"new_session\", \"parentSession\": \"/path/to/parent-session.jsonl\"}\n```\n\nResponse:\n```json\n{\"type\": \"response\", \"command\": \"new_session\", \"success\": true, \"data\": {\"cancelled\": false}}\n```\n\nIf an extension cancelled:\n```json\n{\"type\": \"response\", \"command\": \"new_session\", \"success\": true, \"data\": {\"cancelled\": true}}\n```\n\n### State\n\n#### get_state\n\nGet current session state.\n\n```json\n{\"type\": \"get_state\"}\n```\n\nResponse:\n```json\n{\n  \"type\": \"response\",\n  \"command\": \"get_state\",\n  \"success\": true,\n  \"data\": {\n    \"model\": {...},\n    \"thinkingLevel\": \"medium\",\n    \"isStreaming\": false,\n    \"isCompacting\": false,\n    \"steeringMode\": \"all\",\n    \"followUpMode\": \"one-at-a-time\",\n    \"sessionFile\": \"/path/to/session.jsonl\",\n    \"sessionId\": \"abc123\",\n    \"sessionName\": \"my-feature-work\",\n    \"autoCompactionEnabled\": true,\n    \"messageCount\": 5,\n    \"pendingMessageCount\": 0\n  }\n}\n```\n\nThe `model` field is a full [Model](#model) object or `null`. The `sessionName` field is the display name set via `set_session_name`, or omitted if not set.\n\n#### get_messages\n\nGet all messages in the conversation.\n\n```json\n{\"type\": \"get_messages\"}\n```\n\nResponse:\n```json\n{\n  \"type\": \"response\",\n  \"command\": \"get_messages\",\n  \"success\": true,\n  \"data\": {\"messages\": [...]}\n}\n```\n\nMessages are `AgentMessage` objects (see [Message Types](#message-types)).\n\n### Model\n\n#### set_model\n\nSwitch to a specific model.\n\n```json\n{\"type\": \"set_model\", \"provider\": \"anthropic\", \"modelId\": \"claude-sonnet-4-20250514\"}\n```\n\nResponse contains the full [Model](#model) object:\n```json\n{\n  \"type\": \"response\",\n  \"command\": \"set_model\",\n  \"success\": true,\n  \"data\": {...}\n}\n```\n\n#### cycle_model\n\nCycle to the next available model. Returns `null` data if only one model available.\n\n```json\n{\"type\": \"cycle_model\"}\n```\n\nResponse:\n```json\n{\n  \"type\": \"response\",\n  \"command\": \"cycle_model\",\n  \"success\": true,\n  \"data\": {\n    \"model\": {...},\n    \"thinkingLevel\": \"medium\",\n    \"isScoped\": false\n  }\n}\n```\n\nThe `model` field is a full [Model](#model) object.\n\n#### get_available_models\n\nList all configured models.\n\n```json\n{\"type\": \"get_available_models\"}\n```\n\nResponse contains an array of full [Model](#model) objects:\n```json\n{\n  \"type\": \"response\",\n  \"command\": \"get_available_models\",\n  \"success\": true,\n  \"data\": {\n    \"models\": [...]\n  }\n}\n```\n\n### Thinking\n\n#### set_thinking_level\n\nSet the reasoning/thinking level for models that support it.\n\n```json\n{\"type\": \"set_thinking_level\", \"level\": \"high\"}\n```\n\nLevels: `\"off\"`, `\"minimal\"`, `\"low\"`, `\"medium\"`, `\"high\"`, `\"xhigh\"`, `\"max\"`\n\n`\"xhigh\"` and `\"max\"` are exposed only when supported by the selected model. Some models, including GPT-5.6, expose both.\n\nResponse:\n```json\n{\"type\": \"response\", \"command\": \"set_thinking_level\", \"success\": true}\n```\n\n#### cycle_thinking_level\n\nCycle through available thinking levels. Returns `null` data if model doesn't support thinking.\n\n```json\n{\"type\": \"cycle_thinking_level\"}\n```\n\nResponse:\n```json\n{\n  \"type\": \"response\",\n  \"command\": \"cycle_thinking_level\",\n  \"success\": true,\n  \"data\": {\"level\": \"high\"}\n}\n```\n\n#### get_available_thinking_levels\n\nList the thinking levels supported by the current model. Returns `[\"off\"]` for a model without reasoning support.\n\n```json\n{\"type\": \"get_available_thinking_levels\"}\n```\n\nResponse:\n```json\n{\n  \"type\": \"response\",\n  \"command\": \"get_available_thinking_levels\",\n  \"success\": true,\n  \"data\": {\n    \"levels\": [\"off\", \"minimal\", \"low\", \"medium\", \"high\"]\n  }\n}\n```\n\n### Queue Modes\n\n#### set_steering_mode\n\nControl how steering messages (from `steer`) are delivered.\n\n```json\n{\"type\": \"set_steering_mode\", \"mode\": \"one-at-a-time\"}\n```\n\nModes:\n- `\"all\"`: Deliver all steering messages after the current assistant turn finishes executing its tool calls\n- `\"one-at-a-time\"`: Deliver one steering message per completed assistant turn (default)\n\nResponse:\n```json\n{\"type\": \"response\", \"command\": \"set_steering_mode\", \"success\": true}\n```\n\n#### set_follow_up_mode\n\nControl how follow-up messages (from `follow_up`) are delivered.\n\n```json\n{\"type\": \"set_follow_up_mode\", \"mode\": \"one-at-a-time\"}\n```\n\nModes:\n- `\"all\"`: Deliver all follow-up messages when agent finishes\n- `\"one-at-a-time\"`: Deliver one follow-up message per agent completion (default)\n\nResponse:\n```json\n{\"type\": \"response\", \"command\": \"set_follow_up_mode\", \"success\": true}\n```\n\n### Compaction\n\n#### compact\n\nManually compact conversation context to reduce token usage.\n\n```json\n{\"type\": \"compact\"}\n```\n\nWith custom instructions:\n```json\n{\"type\": \"compact\", \"customInstructions\": \"Focus on code changes\"}\n```\n\nResponse:\n```json\n{\n  \"type\": \"response\",\n  \"command\": \"compact\",\n  \"success\": true,\n  \"data\": {\n    \"summary\": \"Summary of conversation...\",\n    \"firstKeptEntryId\": \"abc123\",\n    \"tokensBefore\": 150000,\n    \"estimatedTokensAfter\": 32000,\n    \"usage\": {\n      \"input\": 32000,\n      \"output\": 1200,\n      \"cacheRead\": 0,\n      \"cacheWrite\": 0,\n      \"totalTokens\": 33200,\n      \"cost\": {\"input\": 0.01, \"output\": 0.02, \"cacheRead\": 0, \"cacheWrite\": 0, \"total\": 0.03}\n    },\n    \"details\": {}\n  }\n}\n```\n\n`estimatedTokensAfter` is a heuristic estimate over the rebuilt message context immediately after compaction, not a provider-exact token count. `usage` reports the LLM call or calls that generated the summary and may be omitted by custom compaction handlers.\n\n#### set_auto_compaction\n\nEnable or disable automatic compaction when context is nearly full.\n\n```json\n{\"type\": \"set_auto_compaction\", \"enabled\": true}\n```\n\nResponse:\n```json\n{\"type\": \"response\", \"command\": \"set_auto_compaction\", \"success\": true}\n```\n\n### Retry\n\n#### set_auto_retry\n\nEnable or disable automatic retry on transient errors (overloaded, rate limit, 5xx).\n\n```json\n{\"type\": \"set_auto_retry\", \"enabled\": true}\n```\n\nResponse:\n```json\n{\"type\": \"response\", \"command\": \"set_auto_retry\", \"success\": true}\n```\n\n#### abort_retry\n\nAbort an in-progress retry (cancel the delay and stop retrying).\n\n```json\n{\"type\": \"abort_retry\"}\n```\n\nResponse:\n```json\n{\"type\": \"response\", \"command\": \"abort_retry\", \"success\": true}\n```\n\n### Bash\n\n#### bash\n\nExecute a shell command and add output to conversation context. Output streams as `bash_execution_update` events while the command runs; the response contains the final result.\n\n```json\n{\"id\": \"req-1\", \"type\": \"bash\", \"command\": \"ls -la\"}\n```\n\nInclude an `id` to associate streamed `bash_execution_update` events with this command.\n\nResponse:\n```json\n{\n  \"id\": \"req-1\",\n  \"type\": \"response\",\n  \"command\": \"bash\",\n  \"success\": true,\n  \"data\": {\n    \"output\": \"total 48\\ndrwxr-xr-x ...\",\n    \"exitCode\": 0,\n    \"cancelled\": false,\n    \"truncated\": false\n  }\n}\n```\n\nIf output was truncated, includes `fullOutputPath`:\n```json\n{\n  \"type\": \"response\",\n  \"command\": \"bash\",\n  \"success\": true,\n  \"data\": {\n    \"output\": \"truncated output...\",\n    \"exitCode\": 0,\n    \"cancelled\": false,\n    \"truncated\": true,\n    \"fullOutputPath\": \"/tmp/pi-bash-abc123.log\"\n  }\n}\n```\n\n**How bash results reach the LLM:**\n\nThe `bash` command executes immediately and returns a `BashResult`. Internally, a `BashExecutionMessage` is created and stored in the agent's message state.\n\nWhen the next `prompt` command is sent, all messages (including `BashExecutionMessage`) are transformed before being sent to the LLM. The `BashExecutionMessage` is converted to a `UserMessage` with this format:\n\n````\nRan `ls -la`\n```\ntotal 48\ndrwxr-xr-x ...\n```\n````\n\nThis means:\n1. Bash output is included in the LLM context on the **next prompt**, not immediately\n2. Multiple bash commands can be executed before a prompt; all outputs will be included\n\n#### abort_bash\n\nAbort a running bash command.\n\n```json\n{\"type\": \"abort_bash\"}\n```\n\nResponse:\n```json\n{\"type\": \"response\", \"command\": \"abort_bash\", \"success\": true}\n```\n\n### Session\n\n#### get_session_stats\n\nGet token usage, cost statistics, and current context window usage.\n\n```json\n{\"type\": \"get_session_stats\"}\n```\n\nResponse:\n```json\n{\n  \"type\": \"response\",\n  \"command\": \"get_session_stats\",\n  \"success\": true,\n  \"data\": {\n    \"sessionFile\": \"/path/to/session.jsonl\",\n    \"sessionId\": \"abc123\",\n    \"userMessages\": 5,\n    \"assistantMessages\": 5,\n    \"toolCalls\": 12,\n    \"toolResults\": 12,\n    \"totalMessages\": 22,\n    \"tokens\": {\n      \"input\": 50000,\n      \"output\": 10000,\n      \"cacheRead\": 40000,\n      \"cacheWrite\": 5000,\n      \"total\": 105000\n    },\n    \"cost\": 0.45,\n    \"contextUsage\": {\n      \"tokens\": 60000,\n      \"contextWindow\": 200000,\n      \"percent\": 30\n    }\n  }\n}\n```\n\n`tokens` and `cost` include assistant messages, usage reported by tools, and compaction/branch-summary generation across the full session. `contextUsage` contains the actual current context-window estimate used for compaction and footer display.\n\n`contextUsage` is omitted when no model or context window is available. `contextUsage.tokens` and `contextUsage.percent` are `null` immediately after compaction until a fresh post-compaction assistant response provides valid usage data.\n\n#### export_html\n\nExport session to an HTML file.\n\n```json\n{\"type\": \"export_html\"}\n```\n\nWith custom path:\n```json\n{\"type\": \"export_html\", \"outputPath\": \"/tmp/session.html\"}\n```\n\nResponse:\n```json\n{\n  \"type\": \"response\",\n  \"command\": \"export_html\",\n  \"success\": true,\n  \"data\": {\"path\": \"/tmp/session.html\"}\n}\n```\n\n#### switch_session\n\nLoad a different session file. Can be cancelled by a `session_before_switch` extension event handler.\n\n```json\n{\"type\": \"switch_session\", \"sessionPath\": \"/path/to/session.jsonl\"}\n```\n\nResponse:\n```json\n{\"type\": \"response\", \"command\": \"switch_session\", \"success\": true, \"data\": {\"cancelled\": false}}\n```\n\nIf an extension cancelled the switch:\n```json\n{\"type\": \"response\", \"command\": \"switch_session\", \"success\": true, \"data\": {\"cancelled\": true}}\n```\n\n#### fork\n\nCreate a new fork from a previous user message on the active branch. Can be cancelled by a `session_before_fork` extension event handler. Returns the text of the message being forked from.\n\n```json\n{\"type\": \"fork\", \"entryId\": \"abc123\"}\n```\n\nResponse:\n```json\n{\n  \"type\": \"response\",\n  \"command\": \"fork\",\n  \"success\": true,\n  \"data\": {\"text\": \"The original prompt text...\", \"cancelled\": false}\n}\n```\n\nIf an extension cancelled the fork:\n```json\n{\n  \"type\": \"response\",\n  \"command\": \"fork\",\n  \"success\": true,\n  \"data\": {\"text\": \"The original prompt text...\", \"cancelled\": true}\n}\n```\n\n#### clone\n\nDuplicate the current active branch into a new session at the current position. Can be cancelled by a `session_before_fork` extension event handler.\n\n```json\n{\"type\": \"clone\"}\n```\n\nResponse:\n```json\n{\n  \"type\": \"response\",\n  \"command\": \"clone\",\n  \"success\": true,\n  \"data\": {\"cancelled\": false}\n}\n```\n\nIf an extension cancelled the clone:\n```json\n{\n  \"type\": \"response\",\n  \"command\": \"clone\",\n  \"success\": true,\n  \"data\": {\"cancelled\": true}\n}\n```\n\n#### get_fork_messages\n\nGet user messages available for forking.\n\n```json\n{\"type\": \"get_fork_messages\"}\n```\n\nResponse:\n```json\n{\n  \"type\": \"response\",\n  \"command\": \"get_fork_messages\",\n  \"success\": true,\n  \"data\": {\n    \"messages\": [\n      {\"entryId\": \"abc123\", \"text\": \"First prompt...\"},\n      {\"entryId\": \"def456\", \"text\": \"Second prompt...\"}\n    ]\n  }\n}\n```\n\n#### get_entries\n\nGet all session entries in append order (excluding the session header). The session is an append-only tree of entries with stable ids, so an entry id works as a durable cursor: pass the last entry id you have seen as `since` to get only entries strictly after it, even across client restarts. Unlike `get_messages`, this includes pre-compaction history and abandoned branches.\n\n```json\n{\"type\": \"get_entries\"}\n```\n\nWith a cursor:\n```json\n{\"type\": \"get_entries\", \"since\": \"abc123\"}\n```\n\nResponse:\n```json\n{\n  \"type\": \"response\",\n  \"command\": \"get_entries\",\n  \"success\": true,\n  \"data\": {\n    \"entries\": [\n      {\"type\": \"message\", \"id\": \"def456\", \"parentId\": \"abc123\", \"timestamp\": \"...\", \"message\": {\"role\": \"user\", \"...\": \"...\"}}\n    ],\n    \"leafId\": \"def456\"\n  }\n}\n```\n\n`leafId` is the id of the current leaf entry (`null` for an empty session), so a client can tell in one round trip whether the active branch moved. If `since` does not match any entry id, the response is `success: false`.\n\n#### get_tree\n\nGet the session as a tree of entries. Each node is `{entry, children, label?, labelTimestamp?}`. A well-formed session has a single root; orphaned entries (broken parent chain) also appear as roots.\n\n```json\n{\"type\": \"get_tree\"}\n```\n\nResponse:\n```json\n{\n  \"type\": \"response\",\n  \"command\": \"get_tree\",\n  \"success\": true,\n  \"data\": {\n    \"tree\": [\n      {\n        \"entry\": {\"type\": \"message\", \"id\": \"abc123\", \"parentId\": null, \"...\": \"...\"},\n        \"children\": [\n          {\"entry\": {\"type\": \"message\", \"id\": \"def456\", \"parentId\": \"abc123\", \"...\": \"...\"}, \"children\": []}\n        ]\n      }\n    ],\n    \"leafId\": \"def456\"\n  }\n}\n```\n\n#### get_last_assistant_text\n\nGet the text content of the last assistant message.\n\n```json\n{\"type\": \"get_last_assistant_text\"}\n```\n\nResponse:\n```json\n{\n  \"type\": \"response\",\n  \"command\": \"get_last_assistant_text\",\n  \"success\": true,\n  \"data\": {\"text\": \"The assistant's response...\"}\n}\n```\n\nReturns `{\"text\": null}` if no assistant messages exist.\n\n#### set_session_name\n\nSet a display name for the current session. The name appears in session listings and helps identify sessions.\n\n```json\n{\"type\": \"set_session_name\", \"name\": \"my-feature-work\"}\n```\n\nResponse:\n```json\n{\n  \"type\": \"response\",\n  \"command\": \"set_session_name\",\n  \"success\": true\n}\n```\n\nThe current session name is available via `get_state` in the `sessionName` field. To set the initial name when starting RPC mode, pass `--name <name>` or `-n <name>` to the `pi --mode rpc` process.\n\n### Commands\n\n#### get_commands\n\nGet available commands (extension commands, prompt templates, and skills). These can be invoked via the `prompt` command by prefixing with `/`.\n\n```json\n{\"type\": \"get_commands\"}\n```\n\nResponse:\n```json\n{\n  \"type\": \"response\",\n  \"command\": \"get_commands\",\n  \"success\": true,\n  \"data\": {\n    \"commands\": [\n      {\"name\": \"session-name\", \"description\": \"Set or clear session name\", \"source\": \"extension\", \"path\": \"/home/user/.pi/agent/extensions/session.ts\"},\n      {\"name\": \"fix-tests\", \"description\": \"Fix failing tests\", \"source\": \"prompt\", \"location\": \"project\", \"path\": \"/home/user/myproject/.pi/agent/prompts/fix-tests.md\"},\n      {\"name\": \"skill:brave-search\", \"description\": \"Web search via Brave API\", \"source\": \"skill\", \"location\": \"user\", \"path\": \"/home/user/.pi/agent/skills/brave-search/SKILL.md\"}\n    ]\n  }\n}\n```\n\nEach command has:\n- `name`: Command name (invoke with `/name`)\n- `description`: Human-readable description (optional for extension commands)\n- `source`: What kind of command:\n  - `\"extension\"`: Registered via `pi.registerCommand()` in an extension\n  - `\"prompt\"`: Loaded from a prompt template `.md` file\n  - `\"skill\"`: Loaded from a skill directory (name is prefixed with `skill:`)\n- `location`: Where it was loaded from (optional, not present for extensions):\n  - `\"user\"`: User-level (`~/.pi/agent/`)\n  - `\"project\"`: Project-level (`./.pi/agent/`)\n  - `\"path\"`: Explicit path via CLI or settings\n- `path`: Absolute file path to the command source (optional)\n\n**Note**: Built-in TUI commands (`/settings`, `/hotkeys`, etc.) are not included. They are handled only in interactive mode and would not execute if sent via `prompt`.\n\n## Events\n\nEvents are streamed to stdout as JSON lines during agent operation. Events do not generally include an `id` field; `bash_execution_update` includes the `id` of its originating `bash` command when one was provided.\n\n### Event Types\n\n| Event | Description |\n|-------|-------------|\n| `agent_start` | Agent begins processing |\n| `agent_end` | One low-level agent run completes (may still be followed by retry, compaction, or queued continuations) |\n| `agent_settled` | Agent run is fully settled; no automatic retry, compaction retry, or queued continuation remains |\n| `turn_start` | New turn begins |\n| `turn_end` | Turn completes (includes assistant message and tool results) |\n| `message_start` | Message begins |\n| `message_update` | Streaming update (text/thinking/toolcall deltas) |\n| `message_end` | Message completes |\n| `bash_execution_update` | Direct RPC bash command output chunk |\n| `tool_execution_start` | Tool begins execution |\n| `tool_execution_update` | Tool execution progress (streaming output) |\n| `tool_execution_end` | Tool completes |\n| `queue_update` | Pending steering/follow-up queue changed |\n| `compaction_start` | Compaction begins |\n| `compaction_end` | Compaction completes |\n| `auto_retry_start` | Auto-retry begins (after transient error) |\n| `auto_retry_end` | Auto-retry completes (success or final failure) |\n| `summarization_retry_scheduled` | Retry scheduled for a transient compaction or branch-summary summarization error |\n| `summarization_retry_attempt_start` | Retried summarization request starts |\n| `summarization_retry_finished` | Summarization retry loop completes |\n| `extension_error` | Extension threw an error |\n\n### agent_start\n\nEmitted when the agent begins processing a prompt.\n\n```json\n{\"type\": \"agent_start\"}\n```\n\n### agent_end\n\nEmitted when one low-level agent run completes. Contains all messages generated during this run. If `willRetry` is true, an automatic retry will follow.\n\n```json\n{\n  \"type\": \"agent_end\",\n  \"messages\": [...],\n  \"willRetry\": false\n}\n```\n\n### agent_settled\n\nEmitted after the full session-level run settles. At this point Pi will not continue automatically through retry, compaction retry, or queued follow-up messages.\n\n```json\n{\"type\": \"agent_settled\"}\n```\n\n### turn_start / turn_end\n\nA turn consists of one assistant response plus any resulting tool calls and results.\n\n```json\n{\"type\": \"turn_start\"}\n```\n\n```json\n{\n  \"type\": \"turn_end\",\n  \"message\": {...},\n  \"toolResults\": [...]\n}\n```\n\n### message_start / message_end\n\nEmitted when a message begins and completes. The `message` field contains an `AgentMessage`.\n\n```json\n{\"type\": \"message_start\", \"message\": {...}}\n{\"type\": \"message_end\", \"message\": {...}}\n```\n\n### message_update (Streaming)\n\nEmitted during streaming of assistant messages. Contains a delta event without a cumulative message snapshot.\n\n```json\n{\n  \"type\": \"message_update\",\n  \"assistantMessageEvent\": {\n    \"type\": \"text_delta\",\n    \"contentIndex\": 0,\n    \"delta\": \"Hello \"\n  }\n}\n```\n\nThe `assistantMessageEvent` field contains one of these delta types:\n\n| Type | Description |\n|------|-------------|\n| `text_start` | Text content block started |\n| `text_delta` | Text content chunk |\n| `text_end` | Text content block ended |\n| `thinking_start` | Thinking block started |\n| `thinking_delta` | Thinking content chunk |\n| `thinking_end` | Thinking block ended |\n| `toolcall_start` | Tool call started |\n| `toolcall_delta` | Tool call arguments chunk |\n| `toolcall_end` | Tool call ended (includes full `toolCall` object) |\n\nExample streaming a text response:\n```json\n{\"type\":\"message_update\",\"assistantMessageEvent\":{\"type\":\"text_start\",\"contentIndex\":0}}\n{\"type\":\"message_update\",\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"Hello\"}}\n{\"type\":\"message_update\",\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\" world\"}}\n{\"type\":\"message_update\",\"assistantMessageEvent\":{\"type\":\"text_end\",\"contentIndex\":0,\"content\":\"Hello world\"}}\n```\n\n`message_update` intentionally omits the former cumulative `message` field and\n`assistantMessageEvent.partial`. Clients that need a live partial message must assemble it\nfrom `message_start` and subsequent events using `contentIndex`. Treat `message_end.message`\nas authoritative. For tool calls, buffer `toolcall_delta.delta`; `toolcall_end.toolCall`\ncontains the completed call.\n\n### bash_execution_update\n\nEmitted once for each output chunk from a direct `bash` command. `id` matches the command's `id`, allowing clients to associate output with the correct command.\n\nEvents stream all output while the command runs, even if the final `bash` response's `output` is truncated.\n\n```json\n{\n  \"type\": \"bash_execution_update\",\n  \"id\": \"req-1\",\n  \"delta\": \"total 48\\n\"\n}\n```\n\n### tool_execution_start / tool_execution_update / tool_execution_end\n\nEmitted when a tool begins, streams progress, and completes execution.\n\n```json\n{\n  \"type\": \"tool_execution_start\",\n  \"toolCallId\": \"call_abc123\",\n  \"toolName\": \"bash\",\n  \"args\": {\"command\": \"ls -la\"}\n}\n```\n\nDuring execution, `tool_execution_update` events stream partial results (e.g., bash output as it arrives):\n\n```json\n{\n  \"type\": \"tool_execution_update\",\n  \"toolCallId\": \"call_abc123\",\n  \"toolName\": \"bash\",\n  \"args\": {\"command\": \"ls -la\"},\n  \"partialResult\": {\n    \"content\": [{\"type\": \"text\", \"text\": \"partial output so far...\"}],\n    \"details\": {\"truncation\": null, \"fullOutputPath\": null}\n  }\n}\n```\n\nWhen complete:\n\n```json\n{\n  \"type\": \"tool_execution_end\",\n  \"toolCallId\": \"call_abc123\",\n  \"toolName\": \"bash\",\n  \"result\": {\n    \"content\": [{\"type\": \"text\", \"text\": \"total 48\\n...\"}],\n    \"details\": {...}\n  },\n  \"isError\": false\n}\n```\n\nUse `toolCallId` to correlate events. The `partialResult` in `tool_execution_update` contains the accumulated output so far (not just the delta), allowing clients to simply replace their display on each update.\n\n### queue_update\n\nEmitted whenever the pending steering or follow-up queue changes.\n\n```json\n{\n  \"type\": \"queue_update\",\n  \"steering\": [\"Focus on error handling\"],\n  \"followUp\": [\"After that, summarize the result\"]\n}\n```\n\n### compaction_start / compaction_end\n\nEmitted when compaction runs, whether manual or automatic.\n\n```json\n{\"type\": \"compaction_start\", \"reason\": \"threshold\"}\n```\n\nThe `reason` field is `\"manual\"`, `\"threshold\"`, or `\"overflow\"`.\n\n```json\n{\n  \"type\": \"compaction_end\",\n  \"reason\": \"threshold\",\n  \"result\": {\n    \"summary\": \"Summary of conversation...\",\n    \"firstKeptEntryId\": \"abc123\",\n    \"tokensBefore\": 150000,\n    \"estimatedTokensAfter\": 32000,\n    \"usage\": {\n      \"input\": 32000,\n      \"output\": 1200,\n      \"cacheRead\": 0,\n      \"cacheWrite\": 0,\n      \"totalTokens\": 33200,\n      \"cost\": {\"input\": 0.01, \"output\": 0.02, \"cacheRead\": 0, \"cacheWrite\": 0, \"total\": 0.03}\n    },\n    \"details\": {}\n  },\n  \"aborted\": false,\n  \"willRetry\": false\n}\n```\n\nIf `reason` was `\"overflow\"` and compaction succeeds, `willRetry` is `true` and the agent will automatically retry the prompt.\n\nIf compaction was aborted, `result` is `null` and `aborted` is `true`.\n\nIf compaction failed (e.g., API quota exceeded), `result` is `null`, `aborted` is `false`, and `errorMessage` contains the error description.\n\n### auto_retry_start / auto_retry_end\n\nEmitted when automatic retry is triggered after a transient error (overloaded, rate limit, 5xx).\n\n```json\n{\n  \"type\": \"auto_retry_start\",\n  \"attempt\": 1,\n  \"maxAttempts\": 3,\n  \"delayMs\": 2000,\n  \"errorMessage\": \"529 {\\\"type\\\":\\\"error\\\",\\\"error\\\":{\\\"type\\\":\\\"overloaded_error\\\",\\\"message\\\":\\\"Overloaded\\\"}}\"\n}\n```\n\n```json\n{\n  \"type\": \"auto_retry_end\",\n  \"success\": true,\n  \"attempt\": 2\n}\n```\n\nOn final failure (max retries exceeded):\n```json\n{\n  \"type\": \"auto_retry_end\",\n  \"success\": false,\n  \"attempt\": 3,\n  \"finalError\": \"529 overloaded_error: Overloaded\"\n}\n```\n\n### summarization_retry_scheduled / summarization_retry_attempt_start / summarization_retry_finished\n\nEmitted when compaction or branch-summary summarization retries after a transient provider error. These events use the same retry settings as automatic assistant-turn retries.\n\n```json\n{\n  \"type\": \"summarization_retry_scheduled\",\n  \"attempt\": 1,\n  \"maxAttempts\": 3,\n  \"delayMs\": 2000,\n  \"errorMessage\": \"terminated\"\n}\n```\n\n```json\n{\n  \"type\": \"summarization_retry_attempt_start\",\n  \"source\": \"compaction\",\n  \"reason\": \"threshold\"\n}\n```\n\nFor branch summaries, `source` is `\"branchSummary\"` and no `reason` is present.\n\n```json\n{\n  \"type\": \"summarization_retry_finished\"\n}\n```\n\n### extension_error\n\nEmitted when an extension throws an error.\n\n```json\n{\n  \"type\": \"extension_error\",\n  \"extensionPath\": \"/path/to/extension.ts\",\n  \"event\": \"tool_call\",\n  \"error\": \"Error message...\"\n}\n```\n\n## Extension UI Protocol\n\nExtensions can request user interaction via `ctx.ui.select()`, `ctx.ui.confirm()`, etc. In RPC mode, these are translated into a request/response sub-protocol on top of the base command/event flow.\n\nThere are two categories of extension UI methods:\n\n- **Dialog methods** (`select`, `confirm`, `input`, `editor`): emit an `extension_ui_request` on stdout and block until the client sends back an `extension_ui_response` on stdin with the matching `id`.\n- **Fire-and-forget methods** (`notify`, `setStatus`, `setWidget`, `setTitle`, `set_editor_text`): emit an `extension_ui_request` on stdout but do not expect a response. The client can display the information or ignore it.\n\nIf a dialog method includes a `timeout` field, the agent-side will auto-resolve with a default value when the timeout expires. The client does not need to track timeouts.\n\nSome `ExtensionUIContext` methods are not supported or degraded in RPC mode because they require direct TUI access:\n- `custom()` returns `undefined`\n- `setWorkingMessage()`, `setWorkingIndicator()`, `setFooter()`, `setHeader()`, `setEditorComponent()`, `setToolsExpanded()` are no-ops\n- `getEditorText()` returns `\"\"`\n- `getToolsExpanded()` returns `false`\n- `pasteToEditor()` delegates to `setEditorText()` (no paste/collapse handling)\n- `getAllThemes()` returns `[]`\n- `getTheme()` returns `undefined`\n- `setTheme()` returns `{ success: false, error: \"...\" }`\n\nNote: `ctx.mode` is `\"rpc\"` and `ctx.hasUI` is `true` in RPC mode because the dialog and fire-and-forget methods are functional via the extension UI sub-protocol. Use `ctx.mode === \"tui\"` to guard TUI-specific features like `custom()` that require a real terminal.\n\n### Extension UI Requests (stdout)\n\nAll requests have `type: \"extension_ui_request\"`, a unique `id`, and a `method` field.\n\n#### select\n\nPrompt the user to choose from a list. Dialog methods with a `timeout` field include the timeout in milliseconds; the agent auto-resolves with `undefined` if the client doesn't respond in time.\n\n```json\n{\n  \"type\": \"extension_ui_request\",\n  \"id\": \"uuid-1\",\n  \"method\": \"select\",\n  \"title\": \"Allow dangerous command?\",\n  \"options\": [\"Allow\", \"Block\"],\n  \"timeout\": 10000\n}\n```\n\nExpected response: `extension_ui_response` with `value` (the selected option string) or `cancelled: true`.\n\n#### confirm\n\nPrompt the user for yes/no confirmation.\n\n```json\n{\n  \"type\": \"extension_ui_request\",\n  \"id\": \"uuid-2\",\n  \"method\": \"confirm\",\n  \"title\": \"Clear session?\",\n  \"message\": \"All messages will be lost.\",\n  \"timeout\": 5000\n}\n```\n\nExpected response: `extension_ui_response` with `confirmed: true/false` or `cancelled: true`.\n\n#### input\n\nPrompt the user for free-form text.\n\n```json\n{\n  \"type\": \"extension_ui_request\",\n  \"id\": \"uuid-3\",\n  \"method\": \"input\",\n  \"title\": \"Enter a value\",\n  \"placeholder\": \"type something...\"\n}\n```\n\nExpected response: `extension_ui_response` with `value` (the entered text) or `cancelled: true`.\n\n#### editor\n\nOpen a multi-line text editor with optional prefilled content.\n\n```json\n{\n  \"type\": \"extension_ui_request\",\n  \"id\": \"uuid-4\",\n  \"method\": \"editor\",\n  \"title\": \"Edit some text\",\n  \"prefill\": \"Line 1\\nLine 2\\nLine 3\"\n}\n```\n\nExpected response: `extension_ui_response` with `value` (the edited text) or `cancelled: true`.\n\n#### notify\n\nDisplay a notification. Fire-and-forget, no response expected.\n\n```json\n{\n  \"type\": \"extension_ui_request\",\n  \"id\": \"uuid-5\",\n  \"method\": \"notify\",\n  \"message\": \"Command blocked by user\",\n  \"notifyType\": \"warning\"\n}\n```\n\nThe `notifyType` field is `\"info\"`, `\"warning\"`, or `\"error\"`. Defaults to `\"info\"` if omitted.\n\n#### setStatus\n\nSet or clear a status entry in the footer/status bar. Fire-and-forget.\n\n```json\n{\n  \"type\": \"extension_ui_request\",\n  \"id\": \"uuid-6\",\n  \"method\": \"setStatus\",\n  \"statusKey\": \"my-ext\",\n  \"statusText\": \"Turn 3 running...\"\n}\n```\n\nSend `statusText: undefined` (or omit it) to clear the status entry for that key.\n\n#### setWidget\n\nSet or clear a widget (block of text lines) displayed above or below the editor. Fire-and-forget.\n\n```json\n{\n  \"type\": \"extension_ui_request\",\n  \"id\": \"uuid-7\",\n  \"method\": \"setWidget\",\n  \"widgetKey\": \"my-ext\",\n  \"widgetLines\": [\"--- My Widget ---\", \"Line 1\", \"Line 2\"],\n  \"widgetPlacement\": \"aboveEditor\"\n}\n```\n\nSend `widgetLines: undefined` (or omit it) to clear the widget. The `widgetPlacement` field is `\"aboveEditor\"` (default) or `\"belowEditor\"`. Only string arrays are supported in RPC mode; component factories are ignored.\n\n#### setTitle\n\nSet the terminal window/tab title. Fire-and-forget.\n\n```json\n{\n  \"type\": \"extension_ui_request\",\n  \"id\": \"uuid-8\",\n  \"method\": \"setTitle\",\n  \"title\": \"pi - my project\"\n}\n```\n\n#### set_editor_text\n\nSet the text in the input editor. Fire-and-forget.\n\n```json\n{\n  \"type\": \"extension_ui_request\",\n  \"id\": \"uuid-9\",\n  \"method\": \"set_editor_text\",\n  \"text\": \"prefilled text for the user\"\n}\n```\n\n### Extension UI Responses (stdin)\n\nResponses are sent for dialog methods only (`select`, `confirm`, `input`, `editor`). The `id` must match the request.\n\n#### Value response (select, input, editor)\n\n```json\n{\"type\": \"extension_ui_response\", \"id\": \"uuid-1\", \"value\": \"Allow\"}\n```\n\n#### Confirmation response (confirm)\n\n```json\n{\"type\": \"extension_ui_response\", \"id\": \"uuid-2\", \"confirmed\": true}\n```\n\n#### Cancellation response (any dialog)\n\nDismiss any dialog method. The extension receives `undefined` (for select/input/editor) or `false` (for confirm).\n\n```json\n{\"type\": \"extension_ui_response\", \"id\": \"uuid-3\", \"cancelled\": true}\n```\n\n## Error Handling\n\nFailed commands return a response with `success: false`:\n\n```json\n{\n  \"type\": \"response\",\n  \"command\": \"set_model\",\n  \"success\": false,\n  \"error\": \"Model not found: invalid/model\"\n}\n```\n\nParse errors:\n\n```json\n{\n  \"type\": \"response\",\n  \"command\": \"parse\",\n  \"success\": false,\n  \"error\": \"Failed to parse command: Unexpected token...\"\n}\n```\n\n## Types\n\nSource files:\n- [`packages/ai/src/types.ts`](../../ai/src/types.ts) - `Model`, `UserMessage`, `AssistantMessage`, `ToolResultMessage`\n- [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AgentMessage`, `AgentEvent`\n- [`src/core/messages.ts`](../src/core/messages.ts) - `BashExecutionMessage`\n- [`src/modes/json-event.ts`](../src/modes/json-event.ts) - `JsonAgentSessionEvent`\n- [`src/modes/rpc/rpc-types.ts`](../src/modes/rpc/rpc-types.ts) - RPC command/response types, extension UI request/response types\n\n### Model\n\n```json\n{\n  \"id\": \"claude-sonnet-4-20250514\",\n  \"name\": \"Claude Sonnet 4\",\n  \"api\": \"anthropic-messages\",\n  \"provider\": \"anthropic\",\n  \"baseUrl\": \"https://api.anthropic.com\",\n  \"reasoning\": true,\n  \"input\": [\"text\", \"image\"],\n  \"contextWindow\": 200000,\n  \"maxTokens\": 16384,\n  \"cost\": {\n    \"input\": 3.0,\n    \"output\": 15.0,\n    \"cacheRead\": 0.3,\n    \"cacheWrite\": 3.75\n  }\n}\n```\n\n### UserMessage\n\n```json\n{\n  \"role\": \"user\",\n  \"content\": \"Hello!\",\n  \"timestamp\": 1733234567890,\n  \"attachments\": []\n}\n```\n\nThe `content` field can be a string or an array of `TextContent`/`ImageContent` blocks.\n\n### AssistantMessage\n\n```json\n{\n  \"role\": \"assistant\",\n  \"content\": [\n    {\"type\": \"text\", \"text\": \"Hello! How can I help?\"},\n    {\"type\": \"thinking\", \"thinking\": \"User is greeting me...\"},\n    {\"type\": \"toolCall\", \"id\": \"call_123\", \"name\": \"bash\", \"arguments\": {\"command\": \"ls\"}}\n  ],\n  \"api\": \"anthropic-messages\",\n  \"provider\": \"anthropic\",\n  \"model\": \"claude-sonnet-4-20250514\",\n  \"usage\": {\n    \"input\": 100,\n    \"output\": 50,\n    \"cacheRead\": 0,\n    \"cacheWrite\": 0,\n    \"cost\": {\"input\": 0.0003, \"output\": 0.00075, \"cacheRead\": 0, \"cacheWrite\": 0, \"total\": 0.00105}\n  },\n  \"stopReason\": \"stop\",\n  \"timestamp\": 1733234567890\n}\n```\n\nStop reasons: `\"stop\"`, `\"length\"`, `\"toolUse\"`, `\"error\"`, `\"aborted\"`\n\n### ToolResultMessage\n\n```json\n{\n  \"role\": \"toolResult\",\n  \"toolCallId\": \"call_123\",\n  \"toolName\": \"bash\",\n  \"content\": [{\"type\": \"text\", \"text\": \"total 48\\ndrwxr-xr-x ...\"}],\n  \"usage\": {\n    \"input\": 100,\n    \"output\": 50,\n    \"cacheRead\": 0,\n    \"cacheWrite\": 0,\n    \"totalTokens\": 150,\n    \"cost\": {\"input\": 0.0003, \"output\": 0.00075, \"cacheRead\": 0, \"cacheWrite\": 0, \"total\": 0.00105}\n  },\n  \"isError\": false,\n  \"timestamp\": 1733234567890\n}\n```\n\n`usage` is optional and reports nested LLM work performed by the tool. When present, it contributes to session token and cost totals.\n\n### BashExecutionMessage\n\nCreated by the `bash` RPC command (not by LLM tool calls):\n\n```json\n{\n  \"role\": \"bashExecution\",\n  \"command\": \"ls -la\",\n  \"output\": \"total 48\\ndrwxr-xr-x ...\",\n  \"exitCode\": 0,\n  \"cancelled\": false,\n  \"truncated\": false,\n  \"fullOutputPath\": null,\n  \"timestamp\": 1733234567890\n}\n```\n\n### Attachment\n\n```json\n{\n  \"id\": \"img1\",\n  \"type\": \"image\",\n  \"fileName\": \"photo.jpg\",\n  \"mimeType\": \"image/jpeg\",\n  \"size\": 102400,\n  \"content\": \"base64-encoded-data...\",\n  \"extractedText\": null,\n  \"preview\": null\n}\n```\n\n## Example: Basic Client (Python)\n\n```python\nimport subprocess\nimport json\n\nproc = subprocess.Popen(\n    [\"pi\", \"--mode\", \"rpc\", \"--no-session\"],\n    stdin=subprocess.PIPE,\n    stdout=subprocess.PIPE,\n    text=True\n)\n\ndef send(cmd):\n    proc.stdin.write(json.dumps(cmd) + \"\\n\")\n    proc.stdin.flush()\n\ndef read_events():\n    for line in proc.stdout:\n        yield json.loads(line)\n\n# Send prompt\nsend({\"type\": \"prompt\", \"message\": \"Hello!\"})\n\n# Process events\nfor event in read_events():\n    if event.get(\"type\") == \"message_update\":\n        delta = event.get(\"assistantMessageEvent\", {})\n        if delta.get(\"type\") == \"text_delta\":\n            print(delta[\"delta\"], end=\"\", flush=True)\n    \n    if event.get(\"type\") == \"agent_end\":\n        print()\n        break\n```\n\n## Example: Interactive Client (Node.js)\n\nSee [`test/rpc-example.ts`](../test/rpc-example.ts) for a complete interactive example, or [`src/modes/rpc/rpc-client.ts`](../src/modes/rpc/rpc-client.ts) for a typed client implementation.\n\nFor a complete example of handling the extension UI protocol, see [`examples/rpc-extension-ui.ts`](../examples/rpc-extension-ui.ts) which pairs with the [`examples/extensions/rpc-demo.ts`](../examples/extensions/rpc-demo.ts) extension.\n\n```javascript\nconst { spawn } = require(\"child_process\");\nconst { StringDecoder } = require(\"string_decoder\");\n\nconst agent = spawn(\"pi\", [\"--mode\", \"rpc\", \"--no-session\"]);\n\nfunction attachJsonlReader(stream, onLine) {\n    const decoder = new StringDecoder(\"utf8\");\n    let buffer = \"\";\n\n    stream.on(\"data\", (chunk) => {\n        buffer += typeof chunk === \"string\" ? chunk : decoder.write(chunk);\n\n        while (true) {\n            const newlineIndex = buffer.indexOf(\"\\n\");\n            if (newlineIndex === -1) break;\n\n            let line = buffer.slice(0, newlineIndex);\n            buffer = buffer.slice(newlineIndex + 1);\n            if (line.endsWith(\"\\r\")) line = line.slice(0, -1);\n            onLine(line);\n        }\n    });\n\n    stream.on(\"end\", () => {\n        buffer += decoder.end();\n        if (buffer.length > 0) {\n            onLine(buffer.endsWith(\"\\r\") ? buffer.slice(0, -1) : buffer);\n        }\n    });\n}\n\nattachJsonlReader(agent.stdout, (line) => {\n    const event = JSON.parse(line);\n\n    if (event.type === \"message_update\") {\n        const { assistantMessageEvent } = event;\n        if (assistantMessageEvent.type === \"text_delta\") {\n            process.stdout.write(assistantMessageEvent.delta);\n        }\n    }\n});\n\n// Send prompt\nagent.stdin.write(JSON.stringify({ type: \"prompt\", message: \"Hello\" }) + \"\\n\");\n\n// Abort on Ctrl+C\nprocess.on(\"SIGINT\", () => {\n    agent.stdin.write(JSON.stringify({ type: \"abort\" }) + \"\\n\");\n});\n```","sourceFile":"rpc.md"},"sdk":{"title":"SDK","markdown":"> pi can help you use the SDK. Ask it to build an integration for your use case.\n\n\nThe SDK provides programmatic access to pi's agent capabilities. Use it to embed pi in other applications, build custom interfaces, or integrate with automated workflows.\n\n**Example use cases:**\n- Build a custom UI (web, desktop, mobile)\n- Integrate agent capabilities into existing applications\n- Create automated pipelines with agent reasoning\n- Build custom tools that spawn sub-agents\n- Test agent behavior programmatically\n\nSee [examples/sdk/](../examples/sdk/) for working examples from minimal to full control.\n\n## Quick Start\n\n```typescript\nimport { createAgentSession, ModelRuntime, SessionManager } from \"@earendil-works/pi-coding-agent\";\n\nconst modelRuntime = await ModelRuntime.create();\nconst { session } = await createAgentSession({\n  sessionManager: SessionManager.inMemory(),\n  modelRuntime,\n});\n\nsession.subscribe((event) => {\n  if (event.type === \"message_update\" && event.assistantMessageEvent.type === \"text_delta\") {\n    process.stdout.write(event.assistantMessageEvent.delta);\n  }\n});\n\nawait session.prompt(\"What files are in the current directory?\");\n```\n\n## Installation\n\n```bash\nnpm install @earendil-works/pi-coding-agent\n```\n\nThe SDK is included in the main package. No separate installation needed.\n\n## Core Concepts\n\n### createAgentSession()\n\nThe main factory function for a single `AgentSession`.\n\n`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.\n\n```typescript\nimport { createAgentSession, SessionManager } from \"@earendil-works/pi-coding-agent\";\n\n// Minimal: defaults with DefaultResourceLoader\nconst { session } = await createAgentSession();\n\n// Custom: override specific options\nconst { session } = await createAgentSession({\n  model: myModel,\n  tools: [\"read\", \"bash\"],\n  sessionManager: SessionManager.inMemory(),\n});\n```\n\n### AgentSession\n\nThe session manages agent lifecycle, message history, model state, compaction, and event streaming.\n\n```typescript\ninterface AgentSession {\n  // Send a prompt and wait for completion\n  prompt(text: string, options?: PromptOptions): Promise<void>;\n\n  // Queue messages during streaming\n  steer(text: string): Promise<void>;\n  followUp(text: string): Promise<void>;\n\n  // Subscribe to events (returns unsubscribe function)\n  subscribe(listener: (event: AgentSessionEvent) => void): () => void;\n\n  // Session info\n  sessionFile: string | undefined;\n  sessionId: string;\n\n  // Model control\n  setModel(model: Model): Promise<void>;\n  setThinkingLevel(level: ThinkingLevel): void;\n  cycleModel(): Promise<ModelCycleResult | undefined>;\n  cycleThinkingLevel(): ThinkingLevel | undefined;\n\n  // State access\n  agent: Agent;\n  model: Model | undefined;\n  thinkingLevel: ThinkingLevel;\n  messages: AgentMessage[];\n  isStreaming: boolean;\n\n  // In-place tree navigation within the current session file\n  navigateTree(targetId: string, options?: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string }): Promise<{ editorText?: string; cancelled: boolean }>;\n\n  // Compaction\n  compact(customInstructions?: string): Promise<CompactionResult>;\n  abortCompaction(): void;\n\n  // Abort current operation\n  abort(): Promise<void>;\n\n  // Cleanup\n  dispose(): void;\n}\n```\n\nSession replacement APIs such as new-session, resume, fork, and import live on `AgentSessionRuntime`, not on `AgentSession`.\n\n### createAgentSessionRuntime() and AgentSessionRuntime\n\nUse the runtime API when you need to replace the active session and rebuild cwd-bound runtime state.\nThis is the same layer used by the built-in interactive, print, and RPC modes.\n\n`createAgentSessionRuntime()` takes a runtime factory plus the initial cwd/session target. The factory closes over process-global fixed inputs, recreates cwd-bound services for the effective cwd, resolves session options against those services, and returns a full runtime result.\n\n```typescript\nimport {\n  type CreateAgentSessionRuntimeFactory,\n  createAgentSessionFromServices,\n  createAgentSessionRuntime,\n  createAgentSessionServices,\n  getAgentDir,\n  SessionManager,\n} from \"@earendil-works/pi-coding-agent\";\n\nconst createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {\n  const services = await createAgentSessionServices({ cwd });\n  return {\n    ...(await createAgentSessionFromServices({\n      services,\n      sessionManager,\n      sessionStartEvent,\n    })),\n    services,\n    diagnostics: services.diagnostics,\n  };\n};\n\nconst runtime = await createAgentSessionRuntime(createRuntime, {\n  cwd: process.cwd(),\n  agentDir: getAgentDir(),\n  sessionManager: SessionManager.create(process.cwd()),\n});\n```\n\n`AgentSessionRuntime` owns replacement of the active runtime across:\n\n- `newSession()`\n- `switchSession()`\n- `fork()`\n- clone flows via `fork(entryId, { position: \"at\" })`\n- `importFromJsonl()`\n\nImportant behavior:\n\n- `runtime.session` changes after those operations\n- event subscriptions are attached to a specific `AgentSession`, so re-subscribe after replacement\n- if you use extensions, call `runtime.session.bindExtensions(...)` again for the new session\n- creation returns diagnostics on `runtime.diagnostics`\n- if runtime creation or replacement fails, the method throws and the caller decides how to handle it\n\n```typescript\nlet session = runtime.session;\nlet unsubscribe = session.subscribe(() => {});\n\nawait runtime.newSession();\n\nunsubscribe();\nsession = runtime.session;\nunsubscribe = session.subscribe(() => {});\n```\n\n### Prompting and Message Queueing\n\n`PromptOptions` controls prompt expansion, queueing behavior while streaming, and prompt preflight notifications:\n\n```typescript\ninterface PromptOptions {\n  expandPromptTemplates?: boolean;\n  images?: ImageContent[];\n  streamingBehavior?: \"steer\" | \"followUp\";\n  source?: InputSource;\n  preflightResult?: (success: boolean) => void;\n}\n```\n\n`preflightResult` is called once per `prompt()` invocation:\n\n- `true` when the prompt was accepted, queued, or handled immediately\n- `false` when prompt preflight rejected before acceptance\n\nIt fires before `prompt()` resolves. `prompt()` still resolves only after the full accepted run finishes, including retries. Failures after acceptance are reported through the normal event and message stream, not through `preflightResult(false)`.\n\nThe `prompt()` method handles prompt templates, extension commands, and message sending:\n\n```typescript\n// Basic prompt (when not streaming)\nawait session.prompt(\"What files are here?\");\n\n// With images\nawait session.prompt(\"What's in this image?\", {\n  images: [{ type: \"image\", source: { type: \"base64\", mediaType: \"image/png\", data: \"...\" } }]\n});\n\n// During streaming: must specify how to queue the message\nawait session.prompt(\"Stop and do this instead\", { streamingBehavior: \"steer\" });\nawait session.prompt(\"After you're done, also check X\", { streamingBehavior: \"followUp\" });\n```\n\n**Behavior:**\n- **Extension commands** (e.g., `/mycommand`): Execute immediately, even during streaming. They manage their own LLM interaction via `pi.sendMessage()`.\n- **File-based prompt templates** (from `.md` files): Expanded to their content before sending or queueing.\n- **During streaming without `streamingBehavior`**: Throws an error. Use `steer()` or `followUp()` directly, or specify the option.\n- **`preflightResult(true)`**: Means the prompt was accepted, queued, or handled immediately.\n- **`preflightResult(false)`**: Means preflight rejected before acceptance.\n\nFor explicit queueing during streaming:\n\n```typescript\n// Queue a steering message for delivery after the current assistant turn finishes its tool calls\nawait session.steer(\"New instruction\");\n\n// Wait for agent to finish (delivered only when agent stops)\nawait session.followUp(\"After you're done, also do this\");\n```\n\nBoth `steer()` and `followUp()` expand file-based prompt templates but error on extension commands (extension commands cannot be queued).\n\n### Agent and AgentState\n\nThe `Agent` class (from `@earendil-works/pi-agent-core`) handles the core LLM interaction. Access it via `session.agent`.\n\n```typescript\n// Access current state\nconst state = session.agent.state;\n\n// state.messages: AgentMessage[] - conversation history\n// state.model: Model - current model\n// state.thinkingLevel: ThinkingLevel - current thinking level\n// state.systemPrompt: string - system prompt\n// state.tools: AgentTool[] - available tools\n// state.streamingMessage?: AgentMessage - current partial assistant message\n// state.errorMessage?: string - latest assistant error\n\n// Replace messages (useful for branching or restoration)\nsession.agent.state.messages = messages; // copies the top-level array\n\n// Replace tools\nsession.agent.state.tools = tools; // copies the top-level array\n\n// Wait for agent to finish processing\nawait session.agent.waitForIdle();\n```\n\n### Events\n\nSubscribe to events to receive streaming output and lifecycle notifications.\n\n```typescript\nsession.subscribe((event) => {\n  switch (event.type) {\n    // Streaming text from assistant\n    case \"message_update\":\n      if (event.assistantMessageEvent.type === \"text_delta\") {\n        process.stdout.write(event.assistantMessageEvent.delta);\n      }\n      if (event.assistantMessageEvent.type === \"thinking_delta\") {\n        // Thinking output (if thinking enabled)\n      }\n      break;\n    \n    // Tool execution\n    case \"tool_execution_start\":\n      console.log(`Tool: ${event.toolName}`);\n      break;\n    case \"tool_execution_update\":\n      // Streaming tool output\n      break;\n    case \"tool_execution_end\":\n      console.log(`Result: ${event.isError ? \"error\" : \"success\"}`);\n      break;\n    \n    // Message lifecycle\n    case \"message_start\":\n      // New message starting\n      break;\n    case \"message_end\":\n      // Message complete\n      break;\n    \n    // Agent lifecycle\n    case \"agent_start\":\n      // Agent started processing prompt\n      break;\n    case \"agent_end\":\n      // Agent finished (event.messages contains new messages)\n      break;\n    \n    // Turn lifecycle (one LLM response + tool calls)\n    case \"turn_start\":\n      break;\n    case \"turn_end\":\n      // event.message: assistant response\n      // event.toolResults: tool results from this turn\n      break;\n    \n    // Session events (queue, compaction, retry)\n    case \"queue_update\":\n      console.log(event.steering, event.followUp);\n      break;\n    case \"compaction_start\":\n    case \"compaction_end\":\n    case \"auto_retry_start\":\n    case \"auto_retry_end\":\n    case \"summarization_retry_scheduled\":\n    case \"summarization_retry_attempt_start\":\n    case \"summarization_retry_finished\":\n      break;\n  }\n});\n```\n\n## Options Reference\n\n### Directories\n\n```typescript\nconst { session } = await createAgentSession({\n  // Working directory for DefaultResourceLoader discovery\n  cwd: process.cwd(), // default\n  \n  // Global config directory\n  agentDir: \"~/.pi/agent\", // default (expands ~)\n});\n```\n\n`cwd` is used by `DefaultResourceLoader` for:\n- Project extensions (`.pi/extensions/`)\n- Project skills:\n  - `.pi/skills/`\n  - `.agents/skills/` in `cwd` and ancestor directories (up to git repo root, or filesystem root when not in a repo)\n- Project prompts (`.pi/prompts/`)\n- Context files (`AGENTS.md` walking up from cwd)\n- Session directory naming\n\n`agentDir` is used by `DefaultResourceLoader` for:\n- Global extensions (`extensions/`)\n- Global skills:\n  - `skills/` under `agentDir` (for example `~/.pi/agent/skills/`)\n  - `~/.agents/skills/`\n- Global prompts (`prompts/`)\n- Global context file (`AGENTS.md`)\n- Settings (`settings.json`)\n- Custom models (`models.json`)\n- Credentials (`auth.json`)\n- Sessions (`sessions/`)\n\nWhen you pass a custom `ResourceLoader`, `cwd` and `agentDir` no longer control resource discovery. They still influence session naming and tool path resolution.\n\n### Model\n\n```typescript\nimport { getModel } from \"@earendil-works/pi-ai\";\nimport { ModelRuntime } from \"@earendil-works/pi-coding-agent\";\n\nconst modelRuntime = await ModelRuntime.create();\n\n// Find specific built-in model (doesn't check if API key exists)\nconst opus = getModel(\"anthropic\", \"claude-opus-4-5\");\nif (!opus) throw new Error(\"Model not found\");\n\n// Find any model by provider/id, including custom models from models.json\n// (doesn't check if API key exists)\nconst customModel = modelRuntime.getModel(\"my-provider\", \"my-model\");\n\n// Get only models that have valid authentication configured\nconst available = await modelRuntime.getAvailable();\n\nconst { session } = await createAgentSession({\n  model: opus,\n  thinkingLevel: \"medium\", // off, minimal, low, medium, high, xhigh, max\n  \n  // Models for cycling (Ctrl+P in interactive mode)\n  scopedModels: [\n    { model: opus, thinkingLevel: \"high\" },\n    { model: haiku, thinkingLevel: \"off\" },\n  ],\n  \n  modelRuntime,\n});\n```\n\nIf no model is provided:\n1. Tries to restore from session (if continuing)\n2. Uses default from settings\n3. Falls back to first available model\n\nTo match CLI model parsing, use the exported resolver helpers:\n\n```typescript\nimport {\n  resolveCliModel,\n  resolveModelScopeWithDiagnostics,\n} from \"@earendil-works/pi-coding-agent\";\n\nconst cliModel = resolveCliModel({\n  cliModel: \"anthropic/claude-opus-4-5:high\",\n  modelRuntime,\n});\nif (cliModel.error) throw new Error(cliModel.error);\nif (cliModel.warning) console.warn(cliModel.warning);\n\nconst { scopedModels, diagnostics } = await resolveModelScopeWithDiagnostics(\n  [\"anthropic/*:high\", \"gpt-5\"],\n  modelRuntime,\n);\nfor (const diagnostic of diagnostics) {\n  console.warn(diagnostic.message);\n}\n```\n\n`resolveCliModel()` uses all registered models so `--api-key` style first-time setup can resolve a model before stored auth exists. `resolveModelScopeWithDiagnostics()` matches `--models` and `enabledModels` semantics while returning warnings instead of printing them.\n\n> See [examples/sdk/02-custom-model.ts](../examples/sdk/02-custom-model.ts)\n\n### API Keys and OAuth\n\nAuthentication resolution priority (handled by `ModelRuntime`):\n1. Runtime overrides (via `setRuntimeApiKey`, not persisted)\n2. Stored credentials in `auth.json` (API keys or OAuth tokens)\n3. Environment variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.)\n4. Fallback resolver (for custom provider keys from `models.json`)\n\n```typescript\nimport { InMemoryCredentialStore } from \"@earendil-works/pi-ai\";\nimport { createAgentSession, ModelRuntime } from \"@earendil-works/pi-coding-agent\";\n\n// Default: uses ~/.pi/agent/auth.json and ~/.pi/agent/models.json\nconst modelRuntime = await ModelRuntime.create();\n\n// Provider-owned auth methods and current status\nfor (const provider of modelRuntime.getProviders()) {\n  const status = await modelRuntime.checkAuth(provider.id);\n  console.log(provider.name, provider.auth, status);\n}\n\n// Runtime API key override (not persisted to disk)\nawait modelRuntime.setRuntimeApiKey(\"anthropic\", \"sk-my-temp-key\");\n\n// Custom credential and model locations\nconst customRuntime = await ModelRuntime.create({\n  authPath: \"/my/app/auth.json\",\n  modelsPath: \"/my/app/models.json\",\n});\n\n// Or inject any pi-ai CredentialStore\nconst credentials = new InMemoryCredentialStore();\nconst inMemoryRuntime = await ModelRuntime.create({ credentials });\n\nconst { session } = await createAgentSession({\n  modelRuntime: customRuntime,\n});\n```\n\n`login()`, `logout()`, `setRuntimeApiKey()`, and `removeRuntimeApiKey()` resolve after the affected provider's cached/built-in catalog, composition, and availability snapshot are locally consistent. They do not wait for remote catalog freshness. If credentials were committed but local synchronization fails, they reject with the exported `CredentialSynchronizationError`; inspect its `providerId`, `operation`, `credential`, and `cause` fields instead of retrying the credential mutation blindly.\n\nPublic model/auth operations and `ModelRuntime.create({ signal })` accept optional abort signals and are unbounded when omitted. SDK applications own deadline policy for remote catalog freshness:\n\n```typescript\nconst signal = AbortSignal.timeout(15_000);\nconst result = await modelRuntime.refresh({\n  providers: [\"anthropic\"],\n  signal,\n});\nif (result.aborted) console.warn(\"Catalog refresh timed out; using cached models\");\nfor (const [providerId, error] of result.errors) {\n  console.warn(`Could not refresh ${providerId}:`, error);\n}\n```\n\nA failed or timed-out network refresh does not undo a successful credential operation. `refresh()` starts a new provider generation, so it does not wait behind an older stalled refresh and stale generations cannot publish afterward.\n\n> See [examples/sdk/09-api-keys-and-oauth.ts](../examples/sdk/09-api-keys-and-oauth.ts)\n\n### System Prompt\n\nUse a `ResourceLoader` to override the system prompt:\n\n```typescript\nimport { createAgentSession, DefaultResourceLoader } from \"@earendil-works/pi-coding-agent\";\n\nconst loader = new DefaultResourceLoader({\n  systemPromptOverride: () => \"You are a helpful assistant.\",\n});\nawait loader.reload();\n\nconst { session } = await createAgentSession({ resourceLoader: loader });\n```\n\n> See [examples/sdk/03-custom-prompt.ts](../examples/sdk/03-custom-prompt.ts)\n\n### Tools\n\nSpecify which built-in tools to enable:\n\n- Built-in tool names: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`\n- Default built-ins: `read`, `bash`, `edit`, `write`\n- `noTools: \"all\"` disables all tools\n- `noTools: \"builtin\"` disables default built-ins while keeping extension and custom tools enabled\n- `excludeTools` disables specific built-in, extension, or custom tool names after any `tools` allowlist is applied\n\nThe `edit` tool returns `details.diff` for Pi's TUI display and `details.patch` as a standard unified patch for SDK consumers.\n\n```typescript\nimport { createAgentSession } from \"@earendil-works/pi-coding-agent\";\n\n// Read-only mode\nconst { session } = await createAgentSession({\n  tools: [\"read\", \"grep\", \"find\", \"ls\"],\n});\n\n// Pick specific tools\nconst { session } = await createAgentSession({\n  tools: [\"read\", \"bash\", \"grep\"],\n});\n\n// Disable one tool while keeping the rest available\nconst { session } = await createAgentSession({\n  excludeTools: [\"ask_question\"],\n});\n```\n\n#### Tools with Custom cwd\n\nWhen you pass a custom `cwd`, `createAgentSession()` builds selected built-in tools for that cwd.\n\n```typescript\nimport { createAgentSession, SessionManager } from \"@earendil-works/pi-coding-agent\";\n\nconst cwd = \"/path/to/project\";\n\n// Use default tools for custom cwd\nconst { session } = await createAgentSession({\n  cwd,\n  sessionManager: SessionManager.inMemory(cwd),\n});\n\n// Or pick specific tools for custom cwd\nconst { session } = await createAgentSession({\n  cwd,\n  tools: [\"read\", \"bash\", \"grep\"],\n  sessionManager: SessionManager.inMemory(cwd),\n});\n```\n\n> See [examples/sdk/05-tools.ts](../examples/sdk/05-tools.ts)\n\n### Custom Tools\n\n```typescript\nimport { Type } from \"typebox\";\nimport { createAgentSession, defineTool } from \"@earendil-works/pi-coding-agent\";\n\n// Inline custom tool\nconst myTool = defineTool({\n  name: \"my_tool\",\n  label: \"My Tool\",\n  description: \"Does something useful\",\n  parameters: Type.Object({\n    input: Type.String({ description: \"Input value\" }),\n  }),\n  execute: async (_toolCallId, params) => ({\n    content: [{ type: \"text\", text: `Result: ${params.input}` }],\n    details: {},\n  }),\n});\n\n// Pass custom tools directly\nconst { session } = await createAgentSession({\n  customTools: [myTool],\n});\n```\n\nUse `defineTool()` for standalone definitions and arrays like `customTools: [myTool]`. Inline `pi.registerTool({ ... })` already infers parameter types correctly.\n\nCustom tools passed via `customTools` are combined with extension-registered tools. Extensions loaded by the ResourceLoader can also register tools via `pi.registerTool()`.\n\nIf you pass `tools`, include each custom or extension tool name you want enabled, for example `tools: [\"read\", \"bash\", \"my_tool\"]`.\n\n> See [examples/sdk/05-tools.ts](../examples/sdk/05-tools.ts)\n\n### Extensions\n\nExtensions are loaded by the `ResourceLoader`. `DefaultResourceLoader` discovers extensions from `~/.pi/agent/extensions/`, `.pi/extensions/`, and settings.json extension sources.\n\n```typescript\nimport { createAgentSession, DefaultResourceLoader } from \"@earendil-works/pi-coding-agent\";\n\nconst loader = new DefaultResourceLoader({\n  additionalExtensionPaths: [\"/path/to/my-extension.ts\"],\n  extensionFactories: [\n    (pi) => {\n      pi.on(\"agent_start\", () => {\n        console.log(\"[Inline Extension] Agent starting\");\n      });\n    },\n  ],\n});\nawait loader.reload();\n\nconst { session } = await createAgentSession({ resourceLoader: loader });\n```\n\nExtensions can register tools, subscribe to events, add commands, and more. See [extensions.md](extensions.md) for the full API.\n\n**Named inline extensions:** By default, inline factories display as `<inline:1>`, `<inline:2>`, etc. in the startup Extensions list. To show a descriptive name instead, wrap the factory:\n\n```typescript\nimport type { InlineExtension } from \"@earendil-works/pi-coding-agent\";\n\nconst myProvider: InlineExtension = {\n  name: \"my-provider\",\n  factory: (pi) => {\n    pi.on(\"agent_start\", () => {\n      console.log(\"[my-provider] Agent starting\");\n    });\n  },\n};\n\nconst loader = new DefaultResourceLoader({\n  extensionFactories: [myProvider],\n});\n```\n\nThis displays as `<inline:my-provider>` instead of `<inline:1>`. Bare factory functions are still accepted for backward compatibility.\n\n**Event Bus:** Extensions can communicate via `pi.events`. Pass a shared `eventBus` to `DefaultResourceLoader` if you need to emit or listen from outside:\n\n```typescript\nimport { createEventBus, DefaultResourceLoader } from \"@earendil-works/pi-coding-agent\";\n\nconst eventBus = createEventBus();\nconst loader = new DefaultResourceLoader({\n  eventBus,\n});\nawait loader.reload();\n\neventBus.on(\"my-extension:status\", (data) => console.log(data));\n```\n\n> See [examples/sdk/06-extensions.ts](../examples/sdk/06-extensions.ts) and [docs/extensions.md](extensions.md)\n\n### Skills\n\n```typescript\nimport {\n  createAgentSession,\n  DefaultResourceLoader,\n  type Skill,\n} from \"@earendil-works/pi-coding-agent\";\n\nconst customSkill: Skill = {\n  name: \"my-skill\",\n  description: \"Custom instructions\",\n  filePath: \"/path/to/SKILL.md\",\n  baseDir: \"/path/to\",\n  source: \"custom\",\n};\n\nconst loader = new DefaultResourceLoader({\n  skillsOverride: (current) => ({\n    skills: [...current.skills, customSkill],\n    diagnostics: current.diagnostics,\n  }),\n});\nawait loader.reload();\n\nconst { session } = await createAgentSession({ resourceLoader: loader });\n```\n\n> See [examples/sdk/04-skills.ts](../examples/sdk/04-skills.ts)\n\n### Context Files\n\n```typescript\nimport { createAgentSession, DefaultResourceLoader } from \"@earendil-works/pi-coding-agent\";\n\nconst loader = new DefaultResourceLoader({\n  agentsFilesOverride: (current) => ({\n    agentsFiles: [\n      ...current.agentsFiles,\n      { path: \"/virtual/AGENTS.md\", content: \"# Guidelines\\n\\n- Be concise\" },\n    ],\n  }),\n});\nawait loader.reload();\n\nconst { session } = await createAgentSession({ resourceLoader: loader });\n```\n\n> See [examples/sdk/07-context-files.ts](../examples/sdk/07-context-files.ts)\n\n### Slash Commands\n\n```typescript\nimport {\n  createAgentSession,\n  DefaultResourceLoader,\n  type PromptTemplate,\n} from \"@earendil-works/pi-coding-agent\";\n\nconst customCommand: PromptTemplate = {\n  name: \"deploy\",\n  description: \"Deploy the application\",\n  source: \"(custom)\",\n  content: \"# Deploy\\n\\n1. Build\\n2. Test\\n3. Deploy\",\n};\n\nconst loader = new DefaultResourceLoader({\n  promptsOverride: (current) => ({\n    prompts: [...current.prompts, customCommand],\n    diagnostics: current.diagnostics,\n  }),\n});\nawait loader.reload();\n\nconst { session } = await createAgentSession({ resourceLoader: loader });\n```\n\n> See [examples/sdk/08-prompt-templates.ts](../examples/sdk/08-prompt-templates.ts)\n\n### Session Management\n\nSessions use a tree structure with `id`/`parentId` linking, enabling in-place branching.\n\n```typescript\nimport {\n  type CreateAgentSessionRuntimeFactory,\n  createAgentSession,\n  createAgentSessionFromServices,\n  createAgentSessionRuntime,\n  createAgentSessionServices,\n  getAgentDir,\n  SessionManager,\n} from \"@earendil-works/pi-coding-agent\";\n\n// In-memory (no persistence)\nconst { session } = await createAgentSession({\n  sessionManager: SessionManager.inMemory(),\n});\n\n// New persistent session\nconst { session: persisted } = await createAgentSession({\n  sessionManager: SessionManager.create(process.cwd()),\n});\n\n// Continue most recent\nconst { session: continued, modelFallbackMessage } = await createAgentSession({\n  sessionManager: SessionManager.continueRecent(process.cwd()),\n});\nif (modelFallbackMessage) {\n  console.log(\"Note:\", modelFallbackMessage);\n}\n\n// Open specific file\nconst { session: opened } = await createAgentSession({\n  sessionManager: SessionManager.open(\"/path/to/session.jsonl\"),\n});\n\n// List sessions\nconst currentProjectSessions = await SessionManager.list(process.cwd());\nconst allSessions = await SessionManager.listAll(process.cwd());\n\n// Session replacement API for /new, /resume, /fork, /clone, and import flows.\nconst createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {\n  const services = await createAgentSessionServices({ cwd });\n  return {\n    ...(await createAgentSessionFromServices({\n      services,\n      sessionManager,\n      sessionStartEvent,\n    })),\n    services,\n    diagnostics: services.diagnostics,\n  };\n};\n\nconst runtime = await createAgentSessionRuntime(createRuntime, {\n  cwd: process.cwd(),\n  agentDir: getAgentDir(),\n  sessionManager: SessionManager.create(process.cwd()),\n});\n\n// Replace the active session with a fresh one\nawait runtime.newSession();\n\n// Replace the active session with another saved session\nawait runtime.switchSession(\"/path/to/session.jsonl\");\n\n// Replace the active session with a fork from a specific user entry\nawait runtime.fork(\"entry-id\");\n\n// Clone the active path through a specific entry\nawait runtime.fork(\"entry-id\", { position: \"at\" });\n```\n\n**SessionManager tree API:**\n\n```typescript\nconst sm = SessionManager.open(\"/path/to/session.jsonl\");\n\n// Session listing\nconst currentProjectSessions = await SessionManager.list(process.cwd());\nconst allSessions = await SessionManager.listAll(process.cwd());\n\n// Tree traversal\nconst entries = sm.getEntries();        // All entries (excludes header)\nconst tree = sm.getTree();              // Full tree structure\nconst path = sm.getPath();              // Path from root to current leaf\nconst leaf = sm.getLeafEntry();         // Current leaf entry\nconst entry = sm.getEntry(id);          // Get entry by ID\nconst children = sm.getChildren(id);    // Direct children of entry\n\n// Labels\nconst label = sm.getLabel(id);          // Get label for entry\nsm.appendLabelChange(id, \"checkpoint\"); // Set label\n\n// Branching\nsm.branch(entryId);                     // Move leaf to earlier entry\nsm.branchWithSummary(id, \"Summary...\");  // Branch with context summary\nsm.createBranchedSession(leafId);       // Extract path to new file\n```\n\n> See [examples/sdk/11-sessions.ts](../examples/sdk/11-sessions.ts) and [Session Format](session-format.md)\n\n### Settings Management\n\n```typescript\nimport { createAgentSession, SettingsManager, SessionManager } from \"@earendil-works/pi-coding-agent\";\n\n// Default: loads from files (global + project merged)\nconst { session } = await createAgentSession({\n  settingsManager: SettingsManager.create(),\n});\n\n// With overrides\nconst settingsManager = SettingsManager.create();\nsettingsManager.applyOverrides({\n  compaction: { enabled: false },\n  retry: { enabled: true, maxRetries: 5 },\n});\nconst { session } = await createAgentSession({ settingsManager });\n\n// In-memory (no file I/O, for testing)\nconst { session } = await createAgentSession({\n  settingsManager: SettingsManager.inMemory({ compaction: { enabled: false } }),\n  sessionManager: SessionManager.inMemory(),\n});\n\n// Custom directories\nconst { session } = await createAgentSession({\n  settingsManager: SettingsManager.create(\"/custom/cwd\", \"/custom/agent\"),\n});\n```\n\n**Static factories:**\n- `SettingsManager.create(cwd?, agentDir?)` - Load from files\n- `SettingsManager.inMemory(settings?)` - No file I/O\n\n**Project-specific settings:**\n\nSettings load from two locations and merge:\n1. Global: `~/.pi/agent/settings.json`\n2. Project: `<cwd>/.pi/settings.json`\n\nProject overrides global. Nested objects merge keys. Setters modify global settings by default.\n\n**Persistence and error handling semantics:**\n\n- Settings getters/setters are synchronous for in-memory state.\n- Setters enqueue persistence writes asynchronously.\n- Call `await settingsManager.flush()` when you need a durability boundary (for example, before process exit or before asserting file contents in tests).\n- `SettingsManager` does not print settings I/O errors. Use `settingsManager.drainErrors()` and report them in your app layer.\n\n> See [examples/sdk/10-settings.ts](../examples/sdk/10-settings.ts)\n\n## ResourceLoader\n\nUse `DefaultResourceLoader` to discover extensions, skills, prompts, themes, and context files.\n\n```typescript\nimport {\n  DefaultResourceLoader,\n  getAgentDir,\n} from \"@earendil-works/pi-coding-agent\";\n\nconst loader = new DefaultResourceLoader({\n  cwd,\n  agentDir: getAgentDir(),\n});\nawait loader.reload();\n\nconst extensions = loader.getExtensions();\nconst skills = loader.getSkills();\nconst prompts = loader.getPrompts();\nconst themes = loader.getThemes();\nconst contextFiles = loader.getAgentsFiles().agentsFiles;\n```\n\n## Return Value\n\n`createAgentSession()` returns:\n\n```typescript\ninterface CreateAgentSessionResult {\n  // The session\n  session: AgentSession;\n  \n  // Extensions result (for runner setup)\n  extensionsResult: LoadExtensionsResult;\n  \n  // Warning if session model couldn't be restored\n  modelFallbackMessage?: string;\n}\n\ninterface LoadExtensionsResult {\n  extensions: Extension[];\n  errors: Array<{ path: string; error: string }>;\n  runtime: ExtensionRuntime;\n}\n```\n\n## Complete Example\n\n```typescript\nimport { getModel } from \"@earendil-works/pi-ai\";\nimport { Type } from \"typebox\";\nimport {\n  createAgentSession,\n  DefaultResourceLoader,\n  defineTool,\n  ModelRuntime,\n  SessionManager,\n  SettingsManager,\n} from \"@earendil-works/pi-coding-agent\";\n\nconst modelRuntime = await ModelRuntime.create({\n  authPath: \"/custom/agent/auth.json\",\n  modelsPath: \"/custom/agent/models.json\",\n});\nif (process.env.MY_KEY) {\n  await modelRuntime.setRuntimeApiKey(\"anthropic\", process.env.MY_KEY);\n}\n\n// Inline tool\nconst statusTool = defineTool({\n  name: \"status\",\n  label: \"Status\",\n  description: \"Get system status\",\n  parameters: Type.Object({}),\n  execute: async () => ({\n    content: [{ type: \"text\", text: `Uptime: ${process.uptime()}s` }],\n    details: {},\n  }),\n});\n\nconst model = getModel(\"anthropic\", \"claude-opus-4-5\");\nif (!model) throw new Error(\"Model not found\");\n\n// In-memory settings with overrides\nconst settingsManager = SettingsManager.inMemory({\n  compaction: { enabled: false },\n  retry: { enabled: true, maxRetries: 2 },\n});\n\nconst loader = new DefaultResourceLoader({\n  cwd: process.cwd(),\n  agentDir: \"/custom/agent\",\n  settingsManager,\n  systemPromptOverride: () => \"You are a minimal assistant. Be concise.\",\n});\nawait loader.reload();\n\nconst { session } = await createAgentSession({\n  cwd: process.cwd(),\n  agentDir: \"/custom/agent\",\n\n  model,\n  thinkingLevel: \"off\",\n  modelRuntime,\n\n  tools: [\"read\", \"bash\", \"status\"],\n  customTools: [statusTool],\n  resourceLoader: loader,\n\n  sessionManager: SessionManager.inMemory(),\n  settingsManager,\n});\n\nsession.subscribe((event) => {\n  if (event.type === \"message_update\" && event.assistantMessageEvent.type === \"text_delta\") {\n    process.stdout.write(event.assistantMessageEvent.delta);\n  }\n});\n\nawait session.prompt(\"Get status and list files.\");\n```\n\n## Run Modes\n\nThe SDK exports run mode utilities for building custom interfaces on top of `createAgentSession()`:\n\n### InteractiveMode\n\nFull TUI interactive mode with editor, chat history, and all built-in commands:\n\n```typescript\nimport {\n  type CreateAgentSessionRuntimeFactory,\n  createAgentSessionFromServices,\n  createAgentSessionRuntime,\n  createAgentSessionServices,\n  getAgentDir,\n  InteractiveMode,\n  SessionManager,\n} from \"@earendil-works/pi-coding-agent\";\n\nconst createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {\n  const services = await createAgentSessionServices({ cwd });\n  return {\n    ...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })),\n    services,\n    diagnostics: services.diagnostics,\n  };\n};\nconst runtime = await createAgentSessionRuntime(createRuntime, {\n  cwd: process.cwd(),\n  agentDir: getAgentDir(),\n  sessionManager: SessionManager.create(process.cwd()),\n});\n\nconst mode = new InteractiveMode(runtime, {\n  migratedProviders: [],\n  modelFallbackMessage: undefined,\n  initialMessage: \"Hello\",\n  initialImages: [],\n  initialMessages: [],\n});\n\nawait mode.run();\n```\n\n### runPrintMode\n\nSingle-shot mode: send prompts, output result, exit:\n\n```typescript\nimport {\n  type CreateAgentSessionRuntimeFactory,\n  createAgentSessionFromServices,\n  createAgentSessionRuntime,\n  createAgentSessionServices,\n  getAgentDir,\n  runPrintMode,\n  SessionManager,\n} from \"@earendil-works/pi-coding-agent\";\n\nconst createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {\n  const services = await createAgentSessionServices({ cwd });\n  return {\n    ...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })),\n    services,\n    diagnostics: services.diagnostics,\n  };\n};\nconst runtime = await createAgentSessionRuntime(createRuntime, {\n  cwd: process.cwd(),\n  agentDir: getAgentDir(),\n  sessionManager: SessionManager.create(process.cwd()),\n});\n\nawait runPrintMode(runtime, {\n  mode: \"text\",\n  initialMessage: \"Hello\",\n  initialImages: [],\n  messages: [\"Follow up\"],\n});\n```\n\n### runRpcMode\n\nJSON-RPC mode for subprocess integration:\n\n```typescript\nimport {\n  type CreateAgentSessionRuntimeFactory,\n  createAgentSessionFromServices,\n  createAgentSessionRuntime,\n  createAgentSessionServices,\n  getAgentDir,\n  runRpcMode,\n  SessionManager,\n} from \"@earendil-works/pi-coding-agent\";\n\nconst createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {\n  const services = await createAgentSessionServices({ cwd });\n  return {\n    ...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })),\n    services,\n    diagnostics: services.diagnostics,\n  };\n};\nconst runtime = await createAgentSessionRuntime(createRuntime, {\n  cwd: process.cwd(),\n  agentDir: getAgentDir(),\n  sessionManager: SessionManager.create(process.cwd()),\n});\n\nawait runRpcMode(runtime);\n```\n\nSee [RPC documentation](rpc.md) for the JSON protocol.\n\n## RPC Mode Alternative\n\nFor subprocess-based integration without building with the SDK, use the CLI directly:\n\n```bash\npi --mode rpc --no-session\n```\n\nSee [RPC documentation](rpc.md) for the JSON protocol.\n\nThe SDK is preferred when:\n- You want type safety\n- You're in the same Node.js process\n- You need direct access to agent state\n- You want to customize tools/extensions programmatically\n\nRPC mode is preferred when:\n- You're integrating from another language\n- You want process isolation\n- You're building a language-agnostic client\n\n## Exports\n\nThe main entry point exports:\n\n```typescript\n// Factory\ncreateAgentSession\ncreateAgentSessionRuntime\nAgentSessionRuntime\n\n// Auth and Models\nModelRuntime // implements pi-ai Models and owns credential storage\nModelRegistry // synchronous extension compatibility facade\nCredentialSynchronizationError\nresolveCliModel\nresolveModelScopeWithDiagnostics\n\n// Resource loading\nDefaultResourceLoader\ntype ResourceLoader\ncreateEventBus\n\n// Constants and helpers\nCONFIG_DIR_NAME\ndefineTool\ngetAgentDir\ngetPackageDir\ngetReadmePath\ngetDocsPath\ngetExamplesPath\n\n// Session management\nSessionManager\nSettingsManager\n\n// Tool factories\ncreateCodingTools\ncreateReadOnlyTools\ncreateReadTool, createBashTool, createEditTool, createWriteTool\ncreateGrepTool, createFindTool, createLsTool\n\n// Types\ntype CreateAgentSessionOptions\ntype CreateAgentSessionResult\ntype ExtensionFactory\ntype InlineExtension\ntype ExtensionAPI\ntype ToolDefinition\ntype Skill\ntype PromptTemplate\ntype Tool\n```\n\nFor extension types, see [extensions.md](extensions.md) for the full API.","sourceFile":"sdk.md"},"security":{"title":"Security","markdown":"Pi is a local coding agent. It runs with the permissions of the user account that starts it, and it treats files writable by that user as inside the same local trust boundary.\n\n## Project Trust\n\nProject trust controls whether pi loads project-local settings, resources, packages, and extensions. It is not a sandbox and it does not restrict what the model can ask tools to do after you start working in a directory.\n\nPi considers a project to have resources that require trust when it finds any of these from the current working directory:\n\n- `.pi/settings.json`\n- `.pi/extensions`, `.pi/skills`, `.pi/prompts`, or `.pi/themes`\n- `.pi/SYSTEM.md` or `.pi/APPEND_SYSTEM.md`\n- project `.agents/skills` in the current directory or an ancestor directory\n\nA bare `.pi` directory does not count as a project resource that requires trust.\n\nWhen an interactive session starts in a project with resources that require trust and no saved decision for the current directory or a parent directory, pi follows `defaultProjectTrust` from global settings. The default value is `\"ask\"`, which asks whether to trust the project when UI is available. Saved decisions are stored by canonical directory in `~/.pi/agent/trust.json`, and the closest saved decision on the current or parent path applies before the global default.\n\nTrusting a project allows pi to load project resources that require trust, including:\n\n- `.pi/settings.json`\n- `.pi` resources such as extensions, skills, prompt templates, themes, and system prompt files\n- missing project packages configured through project settings\n- project-local extensions and project package-managed extensions\n\nDeclining trust skips protected resources. Context files such as `AGENTS.override.md`, `AGENTS.md`, and `CLAUDE.md` are loaded regardless of project trust unless context loading is disabled. Before trust is resolved, pi only loads context files, user/global extensions, and CLI `-e` extensions. User/global and CLI extensions can handle the `project_trust` event; the first extension that returns a yes/no decision owns the decision.\n\nNon-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, `defaultProjectTrust: \"ask\"` and `\"never\"` ignore such resources, while `\"always\"` trusts them. Use `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run.\n\n## No Built-in Sandbox\n\nPi does not include a built-in sandbox. Built-in tools can read files, write files, edit files, and run shell commands with the permissions of the pi process. Extensions are TypeScript modules that run with the same permissions. Package installs, shell commands, language servers, test commands, and other developer tools behave as ordinary local processes.\n\nThis is intentional. Pi is designed to operate on local source trees, invoke project toolchains, and integrate with the user's existing development environment. A partial in-process sandbox would be easy to misunderstand as a security boundary while still depending on the host shell, filesystem, package managers, credentials, and extension code. Real isolation needs to come from the operating system or a virtualization/container boundary.\n\nProject trust is only an input-loading guard. It prevents a repository from silently changing pi's settings or extensions before you approve it. It does not make untrusted code, untrusted prompts, or untrusted model output safe. Prompt injection from repository files, comments, documentation, context files, or build output is expected local-agent risk and cannot be reliably prevented by pi.\n\n## Running Untrusted or Unmonitored Work\n\nFor untrusted repositories, generated code you do not intend to monitor closely, or unattended automation, run pi in a contained environment. Use a container, VM, micro-VM, remote sandbox, or policy-controlled sandbox with only the files and credentials required for the task.\n\nCommon patterns are documented in [Containerization](containerization.md):\n\n- run the whole `pi` process inside a container/sandbox\n- run host pi while routing built-in tool execution into a Gondolin micro-VM\n- mount only the workspace paths the agent should access\n- avoid mounting host `~/.pi/agent` unless the container should access host sessions, settings, and credentials\n- pass the minimum required API keys or use short-lived credentials\n- restrict network access when the task does not need it\n- review diffs and outputs before copying results back to trusted systems\n\nIf you bind-mount a host workspace read/write, writes from inside the container or VM can still modify host files. Use read-only mounts or copy files into and out of the sandbox when you need stronger protection from unintended writes.\n\n## Reporting Security Issues\n\nTo report a security issue, follow the repository [Security Policy](https://github.com/earendil-works/pi-mono/blob/main/SECURITY.md). Do not open a public issue for security-sensitive reports.\n\nExpected local-agent behavior, lack of a built-in sandbox, prompt injection from untrusted content, and behavior of user-installed extensions or skills are generally outside the security boundary unless the report demonstrates a real privilege-boundary bypass or shows how pi grants access that the local user did not already have.","sourceFile":"security.md"},"session-format":{"title":"Session File Format","markdown":"Sessions are stored as JSONL (JSON Lines) files. Each line is a JSON object with a `type` field. Session entries form a tree structure via `id`/`parentId` fields, enabling in-place branching without creating new files.\n\n## File Location\n\n```\n~/.pi/agent/sessions/--<path>--/<timestamp>_<uuid>.jsonl\n```\n\nWhere `<path>` is the working directory with `/` replaced by `-`.\n\n## Deleting Sessions\n\nSessions can be removed by deleting their `.jsonl` files under `~/.pi/agent/sessions/`.\n\nPi also supports deleting sessions interactively from `/resume` (select a session and press `Ctrl+D`, then confirm). When available, pi uses the `trash` CLI to avoid permanent deletion.\n\n## Session Version\n\nSessions have a version field in the header:\n\n- **Version 1**: Linear entry sequence (legacy, auto-migrated on load)\n- **Version 2**: Tree structure with `id`/`parentId` linking\n- **Version 3**: Renamed `hookMessage` role to `custom` (extensions unification)\n\nExisting sessions are automatically migrated to the current version (v3) when loaded.\n\n## Source Files\n\nSource on GitHub ([pi-mono](https://github.com/earendil-works/pi-mono)):\n- [`packages/coding-agent/src/core/session-manager.ts`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/session-manager.ts) - Session entry types and SessionManager\n- [`packages/coding-agent/src/core/messages.ts`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/messages.ts) - Extended message types (BashExecutionMessage, CustomMessage, etc.)\n- [`packages/ai/src/types.ts`](https://github.com/earendil-works/pi-mono/blob/main/packages/ai/src/types.ts) - Base message types (UserMessage, AssistantMessage, ToolResultMessage)\n- [`packages/agent/src/types.ts`](https://github.com/earendil-works/pi-mono/blob/main/packages/agent/src/types.ts) - AgentMessage union type\n\nFor TypeScript definitions in your project, inspect `node_modules/@earendil-works/pi-coding-agent/dist/` and `node_modules/@earendil-works/pi-ai/dist/`.\n\n## Message Types\n\nSession entries contain `AgentMessage` objects. Understanding these types is essential for parsing sessions and writing extensions.\n\n### Content Blocks\n\nMessages contain arrays of typed content blocks:\n\n```typescript\ninterface TextContent {\n  type: \"text\";\n  text: string;\n}\n\ninterface ImageContent {\n  type: \"image\";\n  data: string;      // base64 encoded\n  mimeType: string;  // e.g., \"image/jpeg\", \"image/png\"\n}\n\ninterface ThinkingContent {\n  type: \"thinking\";\n  thinking: string;\n}\n\ninterface ToolCall {\n  type: \"toolCall\";\n  id: string;\n  name: string;\n  arguments: Record<string, any>;\n}\n```\n\n### Base Message Types (from pi-ai)\n\n```typescript\ninterface UserMessage {\n  role: \"user\";\n  content: string | (TextContent | ImageContent)[];\n  timestamp: number;  // Unix ms\n}\n\ninterface AssistantMessage {\n  role: \"assistant\";\n  content: (TextContent | ThinkingContent | ToolCall)[];\n  api: string;\n  provider: string;\n  model: string;\n  usage: Usage;\n  stopReason: \"stop\" | \"length\" | \"toolUse\" | \"error\" | \"aborted\";\n  errorMessage?: string;\n  timestamp: number;\n}\n\ninterface ToolResultMessage {\n  role: \"toolResult\";\n  toolCallId: string;\n  toolName: string;\n  content: (TextContent | ImageContent)[];\n  details?: any;      // Tool-specific metadata\n  usage?: Usage;      // Nested LLM work performed by the tool\n  isError: boolean;\n  timestamp: number;\n}\n\ninterface Usage {\n  input: number;\n  output: number;\n  cacheRead: number;\n  cacheWrite: number;\n  totalTokens: number;\n  cost: {\n    input: number;\n    output: number;\n    cacheRead: number;\n    cacheWrite: number;\n    total: number;\n  };\n}\n```\n\nThe exported pi-ai `StopReason` type also includes `\"pending\"`, but that value is reserved for partial messages in streaming events. Terminal `done`/`error` messages replace it with a completion reason before pi persists the assistant message, so `\"pending\"` should never appear in session JSONL.\n\n### Extended Message Types (from pi-coding-agent)\n\n```typescript\ninterface BashExecutionMessage {\n  role: \"bashExecution\";\n  command: string;\n  output: string;\n  exitCode: number | undefined;\n  cancelled: boolean;\n  truncated: boolean;\n  fullOutputPath?: string;\n  excludeFromContext?: boolean;  // true for !! prefix commands\n  timestamp: number;\n}\n\ninterface CustomMessage {\n  role: \"custom\";\n  customType: string;            // Extension identifier\n  content: string | (TextContent | ImageContent)[];\n  display: boolean;              // Show in TUI\n  details?: any;                 // Extension-specific metadata\n  timestamp: number;\n}\n\ninterface BranchSummaryMessage {\n  role: \"branchSummary\";\n  summary: string;\n  fromId: string;                // Entry we branched from\n  timestamp: number;\n}\n\ninterface CompactionSummaryMessage {\n  role: \"compactionSummary\";\n  summary: string;\n  tokensBefore: number;\n  timestamp: number;\n}\n```\n\n### AgentMessage Union\n\n```typescript\ntype AgentMessage =\n  | UserMessage\n  | AssistantMessage\n  | ToolResultMessage\n  | BashExecutionMessage\n  | CustomMessage\n  | BranchSummaryMessage\n  | CompactionSummaryMessage;\n```\n\n## Entry Base\n\nAll entries (except `SessionHeader`) extend `SessionEntryBase`:\n\n```typescript\ninterface SessionEntryBase {\n  type: string;\n  id: string;           // 8-char hex ID\n  parentId: string | null;  // Parent entry ID (null for first entry)\n  timestamp: string;    // ISO timestamp\n}\n```\n\n## Entry Types\n\n### SessionHeader\n\nFirst line of the file. Metadata only, not part of the tree (no `id`/`parentId`).\n\n```json\n{\"type\":\"session\",\"version\":3,\"id\":\"uuid\",\"timestamp\":\"2024-12-03T14:00:00.000Z\",\"cwd\":\"/path/to/project\"}\n```\n\nFor sessions with a parent (created via `/fork`, `/clone`, or `newSession({ parentSession })`):\n\n```json\n{\"type\":\"session\",\"version\":3,\"id\":\"uuid\",\"timestamp\":\"2024-12-03T14:00:00.000Z\",\"cwd\":\"/path/to/project\",\"parentSession\":\"/path/to/original/session.jsonl\"}\n```\n\n### SessionMessageEntry\n\nA message in the conversation. The `message` field contains an `AgentMessage`.\n\n```json\n{\"type\":\"message\",\"id\":\"a1b2c3d4\",\"parentId\":\"prev1234\",\"timestamp\":\"2024-12-03T14:00:01.000Z\",\"message\":{\"role\":\"user\",\"content\":\"Hello\"}}\n{\"type\":\"message\",\"id\":\"b2c3d4e5\",\"parentId\":\"a1b2c3d4\",\"timestamp\":\"2024-12-03T14:00:02.000Z\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Hi!\"}],\"provider\":\"anthropic\",\"model\":\"claude-sonnet-4-5\",\"usage\":{...},\"stopReason\":\"stop\"}}\n{\"type\":\"message\",\"id\":\"c3d4e5f6\",\"parentId\":\"b2c3d4e5\",\"timestamp\":\"2024-12-03T14:00:03.000Z\",\"message\":{\"role\":\"toolResult\",\"toolCallId\":\"call_123\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"output\"}],\"isError\":false}}\n```\n\n### ModelChangeEntry\n\nEmitted when the user switches models mid-session.\n\n```json\n{\"type\":\"model_change\",\"id\":\"d4e5f6g7\",\"parentId\":\"c3d4e5f6\",\"timestamp\":\"2024-12-03T14:05:00.000Z\",\"provider\":\"openai\",\"modelId\":\"gpt-4o\"}\n```\n\n### ThinkingLevelChangeEntry\n\nEmitted when the user changes the thinking/reasoning level.\n\n```json\n{\"type\":\"thinking_level_change\",\"id\":\"e5f6g7h8\",\"parentId\":\"d4e5f6g7\",\"timestamp\":\"2024-12-03T14:06:00.000Z\",\"thinkingLevel\":\"high\"}\n```\n\n### CompactionEntry\n\nCreated when context is compacted. Stores a summary of earlier messages.\n\n```json\n{\"type\":\"compaction\",\"id\":\"f6g7h8i9\",\"parentId\":\"e5f6g7h8\",\"timestamp\":\"2024-12-03T14:10:00.000Z\",\"summary\":\"User discussed X, Y, Z...\",\"firstKeptEntryId\":\"c3d4e5f6\",\"tokensBefore\":50000}\n```\n\nNewer harness-generated compactions embed the retained post-compaction context directly on the entry, instead of `firstKeptEntryId`:\n\n```json\n{\"type\":\"compaction\",\"id\":\"f6g7h8i9\",\"parentId\":\"e5f6g7h8\",\"timestamp\":\"2024-12-03T14:10:00.000Z\",\"summary\":\"User discussed X, Y, Z...\",\"tokensBefore\":50000,\"retainedTail\":[{\"role\":\"user\",\"content\":\"latest request\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"latest reply\"}],\"provider\":\"anthropic\",\"model\":\"claude-sonnet-4-5\",\"usage\":{...},\"stopReason\":\"stop\"}]}\n```\n\nOptional fields:\n- `usage`: LLM usage from generating the summary; included in session token and cost totals\n- `retainedTail`: Materialized `AgentMessage[]` kept after compaction. This is optional only for backward compatibility with older sessions. Newer harness-generated compactions include it so we can rebuild context from this checkpoint without walking older entries before the compaction entry.\n- `details`: Implementation-specific data (e.g., `{ readFiles: string[], modifiedFiles: string[] }` for default, or custom data for extensions)\n- `fromHook`: `true` if generated by an extension, `false`/`undefined` if pi-generated (legacy field name)\n- `firstKeptEntryId`: for compatibility with old entry format.\n\n### BranchSummaryEntry\n\nCreated when switching branches via `/tree` with an LLM generated summary of the left branch up to the common ancestor. Captures context from the abandoned path.\n\n```json\n{\"type\":\"branch_summary\",\"id\":\"g7h8i9j0\",\"parentId\":\"a1b2c3d4\",\"timestamp\":\"2024-12-03T14:15:00.000Z\",\"fromId\":\"f6g7h8i9\",\"summary\":\"Branch explored approach A...\"}\n```\n\nOptional fields:\n- `usage`: LLM usage from generating the summary; included in session token and cost totals\n- `details`: File tracking data (`{ readFiles: string[], modifiedFiles: string[] }`) for default, or custom data for extensions\n- `fromHook`: `true` if generated by an extension, `false`/`undefined` if pi-generated (legacy field name)\n\n### CustomEntry\n\nExtension state persistence. Does NOT participate in LLM context.\n\n```json\n{\"type\":\"custom\",\"id\":\"h8i9j0k1\",\"parentId\":\"g7h8i9j0\",\"timestamp\":\"2024-12-03T14:20:00.000Z\",\"customType\":\"my-extension\",\"data\":{\"count\":42}}\n```\n\nUse `customType` to identify your extension's entries on reload. Interactive mode can render custom entries via `pi.registerEntryRenderer(customType, renderer)`, but they still do not participate in LLM context.\n\n### CustomMessageEntry\n\nExtension-injected messages that DO participate in LLM context.\n\n```json\n{\"type\":\"custom_message\",\"id\":\"i9j0k1l2\",\"parentId\":\"h8i9j0k1\",\"timestamp\":\"2024-12-03T14:25:00.000Z\",\"customType\":\"my-extension\",\"content\":\"Injected context...\",\"display\":true}\n```\n\nFields:\n- `content`: String or `(TextContent | ImageContent)[]` (same as UserMessage)\n- `display`: `true` = show in TUI with distinct styling, `false` = hidden\n- `details`: Optional extension-specific metadata (not sent to LLM)\n\n### LabelEntry\n\nUser-defined bookmark/marker on an entry.\n\n```json\n{\"type\":\"label\",\"id\":\"j0k1l2m3\",\"parentId\":\"i9j0k1l2\",\"timestamp\":\"2024-12-03T14:30:00.000Z\",\"targetId\":\"a1b2c3d4\",\"label\":\"checkpoint-1\"}\n```\n\nSet `label` to `undefined` to clear a label.\n\n### SessionInfoEntry\n\nSession metadata (e.g., user-defined display name). Set via `/name`, `--name` / `-n`, or `pi.setSessionName()` in extensions.\n\n```json\n{\"type\":\"session_info\",\"id\":\"k1l2m3n4\",\"parentId\":\"j0k1l2m3\",\"timestamp\":\"2024-12-03T14:35:00.000Z\",\"name\":\"Refactor auth module\"}\n```\n\nThe session name is displayed in the session selector (`/resume`) instead of the first message when set.\n\n## Tree Structure\n\nEntries form a tree:\n- First entry has `parentId: null`\n- Each subsequent entry points to its parent via `parentId`\n- Branching creates new children from an earlier entry\n- The \"leaf\" is the current position in the tree\n\n```\n[user msg] ─── [assistant] ─── [user msg] ─── [assistant] ─┬─ [user msg] ← current leaf\n                                                            │\n                                                            └─ [branch_summary] ─── [user msg] ← alternate branch\n```\n\n## Context Building\n\n`buildContextEntries()` walks from the current leaf to the root, producing the active entry list while honoring compaction:\n\n1. Collects all entries on the path\n2. If a `CompactionEntry` is on the path:\n   - Includes the compaction entry first\n   - If `retainedTail` is present, it acts as a self-contained checkpoint and entries after the compaction are included\n   - Otherwise entries from `firstKeptEntryId` to the compaction are included\n   - Then entries after compaction are included\n3. Preserves non-message entries in the selected range so interactive mode can render them\n\n`buildSessionContext()` builds on that entry list to produce the message list for the LLM:\n\n1. Extracts current model and thinking level settings from the full path\n2. Converts selected entries to messages:\n   - `message` -> stored `AgentMessage`\n   - `compaction` -> `compactionSummary` plus `retainedTail` when present\n   - `branch_summary` -> `branchSummary`\n   - `custom_message` -> `CustomMessage`\n   - `custom` -> no context message\n\nThis makes newer compactions act like self-contained checkpoints. `retainedTail` is optional only so older sessions that only store `firstKeptEntryId` continue to load correctly.\n\n## Parsing Example\n\n```typescript\nimport { readFileSync } from \"fs\";\n\nconst lines = readFileSync(\"session.jsonl\", \"utf8\").trim().split(\"\\n\");\n\nfor (const line of lines) {\n  const entry = JSON.parse(line);\n\n  switch (entry.type) {\n    case \"session\":\n      console.log(`Session v${entry.version ?? 1}: ${entry.id}`);\n      break;\n    case \"message\":\n      console.log(`[${entry.id}] ${entry.message.role}: ${JSON.stringify(entry.message.content)}`);\n      break;\n    case \"compaction\":\n      console.log(`[${entry.id}] Compaction: ${entry.tokensBefore} tokens summarized`);\n      break;\n    case \"branch_summary\":\n      console.log(`[${entry.id}] Branch from ${entry.fromId}`);\n      break;\n    case \"custom\":\n      console.log(`[${entry.id}] Custom (${entry.customType}): ${JSON.stringify(entry.data)}`);\n      break;\n    case \"custom_message\":\n      console.log(`[${entry.id}] Extension message (${entry.customType}): ${entry.content}`);\n      break;\n    case \"label\":\n      console.log(`[${entry.id}] Label \"${entry.label}\" on ${entry.targetId}`);\n      break;\n    case \"model_change\":\n      console.log(`[${entry.id}] Model: ${entry.provider}/${entry.modelId}`);\n      break;\n    case \"thinking_level_change\":\n      console.log(`[${entry.id}] Thinking: ${entry.thinkingLevel}`);\n      break;\n  }\n}\n```\n\n## SessionManager API\n\nKey methods for working with sessions programmatically.\n\n### Static Creation Methods\n- `SessionManager.create(cwd, sessionDir?)` - New session\n- `SessionManager.open(path, sessionDir?)` - Open existing session file\n- `SessionManager.continueRecent(cwd, sessionDir?)` - Continue most recent or create new\n- `SessionManager.inMemory(cwd?)` - No file persistence\n- `SessionManager.forkFrom(sourcePath, targetCwd, sessionDir?)` - Fork session from another project\n\n### Static Listing Methods\n- `SessionManager.list(cwd, sessionDir?, onProgress?)` - List sessions for a directory\n- `SessionManager.listAll(onProgress?)` - List all sessions across all projects\n\n### Instance Methods - Session Management\n- `newSession(options?)` - Start a new session (options: `{ parentSession?: string }`)\n- `setSessionFile(path)` - Switch to a different session file\n- `createBranchedSession(leafId)` - Extract branch to new session file\n\n### Instance Methods - Appending (all return entry ID)\n- `appendMessage(message)` - Add message\n- `appendThinkingLevelChange(level)` - Record thinking change\n- `appendModelChange(provider, modelId)` - Record model change\n- `appendCompaction(summary, firstKeptEntryId, tokensBefore, details?, fromHook?)` - Add compaction\n- `appendCustomEntry(customType, data?)` - Extension state (not in context)\n- `appendSessionInfo(name)` - Set session display name\n- `appendCustomMessageEntry(customType, content, display, details?)` - Extension message (in context)\n- `appendLabelChange(targetId, label)` - Set/clear label\n\n### Instance Methods - Tree Navigation\n- `getLeafId()` - Current position\n- `getLeafEntry()` - Get current leaf entry\n- `getEntry(id)` - Get entry by ID\n- `getBranch(fromId?)` - Walk from entry to root\n- `getTree()` - Get full tree structure\n- `getChildren(parentId)` - Get direct children\n- `getLabel(id)` - Get label for entry\n- `branch(entryId)` - Move leaf to earlier entry\n- `resetLeaf()` - Reset leaf to null (before any entries)\n- `branchWithSummary(entryId, summary, details?, fromHook?)` - Branch with context summary\n\n### Instance Methods - Context & Info\n- `buildContextEntries()` - Get active branch entries with compaction applied\n- `buildSessionContext()` - Get messages, thinkingLevel, and model for LLM\n- `getEntries()` - All entries (excluding header)\n- `getHeader()` - Session header metadata\n- `getSessionName()` - Get display name from latest session_info entry\n- `getCwd()` - Working directory\n- `getSessionDir()` - Session storage directory\n- `getSessionId()` - Session UUID\n- `getSessionFile()` - Session file path (undefined for in-memory)\n- `isPersisted()` - Whether session is saved to disk","sourceFile":"session-format.md"},"sessions":{"title":"Sessions","markdown":"Pi saves conversations as sessions so you can continue work, branch from earlier turns, and revisit previous paths.\n\n## Session Storage\n\nSessions auto-save to `~/.pi/agent/sessions/`, organized by working directory. Each session is a JSONL file with a tree structure.\n\n```bash\npi -c                  # Continue most recent session\npi -r                  # Browse and select from past sessions\npi --no-session        # Ephemeral mode; do not save\npi --name \"my task\"    # Set session display name at startup\npi --session <path|id> # Use a specific session file or partial session ID\npi --fork <path|id>    # Fork a session file or partial session ID into a new session\n```\n\nUse `/session` in interactive mode to see the current session file, session ID, message count, tokens, and cost.\n\nFor the JSONL file format and SessionManager API, see [Session Format](session-format.md).\n\n## Session Commands\n\n| Command | Description |\n|---------|-------------|\n| `/resume` | Browse and select previous sessions |\n| `/new` | Start a new session |\n| `/name <name>` | Set the current session display name |\n| `/session` | Show session info |\n| `/tree` | Navigate the current session tree |\n| `/fork` | Create a new session from a previous user message |\n| `/clone` | Duplicate the current active branch into a new session |\n| `/compact [prompt]` | Summarize older context; see [Compaction](compaction.md) |\n| `/export [file]` | Export session to HTML |\n| `/share` | Upload as private GitHub gist with shareable HTML link |\n\n## Resuming and Deleting Sessions\n\n`/resume` opens an interactive session picker for the current project. `pi -r` opens the same picker at startup.\n\nIn the picker you can:\n\n- search by typing\n- toggle path display with Ctrl+P\n- toggle sort mode with Ctrl+S\n- filter to named sessions with Ctrl+N\n- rename with Ctrl+R\n- delete with Ctrl+D, then confirm\n\nWhen available, pi uses the `trash` CLI for deletion instead of permanently removing files.\n\n## Naming Sessions\n\nUse `/name <name>` to set a human-readable session name:\n\n```text\n/name Refactor auth module\n```\n\nSet the name at startup with `--name` or `-n`:\n\n```bash\npi --name \"Refactor auth module\"\npi --name \"CI audit\" -p \"Review this build failure\"\n```\n\nNamed sessions are easier to find in `/resume` and `pi -r`.\n\n## Branching with `/tree`\n\nSessions are stored as trees. Every entry has an `id` and `parentId`, and the current position is the active leaf. `/tree` lets you jump to any previous point and continue from there without creating a new file.\n\n<p align=\"center\"><img src=\"images/tree-view.png\" alt=\"Tree View\" width=\"600\"></p>\n\nExample shape:\n\n```text\n├─ user: \"Hello, can you help...\"\n│  └─ assistant: \"Of course! I can...\"\n│     ├─ user: \"Let's try approach A...\"\n│     │  └─ assistant: \"For approach A...\"\n│     │     └─ user: \"That worked...\"  ← active\n│     └─ user: \"Actually, approach B...\"\n│        └─ assistant: \"For approach B...\"\n```\n\n### Tree Controls\n\n| Key | Action |\n|-----|--------|\n| ↑/↓ | Navigate visible entries |\n| ←/→ | Page up/down |\n| Ctrl+←/Ctrl+→ or Alt+←/Alt+→ | Fold/unfold or jump between branch segments |\n| Shift+L | Set or clear a label on the selected entry |\n| Shift+T | Toggle label timestamps |\n| Enter | Select entry |\n| Escape/Ctrl+C | Cancel |\n| Ctrl+O | Cycle filter mode |\n\nFilter modes are: default, no-tools, user-only, labeled-only, and all. Configure the default with `treeFilterMode` in [Settings](settings.md).\n\n### Selection Behavior\n\nSelecting a user or custom message:\n\n1. Moves the leaf to the selected message's parent.\n2. Places the selected message text in the editor.\n3. Lets you edit and resubmit, creating a new branch.\n\nSelecting an assistant, tool, compaction, or other non-user entry:\n\n1. Moves the leaf to that entry.\n2. Leaves the editor empty.\n3. Lets you continue from that point.\n\nSelecting the root user message resets the leaf to an empty conversation and places the original prompt in the editor.\n\n## `/tree`, `/fork`, and `/clone`\n\n| Feature | `/tree` | `/fork` | `/clone` |\n|---------|---------|---------|----------|\n| Output | Same session file | New session file | New session file |\n| View | Full tree | User-message selector | Current active branch |\n| Typical use | Explore alternatives in place | Start a new session from an earlier prompt | Duplicate current work before continuing |\n| Summary | Optional branch summary | None | None |\n\nUse `/tree` when you want to keep alternatives together. Use `/fork` or `/clone` when you want a separate session file.\n\n## Branch Summaries\n\nWhen `/tree` switches away from one branch to another, pi can summarize the abandoned branch and attach that summary at the new position. This preserves important context from the path you left without replaying the whole branch.\n\nWhen prompted, choose one of:\n\n1. no summary\n2. summarize with the default prompt\n3. summarize with custom focus instructions\n\nSee [Compaction](compaction.md) for branch summarization internals and extension hooks.\n\n## Session Format\n\nSession files are JSONL and contain message entries, model changes, thinking-level changes, labels, compactions, branch summaries, and extension entries.\n\nFor parsers, extensions, SDK usage, and the full SessionManager API, see [Session Format](session-format.md).","sourceFile":"sessions.md"},"settings":{"title":"Settings","markdown":"Pi uses JSON settings files with project settings overriding global settings.\n\n| Location | Scope |\n|----------|-------|\n| `~/.pi/agent/settings.json` | Global (all projects) |\n| `.pi/settings.json` | Project (current directory) |\n\nEdit directly or use `/settings` for common options.\n\n## Project Trust\n\nOn interactive startup, pi asks before trusting a project folder that contains project-local settings, resources, or project `.agents/skills` and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions.\n\nNon-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore those project resources, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run.\n\nIf no extension or saved decision applies, `defaultProjectTrust` controls the fallback behavior. Set it to `\"ask\"`, `\"always\"`, or `\"never\"` in `~/.pi/agent/settings.json`, or change it with `/settings`.\n\n`pi config` and package commands use the same project trust flow, except `pi update` never prompts. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them.\n\nUse `/trust` in interactive mode to save a project trust decision for future sessions, including trust for the immediate parent folder. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect.\n\n## All Settings\n\n### Model & Thinking\n\n| Setting | Type | Default | Description |\n|---------|------|---------|-------------|\n| `defaultProvider` | string | - | Default provider (e.g., `\"anthropic\"`, `\"openai\"`) |\n| `defaultModel` | string | - | Default model ID |\n| `defaultThinkingLevel` | string | - | `\"off\"`, `\"minimal\"`, `\"low\"`, `\"medium\"`, `\"high\"`, `\"xhigh\"`, `\"max\"` |\n| `hideThinkingBlock` | boolean | `false` | Hide thinking blocks in output |\n| `showCacheMissNotices` | boolean | `false` | Show transcript notices for significant prompt-cache misses |\n| `thinkingBudgets` | object | - | Custom token budgets per thinking level |\n\n#### thinkingBudgets\n\n```json\n{\n  \"thinkingBudgets\": {\n    \"minimal\": 1024,\n    \"low\": 4096,\n    \"medium\": 10240,\n    \"high\": 32768\n  }\n}\n```\n\n### UI & Display\n\n| Setting | Type | Default | Description |\n|---------|------|---------|-------------|\n| `theme` | string | `\"dark\"` | Theme name (`\"dark\"`, `\"light\"`, or custom) |\n| `externalEditor` | string | `$VISUAL`, then `$EDITOR`, then Notepad on Windows or `nano` elsewhere | Command for Ctrl+G external editor; takes precedence over environment variables |\n| `quietStartup` | boolean | `false` | Hide startup header |\n| `defaultProjectTrust` | string | `\"ask\"` | Fallback project trust behavior: `\"ask\"`, `\"always\"`, or `\"never\"`. Global setting only |\n| `collapseChangelog` | boolean | `false` | Show condensed changelog after updates |\n| `enableInstallTelemetry` | boolean | `true` | Send an anonymous install/update version ping after first install or changelog-detected updates. This does not control update checks |\n| `enableAnalytics` | boolean | `false` | Opt-in analytics data sharing. Currently only asked for during the experimental first-time setup (`PI_EXPERIMENTAL=1`) |\n| `trackingId` | string | - | Analytics tracking identifier, generated when `enableAnalytics` is turned on |\n| `doubleEscapeAction` | string | `\"tree\"` | Action for double-escape: `\"tree\"`, `\"fork\"`, or `\"none\"` |\n| `treeFilterMode` | string | `\"default\"` | Default filter for `/tree`: `\"default\"`, `\"no-tools\"`, `\"user-only\"`, `\"labeled-only\"`, `\"all\"` |\n| `editorPaddingX` | number | `0` | Horizontal padding for input editor (0-3) |\n| `outputPad` | number | `1` | Horizontal padding for user messages, assistant messages, and thinking (0 or 1) |\n| `autocompleteMaxVisible` | number | `5` | Max visible items in autocomplete dropdown (3-20) |\n| `showHardwareCursor` | boolean | `false` | Show the terminal cursor while TUI positions it for IME support |\n| `tuiMode` | string | `\"regular\"` | Interactive TUI mode: `\"regular\"` or experimental `\"fullscreen\"`. Changes from `/settings` apply immediately; `--tui-mode` overrides this setting at startup |\n| `fullscreenExitOutput` | string | `\"transcript\"` | Fullscreen exit output: `\"transcript\"` prints the final transcript and resume hint, while `\"resume-hint\"` restores the previous screen and prints only the resume hint. Has no effect in regular TUI mode |\n| `fullscreenScrollbar` | string | `\"auto\"` | Fullscreen transcript scrollbar: `\"auto\"` shows it temporarily while scrolling, `\"always\"` reserves the rightmost column and keeps it visible, and `\"hidden\"` hides it. Has no effect in regular TUI mode |\n\nFor VS Code, include `--wait` so pi resumes after the editor exits:\n\n```json\n{\n  \"externalEditor\": \"code --wait\"\n}\n```\n\n### Telemetry and update checks\n\n`enableInstallTelemetry` only controls the anonymous install/update ping to `https://pi.dev/api/report-install`. Opting out of telemetry does not disable update checks; Pi can still fetch `https://pi.dev/api/latest-version` to look for the latest version.\n\nSet `PI_SKIP_VERSION_CHECK=1` to disable the Pi version update check. Use `--offline` or `PI_OFFLINE=1` to disable all startup network operations described here, including update checks, package update checks, and install/update telemetry.\n\n### Network\n\n| Setting | Type | Default | Description |\n|---------|------|---------|-------------|\n| `httpProxy` | string | - | HTTP proxy URL applied as `HTTP_PROXY` and `HTTPS_PROXY`. Global setting only. |\n\n```json\n{\n  \"httpProxy\": \"http://127.0.0.1:7890\"\n}\n```\n\n### Warnings\n\n| Setting | Type | Default | Description |\n|---------|------|---------|-------------|\n| `warnings.anthropicExtraUsage` | boolean | `true` | Show a warning when Anthropic subscription auth may use paid extra usage |\n\n```json\n{\n  \"warnings\": {\n    \"anthropicExtraUsage\": false\n  }\n}\n```\n\n### Compaction\n\n| Setting | Type | Default | Description |\n|---------|------|---------|-------------|\n| `compaction.enabled` | boolean | `true` | Enable auto-compaction |\n| `compaction.reserveTokens` | number | `16384` | Tokens reserved for LLM response |\n| `compaction.keepRecentTokens` | number | `20000` | Recent tokens to keep (not summarized) |\n\n```json\n{\n  \"compaction\": {\n    \"enabled\": true,\n    \"reserveTokens\": 16384,\n    \"keepRecentTokens\": 20000\n  }\n}\n```\n\n### Branch Summary\n\n| Setting | Type | Default | Description |\n|---------|------|---------|-------------|\n| `branchSummary.reserveTokens` | number | `16384` | Tokens reserved for branch summarization |\n| `branchSummary.skipPrompt` | boolean | `false` | Skip \"Summarize branch?\" prompt on `/tree` navigation (defaults to no summary) |\n\n### Retry\n\n| Setting | Type | Default | Description |\n|---------|------|---------|-------------|\n| `retry.enabled` | boolean | `true` | Enable automatic agent-level retry on transient errors |\n| `retry.maxRetries` | number | `3` | Maximum agent-level retry attempts |\n| `retry.baseDelayMs` | number | `2000` | Base delay for agent-level exponential backoff (2s, 4s, 8s) |\n| `retry.provider.timeoutMs` | number | SDK default | Provider/SDK request timeout in milliseconds |\n| `retry.provider.maxRetries` | number | `0` | Provider/SDK retry attempts |\n| `retry.provider.maxRetryDelayMs` | number | `60000` | Max server-requested delay before failing (60s) |\n\nWhen a provider requests a retry delay longer than `retry.provider.maxRetryDelayMs`, the request fails immediately with an informative error instead of waiting silently. Set it to `0` to disable the limit.\n\nKeep `retry.provider.maxRetries` at `0` unless provider-level retries are explicitly needed. Setting it above `0` can make SDK/provider retries handle out-of-usage-limit errors before Pi sees them, which may block the agent until the provider quota resets in some circumstances.\n\n```json\n{\n  \"retry\": {\n    \"enabled\": true,\n    \"maxRetries\": 3,\n    \"baseDelayMs\": 2000,\n    \"provider\": {\n      \"timeoutMs\": 3600000,\n      \"maxRetries\": 0,\n      \"maxRetryDelayMs\": 60000\n    }\n  }\n}\n```\n\n### Message Delivery\n\n| Setting | Type | Default | Description |\n|---------|------|---------|-------------|\n| `steeringMode` | string | `\"one-at-a-time\"` | How steering messages are sent: `\"all\"` or `\"one-at-a-time\"` |\n| `followUpMode` | string | `\"one-at-a-time\"` | How follow-up messages are sent: `\"all\"` or `\"one-at-a-time\"` |\n| `transport` | string | `\"auto\"` | Preferred transport for providers that support multiple transports: `\"sse\"`, `\"websocket\"`, `\"websocket-cached\"`, or `\"auto\"` |\n| `httpIdleTimeoutMs` | number | `300000` | HTTP header/body idle timeout in milliseconds, also used by providers with explicit stream idle timeouts. Set to `0` to disable. |\n| `websocketConnectTimeoutMs` | number | `15000` | WebSocket connect/open handshake timeout in milliseconds for providers that support WebSocket transports. Set to `0` to disable. |\n\n### Terminal & Images\n\n| Setting | Type | Default | Description |\n|---------|------|---------|-------------|\n| `terminal.showImages` | boolean | `true` | Show images in terminal (if supported) |\n| `terminal.imageWidthCells` | number | `60` | Preferred inline image width in terminal cells |\n| `terminal.clearOnShrink` | boolean | `false` | Clear empty rows when content shrinks (can cause flicker) |\n| `images.autoResize` | boolean | `true` | Resize images to 2000x2000 max. Applies to `@file` attachments, `read`, and images returned by tools |\n| `images.blockImages` | boolean | `false` | Block all images from being sent to LLM |\n\n### Shell\n\n| Setting | Type | Default | Description |\n|---------|------|---------|-------------|\n| `shellPath` | string | - | Custom shell path (e.g., for Cygwin on Windows); supports a leading `~` for the home directory |\n| `shellCommandPrefix` | string | - | Prefix for every bash command (e.g., `\"shopt -s expand_aliases\"`) |\n| `npmCommand` | string[] | - | Command argv used for npm package lookup/install operations (e.g., `[\"mise\", \"exec\", \"node@20\", \"--\", \"npm\"]`) |\n\n```json\n{\n  \"npmCommand\": [\"mise\", \"exec\", \"node@20\", \"--\", \"npm\"]\n}\n```\n\n`npmCommand` is used for all npm package-manager operations, including installs, uninstalls, and dependency installs inside git packages. User-scoped npm packages install under `~/.pi/agent/npm/`; project-scoped npm packages install under `.pi/npm/`. Use argv-style entries exactly as the process should be launched. When `npmCommand` is configured, git package dependency installs use plain `install` to avoid npm-specific flags in wrappers or alternate package managers.\n\n### Sessions\n\n| Setting | Type | Default | Description |\n|---------|------|---------|-------------|\n| `sessionDir` | string | - | Directory where session files are stored. Accepts absolute or relative paths, plus `~`. |\n\n```json\n{ \"sessionDir\": \".pi/sessions\" }\n```\n\nWhen multiple sources specify a session directory, precedence is `--session-dir`, `PI_CODING_AGENT_SESSION_DIR`, then `sessionDir` in settings.json.\n\n### Model Cycling\n\n| Setting | Type | Default | Description |\n|---------|------|---------|-------------|\n| `enabledModels` | string[] | - | Model patterns for Ctrl+P cycling (same format as `--models` CLI flag) |\n\n```json\n{\n  \"enabledModels\": [\"claude-*\", \"gpt-4o\", \"gemini-2*\"]\n}\n```\n\n### Markdown\n\n| Setting | Type | Default | Description |\n|---------|------|---------|-------------|\n| `markdown.codeBlockIndent` | string | `\"  \"` | Indentation for code blocks |\n| `markdown.mermaid` | string | `\"streaming\"` | Mermaid rendering mode: `\"off\"`, `\"final\"`, or `\"streaming\"` |\n\n### Resources\n\nThese settings define where to load extensions, skills, prompts, and themes from.\n\nPaths in `~/.pi/agent/settings.json` resolve relative to `~/.pi/agent`. Paths in `.pi/settings.json` resolve relative to `.pi`. Absolute paths and `~` are supported.\n\n| Setting | Type | Default | Description |\n|---------|------|---------|-------------|\n| `packages` | array | `[]` | npm/git packages to load resources from |\n| `extensions` | string[] | `[]` | Local extension file paths or directories |\n| `skills` | string[] | `[]` | Local skill file paths or directories |\n| `prompts` | string[] | `[]` | Local prompt template paths or directories |\n| `themes` | string[] | `[]` | Local theme file paths or directories |\n| `enableSkillCommands` | boolean | `true` | Register skills as `/skill:name` commands |\n\nArrays support glob patterns and exclusions. Use `!pattern` to exclude. Use `+path` to force-include an exact path and `-path` to force-exclude an exact path.\n\n#### packages\n\nString form loads all resources from a package:\n\n```json\n{\n  \"packages\": [\"pi-skills\", \"@org/my-extension\"]\n}\n```\n\nObject form filters which resources to load:\n\n```json\n{\n  \"packages\": [\n    {\n      \"source\": \"pi-skills\",\n      \"skills\": [\"brave-search\", \"transcribe\"],\n      \"extensions\": []\n    }\n  ]\n}\n```\n\nSee [packages.md](packages.md) for package management details.\n\n## Example\n\n```json\n{\n  \"defaultProvider\": \"anthropic\",\n  \"defaultModel\": \"claude-sonnet-4-20250514\",\n  \"defaultThinkingLevel\": \"medium\",\n  \"theme\": \"dark\",\n  \"compaction\": {\n    \"enabled\": true,\n    \"reserveTokens\": 16384,\n    \"keepRecentTokens\": 20000\n  },\n  \"retry\": {\n    \"enabled\": true,\n    \"maxRetries\": 3\n  },\n  \"enabledModels\": [\"claude-*\", \"gpt-4o\"],\n  \"warnings\": {\n    \"anthropicExtraUsage\": true\n  },\n  \"packages\": [\"pi-skills\"]\n}\n```\n\n## Project Overrides\n\nProject settings (`.pi/settings.json`) override global settings. Nested objects are merged:\n\n```json\n// ~/.pi/agent/settings.json (global)\n{\n  \"theme\": \"dark\",\n  \"compaction\": { \"enabled\": true, \"reserveTokens\": 16384 }\n}\n\n// .pi/settings.json (project)\n{\n  \"compaction\": { \"reserveTokens\": 8192 }\n}\n\n// Result\n{\n  \"theme\": \"dark\",\n  \"compaction\": { \"enabled\": true, \"reserveTokens\": 8192 }\n}\n```","sourceFile":"settings.md"},"shell-aliases":{"title":"Shell Aliases","markdown":"Pi runs bash in non-interactive mode (`bash -c`), which doesn't expand aliases by default.\n\nTo enable your shell aliases, add to `~/.pi/agent/settings.json`:\n\n```json\n{\n  \"shellCommandPrefix\": \"shopt -s expand_aliases\\neval \\\"$(grep '^alias ' ~/.zshrc)\\\"\"\n}\n```\n\nAdjust the path (`~/.zshrc`, `~/.bashrc`, etc.) to match your shell config.","sourceFile":"shell-aliases.md"},"skills":{"title":"Skills","markdown":"> pi can create skills. Ask it to build one for your use case.\n\n\nSkills are self-contained capability packages that the agent loads on-demand. A skill provides specialized workflows, setup instructions, helper scripts, and reference documentation for specific tasks.\n\nPi implements the [Agent Skills standard](https://agentskills.io/specification), warning about most violations but remaining lenient. Pi allows skill names to differ from their parent directory even though the standard disallows it; that rule is suboptimal for shared skill directories used across multiple agent harnesses.\n\n## Table of Contents\n\n- [Locations](#locations)\n- [How Skills Work](#how-skills-work)\n- [Skill Commands](#skill-commands)\n- [Skill Structure](#skill-structure)\n- [Frontmatter](#frontmatter)\n- [Validation](#validation)\n- [Example](#example)\n- [Skill Repositories](#skill-repositories)\n\n## Locations\n\n> **Security:** Skills can instruct the model to perform any action and may include executable code the model invokes. Review skill content before use.\n\nPi loads skills from:\n\n- Global:\n  - `~/.pi/agent/skills/`\n  - `~/.agents/skills/`\n- Project (only after the project is trusted):\n  - `.pi/skills/`\n  - `.agents/skills/` in `cwd` and ancestor directories (up to git repo root, or filesystem root when not in a repo)\n- Packages: `skills/` directories or `pi.skills` entries in `package.json`\n- Settings: `skills` array with files or directories\n- CLI: `--skill <path>` (repeatable, additive even with `--no-skills`)\n\nDiscovery rules:\n- In `~/.pi/agent/skills/` and `.pi/skills/`, direct root `.md` files are discovered as individual skills\n- In all skill locations, directories containing `SKILL.md` are discovered recursively\n- In `~/.agents/skills/` and project `.agents/skills/`, root `.md` files are ignored\n\nDisable discovery with `--no-skills` (explicit `--skill` paths still load).\n\n### Using Skills from Other Harnesses\n\nTo use skills from Claude Code or OpenAI Codex, add their directories to settings:\n\n```json\n{\n  \"skills\": [\n    \"~/.claude/skills\",\n    \"~/.codex/skills\"\n  ]\n}\n```\n\nFor project-level Claude Code skills, add to `.pi/settings.json`:\n\n```json\n{\n  \"skills\": [\"../.claude/skills\"]\n}\n```\n\n## How Skills Work\n\n1. At startup, pi scans skill locations and extracts names and descriptions\n2. The system prompt includes available skills in XML format per the [specification](https://agentskills.io/integrate-skills)\n3. When a task matches, the agent uses `read` to load the full SKILL.md (models don't always do this; use prompting or `/skill:name` to force it)\n4. The agent follows the instructions, using relative paths to reference scripts and assets\n\nThis is progressive disclosure: only descriptions are always in context, full instructions load on-demand.\n\n## Skill Commands\n\nSkills register as `/skill:name` commands:\n\n```bash\n/skill:brave-search           # Load and execute the skill\n/skill:pdf-tools extract      # Load skill with arguments\n```\n\nArguments after the command are appended to the skill content as `User: <args>`.\n\nToggle skill commands via `/settings` in interactive mode or in `settings.json`:\n\n```json\n{\n  \"enableSkillCommands\": true\n}\n```\n\n## Skill Structure\n\nA skill is a directory with a `SKILL.md` file. Everything else is freeform.\n\n```\nmy-skill/\n├── SKILL.md              # Required: frontmatter + instructions\n├── scripts/              # Helper scripts\n│   └── process.sh\n├── references/           # Detailed docs loaded on-demand\n│   └── api-reference.md\n└── assets/\n    └── template.json\n```\n\n### SKILL.md Format\n\n````markdown\n---\nname: my-skill\ndescription: What this skill does and when to use it. Be specific.\n---\n\n# My Skill\n\n## Setup\n\nRun once before first use:\n```bash\ncd /path/to/skill && npm install\n```\n\n## Usage\n\n```bash\n./scripts/process.sh <input>\n```\n````\n\nUse relative paths from the skill directory:\n\n```markdown\nSee [the reference guide](references/REFERENCE.md) for details.\n```\n\n## Frontmatter\n\nPer the [Agent Skills specification](https://agentskills.io/specification#frontmatter-required):\n\n| Field | Required | Description |\n|-------|----------|-------------|\n| `name` | Yes | Max 64 chars. Lowercase a-z, 0-9, hyphens. Unlike the standard, Pi does not require this to match the parent directory because that standard requirement is suboptimal for shared skill directories. |\n| `description` | Yes | Max 1024 chars. What the skill does and when to use it. |\n| `license` | No | License name or reference to bundled file. |\n| `compatibility` | No | Max 500 chars. Environment requirements. |\n| `metadata` | No | Arbitrary key-value mapping. |\n| `allowed-tools` | No | Space-delimited list of pre-approved tools (experimental). |\n| `disable-model-invocation` | No | When `true`, skill is hidden from system prompt. Users must use `/skill:name`. |\n\n### Name Rules\n\n- 1-64 characters\n- Lowercase letters, numbers, hyphens only\n- No leading/trailing hyphens\n- No consecutive hyphens\nPi does not require the name to match the parent directory. The Agent Skills standard does, but that requirement is suboptimal for shared skill directories used by multiple tools.\n\nValid: `pdf-processing`, `data-analysis`, `code-review`\nInvalid: `PDF-Processing`, `-pdf`, `pdf--processing`\n\n### Description Best Practices\n\nThe description determines when the agent loads the skill. Be specific.\n\nGood:\n```yaml\ndescription: Extracts text and tables from PDF files, fills PDF forms, and merges multiple PDFs. Use when working with PDF documents.\n```\n\nPoor:\n```yaml\ndescription: Helps with PDFs.\n```\n\n## Validation\n\nPi validates skills against the Agent Skills standard. Most issues produce warnings but still load the skill:\n\n- Name exceeds 64 characters or contains invalid characters\n- Name starts/ends with hyphen or has consecutive hyphens\n- Description exceeds 1024 characters\n\nUnknown frontmatter fields are ignored.\n\n**Exception:** Skills with missing description are not loaded.\n\nName collisions (same name from different locations) warn and keep the first skill found.\n\n## Example\n\n```\nbrave-search/\n├── SKILL.md\n├── search.js\n└── content.js\n```\n\n**SKILL.md:**\n````markdown\n---\nname: brave-search\ndescription: Web search and content extraction via Brave Search API. Use for searching documentation, facts, or any web content.\n---\n\n# Brave Search\n\n## Setup\n\n```bash\ncd /path/to/brave-search && npm install\n```\n\n## Search\n\n```bash\n./search.js \"query\"              # Basic search\n./search.js \"query\" --content    # Include page content\n```\n\n## Extract Page Content\n\n```bash\n./content.js https://example.com\n```\n````\n\n## Skill Repositories\n\n- [Anthropic Skills](https://github.com/anthropics/skills) - Document processing (docx, pdf, pptx, xlsx), web development\n- [Pi Skills](https://github.com/badlogic/pi-skills) - Web search, browser automation, Google APIs, transcription","sourceFile":"skills.md"},"terminal-setup":{"title":"Terminal Setup","markdown":"Pi uses the [Kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) for reliable modifier key detection. Most modern terminals support this protocol, but some require configuration.\n\n## Kitty, iTerm2\n\nWork out of the box.\n\n## Apple Terminal\n\nPi enables enhanced key reporting when available. If Terminal.app still sends plain Return for `Shift+Enter`, pi uses a local macOS modifier fallback to treat that Return as `Shift+Enter`.\n\nThis fallback only works when pi runs on the same Mac as Terminal.app. It cannot detect the local keyboard over remote SSH.\n\n## Ghostty\n\nAdd to your Ghostty config (`~/Library/Application Support/com.mitchellh.ghostty/config` on macOS, `~/.config/ghostty/config` on Linux):\n\n```\nkeybind = alt+backspace=text:\\x1b\\x7f\n```\n\nOlder Claude Code versions may have added this Ghostty mapping:\n\n```\nkeybind = shift+enter=text:\\n\n```\n\nThat mapping sends a raw linefeed byte. Inside pi, that is indistinguishable from `Ctrl+J`, so tmux and pi no longer see a real `shift+enter` key event.\n\nIf Claude Code 2.x or newer is the only reason you added that mapping, you can remove it, unless you want to use Claude Code in tmux, where it still requires that Ghostty mapping.\n\nPi binds `Ctrl+J` as a default newline alias, so `Shift+Enter` keeps working in tmux via that remap without extra pi configuration.\n\n## WezTerm\n\nWezTerm usually works out of the box for `Shift+Enter` via xterm modifyOtherKeys. To use the Kitty keyboard protocol explicitly, create `~/.wezterm.lua`:\n\n```lua\nlocal wezterm = require 'wezterm'\nlocal config = wezterm.config_builder()\nconfig.enable_kitty_keyboard = true\nreturn config\n```\n\nOn macOS, WezTerm binds `Option+Enter` to fullscreen by default. To use `Option+Enter` for pi follow-up queueing, add this key override:\n\n```lua\nlocal wezterm = require 'wezterm'\nlocal config = wezterm.config_builder()\nconfig.keys = {\n  {\n    key = 'Enter',\n    mods = 'ALT',\n    action = wezterm.action.SendString('\\x1b[13;3u'),\n  },\n}\nreturn config\n```\n\nIf you already have a `config.keys` table, add the entry to it.\n\nOn WSL, WezTerm may require a visible hardware cursor for IME candidate window positioning. If CJK IME candidates do not follow the text cursor, set `PI_HARDWARE_CURSOR=1` before running pi or set `showHardwareCursor` to `true` in settings.\n\n## Alacritty\n\nAlacritty usually works out of the box for `Shift+Enter`. On macOS, `Option+Enter` may arrive as plain `Enter`. To use `Option+Enter` for pi follow-up queueing, add to `~/.config/alacritty/alacritty.toml`:\n\n```toml\n[[keyboard.bindings]]\nkey = \"Enter\"\nmods = \"Alt\"\nchars = \"\\u001b[13;3u\"\n```\n\nRestart Alacritty after changing the config.\n\n## VS Code (Integrated Terminal)\n\nVS Code 1.109.5 and newer enable Kitty keyboard protocol in the integrated terminal by default, so `Shift+Enter` should work out of the box.\n\nVS Code versions older than 1.109.5 need an explicit terminal keybinding for `Shift+Enter`.\n\n`keybindings.json` locations:\n- macOS: `~/Library/Application Support/Code/User/keybindings.json`\n- Linux: `~/.config/Code/User/keybindings.json`\n- Windows: `%APPDATA%\\\\Code\\\\User\\\\keybindings.json`\n\nAdd to `keybindings.json`:\n\n```json\n{\n  \"key\": \"shift+enter\",\n  \"command\": \"workbench.action.terminal.sendSequence\",\n  \"args\": { \"text\": \"\\u001b[13;2u\" },\n  \"when\": \"terminalFocus\"\n}\n```\n\n## Windows Terminal\n\nAdd to `settings.json` (Ctrl+Shift+, or Settings → Open JSON file) to forward the modified Enter keys pi uses:\n\n```json\n{\n  \"actions\": [\n    {\n      \"command\": { \"action\": \"sendInput\", \"input\": \"\\u001b[13;2u\" },\n      \"keys\": \"shift+enter\"\n    },\n    {\n      \"command\": { \"action\": \"sendInput\", \"input\": \"\\u001b[13;3u\" },\n      \"keys\": \"alt+enter\"\n    }\n  ]\n}\n```\n\n- `Shift+Enter` inserts a new line.\n- Windows Terminal binds `Alt+Enter` to fullscreen by default. That prevents pi from receiving `Alt+Enter` for follow-up queueing.\n- Remapping `Alt+Enter` to `sendInput` forwards the real key chord to pi instead.\n\nIf you already have an `actions` array, add the objects to it. If the old fullscreen behavior persists, fully close and reopen Windows Terminal.\n\n## xfce4-terminal, terminator\n\nThese terminals have limited escape sequence support. Modified Enter keys like `Ctrl+Enter` and `Shift+Enter` cannot be distinguished from plain `Enter`, preventing custom keybindings such as `submit: [\"ctrl+enter\"]` from working.\n\nFor the best experience, use a terminal that supports the Kitty keyboard protocol:\n- [Kitty](https://sw.kovidgoyal.net/kitty/)\n- [Ghostty](https://ghostty.org/)\n- [WezTerm](https://wezfurlong.org/wezterm/)\n- [iTerm2](https://iterm2.com/)\n- [Alacritty](https://github.com/alacritty/alacritty) (requires compilation with Kitty protocol support)\n\n## IntelliJ IDEA (Integrated Terminal)\n\nThe built-in terminal has limited escape sequence support. Shift+Enter cannot be distinguished from Enter in IntelliJ's terminal.\n\nIf you want the hardware cursor visible, set `PI_HARDWARE_CURSOR=1` before running pi (disabled by default for compatibility).\n\nConsider using a dedicated terminal emulator for the best experience.","sourceFile":"terminal-setup.md"},"termux":{"title":"Termux (Android) Setup","markdown":"Pi runs on Android via [Termux](https://termux.dev/), a terminal emulator and Linux environment for Android.\n\n## Prerequisites\n\n1. Install [Termux](https://github.com/termux/termux-app#installation) from GitHub or F-Droid (not Google Play, that version is deprecated)\n2. Install [Termux:API](https://github.com/termux/termux-api#installation) from GitHub or F-Droid for clipboard and other device integrations\n\n## Installation\n\n```bash\n# Update packages\npkg update && pkg upgrade\n\n# Install dependencies\npkg install nodejs termux-api git\n\n# Install pi\nnpm install -g --ignore-scripts @earendil-works/pi-coding-agent\n\n# Create config directory\nmkdir -p ~/.pi/agent\n\n# Run pi\npi\n```\n\n## Clipboard Support\n\nClipboard operations use `termux-clipboard-set` and `termux-clipboard-get` when running in Termux. The Termux:API app must be installed for these to work.\n\nImage clipboard is not supported on Termux (the `ctrl+v` image paste feature will not work).\n\n## Example AGENTS.md for Termux\n\nCreate `~/.pi/agent/AGENTS.md` to help the agent understand the Termux environment:\n\n````markdown\n# Agent Environment: Termux on Android\n\n## Location\n- **OS**: Android (Termux terminal emulator)\n- **Home**: `/data/data/com.termux/files/home`\n- **Prefix**: `/data/data/com.termux/files/usr`\n- **Shared storage**: `/storage/emulated/0` (Downloads, Documents, etc.)\n\n## Opening URLs\n```bash\ntermux-open-url \"https://example.com\"\n```\n\n## Opening Files\n```bash\ntermux-open file.pdf          # Opens with default app\ntermux-open --chooser image.jpg      # Choose app\n```\n\n## Clipboard\n```bash\ntermux-clipboard-set \"text\"   # Copy\ntermux-clipboard-get          # Paste\n```\n\n## Notifications\n```bash\ntermux-notification -t \"Title\" -c \"Content\"\n```\n\n## Device Info\n```bash\ntermux-battery-status         # Battery info\ntermux-wifi-connectioninfo    # WiFi info\ntermux-telephony-deviceinfo   # Device info\n```\n\n## Sharing\n```bash\ntermux-share -a send file.txt # Share file\n```\n\n## Other Useful Commands\n```bash\ntermux-toast \"message\"        # Quick toast popup\ntermux-vibrate                # Vibrate device\ntermux-tts-speak \"hello\"      # Text to speech\ntermux-camera-photo out.jpg   # Take photo\n```\n\n## Notes\n- Termux:API app must be installed for `termux-*` commands\n- Use `pkg install termux-api` for the command-line tools\n- Storage permission needed for `/storage/emulated/0` access\n````\n\n## Limitations\n\n- **No image clipboard**: Termux clipboard API only supports text\n- **No native binaries**: Some optional native dependencies (like the clipboard module) are unavailable on Android ARM64 and are skipped during installation\n- **Storage access**: To access files in `/storage/emulated/0` (Downloads, etc.), run `termux-setup-storage` once to grant permissions\n\n## Troubleshooting\n\n### Clipboard not working\n\nEnsure both apps are installed:\n1. Termux (from GitHub or F-Droid)\n2. Termux:API (from GitHub or F-Droid)\n\nThen install the CLI tools:\n```bash\npkg install termux-api\n```\n\n### Permission denied for shared storage\n\nRun once to grant storage permissions:\n```bash\ntermux-setup-storage\n```\n\n### Node.js installation issues\n\nIf npm fails, try clearing the cache:\n```bash\nnpm cache clean --force\n```","sourceFile":"termux.md"},"themes":{"title":"Themes","markdown":"> pi can create themes. Ask it to build one for your setup.\n\n\nThemes are JSON files that define colors for the TUI.\n\n## Table of Contents\n\n- [Locations](#locations)\n- [Selecting a Theme](#selecting-a-theme)\n- [Creating a Custom Theme](#creating-a-custom-theme)\n- [Theme Format](#theme-format)\n- [Color Tokens](#color-tokens)\n- [Color Values](#color-values)\n- [Tips](#tips)\n\n## Locations\n\nPi loads themes from:\n\n- Built-in: `dark`, `light`\n- Global: `~/.pi/agent/themes/*.json`\n- Project: `.pi/themes/*.json` (only after the project is trusted)\n- Packages: `themes/` directories or `pi.themes` entries in `package.json`\n- Settings: `themes` array with files or directories\n- CLI: `--theme <path>` (repeatable)\n\nDisable discovery with `--no-themes`.\n\n## Selecting a Theme\n\nSelect a theme via `/settings` or in `settings.json`:\n\n```json\n{\n  \"theme\": \"my-theme\"\n}\n```\n\nOn first run, pi detects your terminal background and defaults to `dark` or `light`.\n\n## Creating a Custom Theme\n\n1. Create a theme file:\n\n```bash\nmkdir -p ~/.pi/agent/themes\nvim ~/.pi/agent/themes/my-theme.json\n```\n\n2. Define the theme with all required colors (see [Color Tokens](#color-tokens)):\n\n```json\n{\n  \"$schema\": \"https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json\",\n  \"name\": \"my-theme\",\n  \"vars\": {\n    \"primary\": \"#00aaff\",\n    \"secondary\": 242\n  },\n  \"colors\": {\n    \"accent\": \"primary\",\n    \"border\": \"primary\",\n    \"borderAccent\": \"#00ffff\",\n    \"borderMuted\": \"secondary\",\n    \"success\": \"#00ff00\",\n    \"error\": \"#ff0000\",\n    \"warning\": \"#ffff00\",\n    \"muted\": \"secondary\",\n    \"dim\": 240,\n    \"text\": \"\",\n    \"thinkingText\": \"secondary\",\n    \"selectedBg\": \"#2d2d30\",\n    \"scrollbarThumb\": \"#555566\",\n    \"userMessageBg\": \"#2d2d30\",\n    \"userMessageText\": \"\",\n    \"customMessageBg\": \"#2d2d30\",\n    \"customMessageText\": \"\",\n    \"customMessageLabel\": \"primary\",\n    \"toolPendingBg\": \"#1e1e2e\",\n    \"toolSuccessBg\": \"#1e2e1e\",\n    \"toolErrorBg\": \"#2e1e1e\",\n    \"toolTitle\": \"primary\",\n    \"toolOutput\": \"\",\n    \"mdHeading\": \"#ffaa00\",\n    \"mdLink\": \"primary\",\n    \"mdLinkUrl\": \"secondary\",\n    \"mdCode\": \"#00ffff\",\n    \"mdCodeBlock\": \"\",\n    \"mdCodeBlockBorder\": \"secondary\",\n    \"mdQuote\": \"secondary\",\n    \"mdQuoteBorder\": \"secondary\",\n    \"mdHr\": \"secondary\",\n    \"mdListBullet\": \"#00ffff\",\n    \"toolDiffAdded\": \"#00ff00\",\n    \"toolDiffRemoved\": \"#ff0000\",\n    \"toolDiffContext\": \"secondary\",\n    \"syntaxComment\": \"secondary\",\n    \"syntaxKeyword\": \"primary\",\n    \"syntaxFunction\": \"#00aaff\",\n    \"syntaxVariable\": \"#ffaa00\",\n    \"syntaxString\": \"#00ff00\",\n    \"syntaxNumber\": \"#ff00ff\",\n    \"syntaxType\": \"#00aaff\",\n    \"syntaxOperator\": \"primary\",\n    \"syntaxPunctuation\": \"secondary\",\n    \"thinkingOff\": \"secondary\",\n    \"thinkingMinimal\": \"primary\",\n    \"thinkingLow\": \"#00aaff\",\n    \"thinkingMedium\": \"#00ffff\",\n    \"thinkingHigh\": \"#ff00ff\",\n    \"thinkingXhigh\": \"#ff0000\",\n    \"thinkingMax\": \"#ff0088\",\n    \"bashMode\": \"#ffaa00\"\n  }\n}\n```\n\n3. Select the theme via `/settings`.\n\n**Hot reload:** When you edit the currently active custom theme file, pi reloads it automatically for immediate visual feedback.\n\n## Theme Format\n\n```json\n{\n  \"$schema\": \"https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json\",\n  \"name\": \"my-theme\",\n  \"vars\": {\n    \"blue\": \"#0066cc\",\n    \"gray\": 242\n  },\n  \"colors\": {\n    \"accent\": \"blue\",\n    \"muted\": \"gray\",\n    \"text\": \"\",\n    ...\n  }\n}\n```\n\n- `name` is required, must be unique, and must not contain `/`.\n- `vars` is optional. Define reusable colors here, then reference them in `colors`.\n- `colors` must define all 51 required tokens. `thinkingMax` is optional and falls back to `thinkingXhigh`; `scrollbarThumb` is optional and falls back to `selectedBg`.\n\nThe `$schema` field enables editor auto-completion and validation.\n\n## Color Tokens\n\nEvery theme must define all 51 required color tokens. `thinkingMax` and `scrollbarThumb` are optional for compatibility with existing themes; when omitted, they use `thinkingXhigh` and `selectedBg`, respectively.\n\n### Core UI (11 colors)\n\n| Token | Purpose |\n|-------|---------|\n| `accent` | Primary accent (logo, selected items, cursor) |\n| `border` | Normal borders |\n| `borderAccent` | Highlighted borders |\n| `borderMuted` | Subtle borders (editor) |\n| `success` | Success states |\n| `error` | Error states |\n| `warning` | Warning states |\n| `muted` | Secondary text |\n| `dim` | Tertiary text |\n| `text` | Default text (usually `\"\"`) |\n| `thinkingText` | Thinking block text |\n\n### Backgrounds & Content (11 required, 1 optional)\n\n| Token | Purpose |\n|-------|---------|\n| `selectedBg` | Selected line background |\n| `scrollbarThumb` | Fullscreen scrollbar thumb background; optional, falls back to `selectedBg` |\n| `userMessageBg` | User message background |\n| `userMessageText` | User message text |\n| `customMessageBg` | Extension message background |\n| `customMessageText` | Extension message text |\n| `customMessageLabel` | Extension message label |\n| `toolPendingBg` | Tool box (pending) |\n| `toolSuccessBg` | Tool box (success) |\n| `toolErrorBg` | Tool box (error) |\n| `toolTitle` | Tool title |\n| `toolOutput` | Tool output text |\n\n### Markdown (10 colors)\n\n| Token | Purpose |\n|-------|---------|\n| `mdHeading` | Headings |\n| `mdLink` | Link text |\n| `mdLinkUrl` | Link URL |\n| `mdCode` | Inline code |\n| `mdCodeBlock` | Code block content |\n| `mdCodeBlockBorder` | Code block fences |\n| `mdQuote` | Blockquote text |\n| `mdQuoteBorder` | Blockquote border |\n| `mdHr` | Horizontal rule |\n| `mdListBullet` | List bullets |\n\n### Tool Diffs (3 colors)\n\n| Token | Purpose |\n|-------|---------|\n| `toolDiffAdded` | Added lines |\n| `toolDiffRemoved` | Removed lines |\n| `toolDiffContext` | Context lines |\n\n### Syntax Highlighting (9 colors)\n\n| Token | Purpose |\n|-------|---------|\n| `syntaxComment` | Comments |\n| `syntaxKeyword` | Keywords |\n| `syntaxFunction` | Function names |\n| `syntaxVariable` | Variables |\n| `syntaxString` | Strings |\n| `syntaxNumber` | Numbers |\n| `syntaxType` | Types |\n| `syntaxOperator` | Operators |\n| `syntaxPunctuation` | Punctuation |\n\n### Thinking Level Borders (6 required, 1 optional)\n\nEditor border colors indicating thinking level (visual hierarchy from subtle to prominent):\n\n| Token | Purpose |\n|-------|---------|\n| `thinkingOff` | Thinking off |\n| `thinkingMinimal` | Minimal thinking |\n| `thinkingLow` | Low thinking |\n| `thinkingMedium` | Medium thinking |\n| `thinkingHigh` | High thinking |\n| `thinkingXhigh` | Extra high thinking |\n| `thinkingMax` | Maximum thinking; optional, falls back to `thinkingXhigh` |\n\n### Bash Mode (1 color)\n\n| Token | Purpose |\n|-------|---------|\n| `bashMode` | Editor border in bash mode (`!` prefix) |\n\n### HTML Export (optional)\n\nThe `export` section controls colors for `/export` HTML output. If omitted, colors are derived from `userMessageBg`.\n\n```json\n{\n  \"export\": {\n    \"pageBg\": \"#18181e\",\n    \"cardBg\": \"#1e1e24\",\n    \"infoBg\": \"#3c3728\"\n  }\n}\n```\n\n## Color Values\n\nFour formats are supported:\n\n| Format | Example | Description |\n|--------|---------|-------------|\n| Hex | `\"#ff0000\"` | 6-digit hex RGB |\n| 256-color | `39` | xterm 256-color palette index (0-255) |\n| Variable | `\"primary\"` | Reference to a `vars` entry |\n| Default | `\"\"` | Terminal's default color |\n\n### 256-Color Palette\n\n- `0-15`: Basic ANSI colors (terminal-dependent)\n- `16-231`: 6×6×6 RGB cube (`16 + 36×R + 6×G + B` where R,G,B are 0-5)\n- `232-255`: Grayscale ramp\n\n### Terminal Compatibility\n\nPi uses 24-bit RGB colors. Most modern terminals support this (iTerm2, Kitty, WezTerm, Windows Terminal, VS Code). For older terminals with only 256-color support, pi falls back to the nearest approximation.\n\nCheck truecolor support:\n\n```bash\necho $COLORTERM  # Should output \"truecolor\" or \"24bit\"\n```\n\n## Tips\n\n**Dark terminals:** Use bright, saturated colors with higher contrast.\n\n**Light terminals:** Use darker, muted colors with lower contrast.\n\n**Color harmony:** Start with a base palette (Nord, Gruvbox, Tokyo Night), define it in `vars`, and reference consistently.\n\n**Testing:** Check your theme with different message types, tool states, markdown content, and long wrapped text.\n\n**VS Code:** Set `terminal.integrated.minimumContrastRatio` to `1` for accurate colors.\n\n## Examples\n\nSee the built-in themes:\n- [dark.json](../src/modes/interactive/theme/dark.json)\n- [light.json](../src/modes/interactive/theme/light.json)","sourceFile":"themes.md"},"tmux":{"title":"tmux Setup","markdown":"Pi works inside tmux, but tmux strips modifier information from certain keys by default. Without configuration, `Shift+Enter` and `Ctrl+Enter` are usually indistinguishable from plain `Enter`.\n\n## Recommended Configuration\n\nAdd to `~/.tmux.conf`:\n\n```tmux\nset -g extended-keys on\nset -g extended-keys-format csi-u\n```\n\nThen restart tmux fully:\n\n```bash\ntmux kill-server\ntmux\n```\n\nPi requests extended key reporting automatically when Kitty keyboard protocol is not available. With `extended-keys-format csi-u`, tmux forwards modified keys in CSI-u format, which is the most reliable configuration. The `extended-keys-format` option requires tmux 3.5 or later.\n\n## Why `csi-u` Is Recommended\n\nWith only:\n\n```tmux\nset -g extended-keys on\n```\n\ntmux defaults to `extended-keys-format xterm`. When an application requests extended key reporting, modified keys are forwarded in xterm `modifyOtherKeys` format such as:\n\n- `Ctrl+C` → `\\x1b[27;5;99~`\n- `Ctrl+D` → `\\x1b[27;5;100~`\n- `Ctrl+Enter` → `\\x1b[27;5;13~`\n\nWith `extended-keys-format csi-u`, the same keys are forwarded as:\n\n- `Ctrl+C` → `\\x1b[99;5u`\n- `Ctrl+D` → `\\x1b[100;5u`\n- `Ctrl+Enter` → `\\x1b[13;5u`\n\nPi supports both formats, but `csi-u` is the recommended tmux setup.\n\n## What This Fixes\n\nWithout tmux extended keys, modified Enter keys collapse to legacy sequences:\n\n| Key | Without extkeys | With `csi-u` |\n|-----|-----------------|--------------|\n| Enter | `\\r` | `\\r` |\n| Shift+Enter | `\\r` | `\\x1b[13;2u` |\n| Ctrl+Enter | `\\r` | `\\x1b[13;5u` |\n| Alt/Option+Enter | `\\x1b\\r` | `\\x1b[13;3u` |\n\nThis affects the default keybindings (`Enter` to submit, `Shift+Enter` for newline) and any custom keybindings using modified Enter.\n\n## Requirements\n\n- tmux 3.5 or later for `extended-keys-format csi-u` (run `tmux -V` to check)\n- A terminal emulator that supports extended keys (Ghostty, Kitty, iTerm2, WezTerm, Windows Terminal)\n\nWith tmux 3.2 through 3.4, omit `extended-keys-format csi-u`; Pi still supports tmux's default xterm `modifyOtherKeys` format.","sourceFile":"tmux.md"},"tui":{"title":"TUI Components","markdown":"> pi can create TUI components. Ask it to build one for your use case.\n\n\nExtensions and custom tools can render custom TUI components for interactive user interfaces. This page covers the component system and available building blocks.\n\n**Source:** [`@earendil-works/pi-tui`](https://github.com/earendil-works/pi-mono/tree/main/packages/tui)\n\n## Component Interface\n\nAll components implement:\n\n```typescript\ninterface Component {\n  render(width: number): string[];\n  handleInput?(data: string): void;\n  wantsKeyRelease?: boolean;\n  invalidate(): void;\n}\n```\n\n| Method | Description |\n|--------|-------------|\n| `render(width)` | Return array of strings (one per line). Each line **must not exceed `width`**. |\n| `handleInput?(data)` | Receive keyboard input when component has focus. |\n| `wantsKeyRelease?` | If true, component receives key release events (Kitty protocol). Default: false. |\n| `invalidate()` | Clear cached render state. Called on theme changes. |\n\nThe TUI appends a full SGR reset and OSC 8 reset at the end of each rendered line. Styles do not carry across lines. If you emit multi-line text with styling, reapply styles per line or use `wrapTextWithAnsi()` so styles are preserved for each wrapped line.\n\n## Focusable Interface (IME Support)\n\nComponents that display a text cursor and need IME (Input Method Editor) support should implement the `Focusable` interface:\n\n```typescript\nimport { CURSOR_MARKER, type Component, type Focusable } from \"@earendil-works/pi-tui\";\n\nclass MyInput implements Component, Focusable {\n  focused: boolean = false;  // Set by TUI when focus changes\n  \n  render(width: number): string[] {\n    const marker = this.focused ? CURSOR_MARKER : \"\";\n    // Emit marker right before the fake cursor\n    return [`> ${beforeCursor}${marker}\\x1b[7m${atCursor}\\x1b[27m${afterCursor}`];\n  }\n}\n```\n\nWhen a `Focusable` component has focus, TUI:\n1. Sets `focused = true` on the component\n2. Scans rendered output for `CURSOR_MARKER` (a zero-width APC escape sequence)\n3. Positions the hardware terminal cursor at that location\n4. Shows the hardware cursor only when `showHardwareCursor` is enabled\n\nThe cursor remains hidden by default. This keeps the fake cursor rendering, while still positioning the hardware cursor for terminals that track IME candidate windows with hidden cursors. Some terminals require a visible hardware cursor for IME positioning; enable it with `showHardwareCursor`, `setShowHardwareCursor(true)`, or `PI_HARDWARE_CURSOR=1`. The `Editor` and `Input` built-in components already implement this interface.\n\n### Container Components with Embedded Inputs\n\nWhen a container component (dialog, selector, etc.) contains an `Input` or `Editor` child, the container must implement `Focusable` and propagate the focus state to the child. Otherwise, the hardware cursor won't be positioned correctly for IME input.\n\n```typescript\nimport { Container, type Focusable, Input } from \"@earendil-works/pi-tui\";\n\nclass SearchDialog extends Container implements Focusable {\n  private searchInput: Input;\n\n  // Focusable implementation - propagate to child input for IME cursor positioning\n  private _focused = false;\n  get focused(): boolean {\n    return this._focused;\n  }\n  set focused(value: boolean) {\n    this._focused = value;\n    this.searchInput.focused = value;\n  }\n\n  constructor() {\n    super();\n    this.searchInput = new Input();\n    this.addChild(this.searchInput);\n  }\n}\n```\n\nWithout this propagation, typing with an IME (Chinese, Japanese, Korean, etc.) will show the candidate window in the wrong position on screen.\n\n## Using Components\n\n**In extensions** via `ctx.ui.custom()`:\n\n```typescript\npi.on(\"session_start\", async (_event, ctx) => {\n  const result = await ctx.ui.custom<string | null>((tui, theme, keybindings, done) =>\n    new MyComponent({\n      theme,\n      keybindings,\n      onChange: () => tui.requestRender(),\n      onSelect: (value) => done(value),\n      onCancel: () => done(null),\n    })\n  );\n});\n```\n\n**In custom tools** via `ctx.ui.custom()`:\n\n```typescript\nasync execute(toolCallId, params, signal, onUpdate, ctx) {\n  const result = await ctx.ui.custom<string | null>((tui, theme, keybindings, done) =>\n    new MyComponent({\n      theme,\n      keybindings,\n      onChange: () => tui.requestRender(),\n      onSelect: (value) => done(value),\n      onCancel: () => done(null),\n    })\n  );\n  // Use result...\n}\n```\n\n## Overlays\n\nOverlays render components on top of existing content without clearing the screen. Pass `{ overlay: true }` to `ctx.ui.custom()`:\n\n```typescript\nconst result = await ctx.ui.custom<string | null>(\n  (tui, theme, keybindings, done) => new MyDialog({ onClose: done }),\n  { overlay: true }\n);\n```\n\nFor positioning and sizing, use `overlayOptions`:\n\n```typescript\nconst result = await ctx.ui.custom<string | null>(\n  (tui, theme, keybindings, done) => new SidePanel({ onClose: done }),\n  {\n    overlay: true,\n    overlayOptions: {\n      // Size: number or percentage string\n      width: \"50%\",          // 50% of terminal width\n      minWidth: 40,          // minimum 40 columns\n      maxHeight: \"80%\",      // max 80% of terminal height\n\n      // Position: anchor-based (default: \"center\")\n      anchor: \"right-center\", // 9 positions: center, top-left, top-center, etc.\n      offsetX: -2,            // offset from anchor\n      offsetY: 0,\n\n      // Or percentage/absolute positioning\n      row: \"25%\",            // 25% from top\n      col: 10,               // column 10\n\n      // Margins\n      margin: 2,             // all sides, or { top, right, bottom, left }\n\n      // Responsive: hide on narrow terminals\n      visible: (termWidth, termHeight) => termWidth >= 80,\n    },\n    // Get handle for programmatic focus and visibility control\n    onHandle: (handle) => {\n      // handle.focus() - focus this overlay and bring it to the visual front\n      // handle.unfocus() - release input to normal fallback\n      // handle.unfocus({ target }) - release input to a specific component or null\n      // handle.setHidden(true/false) - toggle visibility\n      // handle.hide() - permanently remove\n    },\n  }\n);\n```\n\n### Overlay Focus\n\nA focused visible overlay keeps input ownership across temporary non-overlay UI. If an overlay opens another `ctx.ui.custom()` component without `{ overlay: true }`, that replacement UI receives input while it is active; when it closes, the focused overlay can reclaim input.\n\nUse `handle.unfocus()` when a visible overlay should stop owning input and let TUI fall back to another visible capturing overlay or the previous focus target. Use `handle.unfocus({ target })` when a specific component should receive input while the overlay stays visible. Passing `{ target: null }` intentionally leaves no focused component until focus is set again.\n\n### Overlay Lifecycle\n\nOverlay components are disposed when closed. Don't reuse references - create fresh instances:\n\n```typescript\n// Wrong - stale reference\nlet menu: MenuComponent;\nawait ctx.ui.custom((_, __, ___, done) => {\n  menu = new MenuComponent(done);\n  return menu;\n}, { overlay: true });\nsetActiveComponent(menu);  // Disposed\n\n// Correct - re-call to re-show\nconst showMenu = () => ctx.ui.custom((_, __, ___, done) => \n  new MenuComponent(done), { overlay: true });\n\nawait showMenu();  // First show\nawait showMenu();  // \"Back\" = just call again\n```\n\nSee [overlay-qa-tests.ts](../examples/extensions/overlay-qa-tests.ts) for comprehensive examples covering anchors, margins, stacking, responsive visibility, and animation.\n\n## Built-in Components\n\nImport from `@earendil-works/pi-tui`:\n\n```typescript\nimport { Text, Box, Container, Spacer, Markdown } from \"@earendil-works/pi-tui\";\n```\n\n### Text\n\nMulti-line text with word wrapping.\n\n```typescript\nconst text = new Text(\n  \"Hello World\",    // content\n  1,                // paddingX (default: 1)\n  1,                // paddingY (default: 1)\n  (s) => bgGray(s)  // optional background function\n);\ntext.setText(\"Updated\");\n```\n\n### Box\n\nContainer with padding and background color.\n\n```typescript\nconst box = new Box(\n  1,                // paddingX\n  1,                // paddingY\n  (s) => bgGray(s)  // background function\n);\nbox.addChild(new Text(\"Content\", 0, 0));\nbox.setBgFn((s) => bgBlue(s));\n```\n\n### Container\n\nGroups child components vertically.\n\n```typescript\nconst container = new Container();\ncontainer.addChild(component1);\ncontainer.addChild(component2);\ncontainer.removeChild(component1);\n```\n\n### Spacer\n\nEmpty vertical space.\n\n```typescript\nconst spacer = new Spacer(2);  // 2 empty lines\n```\n\n### Markdown\n\nRenders markdown with syntax highlighting.\n\n```typescript\nconst md = new Markdown(\n  \"# Title\\n\\nSome **bold** text\",\n  1,        // paddingX\n  1,        // paddingY\n  theme     // MarkdownTheme (see below)\n);\nmd.setText(\"Updated markdown\");\n```\n\n### Image\n\nRenders images in supported terminals (Kitty, iTerm2, Ghostty, WezTerm, Warp).\n\n```typescript\nconst image = new Image(\n  base64Data,   // base64-encoded image\n  \"image/png\",  // MIME type\n  theme,        // ImageTheme\n  { maxWidthCells: 80, maxHeightCells: 24 }\n);\n```\n\n## Keyboard Input\n\nUse `matchesKey()` for key detection:\n\n```typescript\nimport { matchesKey, Key } from \"@earendil-works/pi-tui\";\n\nhandleInput(data: string) {\n  if (matchesKey(data, Key.up)) {\n    this.selectedIndex--;\n  } else if (matchesKey(data, Key.enter)) {\n    this.onSelect?.(this.selectedIndex);\n  } else if (matchesKey(data, Key.escape)) {\n    this.onCancel?.();\n  } else if (matchesKey(data, Key.ctrl(\"c\"))) {\n    // Ctrl+C\n  }\n}\n```\n\n**Key identifiers** (use `Key.*` for autocomplete, or string literals):\n- Basic keys: `Key.enter`, `Key.escape`, `Key.tab`, `Key.space`, `Key.backspace`, `Key.delete`, `Key.home`, `Key.end`\n- Arrow keys: `Key.up`, `Key.down`, `Key.left`, `Key.right`\n- With modifiers: `Key.ctrl(\"c\")`, `Key.shift(\"tab\")`, `Key.alt(\"left\")`, `Key.ctrlShift(\"p\")`\n- String format also works: `\"enter\"`, `\"ctrl+c\"`, `\"shift+tab\"`, `\"ctrl+shift+p\"`\n\n## Line Width\n\n**Critical:** Each line from `render()` must not exceed the `width` parameter.\n\n```typescript\nimport { visibleWidth, truncateToWidth } from \"@earendil-works/pi-tui\";\n\nrender(width: number): string[] {\n  // Truncate long lines\n  return [truncateToWidth(this.text, width)];\n}\n```\n\nUtilities:\n- `visibleWidth(str)` - Get display width (ignores ANSI codes)\n- `truncateToWidth(str, width, ellipsis?)` - Truncate with optional ellipsis\n- `wrapTextWithAnsi(str, width)` - Word wrap preserving ANSI codes\n\n## Creating Custom Components\n\nExample: Interactive selector\n\n```typescript\nimport {\n  matchesKey, Key,\n  truncateToWidth, visibleWidth\n} from \"@earendil-works/pi-tui\";\n\nclass MySelector {\n  private items: string[];\n  private selected = 0;\n  private cachedWidth?: number;\n  private cachedLines?: string[];\n  \n  public onSelect?: (item: string) => void;\n  public onCancel?: () => void;\n\n  constructor(items: string[]) {\n    this.items = items;\n  }\n\n  handleInput(data: string): void {\n    if (matchesKey(data, Key.up) && this.selected > 0) {\n      this.selected--;\n      this.invalidate();\n    } else if (matchesKey(data, Key.down) && this.selected < this.items.length - 1) {\n      this.selected++;\n      this.invalidate();\n    } else if (matchesKey(data, Key.enter)) {\n      this.onSelect?.(this.items[this.selected]);\n    } else if (matchesKey(data, Key.escape)) {\n      this.onCancel?.();\n    }\n  }\n\n  render(width: number): string[] {\n    if (this.cachedLines && this.cachedWidth === width) {\n      return this.cachedLines;\n    }\n\n    this.cachedLines = this.items.map((item, i) => {\n      const prefix = i === this.selected ? \"> \" : \"  \";\n      return truncateToWidth(prefix + item, width);\n    });\n    this.cachedWidth = width;\n    return this.cachedLines;\n  }\n\n  invalidate(): void {\n    this.cachedWidth = undefined;\n    this.cachedLines = undefined;\n  }\n}\n```\n\nUsage in an extension:\n\n```typescript\npi.registerCommand(\"pick\", {\n  description: \"Pick an item\",\n  handler: async (_args, ctx) => {\n    const items = [\"Option A\", \"Option B\", \"Option C\"];\n    const selected = await ctx.ui.custom<string | null>((tui, _theme, _keybindings, done) => {\n      const selector = new MySelector(items);\n      selector.onSelect = done;\n      selector.onCancel = () => done(null);\n\n      return {\n        render: (width) => selector.render(width),\n        handleInput: (data) => {\n          selector.handleInput(data);\n          tui.requestRender();\n        },\n        invalidate: () => selector.invalidate(),\n      };\n    });\n\n    if (selected !== null) {\n      ctx.ui.notify(`Selected: ${selected}`, \"info\");\n    }\n  }\n});\n```\n\n## Theming\n\nComponents accept theme objects for styling.\n\n**In `renderCall`/`renderResult`**, use the `theme` parameter:\n\n```typescript\nrenderResult(result, options, theme, context) {\n  // Use theme.fg() for foreground colors\n  return new Text(theme.fg(\"success\", \"Done!\"), 0, 0);\n  \n  // Use theme.bg() for background colors\n  const styled = theme.bg(\"toolPendingBg\", theme.fg(\"accent\", \"text\"));\n}\n```\n\n**Foreground colors** (`theme.fg(color, text)`):\n\n| Category | Colors |\n|----------|--------|\n| General | `text`, `accent`, `muted`, `dim` |\n| Status | `success`, `error`, `warning` |\n| Borders | `border`, `borderAccent`, `borderMuted` |\n| Messages | `userMessageText`, `customMessageText`, `customMessageLabel` |\n| Tools | `toolTitle`, `toolOutput` |\n| Diffs | `toolDiffAdded`, `toolDiffRemoved`, `toolDiffContext` |\n| Markdown | `mdHeading`, `mdLink`, `mdLinkUrl`, `mdCode`, `mdCodeBlock`, `mdCodeBlockBorder`, `mdQuote`, `mdQuoteBorder`, `mdHr`, `mdListBullet` |\n| Syntax | `syntaxComment`, `syntaxKeyword`, `syntaxFunction`, `syntaxVariable`, `syntaxString`, `syntaxNumber`, `syntaxType`, `syntaxOperator`, `syntaxPunctuation` |\n| Thinking | `thinkingOff`, `thinkingMinimal`, `thinkingLow`, `thinkingMedium`, `thinkingHigh`, `thinkingXhigh`, `thinkingMax` |\n| Modes | `bashMode` |\n\n**Background colors** (`theme.bg(color, text)`):\n\n`selectedBg`, `userMessageBg`, `customMessageBg`, `toolPendingBg`, `toolSuccessBg`, `toolErrorBg`\n\n**For Markdown**, use `getMarkdownTheme()`:\n\n```typescript\nimport { getMarkdownTheme } from \"@earendil-works/pi-coding-agent\";\nimport { Markdown } from \"@earendil-works/pi-tui\";\n\nrenderResult(result, options, theme, context) {\n  const mdTheme = getMarkdownTheme();\n  return new Markdown(result.details.markdown, 0, 0, mdTheme);\n}\n```\n\n**For custom components**, define your own theme interface:\n\n```typescript\ninterface MyTheme {\n  selected: (s: string) => string;\n  normal: (s: string) => string;\n}\n```\n\n## Debug logging\n\nSet `PI_TUI_WRITE_LOG` to capture the raw ANSI stream written to stdout.\n\n```bash\nPI_TUI_WRITE_LOG=/tmp/tui-ansi.log npx tsx packages/tui/test/chat-simple.ts\n```\n\n## Performance\n\nCache rendered output when possible:\n\n```typescript\nclass CachedComponent {\n  private cachedWidth?: number;\n  private cachedLines?: string[];\n\n  render(width: number): string[] {\n    if (this.cachedLines && this.cachedWidth === width) {\n      return this.cachedLines;\n    }\n    // ... compute lines ...\n    this.cachedWidth = width;\n    this.cachedLines = lines;\n    return lines;\n  }\n\n  invalidate(): void {\n    this.cachedWidth = undefined;\n    this.cachedLines = undefined;\n  }\n}\n```\n\nCall `invalidate()` when state changes, then use the injected `tui.requestRender()` to trigger re-render.\n\n## Invalidation and Theme Changes\n\nWhen the theme changes, the TUI calls `invalidate()` on all components to clear their caches. Components must properly implement `invalidate()` to ensure theme changes take effect.\n\n### The Problem\n\nIf a component pre-bakes theme colors into strings (via `theme.fg()`, `theme.bg()`, etc.) and caches them, the cached strings contain ANSI escape codes from the old theme. Simply clearing the render cache isn't enough if the component stores the themed content separately.\n\n**Wrong approach** (theme colors won't update):\n\n```typescript\nclass BadComponent extends Container {\n  private content: Text;\n\n  constructor(message: string, theme: Theme) {\n    super();\n    // Pre-baked theme colors stored in Text component\n    this.content = new Text(theme.fg(\"accent\", message), 1, 0);\n    this.addChild(this.content);\n  }\n  // No invalidate override - parent's invalidate only clears\n  // child render caches, not the pre-baked content\n}\n```\n\n### The Solution\n\nComponents that build content with theme colors must rebuild that content when `invalidate()` is called:\n\n```typescript\nclass GoodComponent extends Container {\n  private message: string;\n  private content: Text;\n\n  constructor(message: string) {\n    super();\n    this.message = message;\n    this.content = new Text(\"\", 1, 0);\n    this.addChild(this.content);\n    this.updateDisplay();\n  }\n\n  private updateDisplay(): void {\n    // Rebuild content with current theme\n    this.content.setText(theme.fg(\"accent\", this.message));\n  }\n\n  override invalidate(): void {\n    super.invalidate();  // Clear child caches\n    this.updateDisplay(); // Rebuild with new theme\n  }\n}\n```\n\n### Pattern: Rebuild on Invalidate\n\nFor components with complex content:\n\n```typescript\nclass ComplexComponent extends Container {\n  private data: SomeData;\n\n  constructor(data: SomeData) {\n    super();\n    this.data = data;\n    this.rebuild();\n  }\n\n  private rebuild(): void {\n    this.clear();  // Remove all children\n\n    // Build UI with current theme\n    this.addChild(new Text(theme.fg(\"accent\", theme.bold(\"Title\")), 1, 0));\n    this.addChild(new Spacer(1));\n\n    for (const item of this.data.items) {\n      const color = item.active ? \"success\" : \"muted\";\n      this.addChild(new Text(theme.fg(color, item.label), 1, 0));\n    }\n  }\n\n  override invalidate(): void {\n    super.invalidate();\n    this.rebuild();\n  }\n}\n```\n\n### When This Matters\n\nThis pattern is needed when:\n\n1. **Pre-baking theme colors** - Using `theme.fg()` or `theme.bg()` to create styled strings stored in child components\n2. **Syntax highlighting** - Using `highlightCode()` which applies theme-based syntax colors\n3. **Complex layouts** - Building child component trees that embed theme colors\n\nThis pattern is NOT needed when:\n\n1. **Using theme callbacks** - Passing functions like `(text) => theme.fg(\"accent\", text)` that are called during render\n2. **Simple containers** - Just grouping other components without adding themed content\n3. **Stateless render** - Computing themed output fresh in every `render()` call (no caching)\n\n## Common Patterns\n\nThese patterns cover the most common UI needs in extensions. **Copy these patterns instead of building from scratch.**\n\n### Pattern 1: Selection Dialog (SelectList)\n\nFor letting users pick from a list of options. Use `SelectList` from `@earendil-works/pi-tui` with `DynamicBorder` for framing.\n\n```typescript\nimport type { ExtensionAPI } from \"@earendil-works/pi-coding-agent\";\nimport { DynamicBorder } from \"@earendil-works/pi-coding-agent\";\nimport { Container, type SelectItem, SelectList, Text } from \"@earendil-works/pi-tui\";\n\npi.registerCommand(\"pick\", {\n  handler: async (_args, ctx) => {\n    const items: SelectItem[] = [\n      { value: \"opt1\", label: \"Option 1\", description: \"First option\" },\n      { value: \"opt2\", label: \"Option 2\", description: \"Second option\" },\n      { value: \"opt3\", label: \"Option 3\" },  // description is optional\n    ];\n\n    const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {\n      const container = new Container();\n\n      // Top border\n      container.addChild(new DynamicBorder((s: string) => theme.fg(\"accent\", s)));\n\n      // Title\n      container.addChild(new Text(theme.fg(\"accent\", theme.bold(\"Pick an Option\")), 1, 0));\n\n      // SelectList with theme\n      const selectList = new SelectList(items, Math.min(items.length, 10), {\n        selectedPrefix: (t) => theme.fg(\"accent\", t),\n        selectedText: (t) => theme.fg(\"accent\", t),\n        description: (t) => theme.fg(\"muted\", t),\n        scrollInfo: (t) => theme.fg(\"dim\", t),\n        noMatch: (t) => theme.fg(\"warning\", t),\n      });\n      selectList.onSelect = (item) => done(item.value);\n      selectList.onCancel = () => done(null);\n      container.addChild(selectList);\n\n      // Help text\n      container.addChild(new Text(theme.fg(\"dim\", \"↑↓ navigate • enter select • esc cancel\"), 1, 0));\n\n      // Bottom border\n      container.addChild(new DynamicBorder((s: string) => theme.fg(\"accent\", s)));\n\n      return {\n        render: (w) => container.render(w),\n        invalidate: () => container.invalidate(),\n        handleInput: (data) => { selectList.handleInput(data); tui.requestRender(); },\n      };\n    });\n\n    if (result) {\n      ctx.ui.notify(`Selected: ${result}`, \"info\");\n    }\n  },\n});\n```\n\n**Examples:** [preset.ts](../examples/extensions/preset.ts), [tools.ts](../examples/extensions/tools.ts)\n\n### Pattern 2: Async Operation with Cancel (BorderedLoader)\n\nFor operations that take time and should be cancellable. `BorderedLoader` shows a spinner and handles escape to cancel.\n\n```typescript\nimport { BorderedLoader } from \"@earendil-works/pi-coding-agent\";\n\npi.registerCommand(\"fetch\", {\n  handler: async (_args, ctx) => {\n    const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {\n      const loader = new BorderedLoader(tui, theme, \"Fetching data...\");\n      loader.onAbort = () => done(null);\n\n      // Do async work\n      fetchData(loader.signal)\n        .then((data) => done(data))\n        .catch(() => done(null));\n\n      return loader;\n    });\n\n    if (result === null) {\n      ctx.ui.notify(\"Cancelled\", \"info\");\n    } else {\n      ctx.ui.setEditorText(result);\n    }\n  },\n});\n```\n\n**Examples:** [qna.ts](../examples/extensions/qna.ts), [handoff.ts](../examples/extensions/handoff.ts)\n\n### Pattern 3: Settings/Toggles (SettingsList)\n\nFor toggling multiple settings. Use `SettingsList` from `@earendil-works/pi-tui` with `getSettingsListTheme()`.\n\n```typescript\nimport { getSettingsListTheme } from \"@earendil-works/pi-coding-agent\";\nimport { Container, type SettingItem, SettingsList, Text } from \"@earendil-works/pi-tui\";\n\npi.registerCommand(\"settings\", {\n  handler: async (_args, ctx) => {\n    const items: SettingItem[] = [\n      { id: \"verbose\", label: \"Verbose mode\", currentValue: \"off\", values: [\"on\", \"off\"] },\n      { id: \"color\", label: \"Color output\", currentValue: \"on\", values: [\"on\", \"off\"] },\n    ];\n\n    await ctx.ui.custom((_tui, theme, _kb, done) => {\n      const container = new Container();\n      container.addChild(new Text(theme.fg(\"accent\", theme.bold(\"Settings\")), 1, 1));\n\n      const settingsList = new SettingsList(\n        items,\n        Math.min(items.length + 2, 15),\n        getSettingsListTheme(),\n        (id, newValue) => {\n          // Handle value change\n          ctx.ui.notify(`${id} = ${newValue}`, \"info\");\n        },\n        () => done(undefined),  // On close\n        { enableSearch: true }, // Optional: enable fuzzy search by label\n      );\n      container.addChild(settingsList);\n\n      return {\n        render: (w) => container.render(w),\n        invalidate: () => container.invalidate(),\n        handleInput: (data) => settingsList.handleInput?.(data),\n      };\n    });\n  },\n});\n```\n\n**Examples:** [tools.ts](../examples/extensions/tools.ts)\n\n### Pattern 4: Persistent Status Indicator\n\nShow status in the footer that persists across renders. Good for mode indicators.\n\n```typescript\n// Set status (shown in footer)\nctx.ui.setStatus(\"my-ext\", ctx.ui.theme.fg(\"accent\", \"● active\"));\n\n// Clear status\nctx.ui.setStatus(\"my-ext\", undefined);\n```\n\n**Examples:** [status-line.ts](../examples/extensions/status-line.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts)\n\n### Pattern 4b: Working Indicator Customization\n\nCustomize the inline working indicator shown while pi is streaming a response.\n\n```typescript\n// Static indicator\nctx.ui.setWorkingIndicator({ frames: [ctx.ui.theme.fg(\"accent\", \"●\")] });\n\n// Custom animated indicator\nctx.ui.setWorkingIndicator({\n  frames: [\n    ctx.ui.theme.fg(\"dim\", \"·\"),\n    ctx.ui.theme.fg(\"muted\", \"•\"),\n    ctx.ui.theme.fg(\"accent\", \"●\"),\n    ctx.ui.theme.fg(\"muted\", \"•\"),\n  ],\n  intervalMs: 120,\n});\n\n// Hide the indicator entirely\nctx.ui.setWorkingIndicator({ frames: [] });\n\n// Restore pi's default spinner\nctx.ui.setWorkingIndicator();\n```\n\nThis only affects the normal streaming working indicator. Compaction and retry loaders keep their built-in styling. Custom frames are rendered verbatim, so extensions must add their own colors when needed.\n\n**Examples:** [working-indicator.ts](../examples/extensions/working-indicator.ts)\n\n### Pattern 5: Widgets Above/Below Editor\n\nShow persistent content above or below the input editor. Good for todo lists, progress.\n\n```typescript\n// Simple string array (above editor by default)\nctx.ui.setWidget(\"my-widget\", [\"Line 1\", \"Line 2\"]);\n\n// Render below the editor\nctx.ui.setWidget(\"my-widget\", [\"Line 1\", \"Line 2\"], { placement: \"belowEditor\" });\n\n// Or with theme\nctx.ui.setWidget(\"my-widget\", (_tui, theme) => {\n  const lines = items.map((item, i) =>\n    item.done\n      ? theme.fg(\"success\", \"✓ \") + theme.fg(\"muted\", item.text)\n      : theme.fg(\"dim\", \"○ \") + item.text\n  );\n  return {\n    render: () => lines,\n    invalidate: () => {},\n  };\n});\n\n// Clear\nctx.ui.setWidget(\"my-widget\", undefined);\n```\n\n**Examples:** [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts)\n\n### Pattern 6: Custom Footer\n\nReplace the footer. `footerData` exposes data not otherwise accessible to extensions.\n\n```typescript\nctx.ui.setFooter((tui, theme, footerData) => ({\n  invalidate() {},\n  render(width: number): string[] {\n    // footerData.getGitBranch(): string | null\n    // footerData.getExtensionStatuses(): ReadonlyMap<string, string>\n    return [`${ctx.model?.id} (${footerData.getGitBranch() || \"no git\"})`];\n  },\n  dispose: footerData.onBranchChange(() => tui.requestRender()), // reactive\n}));\n\nctx.ui.setFooter(undefined); // restore default\n```\n\nToken stats available via `ctx.sessionManager.getBranch()` and `ctx.model`.\n\n**Examples:** [custom-footer.ts](../examples/extensions/custom-footer.ts)\n\n### Pattern 7: Custom Editor (vim mode, etc.)\n\nReplace the main input editor with a custom implementation. Useful for modal editing (vim), different keybindings (emacs), or specialized input handling.\n\n```typescript\nimport { CustomEditor, type ExtensionAPI } from \"@earendil-works/pi-coding-agent\";\nimport { matchesKey, truncateToWidth } from \"@earendil-works/pi-tui\";\n\ntype Mode = \"normal\" | \"insert\";\n\nclass VimEditor extends CustomEditor {\n  private mode: Mode = \"insert\";\n\n  handleInput(data: string): void {\n    // Escape: switch to normal mode, or pass through for app handling\n    if (matchesKey(data, \"escape\")) {\n      if (this.mode === \"insert\") {\n        this.mode = \"normal\";\n        return;\n      }\n      // In normal mode, escape aborts agent (handled by CustomEditor)\n      super.handleInput(data);\n      return;\n    }\n\n    // Insert mode: pass everything to CustomEditor\n    if (this.mode === \"insert\") {\n      super.handleInput(data);\n      return;\n    }\n\n    // Normal mode: vim-style navigation\n    switch (data) {\n      case \"i\": this.mode = \"insert\"; return;\n      case \"h\": super.handleInput(\"\\x1b[D\"); return; // Left\n      case \"j\": super.handleInput(\"\\x1b[B\"); return; // Down\n      case \"k\": super.handleInput(\"\\x1b[A\"); return; // Up\n      case \"l\": super.handleInput(\"\\x1b[C\"); return; // Right\n    }\n    // Pass unhandled keys to super (ctrl+c, etc.), but filter printable chars\n    if (data.length === 1 && data.charCodeAt(0) >= 32) return;\n    super.handleInput(data);\n  }\n\n  render(width: number): string[] {\n    const lines = super.render(width);\n    // Add mode indicator to bottom border (use truncateToWidth for ANSI-safe truncation)\n    if (lines.length > 0) {\n      const label = this.mode === \"normal\" ? \" NORMAL \" : \" INSERT \";\n      const lastLine = lines[lines.length - 1]!;\n      // Pass \"\" as ellipsis to avoid adding \"...\" when truncating\n      lines[lines.length - 1] = truncateToWidth(lastLine, width - label.length, \"\") + label;\n    }\n    return lines;\n  }\n}\n\nexport default function (pi: ExtensionAPI) {\n  pi.on(\"session_start\", (_event, ctx) => {\n    // Factory receives the TUI, theme, and keybindings from the app\n    ctx.ui.setEditorComponent((tui, theme, keybindings) =>\n      new VimEditor(tui, theme, keybindings)\n    );\n  });\n}\n```\n\n**Key points:**\n\n- **Extend `CustomEditor`** (not base `Editor`) to get app keybindings (escape to abort, ctrl+d to exit, model switching, etc.)\n- **Call `super.handleInput(data)`** for keys you don't handle\n- **Factory pattern**: `setEditorComponent` receives a factory function that gets `tui`, `theme`, and `keybindings`\n- **Pass `undefined`** to restore the default editor: `ctx.ui.setEditorComponent(undefined)`\n\n**Examples:** [modal-editor.ts](../examples/extensions/modal-editor.ts)\n\n## Key Rules\n\n1. **Always use theme from callback** - Don't import theme directly. Use `theme` from the `ctx.ui.custom((tui, theme, keybindings, done) => ...)` callback.\n\n2. **Always type DynamicBorder color param** - Write `(s: string) => theme.fg(\"accent\", s)`, not `(s) => theme.fg(\"accent\", s)`.\n\n3. **Call tui.requestRender() after state changes** - In `handleInput`, call `tui.requestRender()` after updating state.\n\n4. **Return the three-method object** - Custom components need `{ render, invalidate, handleInput }`.\n\n5. **Use existing components** - `SelectList`, `SettingsList`, `BorderedLoader` cover 90% of cases. Don't rebuild them.\n\n## Examples\n\n- **Selection UI**: [examples/extensions/preset.ts](../examples/extensions/preset.ts) - SelectList with DynamicBorder framing\n- **Async with cancel**: [examples/extensions/qna.ts](../examples/extensions/qna.ts) - BorderedLoader for LLM calls\n- **Settings toggles**: [examples/extensions/tools.ts](../examples/extensions/tools.ts) - SettingsList for tool enable/disable\n- **Status indicators**: [examples/extensions/plan-mode/index.ts](../examples/extensions/plan-mode/index.ts) - setStatus and setWidget\n- **Working indicator**: [examples/extensions/working-indicator.ts](../examples/extensions/working-indicator.ts) - setWorkingIndicator\n- **Custom footer**: [examples/extensions/custom-footer.ts](../examples/extensions/custom-footer.ts) - setFooter with stats\n- **Custom editor**: [examples/extensions/modal-editor.ts](../examples/extensions/modal-editor.ts) - Vim-like modal editing\n- **Snake game**: [examples/extensions/snake.ts](../examples/extensions/snake.ts) - Full game with keyboard input, game loop\n- **Custom tool rendering**: [examples/extensions/todo.ts](../examples/extensions/todo.ts) - renderCall and renderResult","sourceFile":"tui.md"},"usage":{"title":"Using Pi","markdown":"This page collects day-to-day usage details that do not fit on the quickstart page.\n\n## Interactive Mode\n\n<p align=\"center\"><img src=\"images/interactive-mode.png\" alt=\"Interactive Mode\" width=\"600\"></p>\n\nThe interface has four main areas:\n\n- **Startup header** - shortcuts, loaded context files, prompt templates, skills, and extensions\n- **Messages** - user messages, assistant responses, tool calls, tool results, notifications, errors, and extension UI\n- **Editor** - where you type; border color indicates the current thinking level\n- **Footer** - working directory, session name, token/cache usage, cost, context usage, and current model. Totals include assistant responses, usage reported by tools, and summary generation.\n\nThe editor can be replaced temporarily by built-in UI such as `/settings` or by custom extension UI.\n\n### Editor Features\n\n| Feature | How |\n|---------|-----|\n| File reference | Type `@` to fuzzy-search project files |\n| Path completion | Press Tab to complete paths |\n| Multi-line input | Shift+Enter, or Ctrl+Enter on Windows Terminal |\n| Copy response | Ctrl+X copies the last assistant message; in `/tree`, it copies the selected message |\n| Images | Paste with Ctrl+V, Alt+V on Windows, or drag into the terminal |\n| Shell command | `!command` runs and sends output to the model |\n| Hidden shell command | `!!command` runs without sending output to the model |\n| External editor | Ctrl+G opens `externalEditor`, `$VISUAL`, `$EDITOR`, Notepad on Windows, or `nano` elsewhere |\n\nSee [Keybindings](keybindings.md) for all shortcuts and customization.\n\n## Slash Commands\n\nType `/` in the editor to open command completion. Extensions can register custom commands, skills are available as `/skill:name`, and prompt templates expand via `/templatename`.\n\n| Command | Description |\n|---------|-------------|\n| `/login`, `/logout` | Manage OAuth or API-key credentials |\n| [`/llama`](llama-cpp.md) | Download, load, and unload llama.cpp router models |\n| `/model` | Switch models |\n| `/scoped-models` | Enable/disable models for Ctrl+P cycling |\n| `/settings` | Thinking level, theme, message delivery, transport |\n| `/resume` | Pick from previous sessions |\n| `/new` | Start a new session |\n| `/name <name>` | Set session display name |\n| `/session` | Show session file, ID, messages, tokens, and cost |\n| `/tree` | Jump to any point in the session and continue from there |\n| `/trust` | Save project trust decision for future sessions |\n| `/fork` | Create a new session from a previous user message |\n| `/clone` | Duplicate the current active branch into a new session |\n| `/compact [prompt]` | Manually compact context, optionally with custom instructions |\n| `/copy` | Copy last assistant message to clipboard |\n| `/export [file]` | Export session to HTML or JSONL |\n| `/import <file>` | Import and resume a session from a JSONL file |\n| `/share` | Upload as private GitHub gist with shareable HTML link |\n| `/reload` | Reload keybindings, extensions, skills, prompts, themes, and context files |\n| `/hotkeys` | Show all keyboard shortcuts |\n| `/changelog` | Display version history |\n| `/quit` | Quit pi |\n\n## Message Queue\n\nYou can submit messages while the agent is still working:\n\n- **Enter** queues a steering message, delivered after the current assistant turn finishes executing its tool calls.\n- **Alt+Enter** queues a follow-up message, delivered after the agent finishes all work.\n- **Escape** aborts and restores queued messages to the editor.\n- **Alt+Up** retrieves queued messages back to the editor.\n\nOn Windows Terminal, Alt+Enter is fullscreen by default. Remap it as described in [Terminal setup](terminal-setup.md) if you want pi to receive the shortcut.\n\nConfigure delivery in [Settings](settings.md) with `steeringMode` and `followUpMode`.\n\n## Sessions\n\nSessions are saved automatically to `~/.pi/agent/sessions/`, organized by working directory.\n\n```bash\npi -c                  # Continue most recent session\npi -r                  # Browse and select a session\npi --no-session        # Ephemeral mode; do not save\npi --name \"my task\"    # Set session display name at startup\npi --session <path|id> # Use a specific session file or session ID\npi --fork <path|id>    # Fork a session into a new session file\n```\n\nUseful session commands:\n\n- `/session` shows the current session file and ID.\n- `/tree` navigates the in-file session tree and can summarize abandoned branches.\n- `/fork` creates a new session from an earlier user message.\n- `/clone` duplicates the current active branch into a new session file.\n- `/compact` summarizes older messages to free context.\n\nSee [Sessions](sessions.md) and [Compaction](compaction.md) for details.\n\n## Context Files\n\nPi loads `AGENTS.md` or `CLAUDE.md` at startup from:\n\n- `~/.pi/agent/AGENTS.md` for global instructions\n- parent directories, walking up from the current working directory\n- the current directory\n\nIf a directory contains `AGENTS.override.md`, Pi loads it instead of `AGENTS.md` or `CLAUDE.md` from that directory. Context files from other directories still layer normally.\n\nUse context files for project conventions, commands, safety rules, and preferences. Disable loading with `--no-context-files` or `-nc`.\n\n### System Prompt Files\n\nReplace the default system prompt with:\n\n- `.pi/SYSTEM.md` for a project\n- `~/.pi/agent/SYSTEM.md` globally\n\nAppend to the default prompt without replacing it with `APPEND_SYSTEM.md` in either location.\n\n### Project Trust\n\nOn interactive startup, pi asks before trusting a project folder that contains project-local settings, resources, or project `.agents/skills` and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions.\n\nBefore the trust decision, pi loads only context files, user/global extensions, and CLI `-e` extensions so they can handle the `project_trust` event. Project-local extensions, project package-managed extensions, and project settings are loaded only after the project is trusted. This split also applies when switching to a session from a different cwd whose trust has not been resolved in the current process.\n\nNon-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore those project resources, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run.\n\nIf no extension or saved decision applies, `defaultProjectTrust` controls the fallback behavior. Set it to `\"ask\"`, `\"always\"`, or `\"never\"` in `~/.pi/agent/settings.json`, or change it with `/settings`.\n\n`pi config` and package commands use the same project trust flow, except `pi update` never prompts. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them.\n\nUse `/trust` in interactive mode to save a project trust decision for future sessions, including trust for the immediate parent folder. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect.\n\n\n## Exporting and Sharing Sessions\n\nUse `/export [file]` to write a session to HTML.\n\nUse `/share` to upload a private GitHub gist with a shareable HTML link.\n\nIf you use pi for open source work and want to publish sessions for model, prompt, tool, and evaluation research, see [`badlogic/pi-share-hf`](https://github.com/badlogic/pi-share-hf). It publishes sessions to Hugging Face datasets.\n\n## CLI Reference\n\n```bash\npi [options] [@files...] [messages...]\n```\n\n### Package Commands\n\n```bash\npi install <source> [-l]     # Install package, -l for project-local\npi remove <source> [-l]      # Remove package\npi uninstall <source> [-l]   # Alias for remove\npi update [source|self|pi]   # Update pi only, or one package source\npi update --all              # Update pi and packages; reconcile pinned git refs\npi update --extensions       # Update packages only; reconcile pinned git refs\npi update --models           # Refresh model catalogs only\npi update --self             # Update pi only\npi update --extension <src>  # Update one package\npi list                      # List installed packages\npi config                    # Enable/disable package resources\n```\n\nThese commands manage pi packages and `pi update` can update the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). `pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command. `pi update` never prompts for project trust.\n\nSee [Pi Packages](packages.md) for package sources and security notes.\n\n### Modes\n\n| Flag | Description |\n|------|-------------|\n| default | Interactive mode |\n| `-p`, `--print` | Print response and exit |\n| `--mode json` | Output all events as JSON lines; see [JSON mode](json.md) |\n| `--mode rpc` | RPC mode over stdin/stdout; see [RPC mode](rpc.md) |\n| `--export <in> [out]` | Export a session to HTML |\n\nIn print mode, pi also reads piped stdin and merges it into the initial prompt:\n\n```bash\ncat README.md | pi -p \"Summarize this text\"\n```\n\n### Model Options\n\n| Option | Description |\n|--------|-------------|\n| `--provider <name>` | Provider, such as `anthropic`, `openai`, or `google` |\n| `--model <pattern>` | Model pattern or ID; supports `provider/id` and optional `:<thinking>` |\n| `--api-key <key>` | API key, overriding environment variables |\n| `--thinking <level>` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max` |\n| `--models <patterns>` | Comma-separated patterns for Ctrl+P cycling |\n| `--list-models [search]` | List available models |\n\n### Session Options\n\n| Option | Description |\n|--------|-------------|\n| `-c`, `--continue` | Continue the most recent session |\n| `-r`, `--resume` | Browse and select a session |\n| `--session <path\\|id>` | Use a specific session file or partial UUID |\n| `--fork <path\\|id>` | Fork a session file or partial UUID into a new session |\n| `--session-dir <dir>` | Custom session storage directory |\n| `--no-session` | Ephemeral mode; do not save |\n| `--name <name>`, `-n <name>` | Set session display name at startup |\n\n### Tool Options\n\n| Option | Description |\n|--------|-------------|\n| `--tools <list>`, `-t <list>` | Allowlist specific built-in, extension, and custom tools |\n| `--exclude-tools <list>`, `-xt <list>` | Disable specific built-in, extension, and custom tools |\n| `--no-builtin-tools`, `-nbt` | Disable built-in tools but keep extension/custom tools enabled |\n| `--no-tools`, `-nt` | Disable all tools |\n\nBuilt-in tools: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`.\n\n### Resource Options\n\n| Option | Description |\n|--------|-------------|\n| `-e`, `--extension <source>` | Load an extension from path, npm, or git; repeatable |\n| `--no-extensions` | Disable extension discovery |\n| `--skill <path>` | Load a skill; repeatable |\n| `--no-skills` | Disable skill discovery |\n| `--prompt-template <path>` | Load a prompt template; repeatable |\n| `--no-prompt-templates` | Disable prompt template discovery |\n| `--theme <path>` | Load a theme; repeatable |\n| `--no-themes` | Disable theme discovery |\n| `--no-context-files`, `-nc` | Disable `AGENTS.md` and `CLAUDE.md` discovery |\n\nCombine `--no-*` with explicit flags to load exactly what you need, ignoring settings. Example:\n\n```bash\npi --no-extensions -e ./my-extension.ts\n```\n\n### Other Options\n\n| Option | Description |\n|--------|-------------|\n| `--system-prompt <text>` | Replace default prompt; context files and skills are still appended |\n| `--append-system-prompt <text>` | Append to system prompt |\n| `--tui-mode <mode>` | TUI mode: `regular` (default) or experimental `fullscreen` |\n| `--verbose` | Force verbose startup |\n| `-a`, `--approve` | Trust project-local files for this run |\n| `-na`, `--no-approve` | Ignore project-local files for this run |\n| `-h`, `--help` | Show help |\n| `-v`, `--version` | Show version |\n\nIn `fullscreen` mode, the transcript scrolls inside the terminal viewport while queued messages, working status, extension widgets, editor, and footer remain fixed at the bottom. Mouse/trackpad input scrolls the region under the pointer; keyboard viewport actions always remain available. Inline images work in terminals that support the Kitty graphics protocol, including Kitty and Ghostty. In iTerm2 they render as text placeholders because its inline-image protocol cannot delete or crop placements during application-owned scrolling. In `regular` mode, pi uses the main screen and terminal-owned scrollback, and iTerm2 inline images continue to render normally.\n\nSet **TUI mode** in `/settings` to switch between `regular` and `fullscreen` immediately and choose the default for future sessions. **Fullscreen exit output** controls whether exiting fullscreen prints the final transcript or restores the previous screen and prints only the session resume hint.\n\n### File Arguments\n\nPrefix files with `@` to include them in the message:\n\n```bash\npi @prompt.md \"Answer this\"\npi -p @screenshot.png \"What's in this image?\"\npi @code.ts @test.ts \"Review these files\"\n```\n\n### Examples\n\n```bash\n# Interactive with initial prompt\npi \"List all .ts files in src/\"\n\n# Non-interactive\npi -p \"Summarize this codebase\"\n\n# Non-interactive with piped stdin\ncat README.md | pi -p \"Summarize this text\"\n\n# Named one-shot session\npi --name \"release audit\" -p \"Audit this repository\"\n\n# Different model\npi --provider openai --model gpt-4o \"Help me refactor\"\n\n# Model with provider prefix\npi --model openai/gpt-4o \"Help me refactor\"\n\n# Model with thinking level shorthand\npi --model sonnet:high \"Solve this complex problem\"\n\n# Limit model cycling\npi --models \"claude-*,gpt-4o\"\n\n# Read-only mode\npi --tools read,grep,find,ls -p \"Review the code\"\n\n# Disable one extension or built-in tool while keeping the rest available\npi --exclude-tools ask_question\n```\n\n## Design Principles\n\nPi keeps the core small and pushes workflow-specific behavior into extensions, skills, prompt templates, and packages.\n\nIt intentionally does not include built-in MCP, sub-agents, permission popups, plan mode, to-dos, or background bash. You can build or install those workflows as extensions or packages, or use external tools such as containers and tmux.\n\nFor the full rationale, read the [blog post](https://mariozechner.at/posts/2025-11-30-pi-coding-agent/).","sourceFile":"usage.md"},"windows":{"title":"Windows Setup","markdown":"Pi requires a bash shell on Windows. Checked locations (in order):\n\n1. Custom path from `~/.pi/agent/settings.json`\n2. Git Bash (`C:\\Program Files\\Git\\bin\\bash.exe`)\n3. `bash.exe` on PATH (Cygwin, MSYS2, WSL)\n\nFor most users, [Git for Windows](https://git-scm.com/download/win) is sufficient.\n\n## Custom Shell Path\n\n```json\n{\n  \"shellPath\": \"C:\\\\cygwin64\\\\bin\\\\bash.exe\"\n}\n```","sourceFile":"windows.md"}}},"navigation":{"en":[{"title":"Start here","items":[{"title":"Pi Documentation","path":"/docs/latest","slug":"index"},{"title":"Quickstart","path":"/docs/latest/quickstart","slug":"quickstart"},{"title":"Using Pi","path":"/docs/latest/usage","slug":"usage"},{"title":"Providers","path":"/docs/latest/providers","slug":"providers"},{"title":"Security","path":"/docs/latest/security","slug":"security"},{"title":"Containerization","path":"/docs/latest/containerization","slug":"containerization"},{"title":"Settings","path":"/docs/latest/settings","slug":"settings"},{"title":"Keybindings","path":"/docs/latest/keybindings","slug":"keybindings"},{"title":"Sessions","path":"/docs/latest/sessions","slug":"sessions"},{"title":"Compaction & Branch Summarization","path":"/docs/latest/compaction","slug":"compaction"}]},{"title":"Customization","items":[{"title":"Extensions","path":"/docs/latest/extensions","slug":"extensions"},{"title":"Skills","path":"/docs/latest/skills","slug":"skills"},{"title":"Prompt Templates","path":"/docs/latest/prompt-templates","slug":"prompt-templates"},{"title":"Themes","path":"/docs/latest/themes","slug":"themes"},{"title":"Pi Packages","path":"/docs/latest/packages","slug":"packages"},{"title":"Custom Models","path":"/docs/latest/models","slug":"models"},{"title":"Custom Providers","path":"/docs/latest/custom-provider","slug":"custom-provider"}]},{"title":"Reference","items":[{"title":"Session File Format","path":"/docs/latest/session-format","slug":"session-format"}]},{"title":"Programmatic Usage","items":[{"title":"SDK","path":"/docs/latest/sdk","slug":"sdk"},{"title":"RPC Mode","path":"/docs/latest/rpc","slug":"rpc"},{"title":"JSON Event Stream Mode","path":"/docs/latest/json","slug":"json"},{"title":"TUI Components","path":"/docs/latest/tui","slug":"tui"}]},{"title":"Platform Setup","items":[{"title":"Windows Setup","path":"/docs/latest/windows","slug":"windows"},{"title":"Termux (Android) Setup","path":"/docs/latest/termux","slug":"termux"},{"title":"tmux Setup","path":"/docs/latest/tmux","slug":"tmux"},{"title":"Terminal Setup","path":"/docs/latest/terminal-setup","slug":"terminal-setup"},{"title":"Shell Aliases","path":"/docs/latest/shell-aliases","slug":"shell-aliases"}]},{"title":"Development","items":[{"title":"Development","path":"/docs/latest/development","slug":"development"}]}]}}
