# Extensions reference

30 extensions load from this package. Twelve are single files in `extensions/`, eighteen are directories whose entry point is `index.ts`. Five more directories (`thinking-collapse/`, `tool-diff/`, `prompt-editor/`, `bash-command-collapse/`, `read-path-collapse/`) contain pure-logic modules and tests only — they have no `index.ts`, so pi never loads them as extensions, but the top-level files import them or their tests cover them.

Every extension is also documented in its own header comment (Chinese, except `rewind/`): the pi internals it relies on, the failure that motivated it and the trade-offs that are not visible in the code. This page is the map.

## Commands

| Command | Extension | Arguments |
| --- | --- | --- |
| `/ask` | `ask-user-question` | — Previews the questionnaire with a demo question. |
| `/background` | `background-tasks` | `[<id>]` \| `kill <id>` — Without arguments lists the session's background tasks; with an id prints its status line plus the tail of its log; `kill` stops one. |
| `/bash-preview` | `bash-command-collapse` | `off` \| `<1-50>` — Output preview lines; `off` restores pi's built-in preview. |
| `/bash-timeout` | `bash-command-collapse` | — Prints the default, maximum and env-overridden bash timeout. |
| `/clear` | `clear-command` | — Alias of `/new`. |
| `/exit` | `exit-command` | — Alias of `/quit` (the argument-free form of the quit words). |
| `/goal` | `verify-loop` | `<condition>` \| `clear` — Sets a completion condition evaluated after every turn by an independent model call; without arguments prints the active goal, `clear` (also `stop` / `off` / `reset` / `none` / `cancel`) removes it. |
| `/init` | `init-command` | `[file.md] [extra instructions]` |
| `/memory` | `memory` | — Auto-memory menu: status line, open the memory folder, show the index, enable/disable for this project. |
| `/plan` | `plan-mode` | — Toggle `plan` mode; leaving `plan` returns to `bypass` (never to `dangerous`). |
| `/plan-status` | `plan-mode` | — Print the current permission mode (`dangerous` / `bypass` / `plan`) and any pending plan. |
| `/recap` | `recap` | — Summarizes the conversation now. |
| `/rewind` | `rewind` | — Checkpoint menu; also Esc Esc at an empty prompt. |
| `/sandbox-boundary` | `sandbox-boundary` | `forget <path>` \| `clear` \| `allow <path>` — Prints the delete boundary and the persistent allowlist; default form lists both. |
| `/tasks` | `simple-task` | `status` (default) \| `clear` \| `on` \| `off` |
| `/theme` | `theme-command` | `[name]` — Without arguments: picker with live preview. |

## Tool overrides

pi registers one handler per tool name (first registration wins), so each of these owns a builtin tool outright.

### `bash-command-collapse.ts` — the `bash` tool

Collapses the command to **2 visual lines** on a single tree: the first line is a status dot `• ` followed by `Run `, the last row ends in `…`, and a `… +N lines` marker follows when source lines are left over; results hang off the same tree, and `└ ` appears **once**, on the first real output line. Command continuation rows and the truncation marker are indented to the `n` of `Run ` — two spaces while the command is starting, `│ ` once it has finished — and only the word `Run` is bold. The row hard-wraps at the column budget the way CSS `word-break: break-all` does rather than pre-wrapping whole words: a 78-column path fills the line completely and breaks at the edge. Output preview lines default to 3 and the preview always keeps the command's status line. The extension also tree-indents output, syntax-highlights the command line, and can give bash output its own color through the `bashOutput` theme token ([themes.md](themes.md#bashoutput-in-detail)). Since 2026-09-24 it also **wraps the command in a seatbelt profile** — see [`bash-command-collapse/sandbox.ts`](#bash-command-collapsesandboxts--the-seatbelt-delete-boundary) below; the renderer always shows your command, never the `sandbox-exec` prefix.

The block deliberately carries **no background and no boundary blank lines**: the dot at the head of the command row is the only state marker, in `dim` while running, `toolDiffAdded` on success and `toolDiffRemoved` on failure (the logic is the same one the earlier `▎` bar used). The dot column and the result indentation share one constant, so `Run`, `│` and `└` all sit in column 2 and every body column starts at 4; the left margin is drawn by the extension itself, and both sides subtract it from their width budget. **Only bash loses its background** — every other tool keeps pi's default shell.

A failed command is painted `error` rather than success — both the trailing status line and the exit-code footer. That decision reads `isError` **and** matches the status line's shape (`Command exited with code N`, `timed out after N seconds`, `aborted`), because shape alone would repaint a command that merely printed that text. The blank line `appendStatus` writes before the status is dropped instead of rendering as a gap in the tree, the status line is exempt from preview trimming, and blank lines **above** the `└ ` keep the `│ ` bar so the fence does not break.

- `PI_BASH_MIN_TIME_MS` (default `2000`) — only show the elapsed-time footer above this duration.
- `PI_BASH_HIGHLIGHT=off` — disable shell syntax highlighting.
- `PI_BASH_SPINNER=off` — disable the `●` on running rows (implemented in `working-indicator`).

Two details that look simplified but cannot be: it decides "arguments are still streaming" from `!streaming && !argsComplete && isPartial === true` (both thresholds are required, or `/resume` replays lose the command line entirely), and `isError` must be read from `context`, not `result`, because pi's result renderer is called without that field. `PI_BASH_TREE` is gone: the prefix is a tree unconditionally. The shape and its 50 end-to-end assertions are in [`bash-command-collapse/render.test.ts`](../extensions/bash-command-collapse/render.test.ts), which renders through pi's own loader and `ToolExecutionComponent`; 23 of the 50 drive the real sandbox and report themselves as **skipped** when nested `sandbox-exec` is unavailable (which it is inside a pi session). There is no switch for the sandbox here — `PI_SANDBOX=off` turns it off for both routes at once.

### `read-path-collapse.ts` — the `read` tool

Two changes: the title row stays on exactly one line, and the block is shelled like the bash block.

**One-line titles.** Long paths lose their front and keep the informative tail — the file name and last directories — as `Read …@earendil-works/pi-coding-agent/dist/core/extensions/loader.js:62-116`. No folding, no second row. Paths that already fit are left as pi rendered them, apart from one color: the path is painted `text` instead of pi's `accent`, so it does not merge with the `Read` label on themes where `accent` and `toolTitle` are the same palette color (`pi-coder-catppuccin`'s mauve).

**The shell.** `renderShell: "self"` gives the block the same shape as the bash block: **no background** in any of the three states and **no boundary blank lines**, a `• ` dot at column 0 (dim while reading, `toolDiffAdded` on success, `toolDiffRemoved` on failure), `Read` at column 2, and the result body indented to the same column with pi's leading blank line stripped. Only `read` is affected; every other tool keeps pi's default shell, which the test suite asserts with a control case.

`ctrl+o` expansion is handled by the same one-line rule, so the collapsed and expanded views wrap identically. The file name is never split; a path that does not fit even so is cut from the left per grapheme.

**Copying one of pi's functions means copying its signature, not its name.** pi's `resolvePath(input, baseDir)` (`utils/paths.js`) has the **opposite argument order** to node's `resolve(base, target)`. This file aliases node's `resolve` as `resolvePath` to mirror pi's source shape, so `resolvePath(filePath, cwd)` looked identical to pi's line while running node's "base first" semantics: an absolute `filePath` made node return `cwd` and dropped the file itself, so `relative(cwd, cwd)` was `""`, the "inside cwd" test passed, and the label degenerated to `"."` — reading `~/.pi/agent/AGENTS.md` rendered as `Read resource .:67-80` in the narrow-terminal branch (reproduced; it triggers at width ≤ 66). Files inside cwd happened to be correct, which is why only a path **outside** cwd exposes this class of bug; a regression assertion now pins it. Those two lines go through `resolveLikePi(input, baseDir)`.

- `PI_READ_COLLAPSE=off` — restore pi's builtin title row (the uppercase `Read`, the dot and the shell are unaffected). There is no `/read-collapse` command.

### `tool-diff.ts` — the `edit` and `write` tools

Claude Code style diffs: full-line background for added and removed lines (including the line-number gutter), inline highlight of the changed span, and syntax highlighting. Both tools are re-registered with `renderShell: "self"`, which is what makes per-line backgrounds possible — the default shell paints whole blocks by status and would cover them.

The definitions come from `createEditToolDefinition` / `createWriteToolDefinition`, **not** from `createEditTool` / `createWriteTool`. The latter are `wrapToolDefinition(createXToolDefinition(…))`, and `wrapToolDefinition` keeps only eight fields (`name`, `label`, `description`, `parameters`, `constrainedSampling`, `prepareArguments`, `executionMode`, `execute`) — so `promptSnippet` and `promptGuidelines` are silently dropped, the `edit` / `write` rows disappear from the system prompt's `<tools>` section (`visibleTools` filters on `!!toolSnippets[name]`) and the five guidance lines in `<rules>` go with them. Nothing errors: the tools still work, the model just cannot see them. pi's own `examples/extensions/built-in-tool-renderer.ts` uses `createEditTool()` and carries this bug, so it is not a safe pattern to copy. Registration spreads `{ ...originalTool }` rather than copying fields by name, which also preserves `executionMode` — losing that one can let `edit` run concurrently and race on a file.

The two line backgrounds come from theme tokens that pi's official schema does not define: `toolDiffAddedBg` and `toolDiffRemovedBg`. When a theme omits them, the extension falls back to the much flatter `toolSuccessBg` / `toolErrorBg`. See [themes.md](themes.md#custom-tokens).

### `thinking-collapse.ts` — thinking blocks

Registered as a markdown transformer for `assistant-thinking`. Every thinking block renders as **one continuous line**, no matter how many paragraphs, list items or fenced blocks the model wrote:

```
Think: …latest token keeps appending at the end
```

- Nothing is line-wrapped. When the line exceeds the terminal width, characters are dropped **from the front** and a leading `…` is added, so the end of the line is always the newest token.
- At paragraph seams (blank lines in the original) a Chinese comma is inserted between two Chinese paragraphs; a paragraph that already ends in punctuation is left alone, and Latin text keeps the space rule.
- There is deliberately no "backfill to fill the row" logic, and no `… (N tokens hidden)` notice — the spinner below already counts tokens.
- No command, no environment switch. The only form is this one.

### `fenceless-code-block/` — markdown code blocks

Removes code fences, including the language label, and lays the code out with pi's own indentation and syntax colors — no background is added. It works by patching `Markdown.prototype.renderToken` at module evaluation time, which is effective because pi's bundled loader points extensions at its own inlined `@earendil-works/pi-tui` namespace. Installing twice (after `/reload`) wraps only once.

- `PI_FENCELESS_CODE=off` — keep the fences.

### `codemode-tree/` — the `codemode` tool

Renders pi's built-in `codemode` tool block with the same tree the bash and read blocks use: `• codemode` at column 0, the script body syntax-highlighted on a `│ ` continuation (keeping pi's 10-visual-line preview budget and its `… (N more lines, ctrl+o to expand)` hint), then the result tree with `└ ` appearing **once**, on the first substantive result line — the first entry of the nested-call list. Everything below it (later calls, the cost summary, output, truncation hints) is indented four columns. The column table is shared with the bash block exactly: dot at 0, `codemode` / `│` / `└` at 2, body at 4, so **every child component renders at `width - 4`** — including the command side, which only carries a two-column indent before the result arrives (those two columns are deliberately empty). Budgeting for the widest prefix is what keeps the wrap width from jumping at the moment the result lands.

The dot is **three-state** (`stateDotSlot` in `render.ts`): white (`text`) while running, green (`toolDiffAdded`) on success, red (`toolDiffRemoved`) on failure. The green slot is `toolDiffAdded` rather than `success` because that is what the bash and read dots use — the two happen to share a value in the current themes and would diverge on a repaint. With the background gone the dot is the **only** outcome lamp (pi's default shell painted `toolErrorBg` red, and that signal is gone with it), so the render cache key includes the state; without it the running white dot would be pinned after the result arrived. `│` and `└` take `muted`. The block has no background and no boundary blank lines (`renderShell: "self"`).

**How it gets codemode's execution logic.** `codemode` is a built-in *extension* (`builtin:codemode`), not a built-in tool, and there is no `createCodemodeToolDefinition()` export — `createCodemodeExtension()` is the only entry point. So the extension runs that same factory against a `Proxy` that intercepts only `registerTool` and forwards every other `pi.*` access to the real API (`getSettings` / `appendEntry` / `getAllTools` are all used inside the factory's closure, and must read live values at call time — `getMode()` reads `pi.getSettings().codemode.mode` every turn). A Proxy rather than a hand-written stub so that pi adding new `pi.*` calls later cannot crash the load. It then spreads the captured definition and overrides only the two renderers plus `renderShell`. Measured on a real CLI (2026-10-01): the captured `parameters` is **the same object reference** as pi's own, which is what keeps the MCP extension's `isCodemodeTool()` check working — it recognises "this is the package's codemode" by schema reference equality to decide whether to auto-activate the tool, and writing a fresh schema would silently break that activation (leaving only the `MCP tools are only reachable from the codemode or tool_search tool…` warning). An A/B comparison in one session also confirmed the description and parameter schema are byte-identical (5674 bytes).

**The built-in is disabled in `settings.json`** (`extensions: ["-builtin:codemode"]`). `codemode` is one of pi's `replaceable: true` built-ins (like `tool-search` and `mcp`): when another extension registers the same tool name pi skips the built-in entirely and prints `registers tool `codemode`, so built-in extension `codemode` was not loaded`. That warning is the expected yield, not an error — but pi renders it in **two** places (the `[Extension issues]` block and a `Warning: Extension package …` line) and no switch suppresses it (`quietStartup` affects the resource list, not diagnostics). Disabling the built-in explicitly clears both (measured through the SDK: warnings zero, codemode still provided by this extension). **The cost**: `PI_CODEMODE_TREE=off` is no longer a fallback to the built-in look — with the extension off and the built-in disabled in settings, the `codemode` tool disappears entirely. Delete that settings entry first if you want the built-in back.

