# Configuration

`@bachi/pi-coder` ships extensions and themes as package resources, which pi loads by itself. It also ships the **global config files** the environment was built with, because those are files pi reads from `~/.pi/agent/` and no package can install them for you.

| In this package | Destination | Purpose |
| --- | --- | --- |
| `config/AGENTS.md` | `~/.pi/agent/AGENTS.md` | The agent's global working rules: persistence, authorization, destructive-action care, shell hygiene, editing and verification rules, communication style. |
| `config/AGENTS.core.md` | `~/.pi/agent/AGENTS.core.md` | The ~7 KB distilled core (`core-rules` re-injects it into the context mid-session). The extension **does nothing, silently**, when this file is missing. |
| `config/settings.json` | `~/.pi/agent/settings.json` | Everything in the key table below. |
| `config/web-search.json` | `~/.pi/agent/web-search.json` | `pi-web-access` configuration; one required key (see below). |
| `config/voice.json` | `~/.pi/agent/voice.json` | Speech configuration for `voice/`: voice name, speech rate, character cap, whether questions are read aloud, and the summary window. Entirely portable — the Aliyun key is **not** here, but in `~/.config/litellm-any/apikey.json` (see below). |
| `config/pi-statusline.json` | `~/.pi/agent/pi-statusline.json` | Legacy. See [pi-statusline.json](#pi-statuslinejson-is-legacy). |

Copy commands are in [installation.md](installation.md#apply-the-global-config-files).

## Read `settings.json` before copying it

`cp` overwrites your file completely — there is no merge. Two entries are specific to the author's machine:

### `npmCommand` pins pnpm

```json
"npmCommand": ["pnpm", "--config.node-linker=hoisted"]
```

This routes every pi npm operation (install, remove, dependency install for git packages) through pnpm with a hoisted layout. **If you do not have pnpm installed, `pi install` will fail.** Delete the key to use plain npm, or replace it with your own wrapper, e.g.:

```json
"npmCommand": ["mise", "exec", "node@20", "--", "npm"]
```

### `doubleEscapeAction` hands Esc-Esc to `rewind`

```json
"doubleEscapeAction": "none"
```

pi's default is `"tree"` (the built-in session-tree navigator). The `rewind` extension takes over the double-Escape gesture and consumes the second press, so with `"tree"` you would get rewind's menu and never pi's tree. `"none"` states that intent explicitly. To go back: remove `extensions/rewind/` and set the value to `"tree"`.

## What each `settings.json` key does

| Key | Value here | Notes |
| --- | --- | --- |
| `lastChangelogVersion` | `"0.99.1"` | Internal marker for "last changelog the user saw". It only suppresses a changelog notice; harmless to keep or delete. |
| `theme` | `"pi-coder-1337"` | Must equal the `name` field inside `themes/pi-coder-1337.json`, not just the file name. |
| `defaultThinkingLevel` | `"xhigh"` | Startup thinking level. Not available on every model; see `thinkingLevelMap` in your `models.json`. |
| `compaction.enabled` / `reserveTokens` / `keepRecentTokens` | `true` / `52429` / `20000` | `keepRecentTokens` is pi's default; `reserveTokens` is raised well above pi's `16384` default because this setup's models stream long thinking blocks. |
| `npmCommand` | `["pnpm", "--config.node-linker=hoisted"]` | See above. Machine-specific. |
| `extensions` | `["-builtin:codemode"]` | Disables pi's built-in `codemode` extension so [`codemode-tree/`](extensions.md#codemode-tree--the-codemode-tool) can own the tool without pi printing a "built-in extension `codemode` was not loaded" warning at startup (the warning is expected first-registration-wins behaviour, but pi renders it in two places and nothing can suppress it). **Cost**: `PI_CODEMODE_TREE=off` no longer falls back to the built-in — with both off the `codemode` tool disappears entirely. Remove this entry to get the built-in look back. No explicit extension *paths* are listed; auto-discovery of `~/.pi/agent/extensions/` and package resources covers them. |
| `defaultTools` | `["read", "bash", "edit", "write", "grep", "find", "ls", "codemode"]` | The tools that are active at startup, listed **explicitly** because `defaultTools` is a **pure whitelist with no `+name` syntax**: every entry is matched by exact name and anything that does not match is dropped silently, with no error and no warning. The value shipped through 2.4.0 was `["+codemode"]`, whose real meaning was “activate **no** builtin tool at all”, not “add codemode to the defaults” — `+` is a force-include marker in `extensions` / `resources`, a different field with a different syntax, and the two are easy to confuse. It went unnoticed because `read`, `bash`, `edit` and `write` were being re-registered by extensions ([`read-path-collapse.ts`](extensions.md#read-path-collapse-ts--the-read-tool), [`bash-command-collapse.ts`](extensions.md#bash-command-collapse-ts--the-bash-tool), [`tool-diff.ts`](extensions.md#tool-diff-ts--the-edit-and-write-tools)) and extension tools are let through unconditionally; the cost was that any builtin **no extension had claimed** — `grep`, `find`, `ls` — did not exist even when listed, which is also why [`explored-group/`](extensions.md#explored-group--the-explored-group) appeared in the same sync. `codemode` is registered **inactive** (`defaultActive: false`, `exposure: "model-only"`) by pi's built-in, so it has to be named here to be reachable directly. **Verifying a change needs a real check**: `SettingsManager.getDefaultTools()` echoes the raw string and tells you nothing about what parsed — build a `createAgentSession({cwd, agentDir})` and read the **names** in `session.agent.state.tools`. |
| `tuiMode` | `"regular"` | pi's default, written out explicitly. |
| `packages` | `[{"source": "npm:pi-web-access", "extensions": []}, "npm:pi-subagents", "git:github.com/jayli/superpowers"]` | The three companion packages. `git:` means pi clones the repository itself into `~/.pi/agent/git/`, and that package brings both an extension and its 15 skills — see [installation.md](installation.md#companion-packages). **`pi-web-access` is written in object form with `"extensions": []`** rather than as a bare string, which is a deliberate coupling: [`web-search-tree/`](extensions.md#web-search-tree--pi-web-accesss-tool-blocks) takes over the rendering of three of that package's tools, and the package registering the same names itself makes pi log five conflicts. The object form loads none of the package's extensions while keeping it **registered**, so `pi update` still upgrades it — the same technique as `extensions: ["-builtin:codemode"]`. Removing the filter without removing `web-search-tree/` brings the conflicts back; removing the extension without removing the filter makes `pi_web_search` / `fetch_content` / `get_search_content` disappear entirely. |
| `steeringMode` | `"one-at-a-time"` | pi's default, explicit. |
| `followUpMode` | `"all"` | **A deliberate non-default** (pi's default is `one-at-a-time`): queued follow-up messages are delivered as **one turn** instead of one turn each. The motivation is background-task notifications — when the main agent is busy, several terminal notifications queue up as follow-ups, and under the default each one wakes its own model turn (measured 2026-09-30: nine notifications, 38 s, nine turns). The same batching applies to follow-ups a human queues with `alt+enter`; delete the key to get per-message delivery back. |
| `markdown.mermaid` | `"streaming"` | pi's default, explicit. |
| `doubleEscapeAction` | `"none"` | See above. |
| `subagents.defaultModel` | `"inherit"` | Pins every subagent to the parent session's model instead of a frontmatter choice. Built-in agents already inherit, so this is mostly a statement of intent — and it **does not** cover the watchdog, which reads `subagents.watchdog.main.model`. |
| `subagents.modelScope` | `{ "enforce": true, "strict": true, "allow": ["inherit"] }` | Caps which models a subagent may run on: only the inherited session model passes, and anything outside the list is a hard error rather than a warning (`strict`). The snapshot allows one extra model, `litellm-any/deepseek-flash-qd`, which is machine-local and removed here — see below. `enforce: true` requires at least one non-empty `allow`, which `["inherit"]` satisfies. |
| `subagents.watchdog` | `{ "enabled": true }` | A `pi-subagents` setting: the opt-in second-model reviewer. On every turn that changed the repository it feeds that turn's diff plus the user's scope to an independent reviewer model looking for missed constraints, correctness risks, test gaps, unsafe changes and drift; clean turns are silent, `high` findings are pushed back into the model's context, `low` / `medium` are shown to the user only, and three identical warnings in a row are judged a deadlock and stop it. `/subagents-watchdog on\|off\|status` drives it inside a session; `settings.json` is read at startup, so an edit takes effect next session. The snapshot also sets `main.model`, which is machine-specific and removed here — see below. |
| `subagents.agentOverrides` | `researcher` / `delegate` / `worker` → `tools: "inherit"` | A `pi-subagents` setting, not a pi core one. |

### Why `tools: "inherit"` on three subagents

`pi-subagents` filters a child agent's tools against a strict whitelist. Core builtins that the host lacks are removed with a warning; **non-core names are passed through and validated by the child session's own registry** — so a tool silently disappears when its name does not exist there.

That is exactly what happens with the built-in `researcher` agent: its frontmatter asks for `web_search`, while `web-search.json` renames that tool to `pi_web_search`. The child registry has no `web_search`, so the tool is dropped and the subagent cannot search at all.

`inherit` deletes the whitelist entirely (`applyToolsOverride` does `delete target.tools`), and the subagent gets every tool the child registry has. It is applied only to the three **write-capable** agents:

- `researcher`, `delegate`, `worker` — they already may write, so nothing is lost.
- Read-only agents (`scout`, `reviewer`, `oracle`) must **not** get `inherit`: it would hand them `write`, `edit` and `bash` and break their read-only contract. `evidence-auditor` has a whitelist of the same broken shape, but inheriting it would silently grant write access, so it is left alone.

Interaction tools need no exclusion: `ask_user_question` checks `ctx.hasUI` and removes itself from child sessions.

## What is not shipped

### `config/settings.json` and `config/AGENTS.md`

`config/settings.json` holds the machine-specific entries described above; everything else in it is portable. `config/AGENTS.md` is the agent's global working rules and is not machine-specific at all; `config/AGENTS.core.md` is its distilled core and equally portable, but remember it is **load-bearing**: `core-rules` injects it, and a missing file means that extension is silently absent from a session.

`config/models.json` and `config/mcp.json` are **not** shipped: provider registrations point at a local gateway and the MCP file holds absolute paths of local server executables, so both belong to the machine that runs them. MCP servers are configured in `~/.pi/agent/mcp.json` or a project `.pi/mcp.json`, and since pi 0.99.1 they are read by pi's built-in `builtin:mcp` rather than by any extension in this package.

`config/subagent/config.json` is new in the snapshot and is **also not shipped**, though it is not machine-local: it holds `pi-subagents`' own `timeoutMs` (3 600 000) and `checkpointBeforeDeadlineMs` (300 000) tuning, and it belongs to `pi-subagents`, which works zero-config without it. The file it mirrors on a real machine is `~/.pi/agent/extensions/subagent/config.json` (note the path: `extensions/`, not the `config/` layout of this snapshot), and `pi-subagents` documents every key in its own `docs/configuration.md`.

Provider and model registrations are machine-specific: this setup's `litellm-any` provider points at a LiteLLM gateway on `127.0.0.1:996` (LAN address on other machines), carries a compat configuration, and registers six model ids that must match the gateway's routes exactly. Shipping it would be wrong on every other machine, so it is excluded.

The settings keys that select a model were removed along with it:

| Removed key | Why |
| --- | --- |
| `defaultProvider: "litellm-any"` | The provider only exists in the excluded `models.json`. |
| `defaultModel: "deepseek-flash"` | Depends on that provider. |
| `modelThinkingLevels` | Pins `deepseek-flash` and `deepseek-flash-qd` to `max`; model ids again. |
| `subagents.watchdog.main.model` | `"litellm-any/deepseek-flash-qd"` — the same provider. Omitting it makes the watchdog inherit the current session model, which is the documented fallback; the author's choice is a speed pick (~1.4 s through the gateway), not a requirement. |
| `subagents.modelScope.allow`'s second entry | `"litellm-any/deepseek-flash-qd"` again. The key itself is kept — `{ "enforce": true, "strict": true, "allow": ["inherit"] }` is portable policy — only the machine-local model id is dropped. |

Everything else in `settings.json` is byte-for-byte the author's file. If you run your own gateway you can add them back:

```json
"defaultProvider": "<provider>",
"defaultModel": "<model-id>",
"modelThinkingLevels": { "<provider>/<model-id>": "max" }
```

and merge a `main` block into the `subagents.watchdog` object that is already there (it sits beside `agentOverrides`, so do not replace the whole `subagents` key):

```json
"subagents": {
  "watchdog": { "enabled": true, "main": { "model": "<provider>/<model-id>" } },
  "agentOverrides": { "...": "..." }
}
```

An extra model in `subagents.modelScope.allow` needs adding there too — with `enforce: true` (and `strict: true`, which also rejects inherited out-of-scope models) anything not in the list is a hard error, so a reviewer model added to the watchdog but not to the scope aborts the run.

A configured reviewer model must be fully qualified as `provider/model` and authenticated in your registry; an unavailable one is **reported, not silently replaced**. Omitting `main.model` altogether inherits the current session model and thinking level, which is what this package relies on. Three sub-switches stay off here as they do upstream — `children` (review subagents), `clarification` (an extra review per prompt) and `cadence` (review every N tool calls) — and `agentEndTimeoutMs` defaults to 30000, after which the review is abandoned rather than blocking the turn. Findings carry an `importance` of `low` / `medium` / `high`: only `high` is steered back into the model's context and triggers a continuation, `low` and `medium` are persisted for the user alone, and a clean review shows nothing. `stalemateRepeats` defaults to 3 — after that many identical warnings in a row the finding is shown as `stalemate`, no continuation is triggered and the turn ends, with your next prompt resetting the count. Its Test Gap category overlaps the `verify-loop` gate on purpose: the gate is deterministic and free, the watchdog is a model judgement that costs a call, so one catches "nothing ran" and the other catches drift and missed edits.

For how providers and thinking levels work, see pi's own `docs/models.md` and `docs/custom-provider.md`.

### `mcp.json`

The MCP server list is machine-specific in the same way: the snapshot's only entry points at the absolute path of a local server executable, which exists on one machine only.

MCP servers are configured in `~/.pi/agent/mcp.json` and/or a project `.pi/mcp.json`, in Claude Code's `mcpServers` shape. Neither file is shipped. Since pi 0.99.1 the reader is pi's built-in `builtin:mcp`; this package's own twelve-file implementation was retired on 2026-09-30 because it registered `/mcp` too and pi resolves that collision first-registered-wins. `pi mcp add` writes the file for you, and `/mcp` opens the management UI. Three differences from the retired extension matter for a copied config: `timeout` is in **seconds** (a `120000` is rejected), project config is **only** `.pi/mcp.json` (symlink an existing `.mcp.json` to it), and legacy SSE (`type: "sse"`) is unsupported. `headersCommand` is gone too; use OAuth or `${VAR}` / `!command` inside static `headers`. The full surface is documented in [extensions.md](extensions.md#mcp-servers--pis-built-in-extension-not-this-packages) and the [Chinese handbook](handbook.zh.md).

So three snapshot config files are deliberately left out of this package: `models.json` (gateway registrations), `mcp.json` (paths of local MCP server executables) and `subagent/config.json` (a companion package's own tuning). `AGENTS.md` and `settings.json` are shipped, and `settings.json` is the only shipped config file that differs from the snapshot — the removed model selections in the table above and nothing else.

### `pi-statusline.json` is legacy

This file configures `npm:@narumitw/pi-statusline`, a package this environment no longer uses — `extensions/statusline/` replaced it. The local statusline reads **no config file at all**: colors come from `theme.fg(...)`, so it follows whatever theme is active, and the second line comes from other extensions calling `ctx.ui.setStatus()`.

The file is kept only so you can switch back to the npm package without re-deriving the palette (it holds a Tokyo Night palette, segment order and per-extension status icons). Nothing in this package reads it.

### `voice.json`

`config/voice.json` is a copy of the author's speech settings and is **fully portable** — nothing in it names a provider, a machine or a path:

| Key | Shipped value | Meaning |
| --- | --- | --- |
| `enabled` | `true` | Master switch, same as `/voice on` / `/voice off`; `PI_VOICE=off` outranks it. |
| `voice` | `"Tingting"` | The `say` voice — **only** for the local engine. |
| `aliyunVoice` | `"longanhuan_v3.1"` | Voice for the Aliyun engine; belongs to the `qwen-audio-3.1-tts-flash` model, and the old TTS model rejects it. |
| `aliyunInstruction` | `"请用重庆话口音播报。"` | Free-text instruction sent as `input.instruction` — how an accent or tone is requested. An empty string omits the field. |
| `maxChars` | `500` | Cap on the locally trimmed text before it is spoken. |
| `speakQuestions` | `true` | Read rhetorical closing questions aloud (silently skipping them makes a working setup look broken). |
| `summarize` | `true` | Rewrite the text into a spoken summary instead of reading the trimmed original. |
| `summaryThreshold` | `6` | Only summarize above this many characters. At the default — the minimum speakable length — **every** utterance is summarized, so the voice stays consistent instead of sometimes speaking the raw text. |
| `summaryMaxChars` | `100` | Hard cap on the spoken summary. The effective budget is `min(this, source length)`, so a summary can never be longer than what it summarizes. |
| `summaryTimeoutMs` | `15000` | Give up and speak the local fallback text after this long. |

The one thing **not** in this file is the Aliyun key. It lives at `~/.config/litellm-any/apikey.json` under `aliyunKey` — outside the agent directory and shared with the gateway's own keystore, which is why that file's `normalize()` explicitly preserves and reserves the field. `/voice key sk-xxx` writes it, `/voice key` shows it masked, `/voice key clear` removes it. With no key everything above still works through the system `say`, with no network at all.

## `web-search.json` — one key, and it is required

```json
{ "toolNames": { "webSearch": "pi_web_search" } }
```

pi registers the `pi-web-access` search tool as `web_search` by default. A LiteLLM Anthropic→OpenAI translation layer treats **any tool literally named `web_search`** as Anthropic's built-in web search (`_is_web_search_tool`), strips it from `tools`, and substitutes an empty `web_search_options: {}` — which the backend rejects. The result is not an error: the request succeeds and the tool simply does not exist for the model.

Renaming the tool changes the same request from `tools=7` to `tools=8` in the gateway log (and to `tools=0` when only `web_search` was sent). If you are not routing through such a gateway, the rename is still harmless.

> When a tool vanishes like this, do not trust the model's own explanation — it will guess from the stale `promptSnippet` still present in the system prompt. Count `tools=N` in the gateway log instead.

## Machine-local files that are intentionally not in the package

| File | Why not |
| --- | --- |
| `~/.pi/agent/auth.json` | Credentials. |
| `~/.pi/agent/trust.json` | Per-machine project trust decisions, keyed by absolute path. |
| `~/.pi/agent/models-store.json` | Cache of pi's built-in model catalog. |
| `~/.pi/agent/sessions/` | Session transcripts. |
| `~/.pi/agent/missions/`, `run-history.jsonl` | `pi-subagents` mission and run history. |
| `~/.pi/agent/rewind/` | The `rewind` extension's shadow snapshot repositories. |
| `~/.pi/agent/npm/`, `bin/` | Installed packages (use `pi install`) and pi's bundled `fd`/`rg`. |
| `~/.pi/agent/web-search-cache/`, `~/.pi/folder-history/*.jsonl` | Runtime caches and history. |

The [Chinese handbook](handbook.zh.md) documents the same list with the reasoning behind each entry, plus the gateway and model routing this environment was tuned for.