**Truncation hints come in two nouns.** Code and output previews say `… (N more lines, …)` / `... (N earlier lines, …)`, but the **nested-call list** says `... (N earlier calls, …)`. Recognising only `lines` left the `calls` hint unrecognised as a hint line, so `└ ` was hung on it and the first real call got the four-column indent instead. Small examples never fold (fewer than 8 nested calls), which is why it only showed up in a large one; the regression assertion uses the built-in renderer's real output string. Both ellipsis forms are matched (`...` from pi's `truncateToWidth`, `…` from its preview component), and the middle word is optional (`(2 earlier calls)` and `(5 lines)` both occur).

- `PI_CODEMODE_TREE=off` — do not register `codemode` (read once at registration). With `-builtin:codemode` in settings this removes the tool entirely; see the cost above.

23 `node --test` cases: `render.test.ts` (11, pure logic — columns, width budget, connected state, the three dot slots including "running beats `isError`", `└ ` exactly once, hints on `│ `, both hint nouns, empty input returning an empty array) and `index.test.ts` (12, through pi's real loader and `ToolExecutionComponent` — shape, the three dot states including "the success dot is byte-identical to bash's" and "the colour changes when the result arrives", no background in any of the three states (truecolor background SGR check), no boundary blank lines, `└ ` once, the `│` column, the before/after-result switch, truncation hints, semantic inheritance (name / exposure / defaultActive / execute / prepareLoadout / parameters / renderShell), the expanded state and no over-width line at several widths). **The colour assertions deliberately do not hardcode color values** — the eleven long-standing bash/read failures do exactly that (`#666666`, which drifts with the theme) — they only assert "same as bash" and "the three differ".

## TUI chrome

### `statusline/` — the footer

Replaces pi's footer with one status line and one status row (plus the optional background-task dock line):

```
🅼 qwen3.8-flash/xhigh | Ctx 0.0% | ᗌ main | (+0,-0)
📁 /Users/you/project
```

The main row shows model/thinking level, context usage, git branch and diff stat; when the working directory is not a git repository it says `no git`. The model icon is `🅼` (U+1F17C, NEGATIVE SQUARED LATIN CAPITAL LETTER M), painted `dim` like the separators — it is structural decoration and should not compete with the model id. It replaced `⚡️`, and the difference is measured rather than cosmetic: U+1F17C is **not** an RGI emoji (`/^\p{RGI_Emoji}$/v` does not match it, with or without VS16), so pi-tui's `graphemeWidth` falls through to `eastAsianWidth` → Ambiguous → **one column**, which is also what Ghostty's default `grapheme-width-method = unicode` gives, so `truncateToWidth`'s truncation maths holds and the whole row is one column narrower than in the `⚡️` days. For the same reason it needs no VS16 (Apple Color Emoji has no U+1F17C, so a variation selector would only add an invisible code point) and it **does** take a foreground color, unlike an emoji. No font in the author's Ghostty stack covers it either (`JetBrainsMonoNL Nerd Font Mono`, `Maple Mono SC NF`), so it is drawn through system fallback — `Lyth Mono Term` (advance 0.604 em, the same width as `M`), `LXGW WenKai` and `YuGothic` on macOS. `line.test.ts` pins the code point and the not-an-RGI-emoji property, because `Ⓜ` (U+24C2) and `🅜` (U+1F15C) are near-identical glyphs. The branch icon is `ᗌ` (U+15CC, CANADIAN SYLLABICS CARRIER RE — a glyph that happens to fork) — one column wide and East Asian Width Neutral, so a CJK-configured terminal cannot render it double-width, and deliberately **not** a Nerd Font glyph, so no patched font is needed. No font in the author's Ghostty stack covers U+15CC (`Lyth Mono Term`, `JetBrainsMonoNL Nerd Font Mono`, `Maple Mono SC NF`), so it is drawn through system fallback (`Euphemia UCAS`, `Noto Sans CanAborig` on macOS); two earlier icons were `⎇` (U+2387) and `⑂` (U+2442, OCR FORK). The second row renders whatever other extensions pass to `ctx.ui.setStatus()`, in the order given by `statusline/line.ts`'s `STATUS_PRIORITY`: `plan-mode`'s mode indicator **first**, then the rest in registration order (`cwd-statusline`'s path, `rewind`'s `◆ N checkpoints`), capped at 5 entries. The mode indicator wins the first slot on purpose: the second row truncates instead of wrapping, so an indicator that trails a growing path can be pushed out of sight, and registration order alone depends on directory names. `simple-task` is no longer one of these — see [below](#simple-task--task-list). One key is exempt from this row entirely: `background-tasks` publishes its task dock under a reserved key that `composeFooterLines` lifts out and renders as the footer's **last line** — see [the interaction note below](#extension-interactions). Lines are truncated, never wrapped. Git reads happen on a debounced background path (400 ms after `turn_end`/`agent_end`/`tool_execution_end`, immediately on branch change, with a 30 s fallback poll) so the render path is a map lookup.

- `PI_STATUSLINE_FREEZE=off` — disable the footer freeze. On every session switch pi unconditionally restores its builtin footer and clears all `setStatus` values, and no extension hook runs before that frame. The guard replays the previous frame's lines instead, which removes a visible flash. Turning it off restores the flash.
- `PI_STATUSLINE_BOOT_SUPPRESS=off` — disable boot-window suppression. pi's built-in footer exists before the first extension runs (measured on this setup: its first frame lands at ~480 ms, this statusline at ~1.2 s), so without it you see the default state line and then watch the statusline replace it. [`statusline/footer-suppress.ts`](../extensions/statusline/footer-suppress.ts) patches `FooterComponent.prototype.render` at **extension-factory time** — before pi's TUI is constructed — to return zero lines, and releases it the moment our footer is installed. A 30 s cap releases it anyway when the handoff never happens (an extension error, or a non-TUI mode), so the bottom is never left permanently empty. The two windows have independent switches because they need different remedies: this one has no previous frame to replay, the freeze above has one.
- No config file. Colors come from `theme.fg(...)`, so `/theme` repaints on the next frame.

### `cwd-statusline.ts` — full working directory

Prints the complete `ctx.cwd` as an extension status line, deliberately uncompressed: no `~` shortening, no truncation of middle segments. Only terminal width truncates it, with an ellipsis, never a wrap.

- `PI_CWD_STATUSLINE=off`, `PI_CWD_ICON` (default ` 📁`).

### `startup-logo/` — the header

Replaces pi's header with a static pi logo, the version and the working directory (shortened to `~/...` inside the home directory), keeping the compact key hints below so no information is lost. The title line carries the **current model and thinking level** after the version — `pi v0.99.2 (deepseek-flash-qd with max effort)` — read live from `ctx.model?.id` and `ctx.thinkingLevel`, so `/model` and `shift+tab` are reflected on the next frame. Both reads sit in a `try/catch` because a `ctx` captured before a session replacement throws when you read it, and the missing part is dropped rather than breaking the header: no model means no parenthesis at all, no level means `(model)`. **Every line is indented one column** (`MARK_INDENT`), the mark, the hint line and the onboarding line alike, so nothing sits flush against the terminal's left edge; `composeHeaderLines` adds it to the text lines and `markLines` carries its own. The model id is external input of unbounded length (route names, an organisation's BYOK `mode-…`), and pi-tui **throws** on a line wider than the terminal (`Rendered line N exceeds terminal width`, taking the whole TUI down), so the title, the hint line and the onboarding line all go through `truncateToWidth` — the budget for the latter two is `width - MARK_INDENT.length`, counted including the indent, so "no frame is ever over-wide" does not depend on how wide the terminal is.

It also prunes the startup resource list **in full** — `[Context]`, `[Skills]`, `[Prompts]`, `[Extensions]` and `[Themes]` all go (2026-09-30: first `[Extensions]`, then `[Skills]`; only diagnostic sections such as `[Skill conflicts]` stay), and the extra blank line pi inserts before `[Context]` is taken out with them, so the list is not replaced by empty space. Pruning works by locating the mounted header component, so it only happens when the logo is installed — with `PI_LOGO=off` pi's own header and the full list come back.

The logo is static by design: no frame table, no timers, no `requestRender`. Narrow terminals degrade to a single-line wordmark.

- `PI_LOGO=off` — do not install the header.
- `header-guard.ts` freezes the header across session switches for the same reason the statusline freeze exists: pi restores its builtin header first, and that frame is visible.

### `below-editor-after-statusline.ts` — widget placement

pi mounts `belowEditor` widgets between the editor and the footer, which pushes the statusline to the very bottom of the screen. `pi-subagents`' fleet status line is registered that way. This extension finds the container holding the probe widget by object identity (never by index) and moves it to the end, so the fleet line sinks below the statusline and the editor keeps the statusline next to it.

The probe must be registered with `placement: "belowEditor"`. Omitting it silently moves the *upper* container instead, with no runtime error. Nothing happens if the container cannot be found.

- `PI_BELOW_EDITOR_AFTER_STATUSLINE=off`.

### `prompt-editor.ts` — the input box

Three changes to the editor.

**A `❯ ` gutter.** The real editing area is narrowed and the gutter is re-added per line, so cursor placement, IME positioning and mouse clicks all stay correct.

**A blank line** between a visible autocomplete list and the statusline, added only when the list is actually rendered (judged by the public `isShowingAutocomplete()`), so the static layout is unchanged.

**`!` bash mode**, matching Claude Code: when the prompt starts with `!` the gutter shows `!` instead of `❯` and the `!` you typed is hidden, so the body reads as the command itself. The mode is render-only — not a single character of the text changes. Detection copies pi's own (`text.trimStart().startsWith("!")`, the same flag that colors the editor border), and Enter submission, ↑ history and Esc clearing keep going through pi's own paths, so there is nothing to keep in sync. Hiding a column has two consequences: the body shifts one column left, so mouse clicks count one extra column, and the cursor has to be pushed off column 0 — otherwise the reverse-video cursor lands on the blank column, and typing there would inject `x!ls` into the text and drop pi out of bash mode. Leaving the mode needs no code: backspacing over the `!`, submitting, or Esc all make pi's own `isBashMode` false again and the next frame draws `❯`. `PI_EDITOR_PROMPT` changes the `❯` but not the bash `!`.

- `PI_EDITOR_PROMPT` (default `❯`), `PI_EDITOR_AUTOCOMPLETE_GAP=off`, `PI_EDITOR_AUTOCOMPLETE_SHIFT` (default 1 column).
- Pure logic lives in [`prompt-editor/bash-prompt.ts`](../extensions/prompt-editor/bash-prompt.ts); the render contract is covered by [`prompt-editor/render.test.ts`](../extensions/prompt-editor/render.test.ts), which loads the real extension through pi's own loader.

### `user-message-bar/` — the user message box

Puts a `▏` and one space at the start of **every** line of a user message box, including the blank padding lines above and below the text, so the body sits two half-width columns in:

```
▏
▏ body text
▏
```

The glyph occupies the one column of left padding that `Box` already reserves, and the extra indent column is taken back out of the line's **trailing** padding — so the background, the line width and the wrap positions stay exactly as they were. That is not a cosmetic preference: pi-tui's main-screen renderer throws `Rendered line N exceeds terminal width` as soon as one line is a column too wide, which takes the whole TUI down, so a bar drawn *next to* the padding is not an option. A line with no column to spare degrades to bar-without-indent, and a line with nothing to spare loses its bar rather than growing past the edge.

The glyph deliberately stays inside the box's `theme.bg("userMessageBg", …)` span so the background block's left edge is continuous. An earlier revision emitted `49m` before the glyph and restored the background after it to leave that one cell bare; it notched the block's left edge and was reverted. Do not reintroduce it — the tests assert exactly one `49m` per line, the trailing one.

The color is the theme's `accent` — the skin's emphasis color — with `selectedBg`, `toolDiffAdded` and `text` as fallbacks for themes that leave it undefined. `PI_USER_MESSAGE_BAR_COLOR=toolDiffAdded` restores the added-line-number green this extension used before.

pi's extension API reaches user messages only through `registerMarkdownTransformer`, which is string-level and never sees the box a message is rendered into, so the bar is drawn by patching `UserMessageComponent.prototype.render`. The patch goes in while the module is evaluated (before any frame is rendered, so resumed sessions get bars too) and is handed the live theme proxy on `session_start`, which is what makes it follow `/theme`. That source has to be dropped again on `session_shutdown`: when the session is replaced (`/clear`, `/new`, `/resume`, `/fork`, `/reload`) pi invalidates the old `ctx` while the previous session's user messages are still mounted and being rendered, and a stale-context read from inside a render tick — where no `try/catch` of ours can catch it — reaches pi's `uncaughtException` and kills the process. The event fires before the invalidation, and reading the theme is wrapped in a `try/catch` on top of that, so the worst case is a few frames without the bar; the next `session_start` restores it. The logic lives in [`user-message-bar/bar.ts`](../extensions/user-message-bar/bar.ts), which takes both the component and the theme as arguments; [`user-message-bar/index.test.ts`](../extensions/user-message-bar/index.test.ts) renders through pi's own `UserMessageComponent`, including the two regressions for the invalidated-`ctx` window.

- `PI_USER_MESSAGE_BAR=off` — leave user message boxes as they are.
- `PI_USER_MESSAGE_BAR_COLOR` (default `accent`) — theme slot to take the color from; a background slot such as `selectedBg` is converted to a foreground.

### `working-indicator/` — the working message

Replaces the fixed `Working` loader with a semantic label, a token count for the current segment and an elapsed time:

```
Tools Calling (↓ 70 tokens · 10s)      Editing (↓ 40 tokens · 3s)
Writing (↓ 120 tokens · 8s)            Reading (↓ 12 tokens · 1s)
Thinking (↓ 900 tokens · 22s)          Working (5s)
```

The token count is **per segment**, not per turn: each reasoning segment and each tool-argument segment starts from zero, so a `bash` command's count reflects that command. Body text is deliberately not counted. `usage.output` is always `0` while streaming, so counts are estimated from streamed characters. Elapsed time is `42s`, `1m 23s` or `1h 23m 32s`. The label uses the normal foreground color while the statistics stay muted, which requires composing everything into the single string pi receives.

The same extension draws the `●` on a running bash row.

It also explains the one window where the spinner turns with nothing to show for it. `pi-subagents`' watchdog runs an independent reviewer model inside `agent_end` on every turn that changed the repository (measured 7–17 s), and pi awaits every `agent_end` handler before clearing the spinner — so that stretch is a spinner with no explanation. This extension arms a one-shot timer on `agent_end` and, if the turn has still not settled when it fires, switches the message to a fixed `Subagent watchdog reviewing` (no elapsed time, no token count: a duration would only imply "much longer"). A normal turn settles in milliseconds, so the 2 s threshold does not misfire; `agent_settled`, `agent_before_settle`, `session_before_compact`, `agent_start` and `session_shutdown` all reset it — the middle two exist so that automatic compaction and `verify-loop`'s `/goal` evaluation (both of which run after the application-level `agent_end` and before `settled`) are not mislabelled as a watchdog review.

- `PI_BASH_SPINNER=off`, `PI_SPINNER_RAINBOW=off`, `PI_SPINNER_COLOR_HOLD` (default `19` frames per color).
- `PI_WORKING_INDICATOR_WATCHDOG=off` — keep the ordinary `Working` message during a watchdog review.
- `PI_WORKING_INDICATOR_WATCHDOG_DELAY_MS` (default `2000`) — how long after `agent_end` without a settle before the message switches.
- `PI_WORKING_SUMMARY=off` — disable the prompt summary line entirely.
- `PI_WORKING_SUMMARY_LLM=off` — truncate long prompts instead of asking a model to compress them.
- `PI_WORKING_SUMMARY_TRIGGER` (default `1`) — ask for a summary as soon as the prompt does not fit the available width. Raising it tolerates truncation up to that multiple (`1.2` ≈ give up the last fifth, `2` ≈ give up half), which is what you want if you do not care to spend a request on every prompt that overflows by a column.
- `PI_WORKING_SUMMARY_RETRY_MS` (default `3000`) — a failed request (error, timeout, or a response with no text) is retried once after this delay; two attempts per prompt is the cap, and a new prompt or the end of the turn cancels the pending retry.
- `PI_WORKING_SUMMARY_MODEL` — `provider/modelId` for that request; defaults to the session model so a typo can only cost the summary, never the request.
- `PI_WORKING_SUMMARY_GAP` (default `1`).

## Workflow

### `simple-task/` — task list

A lightweight task list: `task_set`, `task_update`, `task_get` and `/tasks`. Three states (`pending`, `in_progress`, `done`), no blocks, no dependency graph, no notes, no title-length validation.

State is written with `pi.appendEntry()`, so it rides the session log and **nothing is written into your repository** — no `.pi/tasks/*.json` to gitignore. Rebuilding reads `ctx.sessionManager.getBranch()`, not `getEntries()`, so branch navigation cannot resurrect a discarded branch's tasks.

Since 2026-09-24 the list is **independent of plan mode**: an approved plan is a document, progress is the model's own business, and the mirror contract, the separate id space and the header suppression that went with it have all been deleted. `/tasks` with no argument or `status` prints the list, `clear` empties it, `on` / `off` toggle the widget.

The widget is the whole feature: the packed `✔ n/N` status it used to also write into the statusline's second row was a duplicate of it, and that slot now belongs to `plan-mode`'s mode indicator. Do not add a second copy of the same information back.

All three tools are registered with `renderShell: "self"`, which gives their blocks the same shell as the bash and read blocks: **no background** in any of the three states and **no boundary blank lines**, because pi's default shell is a `Box(1, 1, bgFn)` and `selfRenderContainer` is a plain `Container` that `bgFn` cannot attach to. Unlike bash and read there is no hand-drawn left margin to keep aligned — `renderCall` / `renderResult` return `new Text(…, 1, 0)`, whose `paddingX = 1` puts back the single column of left margin the default `Box(1, 1)` used to draw (one leading space per line, not flush-left) while `paddingY = 0` keeps the top and bottom blank lines away and no `bgFn` means nothing to wrap in a `Box`. The shape is pinned by 3 end-to-end cases in [`simple-task/render.test.ts`](../extensions/simple-task/render.test.ts), which run through pi's own loader and `ToolExecutionComponent` and include a control asserting other tools keep their background.

### `recap/` — conversation summary

`/recap` summarizes the conversation on demand; the same summary appears automatically above the editor after **10 seconds of idling** with no new input, and disappears as soon as you type.

The delay is the point: the recap exists to tell you what a session was doing when you come back to the window, so it is idle-based rather than turn-based. The timer first asks whether any subagent is still running (an in-process RPC to `pi-subagents`, no file import — a missing package is treated as "no subagents") so a background delegation is never summarized as finished.

Deliberately not implemented: no local storage, no session entry, no configuration. The summary lives in memory only, so `/new` or `/resume` does not restore it and it is never sent to the model as context. The summary text itself is **generated in Chinese** (the prompt is hardcoded), which is worth knowing if you do not read Chinese.

`/recap` is **idempotent**: when a summary for the current exchange already exists and is on screen, running it again returns immediately — no model call, no widget reset, no notice. A second run would produce the same summary, and a *failed* second run would replace the summary you already have with a "could not generate" notice. The fingerprint is the last user+assistant pair plus the model, computed in one place (`latestExchange()`) and shared with the automatic path's de-duplication, so a new exchange re-opens the gate.

### `rewind/` — checkpoints and `/rewind`

Claude Code style checkpointing. Before every prompt that starts a turn, the working tree is snapshotted into a **shadow git repository** under `~/.pi/agent/rewind/<project-hash>/git` with `GIT_DIR` pointed at it and `GIT_WORK_TREE` at your project. Your repository's HEAD, index, refs and status are never touched, and this works in directories that are not git repositories at all.

Files the snapshot cannot see — anything `edit`/`write` touches outside the project root, inside it but `.gitignore`d, or inside a nested repository — are covered by lazy pre-image mirroring driven by tool-call events, with blobs addressed by content.

`/rewind`, or Esc Esc at an empty prompt, opens a menu: restore code and conversation, conversation only, code only, summarize from here, or never mind. Conversation restore uses pi's native session-tree navigation, which drops the selected user message and puts its text back into the editor.

- **Requires `doubleEscapeAction: "none"`** in `settings.json`. The extension warns once at session start if the built-in tree navigator would fire instead. It consumes the second Esc (the selector takes focus synchronously, so letting it through would cancel the menu it just opened) while leaving the first Esc alone, so Esc still aborts streaming.
- Known limits: only the `edit` and `write` tools are tracked (`bash` writes outside the root cannot be parsed), and files larger than 8 MB are not copied.

### `init-command.ts` — `/init`

Claude Code style repository memory file generation. Target selection looks only at `ctx.cwd`:

1. `CLAUDE.md` exists → update `CLAUDE.md`
2. else `AGENTS.md` exists → update `AGENTS.md`
3. else create `AGENTS.md`

`/init <file.md> [extra instructions]` overrides the target and appends your requirements. The extension writes nothing itself: it resolves the target and sends a prompt as a user message, so the model's own `read`/`write`/`edit` calls do the work and you can watch and correct them. It waits for the current turn to finish before sending.

### `theme-command.ts` — `/theme`

A one-step theme picker with live preview. Arrow keys preview, Enter persists, Esc cancels. `/theme <name>` switches and persists directly.

The preview works because `ctx.ui.setTheme()` has two distinct paths: passing a **Theme object** only recolors the running UI (`setThemeInstance()`), while passing a **name** applies it and immediately writes `settings.json` (`setThemeName()`). So browsing never touches your settings, and only Enter does. A `Spacer(1)` separates the theme list from the color swatches — both are multi-line blocks and read as one region when they touch. In non-TUI modes the command notifies instead of silently failing.

### `folder-history.ts` — cross-session command history

Persists command history per working directory in `~/.pi/folder-history/<path-with-dashes>.jsonl` and injects previous sessions' entries into the editor's own history array, which makes the **native ↑/↓** walk across sessions.

The mechanism matters: previous sessions' entries are appended to the tail of `Editor.history` (tail = older), so ↑ goes further back in time. No shortcut is registered — a registered `up` key would swallow cursor movement in multi-line prompts and arrow navigation in every selector. `PI_FOLDER_HISTORY_INJECT` (default `100`) caps how many entries come from earlier sessions.

### `clear-command.ts` — `/clear`

Alias of `/new` implemented through `ctx.newSession()` — the same replacement flow the builtin uses, so behavior and on-disk format match. It calls `ctx.waitForIdle()` first so an in-flight turn (including retries and auto-compaction) is never racing the session swap.

### `exit-command.ts` — quit words

Typing `exit`, `quit` or `bye` as the entire prompt quits pi cleanly (sessions are saved; `session_shutdown` still runs). Matching is exact and case-insensitive, so "exit the loop and print a summary" is untouched, and messages with attachments pass through. It only applies in TUI mode: in `--print`, `--mode json` and RPC mode these remain ordinary prompts. Also registers `/exit` as an alias of `/quit`.

- `PI_EXIT_WORDS="exit,quit"` — replace the words; `off` disables the interception.

## Model and tooling

### `plan-mode/` — Claude Code style plan mode + three-state permission mode

Three permission states (user decision, 2026-09-27), walked by `shift+tab` in a **fixed cycle**:

```
dangerous ──shift+tab──▶ bypass ──shift+tab──▶ plan ──shift+tab──▶ dangerous
```

| Mode | Icon / color | Permission meaning |
| --- | --- | --- |
| `dangerous` | `☢` / `error` (red) | pi's native any-permission form — the **seatbelt delete boundary is switched off entirely** |
| `bypass` (default) | `⏵` / `success` (green) | **delete boundary on** (startup, `/resume` and any unrecognized historical value all converge here) |
| `plan` | `⏸` / `warning` (orange) | read-only exploration — tools collapsed + bash write interception, stricter than the sandbox's "deletes only" |

**`dangerous` is reachable only by `shift+tab`.** There is no `/dangerous` command, `/plan` never takes you there (it toggles `plan` only and returns to `bypass` on the way out), and the model path (`enter_plan_mode`) can likewise only enter `plan`. Turning protection off is therefore always a decision the user pressed, never one an automatic path arrives at.

**`plan` has three exits with different landing states** — the one place the three-state design needs to remember where it came from:

| Exit | Lands on | Why |
| --- | --- | --- |
| `shift+tab` | **`dangerous`** (the cycle's next state) | follow the cycle rather than returning the way it came, otherwise one press from `bypass` looks like nothing happened |
| `/plan` | **`bypass`** (the safe default) | a command should not quietly drop the user into the sandbox-off state |
| plan document written, implementation begins | **`returnPhase`** (wherever it came from, possibly `dangerous`) | the user approved the plan, so implementation runs under the permission posture they had chosen |

`returnPhase` is recorded by `enterPlan` (the state before entering `plan`) and only the third exit uses it. Returning to `dangerous` says so explicitly in the notify ("delete boundary is off"), so the user cannot believe they are still protected.

**How the sandbox switch travels.** plan-mode and the two delete-interception layers (the bash seatbelt wrapper in `bash-command-collapse.ts`, the `apply_patch` `tool_call` check in `sandbox-boundary/index.ts`) live in three extension files with one `globalThis` singleton between them: `getSandboxMode()` / `setSandboxMode()` in `bash-command-collapse/sandbox-mode.ts` (the same trick as `allowlist.ts`'s store cache — pi's loader does not guarantee two extensions share a module instance, while `globalThis` guarantees they read the same state). Both consumers read it at **execution time** and AND it with the registration-time env gate (`PI_SANDBOX` + platform): any one of them saying off turns it off. When plan-mode is not installed or is disabled with `PI_PLAN_MODE=off` the singleton stays at its default `bypass` and interception keeps working (fail-safe).

**There is no execute phase** — aligned with Claude Code: approval restores write access and returns to `returnPhase`, "now implement what you just planned" is a one-shot instruction handed to the model, and progress is the model's own business — it builds a task list with `task_set` if it judges the work warrants one. Plan state lives in the session log (`pi.appendEntry("plan-mode")`, not in the model's context), and the plan document is written into `.pi/plans/` in the working directory.

Until 2026-09-24 this was three phases (`bypass` → `plan` → `execute`), where approval mirrored the steps into `simple-task` and tracked `[DONE:n]` markers against them. That whole "the extension owns the progress" mechanism is gone — the mirror contract, the markers, the step widget and the per-turn injection with it — because Claude Code's own `ExitPlanMode(plan)` takes a complete plan text and leaves the task list to the model. It became three states again on 2026-09-27, but the third state is a **permission mode** (`dangerous`), not a progress phase (`execute`) — the two have nothing to do with each other.

Four ways in: `shift+tab` (the three-state cycle), `/plan` (toggles `plan` only), `--plan` at startup, and the model's own `enter_plan_mode` tool.

**The model's way in asks for consent first (Claude Code's mechanism).** `enter_plan_mode` no longer enters directly: it opens a two-option `select` — `进 plan mode（只读探索）` (the default, so Enter accepts the model's request) and `直接实施`. Choosing the second, or pressing Esc, does not enter plan mode; the tool result tells the model the user chose to implement directly and not to call the tool again, so it acts on the instruction in the same turn. Three deliberate points: Esc counts as a refusal (Claude Code's "must consent to entering plan mode"), which also makes "too much friction, skip it" a single keystroke; the dialog is **only on the model path** — `shift+tab` / `/plan` / `--plan` go through `enter(ctx, "user")` and are already the user's own decision; and a headless run (`pi -p`) skips it and enters, keeping the previous behaviour where nobody is interrupted.

**The `brainstorming` mutual-exclusion gate (either-or, user decision 2026-09-26).** The superpowers `brainstorming` skill already carries the whole flow — clarify → 2–3 options → approval → design document → `writing-plans` implementation plan — which overlaps plan mode completely. So when the model calls `enter_plan_mode`, the handler scans the session branch **before** the consent dialog: if this run (everything after the last `role:"user"` message) contains an assistant `toolCall` that is a `read` of a path containing `/brainstorming/` (a fragment match, so a relocated skill library still counts; the judgement lives in `brainstorm.ts`, 13 pure-logic cases), it **neither enters plan mode nor shows a dialog** and instead returns an either-or explanation — follow the skill, do not call this tool again, and if the user wants plan mode they can press `shift+tab` or `/plan` themselves. Only the model path is gated; a user entering by hand never passes through it. Any error in the check fails **open** (the dialog shows as usual), because the costs are asymmetric: a false "not loaded" merely restores the old behaviour, while a false "loaded" would silently take plan mode away. Two known boundaries are recorded in `brainstorm.ts`'s header: reading `SKILL.md` with bash `cat` does not count as loading (only the `read` tool does, the same lexical standard `verify-loop` uses), and the exemption covers the current run only — the next prompt does not inherit it.

**All the routing criteria live in the tool description (also Claude Code's shape).** `enter_plan_mode`'s `description` carries 7 positive conditions (a new feature / several viable approaches / changing existing behaviour or structure / an architectural tradeoff / **more than 2–3 files** / unclear requirements / a fork the user's preference decides — the last one spelled out as "if you were about to ask with `ask_user_question`, use this tool instead"), 4 exemptions (a one-or-two-line fix / a single function with clear requirements / **the user already gave specific detailed instructions** / **pure research, exploration or review**), and GOOD/BAD examples. The global `AGENTS.md`'s `## Uncertainty` keeps a single pointer to it instead of a second copy — Claude Code's system prompt likewise contains no plan rule at all. Criteria in the tool description are read at exactly the moment the model decides whether to call the tool, and they cannot drift away from `AGENTS.md`. This is why the criteria can afford to be loose: a misjudgement costs the user one keystroke at the consent dialog, not a forced round of plan → proposal → approval → document.

**Submitting and approving.** `exit_plan_mode` takes `plan` (the complete markdown for the user — this is what the approval dialog renders), a **required `slug`** (lowercase English plus digits and hyphens, 3–5 words, e.g. `m5-entity-runtime`; the document becomes `.pi/plans/<date>-<slug>.md`, so CJK and punctuation are folded away and a purely Chinese name falls back to `plan`), and an optional `summary` (one line, display only). The dialog offers three answers — **write the plan document and implement it**, **write the document only**, or **reject**. On the first two the model writes the file with the `write` tool while a `tool_call` hook pins that tool to the single approved path, so "plan mode" cannot be used to write anywhere else; a `tool_result` hook notices the successful write and closes the phase itself.

The dialog is **truncated to one screen** (`truncatePlanForDialog`), and the height is computed with pi-tui's own `wrapTextWithAnsi`, so it matches the real render including CJK line-breaking; the overflow line reports how many steps are left and points at the terminal scrollback, while the plan text handed to the model is never truncated. This is not cosmetic: pi pins the viewport to the bottom on every repaint and the confirm dialog is a non-scrollable `Text`, so a long plan is guaranteed to be cut off and manually scrolling up is undone by the next repaint.

**Both dialogs paint their body in `fg`, not `accent`.** They pop through `ctx.ui.select(title, options)` (pi's `ExtensionSelectorComponent`), which wraps the *entire* title string in `theme.fg("accent", title)` and has no separate message parameter — so the body (the model's reason, the full plan text) could only be passed inside the title, and the whole thing came out accent-colored. `consent.ts` fixes it by wrapping **each body line** in `fg("text", line)`: an inner explicit color overrides the outer one, so the accent no longer reaches the body, while the title line and the highlighted option keep pi's accent. It must be per-line rather than one wrap around the block, because pi-tui's `Text` resets style at each in-segment newline — a single opening color code would drop back to accent from the second line on. The only cost is a duplicated `\x1b[39m` reset at line ends, which is invisible. `consent.ts` imports nothing from pi (the theme's `fg` is injected), so `node --test` asserts the wording directly.

**The two tool blocks render as trees.** `enter_plan_mode` / `exit_plan_mode` no longer use pi's default tool shell (`toolSuccessBg` background, `Box(1, 1)`'s blank line above and below, bold name only, flat body). They draw the bash-block family shape:

```
• enter_plan_mode ✔
  │ 已进入 plan mode（只读）。原因：跨 gateway/config.yaml 与 pi、
  └ opencode、codex 三端配置的行为改动。
```

Three things are decided in the pure module `render.ts` (coloring and wrapping happen in `index.ts`, where the real theme is available): the **four-state outcome** (`classifyPlanToolOutcome` — "entered / approved" and "refused / rejected" are both normal returns to pi, distinguished only by `details.consented` / `details.accepted`, so classification must read details and not just `isError`); the **title dot and marker** (`planToolTitleParts` — green `•`+`✔` on success, grey `•`+`✘` when it did not happen but is not an error, red `•`+`✘` on a real error, grey `•` with no marker while running); and the **tree prefixes** (`planResultTreePrefixes` — `│ ` on every line but the last, `└ ` on the last). That last rule **deliberately diverges** from the bash block, whose single `└ ` lands on the first real output line with the body indented below it; here the user asked for `└` to follow through to the last line. Both are user-chosen shapes — do not "unify" them. The geometry is one shared table with the bash block: `•` at column 0, `│` / `└` at column 2, body at column 4, so the tree lines up under `enter_plan_mode`'s first letter and adjacent blocks stay aligned.

**The mode indicator has a fixed slot**: the head of the statusline's second row, with text in all three states — `☢ dangerous` (painted `error`, red, so "the boundary is off" is visible at a glance), `⏵ bypass` (`success`, green — the red moved to `dangerous` when the third state arrived on 2026-09-27) and `⏸ plan`, `⏸ plan · 待批准` or `⏸ plan · 写文档中` (`warning`). See [`statusline/`](#statusline--the-footer) for why the slot is pinned.

**Two independent gates, not one:**

1. **The tool set.** Entering plan mode removes `edit`, `write` and `powershell` from the active tools and restores the set **exactly as it was** on exit. The set is snapshotted rather than hardcoded because this environment has twenty-odd extension-registered tools (`mcp__*`, `ask_user_question`, `task_set` …) that a whitelist would silently drop.
2. **A `tool_call` hook.** `bash` stays available, so write-shaped commands (redirection, `rm` / `mv` / `sed -i`, `git commit`, `npm install`, `sudo` …) are rejected there and the reason is returned to the model as a tool error. The judgement is made per simple command, so `cat a.txt && rm -rf b` still has its `rm` caught; heredoc bodies are stripped first, and fd duplications (`2>&1`) and `/dev/null` targets pass.

**This is a guardrail for a cooperative model, not a sandbox.** Two shapes are deliberately allowed through: `$(...)` command substitution inside double quotes, and `npm run <script>`, whose side effects live in the script. Blocking those would block ordinary exploration; real protection needs an OS-level sandbox.

`shift+tab` is taken from pi's built-in `app.thinking.cycle`. A conflicting `registerShortcut` is skipped by pi's runner, so the key is intercepted with `ctx.ui.onTerminalInput` **before** the editor sees it (only in TUI mode, while idle, and with no extension dialog open) and consumed. Because that displaces the thinking-level cycle, the extension rewrites `app.thinking.cycle` to `ctrl+shift+t` in `~/.pi/agent/keybindings.json` — and only when the key has no binding at all; a user-configured binding is left alone. Matching the key must go through pi-tui's `matchesKey`, not a string compare: `shift+tab` arrives as bare CSI (`\x1b[Z`), as the Kitty protocol's CSI-u (`\x1b[9;2u`) or as xterm's modifyOtherKeys, and pi turns the Kitty protocol on at startup, so a real terminal sends the second form. Pressing `shift+tab` while streaming still cycles the thinking level — plan mode only switches when you are stopped.

Restoring state on startup reads `ctx.sessionManager.getBranch()`, **not** `getEntries()`: the latter returns every entry in the file including branches discarded by `rewind` / fork / branch navigation, so a plan dropped on another branch would come back to life (observed: no plan on the active branch, yet the statusline showed `▶ 0/1 executing`). A stored `normal` (the old name of `bypass`) is normalized through a whitelist, so an existing session's phase field cannot bring back a phase that no longer exists.

- `PI_PLAN_MODE=off` — disable the extension entirely.
- `PI_PLAN_MODE_AUTO=off` — keep `shift+tab` and `/plan`, drop the model's `enter_plan_mode` tool.
- `PI_PLAN_MODE_CONSENT=off` — keep the tool, drop its consent dialog (back to "calling it enters plan mode").

272 `node --test` cases: `plan.test.ts` (114), `plan-text.test.ts` (26), `plan-doc.test.ts` (23), `render.test.ts` (19 — the status line's three states plus the tool block's outcome classification, title decoration and tree prefixes), `brainstorm.test.ts` (13), `consent.test.ts` (10 — both dialogs' body coloring and wording) and `keybinding.test.ts` (10) are pure logic; `index.test.ts` (57) loads the real extension through pi's loader.

### `bash-command-collapse/sandbox.ts` — the seatbelt delete boundary

The replacement for the lexical route. The insight is that **enumerating dangerous commands is the wrong shape**: the incident was one line in a generated script, and the space of ways to delete something is unbounded. So the boundary is drawn the other way round — the command runs inside `sandbox-exec` with an `allow default` base, and **exactly one capability is taken back**: delete.

**The base is `(allow default)`, and that is the 2026-10-01 decision** (user's rule: *block only the dangerous deletes, nothing else*). The earlier profile used `(deny default)` and enumerated what was permitted, which has a consequence that is easy to miss: **the thing being bounded was not deletion but every seatbelt operation nobody had enumerated yet.** Measured — `screencapture` died with `Trace/BPT trap: 5` inside the sandbox while working outside it, and bisecting one rule at a time showed the only missing piece was `iokit-open`: the gap was the enumeration, not the permission decision. With `(allow default)` the profile enumerates only *what may not be deleted*, and a 17-cell matrix run over both bases came out cell-for-cell identical (`file-write-unlink` is a separate mach operation, so `allow default` does not open a hole in it). Known and deliberately unfixed: `ps`, `top` and `launchctl list` are still blocked under `(allow default)` — a seatbelt limitation, not something this profile can grant. The cost is stated: the sandbox no longer backstops *unknown* operations, and apart from deletion it does not care what a command does.

- reads and network: unrestricted;
- writes (`file-write*`): globally allowed — a write cannot make an inode disappear, and the reversibility of an overwrite belongs to git and the `AGENTS.md` discipline (with `allow default` this is no longer a rule the profile writes at all, just the absence of one);
- **deletes (`file-write-unlink`): denied first, then allowed for the delete roots only** — the project directory, the temp roots (`/tmp`, `/private/tmp`, `/var/folders`, `/private/var/folders`, `/var/tmp`, `/private/var/tmp`), the regenerable caches (`SAFE_CACHE_HOME_DIRS`: `~/.cache`, `~/.npm`, `~/.gradle/caches`, `~/.m2/repository`, `~/.cargo/registry`, `~/.bun/install/cache`, `~/.node-gyp`, `~/.Trash`, `~/Library/Caches`, `~/Library/Developer/Xcode/DerivedData`) and whatever `PI_SANDBOX_EXTRA_WRITE` lists.

A delete outside those roots is refused by the **kernel** (`EPERM`), not by a pattern match, so no command shape escapes it: `rm`, a `node` script, `git clean`, a compiled binary — all hit the same wall. `rename` is covered too, because it unlinks the destination. `/var/tmp` is in the boundary because it is macOS's built-in bash 3.2 heredoc temp directory, hard-coded at compile time (`TMPDIR` cannot move it) — without it every heredoc inside the sandbox fails 100% and leaks a `sh-thd-*` file. One consequence is deliberate: a command that tries to delete outside the boundary **fails and has to be rerun**, which is why the extension only asks once per command per session and warns the model that a retry is coming. Non-interactive environments fail closed.

`classifyOutsidePaths` is the one judgement both routes share. It sorts a target into five mutually-exclusive verdicts — **blocked** (never-delete), covered by the persistent allowlist or a session exemption, **dangerous**, **ordinary**, or inside the boundary. The three authorization tiers, in the order they are judged:

- **never-delete** (`NEVER_DELETE_HOME_FILES` / `NEVER_DELETE_HOME_DIRS`): identity, credentials and hand-written config — `~/.zshrc`, `~/.gitconfig`, `~/.env`, `~/.bash_history`, `~/.envrc`, `~/.tool-versions` and 43 other home-level files, plus the `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.kube`, `~/.docker`, `~/.azure`, `~/.gcloud`, `~/.terraform.d`, `~/.helm`, `~/.minikube`, `~/.password-store` subtrees (21 entries). Entries may carry a slash: `~/.config/gh` (GitHub token in `hosts.yml`) and `~/.config/gcloud` (credential store) are nested entries that fish the real credentials back out of `~/.config` after that subtree left this tier. **Deliberately absent**: `~/.config`, `~/.pi`, `~/.claude`, `~/.codex` — tool-state directories holding locks, caches and session logs that need routine cleanup; locking the whole subtree made even pi's own stale-lock cleanup fail with kernel `EPERM` (`pi update --extensions` exited 1). They fall to the ordinary tier: dialog, rememberable. The trade-off is stated: the guard's self-protection (`extensions/`, `AGENTS.md`, `sessions/`, `rewind/`, the allowlist file all live under `~/.pi`) drops from "kernel refuses unconditionally" to "dialog + explicit consent". **No dialog, no way to allow it** for what remains in the tier: the allowlist, a session exemption and `PI_SANDBOX_EXTRA_WRITE` all lose to it, because the profile emits a `deny file-write-unlink` line *after* the allow lines and seatbelt lets the later rule win. Judged first and ahead of the boundary, except under the project directory (so working inside `~/.pi/agent/extensions/x` can still delete its own files).
- **dangerous**: a system root, a `bin` directory, an application install directory, `~/Library`, or anything containing a VCS store. Asked about **every time**; only a session-scoped exemption (`Allow for this session`) is offered.
- **ordinary**: asked once; `Allow for this session（并记住该目录）` writes the directory range into the persistent allowlist.

The dialogs are three-way: dangerous paths get `Deny` / `Allow once` / `Allow for this session`; ordinary paths get `Deny` / `Allow for this session（并记住该目录）` / `Allow once`. When a delete is refused the extension extracts the blocked paths from the failure output by **exclusion** (it keeps the in-line absolute-path scan and only skips lines containing `here document` and lines whose leading program is a shell or `sandbox-exec`) rather than by a program-name allowlist, which would silently drop the python3 `PermissionError`, `find:` and `ln:` shapes. **If it cannot extract a path it does not prompt** — it reports the original error plus a `[沙箱]` line pointing at `/sandbox-boundary allow <directory>`; the old "ask once per whole command, then rerun outside the sandbox" fallback is gone. An approved delete reruns **inside** the sandbox with the approved range widened, so the rest of the command stays supervised.

A delete can also be masked by a command that succeeds overall: in `rm <outside> ; <ok>` the kernel refuses the `rm` with `EPERM` but the whole command exits 0, so pi does not throw and the catch branch (the dialog) never runs — the refusal would be swallowed silently. `maskedDenialPaths` catches that shape: when a **successful** command's output still contains a denial and the target file is in fact still there, the result gets one appended `[沙箱]` line naming the blocked paths and the authorization exit. It does not prompt and does not rerun (user decision 2026-09-26: annotate only) — the command still counts as succeeded, and the note just makes the swallowed refusal visible to both the model and the user.

**Two guards against false positives, and they are ANDed.** The first is the presence of denial *text*; the second is `looksLikeDeletionCommand`, a **verb gate** added 2026-10-01 that asks whether the command could delete anything at all. It is needed because the byte match and the on-disk existence check can both be satisfied by a command that deleted nothing: `echo "rm: /path: Operation not permitted"` over a path that really exists triggered `[沙箱] 命令整体成功，但以下删除被沙箱拦下（文件仍在）` in a measured run. The gate splits on `;` `&&` `||` `|` `&` and newlines, skips leading `VAR=value` prefixes and wrapper words (`sudo`, `env`, `nohup`, `time`, `xargs`, …), then looks at the segment's program basename — `rm`, `rmdir`, `unlink`, `mv`, `ln`, `shred`, `trash`, plus `sed` / `perl` / `ruby` when `-i` is present (in-place edit rewrites via `rename`, which is an unlink of the source). `git` is resolved to its subcommand so `git clean` and `git worktree remove|prune` count while `git commit -m "clean up"` does not. **The direction is deliberate: annotate too little rather than too much.** This is an allowlist, so a delete hidden inside a script (`node x.js` calling `unlink`) is not annotated — a missed note costs one line of guidance and the non-zero exit path still escalates; a gate that annotates `cat` and `grep` output would emit noise on every log that happens to contain those words.

- `PI_SANDBOX=off` — disable both the profile and the `apply_patch` gate (also automatic off macOS, where there is no `sandbox-exec`).
- `PI_SANDBOX_EXTRA_WRITE` — colon-separated extra delete roots, `~` expanded, like `PATH`.

### `bash-command-collapse/sandbox-mode.ts` — the runtime on/off singleton

A `globalThis` singleton holding one of `dangerous` / `bypass` / `plan`, defaulting to `bypass`. It is how [`plan-mode/`](#plan-mode--claude-code-style-plan-mode--three-state-permission-mode)'s three-state cycle reaches the two delete-interception layers, which live in different extension files: `bash-command-collapse.ts` reads `getSandboxMode()` at **execution time** and skips the seatbelt wrapper entirely when it is `dangerous`, and `sandbox-boundary/index.ts` does the same for the `apply_patch` gate. Execution-time is the point — a registration-time read could never be flipped by a later `shift+tab`. Both consumers AND it with the registration-time env gate (`PI_SANDBOX` + platform), so any one of them saying off turns the boundary off; when plan-mode is absent or disabled the singleton stays `bypass` and interception is unchanged (fail-safe). `sandbox-mode.test.ts` covers the default, the set/reset round-trip and the cross-module identity.

### `bash-command-collapse/allowlist.ts` — the persistent allowlist

`~/.pi/agent/sandbox-allowlist.json` holds the directories the user has confirmed safe (`PI_SANDBOX_ALLOWLIST` moves the file). Remembering a directory means adding it to the profile's `file-write-unlink` allow list, so the delete simply succeeds inside the sandbox while the rest of the command stays supervised.

The store is a `globalThis` singleton on purpose: the bash route and the `apply_patch` route share it, so remembering a directory on one side takes effect on the other in the same session, and the two can never drift into different boundaries. It is **global rather than per-project** — a directory approved in project A is approved in project B — which is the stated intent, not an oversight. Three defences keep it honest: `remember()` only accepts roots that pass `isSafeAllowlistRoot` (never `/`, `$HOME`, a never-delete path such as `~/.ssh` or `~/.zshrc`, anything containing `.git`, or an ancestor of any **dangerous** root — the ancestor gate checks the dangerous tier only, since the never-delete list gained nested entries like `~/.config/gh` and checking it there would make `~/.config` unrememberable forever; safety is unaffected because the kernel deny lines reclaim never-delete subtrees after the allow lines), `memoryScopeFor` never stores a range shallower than `MIN_ALLOWLIST_DEPTH` (so one confirmation cannot hand over a whole home directory), and the file is filtered again on load, so a hand-edited `"/"` cannot get in. Writes are atomic (temp file + rename). A corrupt, unreadable or unknown-version file degrades to an empty allowlist and **never throws**: the failure direction of a gate whose job is to prompt less must be "ask once more".

### `sandbox-boundary/` — the non-shell half of the boundary

The same boundary for tools that never touch a shell. `write` / `edit` are direct `fs` calls inside the extension process, so no shell profile reaches them — but the only capability the seatbelt profile takes away is `file-write-unlink` outside the boundary, and `write` / `edit` cannot unlink anything. So the boundary only needs one thing here, and only one tool has it: `apply_patch`'s `*** Delete File:` lines. `Update File` and `Add File` are writes and pass.

Two differences from the bash side matter. It runs on the `tool_call` hook, so it rejects **before** execution and knows every target path up front — there is no "run the command, fail, ask, rerun" cost, so `Allow once` is simply a pass without remembering. And because it shares `classifyOutsidePaths`, the same allowlist and the same session exemptions with the bash side, remembering on either side covers both. A never-delete path in a patch is refused outright — the **whole** patch is rejected, with no option to approve the rest, so one `*** Delete File: ~/.ssh/id_rsa` cannot ride along with legitimate operations.

When a target is already covered by the allowlist it passes silently but emits a `notify`, so "why did it not ask?" has an answer on screen.

- `/sandbox-boundary` prints the delete boundary and the allowlist; `forget <path>` removes one entry, `clear` empties the list, `allow <path>` pre-authorizes a directory.
- `PI_SANDBOX=off` disables it together with the bash profile, so there is never a state where one side is bounded and the other is not.

### `core-rules/` — keeping the core rules in context

`~/.pi/agent/AGENTS.md` is a **user-level** file that pi renders into `<project_context>` in the system prompt, after the preamble, tools, rules and docs — and in this setup it is followed by a 105 KB project `AGENTS.md`. It is sent on every request and never lost, but it sits in the middle of a giant blob at the very front, so its instruction-following strength decays as the conversation grows. Nothing about it fails; it just gets quieter.

This extension pushes the distilled core back to the end. Codex solves the same problem in a different way, and the design is copied from there: `~/.pi/agent/AGENTS.core.md` is not part of the system prompt but a world-state section, injected as a user-role message, re-sent when it changes, re-injected after a compaction, and placed near the end. In pi the landing spot is even better: a message returned from `before_agent_start` is appended **after** the user message and persisted into the session, so it is the most recent thing in context — closer to the end than Codex's "before the last real user message".

One decision (`decision.ts`) covers all three Codex triggers: scan the model-visible projection for this extension's own messages — absent means the session just started or compaction dropped it, present with a different hash means the content changed, same hash means skip. Nothing is sent when nothing changed. The file is read on every `before_agent_start`, so editing it takes effect on the next prompt — no `/reload`, no restart.

The injected body is the distilled ~7 KB core, not the ~27 KB file: persistence, the destructive-action rules, the blast-radius table, authorization, the plan gate, delegation (including the orchestration trigger — direct `{ agent, task }` for one bounded child, one top-level `workflowScript` call for keyed children / sequencing / fanout / steering / retry / aggregation), the skill-trigger rules, communication and the git/shell bottom line. The full rules stay in the system prompt; this is the part that must not decay. **A missing `AGENTS.core.md` makes the extension skip silently** — it is an optional enhancement and should not make pi noisy at startup. It is shipped as [`config/AGENTS.core.md`](../config/AGENTS.core.md).

- `PI_CORE_RULES=off` — disable the extension.

### `verify-loop/` — the verification gate and `/goal`

Completion claims used to rest entirely on the model's own report: it edits files, says "done, tests pass", and pi ends the turn with nothing checking that sentence. This extension turns that discipline into code, mirroring two Claude Code mechanisms on pi's `agent_before_settle` boundary — "the final actionable boundary: it can append entries and request one continuation".

**(1) The gate** (Claude Code's `type: "command"` Stop hook). On every settle — only `outcome === "completed"`; abort and error skip, matching CC's Stop / StopFailure split — it scans the current run (everything after the last user message): if files were changed (`edit` / `write` / `apply_patch` / `multiedit`, non-document paths) but **no bash command ran after the change**, it appends a `display: true` injection message (visible to the user, like CC's "Stop hook feedback", and entering the model context as a user-role message) and returns `continue: true` to force one more turn.

The block count is **not an in-memory counter**: it counts this extension's already-injected messages in the model-visible projection. `agent_start` re-fires on every boundary continuation (`runAgentLoopContinue` emits it), so a reset hooked to it would zero the counter mid-chain and defeat the cap; counting from the projection is branch-correct, survives resume, needs no mutable state, and the injected messages are `role: "custom"` (not user), so they do not cut the run window — the whole continuation chain shares one window, exactly CC's "consecutive blocks within one turn" semantics. The cap defaults to 2 (`PI_VERIFY_LOOP_CAP`; CC's generic 8 is for arbitrary user hooks).

**The verification criterion is any bash call.** The first live smoke test (2026-09-25) measured a false positive: after editing `probe.js` the model ran `node --input-type=module -e "import('./probe.js')…"` — genuine evidence, but matching no test-runner shape, so the gate blocked a second time. A lexical gate cannot judge whether a command is a *relevant* test (that is the evaluator's job), so the gate only asks whether the actual state was observed after the change. `PI_VERIFY_PATTERN=strict` restores the test/build/lint-only pattern, or supply a custom regexp. The known cost, stated rather than fixed: an `ls` passes the gate — CC's command-type Stop hook is equally coarse.

**(2) `/goal`** (CC's session-level prompt evaluator: set by hand, evaluated automatically afterwards). `/goal <condition>` (≤ 4000 characters, CC's limit) persists via `appendEntry` and immediately starts a turn with the condition as the instruction; on every later settle it first asks whether a subagent is still running (if so the turn is skipped — CC's "background work defers evaluation", reusing `recap/subagents.ts`'s RPC), then makes one **tool-less** independent model call (condition + `serializeConversation(convertToLlm(projection))` tail-truncated to 120k characters by default) and parses a three-verdict JSON (`met` / `not_met` / `impossible`, bare or fenced): not met → the reason is injected and the turn continues; met or impossible → an entry is recorded and the goal cleared. **Fail-open**: an evaluation failure, timeout or unparseable answer passes the turn through (CC's hooks likewise never block on their own failure). No-progress detection (2 consecutive continuations with zero tool calls → stop the loop, keep the goal — CC: "stops the loop … with the goal still set") and the 8-continuation cap (`PI_GOAL_CAP`, CC's number) are counted from the projection the same way. Resume restores an active goal but resets the turn count (CC: "carries the condition over but resets the turn count"); met / impossible goals are not restored. The evaluator model is `PI_VERIFY_EVALUATOR_MODEL=provider/modelId`, defaulting to `litellm-any/qwen3.8-flash` and falling back to the current session model.

**The one deliberate divergence from CC**: CC ships no hooks by default (the user configures them in settings.json); pi has no hooks configuration layer, so the gate is **on (`block`) by default** with its trigger narrowed as far as it goes, and `PI_VERIFY_LOOP=off|notify|block` switches it. While a goal is active the statusline's second row shows `◎ /goal active` (key `verify-goal`).

91 `node --test` cases: `gate.test.ts` (24), `goal.test.ts` (23) and `evaluator.test.ts` (23) are pure logic; `index.test.ts` (21) loads the real extension through pi's loader with a fake subagent bus and a fake model registry.

- `PI_VERIFY_LOOP=off|notify|block` — the gate's force; default `block`.
- `PI_VERIFY_LOOP_CAP` — consecutive-block cap; default `2`.
- `PI_VERIFY_PATTERN=strict|<regexp>` — what counts as verification; default: any bash call.
- `PI_VERIFY_DOC_EXT` — extensions whose edits are not mutations; default `.md,.txt` (empty string disables the exclusion).
- `PI_GOAL_CAP` — `/goal` continuation cap; default `8`.
- `PI_VERIFY_EVALUATOR_MODEL` — evaluator model `provider/modelId`; default `litellm-any/qwen3.8-flash`, then the session model.
- `PI_GOAL_CONTEXT_CHARS` — conversation character budget for the evaluator; default `120000`.
- `PI_GOAL_TIMEOUT_MS` — evaluation call timeout; default `45000`.

### `memory/` — Claude Code style auto-memory

Cross-session learning, filling the biggest gap in issue #13. Storage follows CC: `~/.pi/agent/memory/<project-slug>/` holds a `MEMORY.md` index plus one file per memory, with CC-compatible frontmatter (`name` / `description` / `metadata.type` — one of `user` / `feedback` / `project` / `reference` — and `modified`). The slug is derived from the git root of `ctx.cwd` (`findProjectRoot`), so the same project keeps the same memory wherever the checkout lives.

**The index is derived mechanically — the model never hand-writes it (option C).** After every `memory_write` the extension scans all body files' frontmatter and rebuilds `MEMORY.md` from scratch (idempotent: identical content is not rewritten, so mtime and caches stay stable). This removes the failure mode both CC and Qoder fight — "wrote a memory but forgot the index, so it is saved yet never recalled". Hand-editing a body file is picked up on the next `before_agent_start`, which also rebuilds the index. The index is capped (`INDEX_MAX_LINES` 200 / `INDEX_MAX_BYTES` 25000) with an overflow count.

**Injection goes through `before_agent_start` mutating `systemPromptOptions.sections.memory`** — discipline text + index (an empty store injects nothing, and a disabled project injects nothing). A section lands in the system message, replays with the transcript and survives compaction; since the index only changes on writes its bytes are naturally stable, so none of the KV-cache snapshot machinery a log-based memory needs. The discipline text carries CC's three gates (applicable / durable / legible), the tense rule (save past-tense observations — measurements, decisions with rejected options, user corrections — never present-tense repo-state claims, which rot), the read-side verification duty (a memory naming a file/function/flag must be re-checked before acting on it, the same standard as `AGENTS.md`'s `## Verification`) and no secrets.

Four tools, all file operations wrapped in `withFileMutationQueue` (tool calls run in parallel):

- `memory_write` — write the body file and rebuild the index; the same name updates in place.
- `memory_read` — read one memory's full body, or list all when the name is omitted.
- `memory_forget` — delete the body file and update the index.
- `memory_search` — zero-dependency keyword search, frontmatter hits weighted 3, body hits 1.

`/memory` is the only user-facing surface (mirroring CC's three items): a status line, open the memory folder, show the index as a widget, and an enable/disable toggle (a per-project `.disabled` marker). `PI_MEMORY=off` disables the extension entirely; `PI_MEMORY_DIR` overrides the memory root (test isolation). Deliberately not in v1: background dream consolidation (a mount point is left), a USER/PROJECT dual scope (per-project only), semantic search, and a mechanical write gate — tense and secrets are enforced by the discipline text.

**The four tool blocks render as trees, not cards.** They no longer use pi's default tool shell (`toolSuccessBg` background + a blank line above and below + flat body text). Each declares `renderShell: "self"` and draws the same tree shape as the bash and plan blocks: a flush-left title `• memory_write` whose dot is the outcome lamp (green `success`, grey `dim` for declined, red `error`), then the body hanging off a 2-column indented tree (`│` on continuation lines, `└` on the last one; the structural glyphs take `muted`, the body `text`). The geometry is one shared table with the plan and background-task blocks — `•` at column 0, `│` / `└` at column 2, body at column 4, so the tree lines up under the tool name's first letter. **These blocks carry no `✔` / `✘` marker at all**: the dot already reports the outcome, and a memory tool is one synchronous read or write with no "still running" phase for a checkmark to assert. The preview truncation pi's default shell provided is re-implemented here (`PREVIEW_MAX_LINES` 10 plus a `… (N more lines, ctrl+o to expand)` hint, counted in wrapped visual lines), because without it a long `memory_read` body or a nameless listing of dozens of memories would fill the screen; `ctrl+o` expands without truncation. The shape decisions (outcome classification, title decoration, tree prefixes, preview hint) live in the pure module `render.ts`; coloring and wrapping live in `index.ts`, which is where the real theme is available.

46 `node --test` cases: `store.test.ts` (10), `context.test.ts` (4) and `render.test.ts` (11 — outcome classification, the four title states all marker-free, tree prefixes, the geometry constants, the preview hint wording) are pure logic; `index.test.ts` (21) loads the real extension through pi's loader, including a regression assertion for the issue #13 bug where the injected `promptSnippet` was stripped and nine block-rendering cases (self shell, the four title states distinguished **only** by dot color, tree geometry, `└` still only on the last line after wrapping, preview truncation and its expanded form, no background and no boundary blank lines through pi's real component with other tools' backgrounds as the control, and an in-flight block showing only its title).

- `PI_MEMORY=off` — disable the extension entirely.
- `PI_MEMORY_DIR` — override the memory root directory (used for test isolation).

### `background-tasks/` — the background-execution primitive pi lacks

pi 0.87.1 has no way to run a command in the background (`run_in_background` appears nowhere in its dist, and `ExecOptions` carries only `signal` / `timeout` / `cwd`), so a long task blocks the foreground `bash` tool until its timeout. This extension supplies the missing primitive with three tools shaped like Claude Code's, plus one command:
| Here | CC equivalent |
| --- | --- |
| `run_in_background` | `Bash` with `run_in_background: true` |
| `background_output` | `BashOutput` — incremental by default; `offset` is an absolute character position |
| `background_kill` | `KillShell` — SIGTERM to the whole process group, SIGKILL after a 2 s grace period |
| `/background` | `/bashes` |

**Completion wakes the model.** When a task reaches a terminal state the extension injects a `<background-task-notification>` message and triggers a follow-up turn — immediately when idle, queued after the current turn while streaming (`deliverAs: "followUp"`). That is why the tool descriptions say not to sleep or poll: the notification is terminal truth, and `background_output` is an inspection tool for when the output content is actually needed.

**Tasks live and die with the pi session.** `session_shutdown` calls `killAll()`. `detached: true` exists only so the command leads its own process group and `kill(-pid)` takes the whole group (an `npm test` worker, each stage of a pipeline); the child is **not** `unref`'d, so nothing survives pi and no cross-restart recovery logic is needed. Two costs are recorded in the header: a SIGKILLed pi never runs shutdown, and `/reload` ends running tasks. Session *replacement* (`/clear`, `/new`, `/resume`) also fires `session_shutdown` while the extension instance survives, so `session_start` resets the `disposed` flag — otherwise tasks in the new session could never wake the model — and a registry identity check stops a late `exit` from a task the old session killed from injecting into the new one.

**Background commands do not run inside the seatbelt delete boundary.** The foreground `bash` tool is wrapped by `bash-command-collapse.ts`; this extension spawns directly, which is semantically the user running `cmd &` themselves. Both the tool description and `/background`'s output print that warning, and destructive work is expected to stay in the foreground.

**Each task runs in its own git worktree by default.** Concurrent background tasks used to share the session's working tree, and a real 8-task session showed the consequence: one task's A/B script rewrote a config file while another task, claiming to measure the *baseline*, read the treated code — the baseline data was silently invalid. Throttling cannot fix that (all eight were under any sane limit, and genuinely parallel-safe work like `cargo test` would be penalized); the root cause is **several writers sharing one working tree**, so the fix is isolation, not queueing. `worktree.ts` (pure logic, no pi API) implements Claude Code's `isolation: "worktree"` semantics:

| State of the worktree at terminal | Handling |
| --- | --- |
| Untouched (no uncommitted changes **and** no new commit) | Removed entirely (`git worktree remove` + `prune`) — no trace |
| Uncommitted changes or a new commit | **Kept**; the path (plus a branch name when there is a commit) is reported to the model |
| Removal failed | Kept, with the original git error — fail-safe: better to leak than to delete output |

Keeping a changed worktree rather than deleting it is the point: at that moment it is no longer "a background task running tests" but "a parallel author", and removing it would remove its work. Three implementation choices: the **baseline is HEAD** (as in CC), so the working tree's uncommitted changes are *not* carried in — that makes "did the task change anything" a reliable comparison against HEAD, at the cost that testing your own uncommitted edits needs `worktree: false`; **gitignored dependency directories are symlinked** from the main repository (`DEFAULT_LINK_DIRS` is `["node_modules"]`, linked only when gitignored *and* actually present, skipping entries that are already symlinks), because a worktree has no `node_modules` and the task would not run at all; and the **worktree lives in the system temp directory** (`mkdtempSync(os.tmpdir() + "/pi-bg-")`), inside the seatbelt-deletable boundary and out of the repository tree. It uses `--detach` rather than `-B <branch>` so the branch list stays clean, and only attaches a `pi/bg_<id>-<timestamp>` ref when there is a new commit (a detached commit would otherwise be garbage-collected). Creation and removal both write the main repository's `.git/worktrees/`, so `serializeGit` queues just those two steps in-process; the tasks themselves still run in parallel. Isolation is an enhancement, not a precondition: outside a git repository, or when creation fails, the task **silently degrades** to running in the original `cwd` and the model is told so. Cleanup runs **before** the notification is sent, on purpose — the notification triggers a follow-up turn, so sending it first would make "is the worktree still there" a race; the cost is a few tens of milliseconds of latency. Cleanup is not gated on `disposed`, because `session_shutdown`'s `killAll()` is the biggest leak path.

**The dock gains a second line when the turn ends with a task still running.** After `agent_settled`, a task that is still running *and* has been running for at least `TURN_NOTE_THRESHOLD_MS` (5 s) gets one more line under its dock row:

```
⚙ bg_2 running 5m10s · <cmd>
  └ Task is still running
```

All three conditions must hold: still running (a terminal task does not need it), the turn has ended (`agent_start` clears that state, so it never says this mid-turn), and past the threshold (which filters out "the turn just ended and this long task started one second ago"). It is a **statement of fact** — turn state is decidable from events — and deliberately not an inference that the task is useless: the extension cannot distinguish an orphan from a task that was always meant to run long (an 8-minute `npm test` continuing after the main work finishes is normal), so it says only the half it can confirm. **The wording is English and drops the first half** (user decision 2026-10-01): the dock's own position already says the turn ended, so repeating it only spends width, and `running` in the same line is English too — one language inside the line. `agent_settled` fires after automatic compaction and after `verify-loop`'s `/goal` evaluation (both run between `agent_end` and `agent_settled`), so "the turn ended" means the real end. `statusline/line.ts` splits the value on newlines into separate footer rows, each indented and truncated on its own, and does **not** `trim()` it — the second line's indent (the `└` hanging under the task id) is carried by leading spaces that the publisher owns.

**The three tool blocks and the terminal notification render as trees.** They no longer use pi's default tool shell (`toolSuccessBg` background, `Box(1, 1)`'s blank line above and below, bold tool name only, flat body). They declare `renderShell: "self"` and draw the bash/plan family shape: `• run_in_background` flush left with the dot as the outcome lamp, body on a 2-column indented tree (`│` continuing, `└` last; structural glyphs `muted`, body `text`). **Markers belong to the terminal notification only, and are always `✔`** — a checkmark in this interface language means "the task finished", while a successful `run_in_background` means the task has only just *started*, and a cross is redundant on a tool block whose dot color already reports the outcome. So tool blocks carry a dot and nothing else; the notification keeps `• … ✔` at the end of its title line and stays `✔` even on failure (the dot turns red and the body states the reason). Outcome classification reads `details.ok`, not just `isError`: all three tools return normally with `ok: false` for a bad argument, a missing task or an unkillable pid, which pi does not treat as an error. The preview truncation pi's default shell provided is re-implemented (`PREVIEW_MAX_LINES` 10 + `… (N more lines, ctrl+o to expand)`, counted in wrapped visual lines). Shape decisions live in the pure module `render.ts`; coloring and wrapping live in `index.ts`.

Output is written twice: an in-memory ring buffer (`MAX_BUFFER_CHARS` 256 KB, oldest chunks dropped with an honest `droppedBefore` count) and a complete log file at `<agentDir>/bg-tasks/<sessionId>/<id>.log`, created synchronously because the tool result hands that path to the model immediately. stdout and stderr merge into one stream (equivalent to `2>&1`). A single read is capped at `MAX_READ_CHARS` (30 000) and returns the tail when exceeded. `registry.ts` is pure logic — spawn, clock, caps and grace period are all injectable — and `index.ts` only wires it up. Deliberately absent from this minimal version: recovery across pi restarts, an automatic timeout kill (CC's background bash has none either), agent-type tasks, and split stdout/stderr.

**A task dock on the statusline's bottom line.** While a task runs (or has just reached a terminal state) the footer gains one last line — `⚙ bg_1 running 12s · npm run test --silent…`. The wording and coloring live in the pure module `status.ts` (icon `dim`, id `accent`, the status word colored by outcome — running → `warning`, exit 0 → `success`, non-zero and killed → `error` — elapsed time `muted`, command `dim`); `index.ts` only publishes and keeps time: a 1 s `setInterval` (`unref`'d) recomputes and calls `ctx.ui.setStatus("background-tasks", …)`, stopping itself and clearing the key when there is nothing to show. It always renders exactly one line (the most recently started running task wins; with none running, the newest terminal task still inside its linger window, the rest folded into `(+N)`), and a terminal line disappears after `TERMINAL_LINGER_MS` (10 s). **`(+N)` is placed before the command on purpose** — when the row exceeds the terminal width only the trailing command is truncated, and the count is the one piece of information that matters with several tasks, so it must not go with it. During a prompt (`ui_prompt_start` → `ui_prompt_end`) it publishes **not even once**: not just the per-second tick is stopped, the event-driven publish is skipped too (a task that happens to finish inside the prompt). pi's main-screen render pins the viewport to the bottom, so a single repaint would drag the user's manually scrolled-back scrollback down again — the same reason `working-indicator` freezes its repaints. When the prompt closes, `ui_prompt_end` recomputes from the **current real state**, so nothing is lost (the elapsed time is recomputed too, never left at a stale value). See [`statusline/`](#statusline--the-footer) for how the reserved key is lifted onto its own line.

125 `node --test` cases: `registry.test.ts` (16) drives the full state machine with fake child processes whose pids sit above the system limit, so the group-kill path can never touch a real process; `worktree.test.ts` (17) drives **real git** in a tmpdir fixture — create, the three cleanup outcomes, the symlinked dependency directory, the branch ref only when there is a commit, and degradation when the directory is not a repository; `status.test.ts` (26) covers the dock line's four outcome shapes, task selection, the linger boundary, the coarse command truncation, the color slots, a `(+N)` truncation regression and the turn-ended second line (all three conditions, the threshold boundary, the `└` glyph and its indent); `render.test.ts` (20) covers outcome classification from `details.ok`, the marker-free tool titles against the notification's `✔`, the tree prefixes and the preview hint; `index.test.ts` (46) loads the real extension through pi's loader and **really spawns** — completion notification, non-duplicating incremental reads, group kill reaching grandchildren, no orphans after shutdown, `/background` details not advancing the model's read offset, `disposed` reset after a session replacement, a stale registry's late terminal state not injecting into the new session, the worktree lifecycle end to end (isolation note in the result, cleanup before notification, `worktree: false` opting out), the block rendering through pi's real component, plus the dock cases: publish on start, the per-second tick advancing the elapsed time, a terminal line clearing itself and stopping the timer after its linger window, prompt freeze and recovery, **no event-driven publish during a prompt either** (a terminal notification landing inside a prompt does not repaint), key cleared and timer stopped on shutdown, and not a single `setStatus` call when `DOCK=off`.

- `PI_BACKGROUND_TASKS=off` — disable the extension entirely.
- `PI_BACKGROUND_TASKS_DIR` — override the log root directory (used for test isolation).
- `PI_BACKGROUND_TASKS_WORKTREE=off` — disable worktree isolation for every task; the per-call `worktree: false` argument does the same for one task.
- `PI_BACKGROUND_TASKS_DOCK=off` — disable only the statusline dock line; the tools and the completion notification are unaffected.
- `PI_BACKGROUND_TASKS_DOCK_LINGER_MS` — how long a terminal dock line lingers before disappearing (default 10 000; tests shorten it).
- `PI_BACKGROUND_TASKS_TURN_NOTE_MS` — how long a task must have been running before the turn-ended second line appears (default 5 000).

### `auto-default-model/` — persistent model switches

pi's `/model` picker only changes the current session; persisting it takes a separate Ctrl+S (`setModel(model, { persist: true })`). This extension performs that step automatically on every model switch — the picker, Ctrl+P cycling, a subagent profile switch, anything that calls `pi.setModel()`.

It writes through pi's own `SettingsManager`, so it uses the same file lock as pi (`proper-lockfile`), merges only the changed fields into the newest on-disk content, and therefore cannot clobber concurrent `/theme` or `/settings` writes. It skips session restore (`source === "restore"`) and skips no-op writes. Failures are notified; successes are silent, because the model name is already visible in the statusline.

- `PI_AUTO_DEFAULT_MODEL=off`.

### `ask-user-question/` — the `ask_user_question` tool

A Claude Code style structured question tool. The model asks instead of guessing; a questionnaire appears in the terminal with up to **4 questions**, each with **2–4** described options, a free-text row appended automatically, Space to multi-select, ↑/↓ to navigate and Esc to abandon the whole questionnaire. Answers return to the model as structured text.

The labels `Other` and `Type something.` are reserved — validation rejects them — and the number of questions and options is enforced by the tool's TypeBox schema, while string length limits are enforced by runtime truncation. Non-TUI hosts (RPC, print) fall back to sequential `select`/`input` dialogs. The tool removes itself in child sessions where `ctx.hasUI` is false.

The tool block declares `renderShell: "self"`, so pi no longer wraps it in `contentBox` (`Box(1, 1, bgFn)`): **no background** in any of the three states (`toolPendingBg` / `toolSuccessBg` / `toolErrorBg` are all un-painted, because `selfRenderContainer` is a plain `Container` that `bgFn` cannot attach to) and **no boundary blank lines** (those were that Box's `paddingY`). Both renderers return `Text` with `paddingX = 1`, which puts back the single column of left margin the default `Box(1, 1)` used to draw — one leading space per line, not flush-left — while `paddingY = 0` keeps the top and bottom clear. Same look as the `simple-task`, bash and read blocks. 4 end-to-end cases in [`ask-user-question/render.test.ts`](../extensions/ask-user-question/render.test.ts) pin it through pi's own loader and `ToolExecutionComponent`, including a control asserting other tools keep their background.

63 `node --test` cases: `validate.test.ts` (14), `answers.test.ts` (9), `model.test.ts` (21) and `render.test.ts` (4) are pure logic or loader-driven; `dialog.test.ts` (15) drives the questionnaire itself.

- `PI_ASK_USER_QUESTION=off` — do not register the tool.
- `/ask` previews the dialog with a demo questionnaire.

### MCP servers — pi's built-in extension, not this package's

This package used to ship its own zero-dependency MCP implementation (`extensions/mcp/`, twelve files: three transports, a hand-written JSON-RPC layer, `headersCommand` for dynamic auth headers, `/mcp`). It was **retired on 2026-09-30** because pi 0.99.1 made MCP a built-in extension (`builtin:mcp`) that registers `/mcp` too, and pi resolves that conflict first-registered-wins — every start printed `Extension …/extensions/mcp/index.ts registers command /mcp, so built-in extension mcp was not loaded`. The full implementation stays in the git history (`git show bf3ab71` in the snapshot repository).

Use pi's built-in instead. It registers each MCP tool as `mcp__<server>__<tool>` (the same naming the retired extension used, so prompts and permission rules written for it keep working), and adds OAuth, a `/mcp` management UI, a `pi mcp` CLI and exposure/`codemode` integration. Three differences from the retired version are worth knowing:

- **`timeout` is in seconds** (default 60), not milliseconds — a copied `120000` is rejected as invalid.
- **Project config is only `.pi/mcp.json`**; it no longer walks up looking for Claude Code's `.mcp.json`. `ln -s .mcp.json .pi/mcp.json` reuses an existing one.
- **No legacy SSE** (`type: "sse"` is rejected) and no `headersCommand`; the replacements are OAuth, or `${VAR}` / `!command` inside static `headers`.

Disable it with `"extensions": ["-builtin:mcp"]` in `settings.json` or through `pi config`'s Built-in section. The [Chinese handbook](handbook.zh.md) documents the built-in's full surface.

### `subagent-log-guard/` — stderr guard

`pi-subagents` prints launch diagnostics such as `[pi-subagents] Agent 'researcher': host runtime tool availability omitted [...]` with `console.warn`. In interactive mode pi does not take over stdout/stderr, so that text is written straight into the alternate screen at the hardware cursor — right on top of the editor row — and the differential renderer will not repaint it. The result is permanent garbage across the input box.

This extension wraps `process.stderr.write` in processes that have a UI and stops lines beginning with `[pi-subagents]` from reaching the terminal. Only that prefix is filtered; everything else writes through untouched. Processes without a UI (RPC, print, subagent runners) are not patched at all, which is why background subagent diagnostics still land in `runner.stderr.log`.

- `PI_SUBAGENT_LOG_GUARD=notify` — route the messages through `ctx.ui.notify(..., "warning")` instead of dropping them.
- `PI_SUBAGENT_LOG_GUARD=off` — remove the guard (useful when tracing who printed a line).

## Environment switches

Every switch is an environment variable read at use time, not cached at load, so it can be scoped per project or set in a shell alias. An unset variable means "on"; `off` always disables.

| Variable | Default | Owning extension | Effect |
| --- | --- | --- | --- |
| `PI_ASK_USER_QUESTION=off` | on | `ask-user-question` | Do not register the `ask_user_question` tool. |
| `PI_AUTO_DEFAULT_MODEL=off` | on | `auto-default-model` | Do not persist model switches to `settings.json`. |
| `PI_BACKGROUND_TASKS=off` | on | `background-tasks` | Do not register the background-task tools or `/background`. |
| `PI_BACKGROUND_TASKS_DIR` | `<agentDir>/bg-tasks` | `background-tasks` | Override the log root directory (used for test isolation). |
| `PI_BACKGROUND_TASKS_DOCK=off` | on | `background-tasks` | Do not publish the statusline task dock line; the tools and completion notifications are unaffected. |
| `PI_BACKGROUND_TASKS_DOCK_LINGER_MS` | `10000` | `background-tasks` | How long a terminal dock line lingers before disappearing (tests shorten it). |
| `PI_BACKGROUND_TASKS_TURN_NOTE_MS` | `5000` | `background-tasks` | How long a task must have been running before the dock adds the "turn ended, task still running" second line. |
| `PI_BACKGROUND_TASKS_WORKTREE=off` | on | `background-tasks` | Run every background task directly in the working directory instead of a fresh git worktree; the per-call `worktree: false` argument does the same for one task. |
| `PI_BASH_HIGHLIGHT=off` | on | `bash-command-collapse` | Disable shell syntax highlighting in bash title rows. |
| `PI_BASH_MIN_TIME_MS` | `2000` | `bash-command-collapse` | Only show the elapsed-time footer above this duration. |
| `PI_BASH_PREVIEW` | `3` | `bash-command-collapse` | bash output preview lines (1–50); `off` restores pi's built-in preview. |
| `PI_BASH_SPINNER=off` | on | `working-indicator` | Disable the `●` spinner on running bash rows. |
| `PI_BASH_STREAM=on` | off | `bash-command-collapse` | Use pi's native streaming for bash instead of the collapse path. |
| `PI_BASH_TREE=off` | on | `bash-command-collapse` | Disable tree indentation (`│`/`└`) for bash output. **Retired** — the prefix is always a tree, the switch is no longer read. |
| `PI_BELOW_EDITOR_AFTER_STATUSLINE=off` | on | `below-editor-after-statusline` | Leave `belowEditor` widgets where pi puts them. |
| `PI_CODEMODE_TREE=off` | on | `codemode-tree` | Do not register `codemode`. Because the shipped `settings.json` disables `builtin:codemode`, this removes the tool entirely rather than restoring pi's built-in rendering. |
| `PI_CORE_RULES=off` | on | `core-rules` | Do not re-inject the distilled global rules into the context. |
| `PI_CWD_ICON` | ` 📁` | `cwd-statusline` | Icon used by the cwd status line. |
| `PI_CWD_STATUSLINE=off` | on | `cwd-statusline` | Do not print the cwd status line. |
| `PI_EDITOR_AUTOCOMPLETE_GAP=off` | on | `prompt-editor` | Do not add the blank line under the autocomplete list. |
| `PI_EDITOR_AUTOCOMPLETE_SHIFT` | `1` | `prompt-editor` | Columns to shift the autocomplete list left. |
| `PI_EDITOR_PROMPT` | `❯` | `prompt-editor` | Editor prompt character. The bash-mode `!` is not affected. |
| `PI_EXIT_WORDS` | `exit,quit,bye` | `exit-command` | Comma-separated quit words; `off` disables the input interception. |
| `PI_FENCELESS_CODE=off` | on | `fenceless-code-block` | Keep Markdown code fences. |
| `PI_FOLDER_HISTORY_INJECT` | `100` | `folder-history` | History entries injected from previous sessions. |
| `PI_GOAL_CAP` | `8` | `verify-loop` | `/goal` continuation cap (CC's number). |
| `PI_GOAL_CONTEXT_CHARS` | `120000` | `verify-loop` | Conversation character budget sent to the `/goal` evaluator. |
| `PI_GOAL_TIMEOUT_MS` | `45000` | `verify-loop` | `/goal` evaluation call timeout. |
| `PI_LOGO=off` | on | `startup-logo` | Do not install the startup header. |
| `PI_MEMORY=off` | on | `memory` | Disable auto-memory entirely. |
| `PI_MEMORY_DIR` | `~/.pi/agent/memory` | `memory` | Override the memory root directory (used for test isolation). |
| `PI_PLAN_MODE=off` | on | `plan-mode` | Disable plan mode entirely. |
| `PI_PLAN_MODE_AUTO=off` | on | `plan-mode` | Do not register the model's `enter_plan_mode` tool; `shift+tab` and `/plan` still work. |
| `PI_PLAN_MODE_CONSENT=off` | on | `plan-mode` | Do not ask for consent before the model's `enter_plan_mode` enters plan mode. |
| `PI_READ_COLLAPSE=off` | on | `read-path-collapse` | Keep pi's built-in `read` title row. |
| `PI_SANDBOX=off` | on | `bash-command-collapse`, `sandbox-boundary` | Disable the delete boundary: no seatbelt profile wraps `bash`, and `apply_patch` deletes are not checked. Also off automatically on platforms without `sandbox-exec`. |
| `PI_SANDBOX_ALLOWLIST` | `~/.pi/agent/sandbox-allowlist.json` | `bash-command-collapse`, `sandbox-boundary` | Path of the persistent allowlist both sides share. |
| `PI_SANDBOX_EXTRA_WRITE` | — | `bash-command-collapse` | Colon-separated extra delete roots, `~` expanded, like `PATH`. |
| `PI_SPINNER_COLOR_HOLD` | `19` | `working-indicator` | Frames per color in the spinner cycle. |
| `PI_SPINNER_RAINBOW=off` | on | `working-indicator` | Disable the rainbow spinner. |
| `PI_STATUSLINE_BOOT_SUPPRESS=off` | on | `statusline` | Do not silence pi's built-in footer during the boot window, before this statusline is installed. |
| `PI_STATUSLINE_FREEZE=off` | on | `statusline` | Disable the footer freeze that hides the one-frame flash on session switch. |
| `PI_VERIFY_DOC_EXT` | `.md,.txt` | `verify-loop` | File extensions whose edits do not count as mutations for the gate; an empty string disables the exclusion. |
| `PI_VERIFY_EVALUATOR_MODEL` | `litellm-any/qwen3.8-flash` | `verify-loop` | Evaluator model for `/goal` as `provider/modelId`; falls back to the current session model. |
| `PI_VERIFY_LOOP` | `block` | `verify-loop` | The verification gate's force: `off` disables it, `notify` reports without forcing a continuation, `block` (default) injects and continues. |
| `PI_VERIFY_LOOP_CAP` | `2` | `verify-loop` | Consecutive gate blocks before the turn is let through. |
| `PI_VERIFY_PATTERN` | any bash call | `verify-loop` | `strict` only accepts test/build/lint shapes; any other value is compiled as a case-insensitive regexp. |
| `PI_SUBAGENT_LOG_GUARD` | `drop` | `subagent-log-guard` | `notify` shows the diagnostics through `ctx.ui.notify`; `off` disables the guard. |
| `PI_USER_MESSAGE_BAR=off` | on | `user-message-bar` | Do not draw the `▏` bar into user message boxes. |
| `PI_USER_MESSAGE_BAR_COLOR` | `accent` | `user-message-bar` | Theme slot the bar takes its color from (fallbacks `selectedBg` → `toolDiffAdded` → `text`); a background slot such as `selectedBg` is converted to a foreground. `PI_USER_MESSAGE_BAR_COLOR=toolDiffAdded` restores the added-line green. |
| `PI_WORKING_SUMMARY=off` | on | `working-indicator` | Disable the prompt summary line. |
| `PI_WORKING_SUMMARY_GAP` | `1` | `working-indicator` | Minimum blank columns between the working label and the summary. |
| `PI_WORKING_SUMMARY_LLM=off` | on | `working-indicator` | Truncate the summary instead of asking a model to compress it. |
| `PI_WORKING_SUMMARY_MODEL` | session model | `working-indicator` | `provider/modelId` used for the summary request. |
| `PI_WORKING_SUMMARY_RETRY_MS` | `3000` | `working-indicator` | Delay before the single retry after a failed summary request. |
| `PI_WORKING_SUMMARY_TRIGGER` | `1` | `working-indicator` | Request a summary once the prompt exceeds this multiple of the available width. |
| `PI_WORKING_INDICATOR_WATCHDOG=off` | on | `working-indicator` | Do not switch the working message to `Subagent watchdog reviewing` while a turn is blocked after `agent_end`. |
| `PI_WORKING_INDICATOR_WATCHDOG_DELAY_MS` | `2000` | `working-indicator` | How long after `agent_end` without a settle before that message appears. |

## Extension interactions

- **Esc Esc is shared.** `rewind` replaces pi's built-in double-Escape action and needs `doubleEscapeAction: "none"`; see above.
- **`shift+tab` is shared.** `plan-mode` consumes it before the editor sees it and rebinds the thinking-level cycle to `ctrl+shift+t`; while a turn is streaming the key still reaches `app.thinking.cycle`.
- **The `bash` tool can only be registered once.** Everything that shapes its rendering lives in `bash-command-collapse.ts` for that reason — a second file registering `bash` would be ignored silently.
- **`recap` imports `simple-task/gap.ts`.** The neighbour-gap heuristic is shared rather than duplicated, so `recap` and `simple-task` must be installed together. In this package they always are; if you copy extensions individually, copy both.
- **`verify-loop` imports `recap/subagents.ts`.** The `/goal` evaluator skips its turn while a subagent is still running (CC's "background work defers evaluation"), and that probe is the pi-subagents in-process RPC `recap` already implements; the probe fails open, but `verify-loop` should not be installed without `recap`.
- **`sandbox-boundary` imports `bash-command-collapse/sandbox.ts` and `allowlist.ts`.** The bash seatbelt profile and the `apply_patch` gate are two halves of one boundary and share one judgement plus one allowlist singleton, so those three must be installed together; installing `sandbox-boundary` alone would leave it with no boundary and no memory.
- **`plan-mode` and `simple-task` are independent.** Until 2026-09-24 they shared the `plan-mirror.ts` contract and had to be installed together; an approved plan is now a document and progress is the model's own business, so nothing links them. `plan-mode` still writes the plan file with the `write` tool while its `tool_call` hook pins that path.
- **`plan-mode` writes the sandbox mode; `bash-command-collapse` and `sandbox-boundary` read it.** The three-state cycle reaches the two delete-interception layers through the `getSandboxMode()` / `setSandboxMode()` singleton in `bash-command-collapse/sandbox-mode.ts` (a `globalThis` singleton, because pi's loader does not guarantee two extensions share a module instance). `dangerous` switches the seatbelt wrapper and the `apply_patch` gate off at execution time. Without `plan-mode` installed the singleton stays `bypass` and both gates behave exactly as before the three-state change (fail-safe), so `plan-mode` is optional for the sandbox — but the sandbox extensions must be present for `dangerous` to have anything to switch off.
- **Two `tool_call` hooks coexist.** `plan-mode` rejects write-shaped commands while planning and pins the `write` tool to the approved plan path; `sandbox-boundary` checks `apply_patch` deletes. They are independent gates with different scopes, and a command can be refused by either. A third one — `destructive-guard`, the lexical delete gate that judged targets at all times — was retired from the live environment on 2026-09-24 (the seatbelt boundary replaced it) and removed from the package on 2026-09-27; its full implementation is in the git history.
- **The theme preview and the theme files are coupled.** `/theme` persists the name it previewed, and the name must match the `theme` field's expectations in [themes.md](themes.md).
- **MCP tool names are namespaced.** pi's built-in `builtin:mcp` registers `mcp__<server>__<tool>`, which collides with neither the builtins nor these extensions' own tools; names past 64 characters are truncated with a hash suffix, which stays inside the tool-name limit the model APIs enforce while keeping truncated names distinguishable. This package no longer ships an MCP extension of its own — see [MCP servers](#mcp-servers--pis-built-in-extension-not-this-packages).
- **`background-tasks` sits outside the delete boundary.** `bash-command-collapse.ts` wraps the foreground `bash` tool in the seatbelt profile and `sandbox-boundary` gates `apply_patch`; `background-tasks` spawns directly, so neither applies to a background command. The gap is documented in its tool descriptions and in `/background`'s output rather than closed, because closing it would mean wrapping every background spawn in the same profile. Its default git-worktree isolation is a different kind of protection — it keeps concurrent tasks from writing into one another's working tree — and it places the worktree in the system temp directory precisely because that is inside the seatbelt-deletable boundary, so `git worktree remove` is not blocked by the kernel.
- **The background-task dock is a reserved footer key with its own last line.** `background-tasks` publishes its task line through `setStatus("background-tasks", …)` (`⚙ bg_1 running 12s · command…`), and `statusline/line.ts`'s `composeFooterLines` **lifts that key out** of the concatenated second row to render it as the footer's **last line** (`formatExtensionStatuses` skips it) — so it neither spends any of the 5-entry budget above nor gets truncated on the same row as a long cwd. `line.ts` imports the key name directly from `background-tasks/status.ts`'s `STATUS_KEY` (the same cross-directory import trade-off as `plan-mode`'s `STATUS_KEY`): if the two literals ever drifted, the dock line would silently fall back into the concatenated second row with no error at all. The dock line carries its own ANSI (colored segment by segment on the publishing side) and `line.ts` renders it as-is; terminal width is settled by `truncateToWidth`, so the id / status / elapsed time are never truncated — only the trailing command. **The value may contain one newline** (the turn-ended second line, see [`background-tasks/`](#background-tasks--the-background-execution-primitive-pi-lacks)), which `composeFooterLines` splits into separate footer rows, each indented by the same one column and truncated on its own; it deliberately does **not** `trim()` the value, because that second line's indent (the `└` hanging under the task id) is expressed by leading spaces that the publishing side owns — trimming would eat it. With no background tasks the footer output is byte-identical to before.
- **`codemode-tree` and `settings.json` are coupled.** The extension only owns the `codemode` tool because the shipped config disables pi's built-in (`extensions: ["-builtin:codemode"]`) and activates the captured definition (`defaultTools: ["+codemode"]`). Removing the extension without removing that settings entry leaves **no** `codemode` tool at all — pi's built-in is switched off and nothing replaces it. The same applies to `PI_CODEMODE_TREE=off`. See [`codemode-tree/`](#codemode-tree--the-codemode-tool).
- **Three extensions read theme tokens that pi's schema does not define** (`toolDiffAddedBg`, `toolDiffRemovedBg`, `bashOutput`) and degrade quietly when a theme omits them.

## State on disk

| Location | Written by | Contents |
| --- | --- | --- |
| `~/.pi/agent/settings.json` | `auto-default-model` | `defaultProvider` / `defaultModel` on every model switch. |
| `~/.pi/agent/rewind/<project-hash>/git` | `rewind` | Shadow git repository with pre-turn snapshots. Never touched by your repository. |
| `~/.pi/folder-history/<path-with-dashes>.jsonl` | `folder-history` | Command history per working directory. |
| Session log (via `appendEntry`) | `simple-task` | Task list state; discarded with the session, never written to the repo. |
| Session log (via `appendEntry`) | `plan-mode` | Plan phase and the plan text; same lifetime, never written to the repo. |
| Session log (via `appendEntry`) | `verify-loop` | The active `/goal` (condition, status, evaluated turns, last verdict), rebuilt from `getBranch()` on `session_start` — resume restores it, a new session starts clean. The counters (gate blocks, goal continuations, no-progress turns) are **neither persisted nor in memory**: they are counted from the injected `verify-loop` messages in the model-visible projection, because `agent_start` re-fires on every boundary continuation and would zero an in-memory counter. |
| `.pi/plans/<date>-<slug>.md` | `plan-mode` | The approved plan document, written into the project by the model (pinned to that one path by a `tool_call` hook). Upstream adds `.pi/` to the project's `.gitignore` — a plan is a working artefact. |
| `~/.pi/agent/sandbox-allowlist.json` | `bash-command-collapse`, `sandbox-boundary` | The persistent delete allowlist. Machine-local state, an authorization decision rather than configuration, so it is deliberately not in any snapshot. |
| `~/.pi/agent/memory/<project-slug>/` | `memory` | One file per memory (CC-compatible frontmatter) plus a mechanically derived `MEMORY.md` index and an optional `.disabled` marker. Per-project, keyed by the git root of `cwd`; `PI_MEMORY_DIR` moves the root. |
| `~/.pi/agent/bg-tasks/<sessionId>/<id>.log` | `background-tasks` | The complete output of each background task (stdout and stderr merged), written synchronously at spawn. `PI_BACKGROUND_TASKS_DIR` moves the root. Nothing is restored across pi restarts — the session's tasks are killed on shutdown. |
| `<tmpdir>/pi-bg-*` | `background-tasks` | The isolated git worktree each task runs in, created from HEAD with gitignored dependency directories symlinked in. Removed automatically when the task touched nothing; **kept** when it changed or committed anything (the path is reported to the model), and kept on a failed removal. `PI_BACKGROUND_TASKS_WORKTREE=off` or `worktree: false` skips this entirely. |
| In memory only | `core-rules` | Nothing — the injected message goes into the session log, and the only in-memory state is the content hash scan. |
| In memory only | `recap` | The current summary; lost on `/new` or `/resume` by design. |
| Nothing | everything else | The remaining extensions are pure display or event wiring. |

## Adding, disabling and removing extensions

- **Disable one** — `pi config` lists every resource from packages and local directories with an on/off toggle, in global or project scope. Or set the switch listed above when the extension has one.
- **Remove one** — delete its file (or its directory) from the package, or copy the ones you want into `~/.pi/agent/extensions/` and stop installing the package. Deleting subdirectories is safe except for the directories other files import: the helper-only `thinking-collapse/`, `tool-diff/` and `prompt-editor/`, plus `simple-task/` (whose `gap.ts` is imported by `recap`), `recap/` (whose `subagents.ts` is imported by `verify-loop`) and `bash-command-collapse/` (whose `sandbox.ts`, `allowlist.ts` and `sandbox-mode.ts` are imported by `sandbox-boundary`, and whose `sandbox-mode.ts` is also imported by `plan-mode`).
- **Edit one** — work in a checkout and run pi against it; see [development.md](development.md).
