rpi — reference manual
rpi is a terminal coding agent for Pi v3 sessions: a single Rust binary that
switches between print mode, a line REPL, a full-screen inline TUI, and headless
JSON/RPC modes. It supports multiple model providers, local llama.cpp models,
extensions, skills, prompt templates, packages, session export and sharing, a
security model built around project trust and credential redaction, and a full
orchestration stack: durable goals, Todo DAGs, subagent jobs with IRC
coordination, isolated concurrent workflows, MCP/ACP protocol clients, voice
input, memory backends, and host hooks.
Getting started
- Quick start — first-run workflow
- Installation — supported platforms, installers, verification
User guide
- TUI and keybindings
- CLI modes and commands
- Goals
- Todos and the Todo DAG
- Orchestration: subagents, jobs, and IRC
- Isolated concurrent workflows
- Session recovery: rewind, checkpoints, handoffs, and TTL
- Live voice (
/live) - Models and custom providers
- Authentication
- RPC JSONL protocol
- Web client (
/web) - E2E scenarios (user-perspective tmux tests)
Reference
- Settings, configuration, and trust
- Configuration profiles, TOML settings, and scoped auth
- Environment variables
- Sandbox and overlayfs isolation
- Hooks and trust hooks
- Extensions and process protocol
- Skills
- Packages
- Memory
- Extended tool catalog
- Model Context Protocol (MCP) client
- Agent Client Protocol (ACP) mode
- Prompt templates and system prompt assembly
- Local / self-hosted models with llama.cpp
- Security
- Export and share
- Update safety
Quick start
First-run workflow
-
Install
rpi:curl -fsSL https://raw.githubusercontent.com/0x8f701/rpi/master/install.sh | shSee
install.mdfor Windows, pinned binary releases, manual archive verification, and rollback behavior. -
Verify the binary:
rpi --version -
Configure one provider credential. For example:
rpi login anthropicFor non-interactive or CI use, set the provider-specific environment variable to a redacted credential value. Do not export placeholder values. For other providers,
auth.json,models.json, and precedence rules, seeauthentication.md. -
Run a non-interactive task to confirm the end-to-end path:
rpi --print -m anthropic/claude-sonnet-4-5 "List the Rust files in this directory" -
Start an interactive session:
rpi -m anthropic/claude-sonnet-4-5The CLI opens the normal-screen inline TUI when both stdin and stdout are terminals; otherwise it uses the line REPL. See
cli-modes.mdfor every flag, subcommand, and slash command.
Non-interactive print mode
rpi --print "Explain this crate" -m anthropic/claude-sonnet-4-5
# Or stream events as JSON lines for tooling
rpi --mode json -m openai/gpt-5.5 "List all unfinished task markers in src/"
Positional prompts initialize the interactive session on a terminal and select
print mode when stdin or stdout is not a terminal. --listen is a separate
headless Web-only service and rejects positional prompts; submit Web prompts
through /web, /ws, or /rpc. Use --print when a script must be
non-interactive:
rpi -m openai/gpt-5.5 "Review this repository"
Interactive session
rpi -m anthropic/claude-sonnet-4-5
- If both
stdinandstdoutare terminals, you get the normal-screen inline TUI. - Otherwise you get the line REPL (
>prompt).
In both modes you can type a message or use slash commands. Type /help for the
22 primary commands, including /workflow, the TUI-only /code-review and
/btw overlays, and /live.
Model and thinking-level switches work as slash commands in the line REPL; the
TUI uses /model plus keybindings such as Ctrl+L and Ctrl+T.
Switch models and reasoning level
In the line REPL:
/model openai/gpt-5.5
/think high
From the shell you can set the initial model and thinking level:
rpi -m openai/gpt-5.5 --think high --print "Refactor this function"
See models.md for model-spec syntax and custom providers.
Manage sessions
# List native Pi sessions for the current directory
rpi sessions
# Resume the newest native Pi session for this directory
rpi --continue
# Resume a native or foreign session by path, exact id, or prefix
rpi --resume rollout-abc123.jsonl
rpi --resume abc123
In the interactive TUI and line REPL, /resume and /sessions open a
current-cwd-scoped unified catalog with [source] badges for native Pi, OMP,
Codex, Claude, Grok/Hyper, and Droid sessions. Selecting a foreign session
imports it once into the effective native session root; later resumes reuse
the converted file. Foreign source files cannot be renamed or deleted from the
selector, but native and imported JSONL files can be. --continue stays
native-only and resumes the newest native Pi session for the directory.
Sessions are stored as append-only JSONL files under <agent-dir>/sessions/.
See cli-modes.md for path encoding,
import details, and selector management rules.
Discover models
rpi models
rpi models claude
rpi models openai
The filter is case-sensitive and matches against provider name or model id.
Log in
rpi login
rpi login anthropic
login stores credentials in auth.json. logout removes them. For
non-interactive configuration, use auth.json or environment variables (see
authentication.md).
Export or share a session
# Export a session file to a self-contained HTML file
rpi export <agent-dir>/sessions/--<workspace>--/timestamp_id.jsonl
# Export the current branch as JSONL for later resume
rpi export session.jsonl --jsonl --output backup.jsonl
In the TUI or REPL, /share creates a secret GitHub gist via the gh CLI.
See export-share.md for formats and sharing.
Use a local model
rpi llama configure http://127.0.0.1:8080
rpi -m llama.cpp/<model-id> --print "Hello"
See local-llama.md for router setup and GGUF downloads.
Manage packages
rpi install git:github.com/owner/pi-my-tools
rpi list
rpi update --extensions
Project packages need a trusted project. See settings-trust.md
and packages.md.
Configure loaded resources
# Open an interactive selector for enabled extensions/skills/prompts/themes
rpi config
# Scope the selector to project-local settings
rpi config --local
Project scope is refused unless the project is trusted. See packages.md
for the package/resource model.
Next steps
- Read
cli-modes.mdfor the full command surface. - Read
settings-trust.mdto configure defaults insettings.json. - Read
models.mdto add custom providers or local models.
Installation
Supported platforms
The release workflow builds five native targets:
aarch64-apple-darwinaarch64-unknown-linux-gnu(glibc 2.31 baseline, Ubuntu 20.04)x86_64-apple-darwinx86_64-pc-windows-msvcx86_64-unknown-linux-gnu(glibc 2.31 baseline)
Prebuilt binaries are published as GitHub Release assets named
rpi-<version>-<target>.tar.gz (or .zip on Windows), alongside a
SHA256SUMS manifest. Installation does not require Rust or a source checkout.
The installers download, verify, and atomically activate the matching binary.
Or use the built-in updater after installation:
rpi update --self
Supported install paths
- Prebuilt binary installer —
install.sh(macOS / Linux) orinstall.ps1(Windows); recommended for users. - Prebuilt GitHub Release asset — download the matching
.tar.gz/.zipandSHA256SUMS, then verify and extract manually. - Self-update —
rpi update --selfafter the binary is installed. - Source build — developer fallback requiring Rust 1.88 or later.
See update.md for release-check behavior and in-place update safety.
One-line installer
macOS / Linux:
curl -fsSL https://raw.githubusercontent.com/0x8f701/rpi/master/install.sh | sh
Windows (PowerShell):
irm https://raw.githubusercontent.com/0x8f701/rpi/master/install.ps1 | iex
By default the installer resolves the latest published stable binary release.
If the release does not contain the exact platform archive and SHA256SUMS, the
installer fails without changing the existing installation.
Pin a version
Pin both the installer script and the requested release tag:
curl -fsSL https://raw.githubusercontent.com/0x8f701/rpi/v0.2.7/install.sh | bash -s -- --version v0.2.7
On Windows, download the script from the same tag before invoking it:
irm https://raw.githubusercontent.com/0x8f701/rpi/v0.2.7/install.ps1 -OutFile install.ps1
powershell -ExecutionPolicy Bypass -File ./install.ps1 -Version v0.2.7
What the installer does
- Detects the host OS/architecture.
- Fetches release metadata from the GitHub API.
- Downloads the archive and
SHA256SUMS. - Verifies the archive digest.
- Extracts the
rpi/rpi.exebinary. - Smoke-tests the binary with
--version. - Writes it to a content-addressed path under
PI_HOME/downloadsand atomically swapsPI_HOME/bin/rpito that path on Unix. Windows atomically replacesPI_HOME/bin/rpi.exebecause a running executable cannot be a symlink target. - Records the installed identity in
PI_HOME/update-state.json. - On Unix, removes a legacy installer-managed
PI_HOME/bin/pisymlink only when it still points at a previous installer-owned download path.
If any step fails, the installer rolls back the active symlink and leaves the previous install untouched.
When the install directory is not already on PATH, the Unix installer updates
the detected shell profile and the Windows installer updates the user PATH.
Open a new terminal before running rpi after such a change.
Installer environment variables
| Variable | Default | Purpose |
|---|---|---|
PI_HOME | ~/.rpi (Unix) / %USERPROFILE%\.rpi (Windows) | Install root for the binary and update state |
PI_UPDATE_BASE_URL | https://api.github.com/repos/0x8f701/rpi/releases | Release API base |
GITHUB_TOKEN | (none) | Authenticates the GitHub API call to avoid unauthenticated rate limits |
The token is sent only to the fixed GitHub API endpoint
(https://api.github.com/repos/0x8f701/rpi/releases), never to release-asset
hosts or a custom PI_UPDATE_BASE_URL endpoint. install.sh, install.ps1,
and rpi update --self all apply the same scoping.
Developer source build
This is not the normal installation path. It requires Rust 1.88 or later.
git clone https://github.com/0x8f701/rpi.git
cd rpi
cargo install --path crates/pi-cli --locked --bin rpi
rpi --version
To build a distribution binary in-tree without installing into Cargo's bin directory:
cargo build --package pi-cli --bin rpi --profile release-dist --locked
./target/release-dist/rpi --version
The JSONL RPC control plane is the rpi rpc subcommand (≡ --mode rpc), so no
companion binary is built or installed separately.
Verifying a downloaded release
After a release is published, replace <version> and <target> with an actual
tag version and one of the supported target triples:
version="<version>"
target="x86_64-unknown-linux-gnu"
curl -fsSL -O "https://github.com/0x8f701/rpi/releases/download/v${version}/rpi-${version}-${target}.tar.gz"
curl -fsSL -O "https://github.com/0x8f701/rpi/releases/download/v${version}/SHA256SUMS"
sha256sum -c --ignore-missing SHA256SUMS
Directory layout
After installation:
$PI_HOME/ # default ~/.rpi on Unix, %USERPROFILE%\.rpi on Windows
├── bin/
│ └── rpi -> ../downloads/rpi-<version>-<os>-<arch>-sha256-<digest>
├── downloads/
│ └── rpi-<version>-<os>-<arch>-sha256-<digest>
└── update-state.json
On Windows the active executable is $PI_HOME/bin/rpi.exe rather than a
symlink into downloads/.
On Unix, the installer creates the managed directories ($PI_HOME,
bin/, downloads/) owner-only (0700) and writes update-state.json and
the install lock as owner-only (0600), independent of the caller's umask.
Installed binaries keep their executable mode (0755).
Runtime configuration and sessions are stored separately under <agent-dir>/
(the upstream pi layout, defaulting to ~/.pi/agent). The binary location and
the agent directory are independent, so you can point PI_CODING_AGENT_DIR at
a different config tree.
TUI and keybindings
The TUI is a normal-screen inline terminal interface built with crossterm and
ratatui. It preserves terminal and tmux scrollback instead of switching to the
alternate screen. It is selected when both stdin and stdout are terminals;
otherwise the CLI uses print mode for positional prompts or the line REPL for a
text session. The dispatch lives in crates/pi-cli/src/lib.rs:138-169.
Layout
rpi (rs) · provider/model · /cwd
┌─────────────────────────────────────┐
│ Conversation │
│ ... │
├─────────────────────────────────────┤
│ Message │
│ > type here │
├─────────────────────────────────────┤
│ Status line │
└─────────────────────────────────────┘
The conversation area keeps the most recent 4,000 transcript entries;
when the limit is exceeded the oldest entries are dropped (MAX_TRANSCRIPT_LINES = 4_000 in crates/pi-cli/src/tui.rs:58, trimmed in tui.rs:1703).
Tool execution is shown as status lines (· toolName(args) / └ ok /
└ error).
When the transcript is empty, the welcome screen lists a few Recent sessions
from the unified catalog, scoped to the current working directory. Each entry
shows a [source] badge for native Pi, OMP, Codex, Claude, Grok/Hyper, or Droid
sessions. Selecting a foreign session imports it once; subsequent resumes reuse
the converted file under the effective native session root.
Built-in themes
Only two built-in palettes are always available:
dark— the exact installed OMP v17.2.6 defaulttitaniumpalette: electric blue chrome, green success/readouts, gold highlights, and dark titanium surfaces (DARKincrates/pi-cli/src/theme.rs).light— a high-contrast palette for light terminals (LIGHTintheme.rs).
The initial theme follows safe terminal background detection; dark terminals therefore use the OMP-default Titanium palette. It can be pinned in settings.json:
{
"theme": "light"
}
Switch themes in the TUI with /theme, /theme next, /theme prev, or
/theme <name>. The theme manager also live-reloads custom theme files and
leaves the active palette unchanged when a reload fails (theme.rs:353).
Custom themes
Place JSON theme files in one of the theme directories:
- Global:
<pi-dir>/themes/(the.pidirectory under the user's home directory:HOMEon Unix,USERPROFILEon Windows, resolved byhome_dir()incrates/pi-cli/src/tui.rs:4985) - Project:
<workspace>/.pi/themes/
Directory order is global-first, project-last (config_paths in crates/pi-cli/src/tui.rs:4971).
A theme file extends dark or light and overrides only the colors it
specifies:
{
"name": "solarized",
"extends": "dark",
"vars": {
"blue": "#268bd2",
"green": "#859900"
},
"colors": {
"accent": "blue",
"success": "green",
"selectedBg": "#073642",
"mdHeading": "#b58900",
"syntaxKeyword": "blue",
"toolDiffAdded": "green"
}
}
extends must be "dark" or "light" (theme.rs:522). Role names may be
camelCase (upstream) or snake_case (Rust-native); underscores and hyphens are
ignored (theme.rs:567).
Semantic roles cover the complete transcript and editor surface (defined on
Theme in theme.rs:41):
- Core:
accent,border,borderAccent,borderMuted,success,error,warning,muted,dim,text,thinkingText - Messages and tools:
userMessageText,customMessageText,customMessageLabel,toolTitle,toolOutput - Markdown:
mdHeading,mdLink,mdLinkUrl,mdCode,mdCodeBlock,mdCodeBlockBorder,mdQuote,mdQuoteBorder,mdHr,mdListBullet - Diffs and syntax:
toolDiffAdded,toolDiffRemoved,toolDiffContext,syntaxComment,syntaxKeyword,syntaxFunction,syntaxVariable,syntaxString,syntaxNumber,syntaxType,syntaxOperator,syntaxPunctuation - Thinking and modes:
thinkingOff,thinkingMinimal,thinkingLow,thinkingMedium,thinkingHigh,thinkingXhigh,thinkingMax,bashMode - Backgrounds:
selectedBg,userMessageBg,customMessageBg,toolPendingBg,toolSuccessBg,toolErrorBg
Color values may be ratatui named colors (black, red, cyan, gray,
darkgray, lightgreen, white, reset, ...) or hex #rrggbb / #rgb.
Empty-string values resolve to the terminal default color for text,
userMessageText, customMessageText, and toolTitle (theme.rs:100).
Theme files are validated before loading; a malformed file is skipped with a
diagnostic and the active theme is left unchanged.
Keybindings
The TUI maps incoming keys to stable actions through a KeyBindingsManager.
Built-in defaults reproduce the original TUI behavior (default_bindings in
crates/pi-cli/src/keybindings.rs:354). The dispatch layer never checks
hard-coded chords; it resolves every key event to an Action (keybindings.rs:298).
Global editor and application defaults
| Chord | Action | Stable ID (config name) |
|---|---|---|
enter | Submit message | tui.input.submit |
shift+enter, ctrl+j | Insert newline | tui.input.newLine |
backspace | Delete character backward | tui.editor.deleteCharBackward |
delete | Delete character forward | tui.editor.deleteCharForward |
left, ctrl+b | Move cursor left | tui.editor.cursorLeft |
right, ctrl+f | Move cursor right | tui.editor.cursorRight |
up | Move cursor / previous history line | tui.editor.cursorUp |
down | Move cursor / next history line | tui.editor.cursorDown |
alt+left, ctrl+left, alt+b | Word left | tui.editor.cursorWordLeft |
alt+right, ctrl+right, alt+f | Word right | tui.editor.cursorWordRight |
home, ctrl+a | Start of line | tui.editor.cursorLineStart |
end, ctrl+e | End of line | tui.editor.cursorLineEnd |
ctrl+] | Jump forward | tui.editor.jumpForward |
ctrl+alt+] | Jump backward | tui.editor.jumpBackward |
pageup | Scroll transcript up one page | tui.editor.pageUp |
pagedown | Scroll transcript down one page | tui.editor.pageDown |
ctrl+w, alt+backspace | Delete word backward | tui.editor.deleteWordBackward |
alt+d, alt+delete | Delete word forward | tui.editor.deleteWordForward |
ctrl+u | Clear the entire composer | tui.editor.clear |
ctrl+k | Delete to line end | tui.editor.deleteToLineEnd |
ctrl+y | Yank | tui.editor.yank |
alt+y | Yank pop | tui.editor.yankPop |
ctrl+- | Undo | tui.editor.undo |
esc | Abort in-flight run, or clear input when idle | app.interrupt |
ctrl+c | Clear input | app.clear |
ctrl+d | Quit (when input is empty) | app.exit |
tab | Accept slash-command completion | tui.input.tab |
ctrl+v, alt+v | Paste from clipboard | app.clipboard.pasteImage |
ctrl+x, ctrl+shift+c | Copy last assistant message | app.message.copy |
ctrl+g | Open external editor | app.editor.external |
ctrl+z | Suspend (yield terminal) | app.suspend |
shift+tab | Cycle thinking level | app.thinking.cycle |
ctrl+t | Toggle thinking block visibility | app.thinking.toggle |
ctrl+p | Cycle model forward | app.model.cycleForward |
ctrl+shift+p | Cycle model backward | app.model.cycleBackward |
ctrl+l | Open model selector | app.model.select |
ctrl+o | Expand/collapse tool details | app.tools.expand |
alt+enter | Queue follow-up | app.message.followUp |
alt+up | Dequeue last prompt | app.message.dequeue |
Contextual selector defaults
Some chords are intentionally reused across mutually exclusive panels; they
only dispatch when the matching panel is open (resolve_in in
keybindings.rs:303, selector actions in keybindings.rs:323).
Saved session selector (/sessions, /resume with no argument):
| Chord | Action |
|---|---|
ctrl+n | Toggle named-only filter (app.session.toggleNamedFilter) |
ctrl+p | Toggle path display (app.session.togglePath) |
ctrl+s | Toggle sort (app.session.toggleSort) |
ctrl+r | Rename selected session (app.session.rename) |
ctrl+d | Delete selected session (app.session.delete) |
ctrl+backspace | Delete without exiting selector (app.session.deleteNoninvasive) |
Scoped model selector (/scoped-models):
| Chord | Action |
|---|---|
ctrl+s | Save scoped patterns (app.models.save) |
ctrl+a | Enable all (app.models.enableAll) |
ctrl+x | Clear all (app.models.clearAll) |
ctrl+p | Toggle provider (app.models.toggleProvider) |
alt+up | Move selected up (app.models.reorderUp) |
alt+down | Move selected down (app.models.reorderDown) |
Session tree / fork panel (/tree, /fork):
| Chord | Action |
|---|---|
left | Fold the current node, or move up (app.tree.foldOrUp) |
right | Unfold the current node, or move down (app.tree.unfoldOrDown) |
alt+shift+l | Edit node label (app.tree.editLabel) |
alt+shift+t | Toggle label timestamps (app.tree.toggleLabelTimestamp) |
ctrl+d | Default filter (app.tree.filter.default) |
ctrl+t | No-tools filter (app.tree.filter.noTools) |
ctrl+u | User-only filter (app.tree.filter.userOnly) |
ctrl+l | Labeled-only filter (app.tree.filter.labeledOnly) |
ctrl+a | All entries filter (app.tree.filter.all) |
ctrl+o | Cycle filter forward (app.tree.filter.cycleForward) |
ctrl+shift+o | Cycle filter backward (app.tree.filter.cycleBackward) |
Up/Down/PageUp/PageDown move the selection in all selector and tree panels.
Custom keybindings
Place keybinding files in one of the keybinding paths:
- Global:
<pi-dir>/keybindings.json(the.pidirectory under the user's home directory) - Project:
<workspace>/.pi/keybindings.json
Project files overlay global files, which overlay built-in defaults (KeyBindingsManager::load in keybindings.rs:267). A file with any malformed chord, unknown action, or duplicate canonical chord is rejected in full with a diagnostic.
Two file formats are accepted:
Upstream action-to-chord object (preferred):
{
"tui.input.submit": ["ctrl+enter"],
"tui.input.newLine": ["shift+enter", "ctrl+j"],
"app.exit": "ctrl+q"
}
Legacy pi-rs bindings array:
{
"bindings": [
{ "chord": "ctrl+enter", "action": "editor_submit" },
{ "chord": "enter", "action": "editor_newline" },
{ "chord": "ctrl+q", "action": "quit" }
]
}
Overriding an action replaces all of its earlier chords, matching upstream behavior.
Valid actions are the stable IDs listed above. The canonical registry is
VALID_ACTION_NAMES in keybindings.rs:177. Chords use +-separated
modifiers and a key token. Modifiers are ctrl (alias control), alt
(alias option), and shift. Key tokens include special names (enter,
tab, esc, left, right, up, down, home, end, backspace,
delete, space, pageup, pagedown, f1–f12) or a single Unicode
scalar (a, /, 1).
Selectors, tree, fork, and dialogs
The TUI uses several modal page overlays inside the inline viewport. Typing
filters the list, Enter confirms the selection, and Esc closes the overlay.
- Model selector (
ctrl+lor/modelwith no argument): searchable list of configured providers and models (open_model_panelintui.rs:1963). - Thinking level selector (
/settings→ "Thinking level" or/themenot used): one entry per level (open_thinking_panelintui.rs:1984). - Settings panel (
/settings): toggles thinking level, theme, and automatic compaction (open_settings_panelintui.rs:2027). - Trust panel (
/trust): chooseTrusted,Untrusted, orAskfor the current project (open_trust_panelintui.rs:2080). Saved session selector (/sessions,/resumewith no argument): unified catalog scoped to the current cwd, filter by name/path, sort by newest or name, rename (ctrl+r), and delete (ctrl+d) saved sessions. Rows show a[source]badge; foreign source files cannot be renamed or deleted, while native Pi sessions and already-imported conversions can be (handle_session_selector_keyintui.rs:2281). - Scoped model selector (
/scoped-models): enable/disable models forctrl+p/ctrl+shift+pcycling, save the scope, and reorder enabled models (handle_scoped_model_selector_keyintui.rs:2387). - Session tree (
/tree): browse the branching message tree, fold/unfold, apply filters, and edit node labels (handle_tree_panel_keyintui.rs:2143). - Fork panel (
/fork): a tree filtered to user messages; selecting one and pressingEntercopies the active path up to that message into a new session (TreePanelMode::Forkintree_panel.rs:9,open_fork_panelintui.rs:2066).
Extension dialogs are raised when an extension requests interactive UI
(ExtensionDialog in tui.rs:127). They include:
- Select:
Up/Downto choose,Enterto accept,Escto cancel. - Confirm: arrow keys or
Tabto swap the default;y/Yorn/NorEnterto accept;Escto cancel. - Input / Editor: full editor keys from the keybinding table,
Enter(tui.input.submit) to accept,Escto cancel.
(handle_extension_dialog_key in tui.rs:2576)
Terminal image behavior
Images are rendered only in the inline TUI. The line REPL and structured modes never receive image protocol bytes.
Protocol detection uses explicit environment evidence
(detect_protocol in terminal_images.rs):
- Kitty:
KITTY_WINDOW_ID,TERM=xterm-kitty, orTERM_PROGRAMequal towezterm/ghostty. Under tmux, Kitty output is enabled only for tmux 3.3 or newer and every graphics APC is DCS-wrapped for passthrough. GNU Screen and older/unknown tmux versions fall back to metadata text. - iTerm2:
TERM_PROGRAM=iTerm.appandITERM_SESSION_IDpresent. - Sixel:
TERMcontainssixel. Sixel is detected for truthful diagnostics but is not emitted because the project does not yet include a bounded safe encoder; the TUI falls back to metadata text (supports_imagesinterminal_images.rs).
The TUI does not read stdin for an active graphics probe during startup, so terminal capability detection cannot consume user keystrokes. The implemented query contract uses Kitty's valid 1x1 RGB form and its response splitter preserves unrelated bytes, but the interactive startup path does not invoke it.
Image display settings come from terminal.showImages and
terminal.imageWidthCells in settings.json (TerminalSettings in
settings.rs; the default width is 60). Images are suppressed whenever an
overlay such as a selector, tree, or dialog is open.
Layout and safety: the renderer decodes and validates the image, refuses
empty data and payloads larger than the configured MAX_IMAGE_BYTES limit,
clamps the layout to the viewport and
imageWidthCells, preserves aspect ratio, and falls back to ordinary metadata
when display is disabled or the protocol is unsupported.
Presentation: validated PNG payloads are transmitted in chunks of at most
4,096 base64 bytes with Kitty's transmit-only action (a=t) and separate
pixel width/height controls (s=, v=). Image and placement IDs are explicit,
non-zero, and randomized per renderer. Frame reconciliation deletes only the
renderer-owned placement that moved or disappeared; cleanup deletes only its
owned image IDs, never another application's placements. iTerm images retain
the inline 1337 fallback with preserveAspectRatio=1.
Slash commands
The TUI and line REPL can execute the same built-ins, but /help, slash
completion, and RPC discovery expose only the 22 commands in
PRIMARY_COMMAND_NAMES (interactive_commands.rs). Prompt templates, dynamic
skills, and extension commands remain executable through their namespaced paths.
| Command | Description |
|---|---|
/settings | Inspect settings or open the settings page |
/model [provider/model] | Select or switch model |
/branch | Create a branch from a previous message |
/resume [path, id, or prefix] | Resume native or discovered foreign sessions |
/fork | Fork from a previous user message |
/export [path] | Export session to HTML or JSONL |
/dump [--jsonl] [path] | Export with an explicit format flag |
/handoff [--prose] | Generate a handoff summary (copied to clipboard) |
/agents | Manage agent definitions and model overrides |
/role | Manage the current agent role |
/compact [--snap] [instructions] | Manually compact session context (--snap is deterministic) |
/rewind [entry-index|checkpoint-name] | Roll the session back, archiving the dropped tail |
/checkpoint <name> | Mark the current position as a named rewind target |
/ps | List supervised processes |
/loop [interval] <prompt> | Run a recurring prompt |
/goal | Manage the durable session goal |
/workflow | Manage isolated concurrent workflows |
/code-review [<from> <to>] | Open a fullscreen Git diff browser: bare shows tracked HEAD→working-tree changes; two refs compare any two commits/branches/tags |
/btw [prompt] | Open a persistent detached side conversation forked from the active main branch |
/queue [cancel] | Show pending steering/follow-up prompts (cancel clears them) |
/live | Hold-to-talk voice input |
Other built-ins such as /help, /new, /sessions, /tree, /todo,
/snapcompact, /share, /copy, /login, /logout, /process, /theme,
and /quit remain manually executable but are intentionally omitted from
primary discovery.
/btw is read-only by default. Ctrl+T toggles edit/exec tools while the side agent is idle; Esc aborts a streaming turn or closes an idle overlay; Alt+R reforks from the current main leaf; Alt+N clears the side transcript. The overlay has its own editor, transcript, events, stream, and abort lifecycle. Closing it preserves the side controller for reopening, while TUI shutdown aborts and joins active side work.
/code-review displays staged and unstaged tracked changes against HEAD. Tab changes pane focus; j/k, arrows, and mouse clicks select hunks; c comments on the selected hunk; Space folds its inline review thread. Each thread appears after the complete hunk body with distinct comment/answer cards, and repeated agent progress messages collapse into one answer per exchange. Page keys and the mouse wheel scroll; tree clicks select or fold; Esc/q closes the page. Mouse capture is enabled only for this page and restored on every close or overlay transition.
/code-review accepts zero or exactly two arguments. Bare /code-review shows
tracked HEAD→working-tree changes. /code-review <from> <to> resolves each ref
(commit hash, branch, or tag) to a commit and renders the commit-to-commit diff
labeled <from> → <to> in the panel title; working-tree changes are ignored. One
argument or more than two is invalid — the page does not open and the status line
shows Usage: /code-review [<from> <to>]. Pressing r refreshes the snapshot and
preserves the selected revision pair.
Loop intervals accept positive bare seconds (/loop 300 check status) or compact s, m, h, and d units (/loop 3s echo hello, /loop 30m check deploy). Scheduled turns appear as Loop <id> · <cadence> system cards; the internal model instruction wrapper is not shown as a user message.
TUI vs REPL-only limitations
When stdout is not a TTY, main_run falls back to repl::interactive
(lib.rs:142). The REPL shares the same slash-command
catalog but lacks the TUI's modal page overlays:
- No modal pages: model, settings, trust, saved-session, scoped-model,
session-tree, fork,
/btw, and/code-reviewoverlays are TUI-only. In the REPL,/scoped-modelsand/themereport that they require the TUI,/treeprints JSON,/forkwith no argument prints candidate messages, and/modelaccepts a concrete spec. - No configurable keybindings or themes: the REPL uses a fixed line-editing interface and ignores theme files.
- No terminal image rendering: image attachments are processed and sent to the model, but the REPL displays only text metadata.
- No interactive extension UI: the REPL is composed without the TUI's
ExtensionUiAdapter(session_run.rs:491), so extension select/confirm/input dialogs cannot be answered interactively.
CLI modes and commands
rpi is a single binary that switches between non-interactive, headless, and interactive modes based on the flags you pass.
Top-level syntax
rpi [OPTIONS] [PROMPT]...
When no subcommand is given, a top-level run is selected by the flags and terminal:
--listen→ headless Web-only service. It does not start or display a TUI or line REPL, ignores terminal interactivity, and remains alive until Ctrl-C/SIGTERM even when standard input is closed.--mode jsonor--mode rpc→ headless structured I/O.-p/--print→ print mode.- A non-empty positional prompt with non-terminal stdin or stdout → print mode.
- Both stdin and stdout are terminals → TUI, with positional prompts submitted as initial turns.
- Otherwise → line REPL, with positional prompts submitted as initial turns.
Multiple positional
[PROMPT]arguments are separate initial turns in JSON, print, TUI, and REPL modes. An empty prompt is skipped by structured modes and does not force print mode.
Top-level flags
| Flag | Short | Value | Meaning |
|---|---|---|---|
--model <SPEC> | -m | e.g. anthropic/claude-sonnet-4-5 | Model to use. |
--provider <PROVIDER> | provider id | Provider used with --model. | |
--models <PATTERNS> | comma-separated patterns | Scope interactive model cycling. | |
--print | -p | Force print mode. | |
--mode <text|json|rpc> | protocol | Output protocol. json streams one-shot events; rpc reads LF-delimited JSON commands on stdin; text uses normal terminal selection. | |
--continue | -c | Resume the newest native Pi session for this directory. Native-only; foreign sessions are never selected. | |
--resume <PATH_OR_ID> | -r | path or id | Resume a native Pi session or import and resume a discovered OMP, Codex, Claude, Grok, or Droid session. Exact ids and unambiguous prefixes are accepted. |
--session <PATH_OR_ID> | path or id | Open a session by file path, exact id, or unambiguous prefix. | |
--session-id <ID> | exact id | Open an exact project session id, creating it when absent. | |
--fork <PATH_OR_ID> | path or id | Fork a session by file path, exact id, or unambiguous prefix. | |
--session-dir <DIR> | directory | Override the directory used for session storage and id lookup. | |
--no-session | Do not persist a session file for this run. | ||
--name <NAME> | -n | display name | Set the session display name. |
--system-prompt <TEXT_OR_PATH> | --system | text or file | Override the system prompt with text or an existing file. |
--append-system-prompt <TEXT_OR_PATH> | text or file | Append to the system prompt; repeatable. | |
--cwd <DIR> | -C | directory | Working directory for this run and global subcommands. |
--add-dir <DIR> | directory | Add a directory to scoped tools and @file expansion; repeatable. | |
--thinking <LEVEL> | --think | off|minimal|low|medium|high|xhigh|max | Initial reasoning level. |
--api-key <KEY> | secret | Override the API key for the resolved model's provider. | |
--tools <TOOLS> | -t | comma-separated names | Allowlist applied after tool assembly. |
--exclude-tools <TOOLS> | -xt | comma-separated names | Denylist applied after the allowlist. |
--no-tools | -nt | Disable all built-in, extension, orchestration, and custom tools. | |
--no-builtin-tools | -nbt | Disable built-in tools while preserving others. | |
--extension <PATH> | -e | file or directory | Load an explicit extension manifest; repeatable. --extensions is an alias. |
--no-extensions | -ne | Disable discovered/configured extensions while retaining explicit --extension paths. | |
--skill <PATH> | file or directory | Load an explicit skill; repeatable. | |
--no-skills | -ns | Disable discovered/configured skills while retaining explicit --skill paths. | |
--prompt-template <PATH> | file or directory | Load an explicit prompt template; repeatable. | |
--no-prompt-templates | -np | Disable discovered/configured prompt templates while retaining explicit paths. | |
--theme <PATH> | file or directory | Load an explicit theme; repeatable. | |
--no-themes | Disable discovered/configured themes while retaining explicit paths. | ||
--no-context-files | -nc | Disable AGENTS.md and CLAUDE.md discovery. | |
--list-models [SEARCH] | optional filter | List models and exit. | |
--offline | Disable nonessential startup networking such as catalog refreshes and update checks. | ||
--verbose | Force verbose startup diagnostics. | ||
--approve | -a | Trust project-local .pi settings/resources for this run only. | |
--no-approve | Refuse project-local .pi settings/resources for this run only. | ||
--approval-mode <MODE> | yolo|write|ask | Host tool approval policy. yolo allows all capabilities, write confirms Exec, and ask confirms every tool call. Non-interactive confirmation fails closed. This is separate from project trust flags. | |
--listen <SOCKET_ADDR> | e.g. 127.0.0.1:8765 | Start the headless Web-only HTTP/WebSocket service. Serves /web, /ws, and /rpc; never starts a TUI or REPL and stays alive with closed stdin until Ctrl-C/SIGTERM. Positional prompts are rejected; submit them through the Web/RPC API. Loopback is the default. Authentication is optional: a tokenless loopback bind accepts same-origin browsers and native clients; non-loopback binds require --listen-allow-insecure-remote (token optional there too). | |
--listen-token-file <PATH> | token file | Optional Bearer token file. When set, the token is mandatory on every bind (browsers present rpi-auth.<token>; /rpc requires Authorization: Bearer). When unset, the listener is tokenless: loopback accepts same-origin browsers, and --listen-allow-insecure-remote accepts same-origin LAN browsers (browser Origin authority equals HTTP Host). | |
--listen-allow-insecure-remote | Permit a non-loopback --listen address (token optional). Plaintext HTTP/WebSocket exposes any bearer token and control traffic to passive network observers. | ||
--listen-advertised-origin <URL> | http(s) origin | Advertised origin for collaboration links (/collab, collab_start without an explicit baseUrl) and the reachable /web URL printed at startup. Not required for ordinary /web, /ws, or /rpc: tokenless browser access uses ordinary same-origin (Origin authority equals HTTP Host). Strict origin: http/https scheme, a host with an optional numeric port, and no credentials, path, query, or fragment (a trailing / is normalized away). Loopback and other specific binds advertise their bound address automatically; a wildcard bind (0.0.0.0 or ::) without this flag prints no reachable URL and /collab fails closed instead of synthesizing links from an unreachable wildcard. | |
--version | -v, -V | Print version and exit. | |
--help | -h | Print help and exit. |
Short aliases are normalized before clap parses: -v maps to --version, -xt to --exclude-tools, -nt to --no-tools, -nbt to --no-builtin-tools, -ne to --no-extensions, -ns to --no-skills, -np to --no-prompt-templates, and -nc to --no-context-files.
--provider requires --model; --api-key requires --model or --models.
--continue, --resume, --session, --session-id, --fork, and --no-session
are mutually exclusive.
For explicit LAN access — tokenless (no token, same-origin browser):
$ rpi --listen 0.0.0.0:8765 --listen-allow-insecure-remote
Open http://<host-lan-ip>:8765/web — or any hostname that routes to the
host — from another machine; the browser auto-connects with no token. No
--listen-advertised-origin is needed for ordinary /web, /ws, or
/rpc: the browser's Origin authority must equal the HTTP Host
(ordinary same-origin, which rejects unrelated cross-origin pages but is
not authentication and not DNS-rebinding protection). This is plaintext and
unauthenticated; use only on a network where passive observers are an
accepted risk. To make the token mandatory, add --listen-token-file:
$ rpi --listen 0.0.0.0:8765 --listen-token-file <workspace>/rpi-token --listen-allow-insecure-remote
The token authenticates clients but does not encrypt traffic. rpi agent serve
remains loopback-only. --listen-advertised-origin is only for collaboration
links and the reachable URL printed at startup; loopback and specific binds
advertise their bound address automatically, and a wildcard bind without it
prints no reachable URL (ordinary web access still works via same-origin).
Subcommands
rpi models [FILTER]
List available models. Provider headers are printed in bold. The filter is case-sensitive and matches against provider name or model id.
rpi sessions
List native Pi v3 sessions for the configured working directory, newest first. Honors the global -C / --cwd flag in any position:
rpi --cwd /path sessions
rpi sessions -C /path
rpi import-session <SOURCE> <INPUT> [--output PATH]
Convert an external session to native Pi v3 JSONL.
Supported SOURCE values:
piompcodexclaudegrokdroid
With --output the file is written to that path (or into the directory if the path is an existing directory). Without --output the file is placed under the per-cwd session directory and the command prints the emitted path and message count.
rpi login [PROVIDER] / rpi logout [PROVIDER]
Configure or remove stored credentials in auth.json. When run in an interactive terminal with no provider, a list of configured providers is shown. Outside a terminal the provider argument is required.
rpi reload
Validate the active settings/resource snapshot and print a JSON summary to stdout.
rpi export <SESSION_PATH> [--output PATH] [--jsonl]
Export a native Pi v3 session file to a self-contained HTML file (default) or to a current-branch JSONL file with --jsonl. No model, auth, or network access is required. Prints the output path to stdout.
rpi install <SOURCE> [--local] / rpi remove <SOURCE> [--local] / rpi list
Install, remove, or list local/git rpi packages. --local persists the package in the project's .pi/settings.json instead of the global agent settings. Project packages are only loaded when the project is trusted.
rpi config [-l|--local]
Configure enabled package resources (extensions, skills, prompts, themes) for global or project scope. In a terminal it opens an interactive selector; with non-TTY stdout it prints deterministic JSON. Project scope is refused unless the project is trusted.
rpi update [OPTIONS] [PACKAGE]
Update the managed installation, configured packages, or dynamic model catalogs.
- no arguments or
--self: update the managedrpiinstallation. --extensions: reconcile every configured package.--self --extensionsor--all: update packages, then updaterpi.--models: refresh dynamic model catalogs.--extension SOURCEor positionalPACKAGE: update one configured package.- positional
selforrpi: update the managed installation. --force: reinstall the selected self-update even when version and checksum match.
A package source cannot be combined with --self, --extensions, or --force.
rpi llama <COMMAND>
Manage a llama.cpp router and local GGUF downloads:
| Subcommand | Purpose |
|---|---|
configure URL [--api-key KEY] | Configure and validate a router |
status [--reload] | Show router catalog |
refresh | Refresh live models, fall back to cached catalog |
load MODEL / unload MODEL | Load/unload a model |
search QUERY | Search Hugging Face for GGUF repos |
details OWNER/REPO | List quantizations and checksums |
download OWNER/REPO [-q QUANT] | Download one quantization atomically |
installed | List local downloads |
Interactive and service modes
- Web-only listener — when
--listenis passed. This service is headless and signal-driven; standard input is not an input or lifetime owner. - Headless JSON/RPC — when
--mode jsonor--mode rpcis passed. - Print mode — when
-p/--printis set, or a positional prompt is supplied while stdin or stdout is not a terminal. - TUI — when both stdin and stdout are terminals.
- Line REPL — fallback for non-terminal text sessions.
All share the same session engine; only the input and rendering differ.
Print mode output
Print mode streams assistant text and tool activity to stdout:
· bash({command})
└ ok
The result is...
A trailing newline is appended after the final assistant text.
Primary slash commands
/help, slash completion, and RPC command discovery expose exactly these 21
primary commands. Other built-ins remain manually executable where documented.
| Command | Description |
|---|---|
/settings | Inspect settings (REPL) or open the settings panel (TUI) |
/model [spec] | Switch model without clearing the transcript |
/branch | Create a new branch from a previous message |
/resume [path|id|prefix] | List or resume native and discovered foreign sessions |
/fork | Fork from a previous user message |
/export [path] | Export the session to HTML or JSONL |
/dump [--jsonl] [path] | Export with an explicit format flag |
/handoff [--prose] | Generate a handoff summary (copied to clipboard) |
/agents | Manage agent definitions and model overrides |
/role | Manage the current agent role |
/compact [--snap] [instructions] | Manually compact session context (--snap is deterministic, no LLM call) |
/rewind [entry-index|checkpoint-name] | Roll the session back, archiving the dropped tail |
/checkpoint <name> | Mark the current position as a named rewind target |
/ps | List supervised processes |
/loop [interval] <prompt> | Run a prompt on a recurring interval |
/goal | Create and manage the durable session goal |
/workflow | Create and manage isolated concurrent workflows |
/code-review [<from> <to>] | Browse a Git diff in a fullscreen tree/diff page: bare shows tracked HEAD→working-tree staged and unstaged changes; two refs compare any two commits/branches/tags |
/btw [prompt] | Open a persistent detached side conversation forked from the active main branch |
/queue [cancel] | Show pending steering/follow-up prompts (cancel clears them) |
/live | Hold-to-talk voice input (TUI) |
Frequently used hidden built-ins include /help, /new, /sessions, /tree,
/todo, /login, /logout, /share, /copy, /process, /theme,
/snapcompact (alias of /compact --snap), and /quit. They remain
executable but do not appear in primary discovery.
/btw starts read-only. Ctrl+T toggles fresh workspace-scoped edit/exec tools when idle, Esc aborts a streaming side turn or closes an idle overlay, Alt+R reforks from the current main branch, and Alt+N clears the side transcript. Closing the overlay keeps its controller for reopening; leaving the TUI aborts and joins active side work. peek_main reads the current active main branch without writing to the main session.
/code-review uses direct bounded Git argv with pager, external diff, text conversion, configured filters, and interactive prompts disabled. It never sends repository content to a server. The page supports keyboard and mouse tree/hunk navigation, inline per-hunk comment threads after each complete hunk, fold/unfold controls, and one consolidated answer card per review exchange. Esc or q closes it and restores normal terminal mouse selection.
/code-review accepts zero or exactly two arguments. Bare /code-review shows
tracked HEAD→working-tree staged and unstaged changes. /code-review <from> <to>
resolves each ref — a commit hash, branch, or tag — to a commit and renders a
commit-to-commit diff labeled <from> → <to> in the panel title; working-tree
changes are ignored on this path. One argument or more than two is invalid: the
panel does not open and the status line shows Usage: /code-review [<from> <to>].
Pressing r refreshes the snapshot while preserving the selected revision pair.
Loop intervals accept positive bare seconds (/loop 300 check status) or compact
s, m, h, and d units (/loop 3s echo hello, /loop 30m check deploy).
Values are honored exactly; zero and overflow are rejected.
REPL-only additions
The line REPL also accepts:
| Command | Description |
|---|---|
/think <level> | Set reasoning level (off, minimal, low, medium, high, xhigh, max) |
!command | Run a bash command recorded in context |
!!command | Run a bash command excluded from context |
In the TUI, reasoning level and model cycling are controlled by keybindings instead. See tui.md.
TUI-only slash commands
| Command | Description |
|---|---|
/theme [name|next|prev] | Show or switch the active theme |
/scoped-models | Enable or disable models for cycling (opens TUI panel) |
TUI keybindings
The TUI uses the terminal's normal screen with inline redraws, preserving terminal and tmux scrollback. The transcript is above a composer and status line.
| Key | Action |
|---|---|
Enter | Submit the current message |
Shift+Enter or Ctrl+J | Insert a newline |
Ctrl+D (on empty input) | Quit |
Esc | Cancel the in-flight run, or clear input when idle |
Ctrl+C | Clear input |
Ctrl+U | Clear the entire composer |
Tab | Accept the selected slash-command completion |
Up / Down | Move the cursor, select a completion, or navigate submitted prompt history |
Left / Right | Move cursor |
Backspace / Delete | Edit text |
Home / End | Move to start/end of line |
Ctrl+V / Alt+V | Paste from clipboard |
Ctrl+X / Ctrl+Shift+C | Copy last assistant message |
Ctrl+G | Open external editor |
Ctrl+L | Open model selector |
Ctrl+P / Ctrl+Shift+P | Cycle model forward/backward |
Ctrl+T | Toggle thinking-block visibility |
Shift+Tab | Cycle thinking level |
Ctrl+O | Expand tools panel |
Type /hotkeys in either interactive mode to see a shortcut summary. Default bindings can be overridden with custom keybinding JSON files. See tui.md.
Session resume and import
Native sessions are Pi v3 JSONL files. Each line is an object with a type field. The active branch is reconstructed from parentId chains, so sessions can contain forks and the latest branch is followed on resume.
Session files live under:
<agent-dir>/sessions/--<workspace>--/<timestamp>_<id>.jsonl
<workspace> is the encoded cwd: the absolute path with leading separators removed and /, \, and : replaced by -.
The full-screen TUI selectors opened by /resume and /sessions, the line REPL bare /resume command, and the TUI startup Recent sessions list all draw from the same supported unified catalog. Automatic lists are scoped to the current working directory and display a [source] badge next to each row. Supported sources are native Pi (pi), OMP (omp), Codex (codex), Claude (claude), Grok/Hyper (grok/hyper), and Droid (droid).
--resume and /resume share this catalog. Native selections open the existing file without copying. Foreign selections are converted to Pi v3 once into the effective native session root, retain source lineage, and reuse that converted file on later resumes. In selectors, foreign source files are read-only: they cannot be renamed or deleted. Native Pi sessions and already-imported conversions are regular JSONL files under the session root and can be renamed or deleted from the selector. Only convertible user/assistant text messages are preserved during import; tool calls, reasoning, attachments, and branches are dropped.
--continue remains native-only and resumes the newest native Pi session for the directory.
Model spec syntax
provider/id— explicit provider and model id.id— bare id matched across all providers.provider/id:level— explicit model plus thinking level suffix.id:level— bare id plus thinking level.
Resolution is case-insensitive. If the requested id is unknown but the provider is known, the CLI falls back to a synthetic custom-id model cloned from that provider's default template and prints a warning. This is useful with custom models.json entries.
Cancellation
- In print mode,
Ctrl-Caborts the in-flight turn and the CLI exits. - In the REPL,
Ctrl-Caborts the current turn and returns to the prompt. - In the TUI,
Escaborts the in-flight run.
Exit codes
0— success (--helpand--versionalso exit0).1— any runtime/dispatch error. Errors are printed to stderr without secret values (crates/pi-cli/src/main.rs:24-27). Subcommands that fail (e.g.rpi exporton a missing session) surface through this same path.2— argument/usage errors, reported by clap's parser (error.exit()incrates/pi-cli/src/args.rs:615-616).
There is no finer-grained exit-code table; all failure modes collapse to 1
(or 2 for a usage error before dispatch).
Related documentation
authentication.md— env vars,auth.json,models.jsonmodels.md— model catalog, custom providerssettings-trust.md—settings.json, config directory, trusttui.md— TUI themes and keybindingsrpc-json.md— JSON/RPC event schemalocal-llama.md— local model setuppackages.md— package manager details
Goals
A goal is a durable, session-scoped objective that describes why future
turns may continue. It is deliberately separate from todos, plans, scheduled
loops, and conversation messages: goals live in their own state machine with a
lifecycle, an optional token budget, role-model pins, and a revisioned journal
(crates/pi-coding/src/goal.rs).
The goal module only records state and returns a continuation decision — it
never starts a turn. The Application layer decides whether to keep an active
goal's work running and whether a goal turn should be queued or fired
(crates/pi-coding/src/application.rs::activate_goal).
Lifecycle
A goal is one of (GoalLifecycle, goal.rs):
| Lifecycle | Meaning |
|---|---|
active | The goal is current and its work may continue. |
paused | The goal exists but its work is suspended. |
completed | The objective is done. |
dropped | The objective is abandoned. |
Only one goal is current at a time (GoalState.current). The state carries a
revision counter; every transition is appended to the session journal as a
typed event (GoalEvent), and the journal is replayed to reconstruct state on
resume (goal_events_from_session_tree). Malformed or future goal entries
fail closed during replay.
Usage
/goal show # inspect the current goal
/goal create <objective> # set a goal (bare text is a create shorthand)
/goal create --tokens N <obj> # set a goal with an explicit token budget
/goal pause # pause goal work
/goal resume # resume goal work
/goal complete # mark the objective done
/goal drop # abandon the objective
/goal pin <text> # add a role-model pin
/goal pins # list pins
/goal unpin <index> # remove pin at 0-based index
Source: crates/pi-cli/src/goal_commands.rs (parse contract and executor),
crates/pi-coding/src/application.rs (goal activation, pause, resume,
complete, drop, pin, unpin).
Budget and usage
A goal may carry a tokenBudget (set with --tokens N; zero is rejected).
Usage is tracked as GoalUsage { tokens_used, active_time_seconds }: the
runtime accumulates model usage and active wall-clock time while the goal is
active, and the goal continuation decision accounts for the budget. Without a
budget the goal runs without a token ceiling. The /goal show summary renders
active · 123/500 tokens · <objective> and includes time spent and pins
(goal_commands.rs::format_goal_details).
Pins
A goal supports up to MAX_GOAL_PINS = 8 pins — short example or
instruction strings (at most MAX_GOAL_PIN_CHARS = 200 characters each) shown
verbatim in the goal turn's context as role models. Pins are managed with
/goal pin <text>, /goal pins, and /goal unpin <index>.
Journal and invariants
Every goal event records both the transition and its resulting snapshot
(GoalEventKind + goal), revisioned and validated against the previous
state before it is appended (validate_replayed_transition). The typed
event kinds (GoalEventKind in goal.rs:120-133):
| Event kind | Payload | Meaning |
|---|---|---|
created | — | A goal was created (must be the journal's first event). |
fork_cloned | source | A forked session cloned this goal from a source snapshot. |
paused | reason (manual / resume_safety / budget_exhausted) | Goal work was suspended; resume_safety is the forced pause on session resume, budget_exhausted can only be resumed by a fresh budget. |
resumed | — | Goal work resumed from paused. |
completed | — | Objective marked done. |
dropped | — | Objective abandoned. |
usage_updated | delta | Token/time usage accumulated while active. |
pins_updated | pins | A pin was appended or removed; carries the resulting pin list. |
Replay validation (validate_replayed_transition, goal.rs:880-993) rejects
any event that follows a terminal lifecycle (except usage_updated), a
resumed event after budget_exhausted pausing, completed/dropped with a
pause reason or changed pins, and usage_updated deltas inconsistent with the
previous snapshot. A created event after the first one, a fork_cloned
whose source does not match the current goal, and malformed or future-version
entries all fail closed.
- The journal is stored in the session file as a custom entry type
pi.goal.event(GOAL_SESSION_CUSTOM_TYPE) at versionGOAL_SESSION_ENTRY_VERSION = 1. - Objective size is capped at
MAX_GOAL_OBJECTIVE_BYTES = 64 KiB, well under the session record limit. - A fork clones the current goal state; the cloned goal is validated so a
forked journal cannot diverge (
validate_fork_cloned). - Goal usage is session-scoped: it accumulates across turns while active and is serialized with the goal, so a resumed session continues the same budget accounting.
rewind(seesession-recovery.md) treats goal journal entries as regular session entries; rewinding past a goal event rolls the goal back to the corresponding prior snapshot.
The goal tool
Orchestration children receive a goal tool exposing the same operations
(create/show/pause/resume/complete/drop) so a delegated worker can manage its
own durable goal. See orchestration.md.
Related documentation
todos.md— the task-level plan, distinct from the goalorchestration.md— thegoaltool in child sessionssession-recovery.md— the goal appears in handoff envelopes (HandoffGoalincrates/pi-coding/src/handoff.rs)
Todos and the Todo DAG
Todos are the task-level plan of a session: an ordered set of phases, each
holding tasks that can depend on other tasks, forming a directed acyclic
graph (DAG). The todo tool (on by default; disable with
settings.orchestration.todo = false) lets the model create, edit, and
execute the plan in natural language, and the TUI renders it in a dedicated
Todo DAG panel. Workflows plan into the same canonical structure and execute
it with delegated subagents (see workflows.md).
Model
TodoStatus—pending,in_progress,completed,abandoned(crates/pi-coding/src/todo.rs).TodoItem—id(stabletask-<uuid>),content,status,depends_on(dependency task ids),ready,blocked_by(derived blocked-reasons with the blocking task's content and status), and an optionalagent(a typed routing contract naming the agent that must execute the task).TodoPhase—name+tasks.TodoState—phases+storage(Session— persisted in the session file — orMemory).
Readiness is projected after every mutation: a pending/in_progress task
is ready only when all of its depends_on tasks are completed or
abandoned; otherwise it is blocked_by each unfinished dependency
(project_readiness in todo.rs). Cycle detection rejects any mutation that
would make the dependency graph cyclic (graph_contains_cycle), and phase
names/task contents are normalized and validated before an operation commits
(prepare_todo_phases).
todo tool operations
The tool is a single todo operation with these ops (TodoOp in todo.rs,
serialized snake_case):
| Op | Fields | Effect |
|---|---|---|
init | list (phases with items and optional parallel agents), or items/phase | Create the plan. A re-init describing exactly the current plan preserves ids/dependencies/statuses; otherwise it replaces the plan. |
append | phase, items | Add tasks to a phase. |
start | task | Mark a task in_progress. |
done | task or phase | Mark task(s) completed. |
drop | task or phase | Mark task(s) abandoned. |
rm | task or phase, cascade | Remove tasks/phase; cascade removes dependents too. |
add_dependency | task, dependsOn | Add dependency edges (cycle-checked). |
remove_dependency | task, dependsOn | Remove dependency edges. |
update_dependencies | task, dependsOn | Replace the dependency set. |
view | — | Render the current plan. |
An init phase may carry agents[i] names parallel to items[i]: that task
is routed to the named agent during DAG execution (validated against the
agent catalog — unknown or disabled agent names fail actionably).
Results report the mutated phases, the list of tasks that just transitioned
to completed (completedTasks), and a plain-text summary. The todo tool is
transactional under orchestration: mutations run inside a gate with a
check/commit pair so DAG execution and planning cannot race
(TodoMutationTransaction).
/todo and the Todo DAG panel
In the TUI and REPL:
/todo # open the Todo DAG panel (TUI) / print the plan (REPL)
/todo list # print the plan
/todo markdown # print the plan as Markdown checklists
Source: crates/pi-cli/src/interactive_commands.rs (/todo builtin),
crates/pi-cli/src/todo_dag_panel.rs and crates/pi-cli/src/todo_dag_view.rs
(panel), crates/pi-cli/src/todo_dag_panel.rs (TodoDagPanel).
The Todo DAG panel (TodoDagPanel in todo_dag_panel.rs) shows every DAG in
the session: the main session's DAG plus one DAG per active workflow. Each DAG
header renders the execution label and counts (✓ done · open · active · blocked); subagent rows under it show • <name> (<agent>) · <status> · <current task> with the owning todoTaskId. Keys: ↑/↓/j/k select a DAG
or subagent row, Enter opens the detail page (task list with depends_on
and blocked_by, plus linked jobs per task) or the subagent page (identity,
type, status, owning DAG, linked todo task, current task summary, progress).
Esc returns to the overview, Esc/q closes.
DAG execution
The DAG executes in two places:
- Orchestration:
taskcalls pass atodoTaskIdwhen the child owns a canonical Todo DAG item (orchestration/tools.rs); the runtime records the ownership on the job snapshot, and the panel deduplicates subagent rows against the DAG by task identity. - Workflows: the supervisor arms execution over the committed plan
(
workflow/supervisor.rs::arm_plan_and_run); a workflow's Todo mutations never auto-arm — arming happens on Planning → Running and on resume. Seeworkflows.md.
Steering and follow-up queues (/queue)
The session maintains two prompt queues with independent modes
(QueueMode: all or one-at-a-time), configured by
settings.steeringMode and settings.followUpMode (default all):
- steer — an interrupting prompt delivered into the active turn.
- follow-up — a prompt queued for the next turn.
When a mode is one-at-a-time, only one queued message of that kind is
delivered at a time; the rest stay pending.
/queue # show pending steering/follow-up prompts
/queue cancel # clear both queues
Source: crates/pi-cli/src/interactive_commands.rs (/queue builtin),
crates/pi-coding/src/application.rs (queued_messages/drain_queued_messages),
crates/pi-coding/src/session.rs (queue storage). The RPC surface exposes
set_steering_mode/set_follow_up_mode and prompt with
streamingBehavior "steer"/"followUp" (see rpc-json.md).
RPC
set_todos replaces the session's phases ({"type":"set_todos","phases":[...]});
todo_updated events announce changes. See rpc-json.md.
Invariants
- The Todo DAG is always acyclic; cyclic mutations are rejected.
- Readiness is derived, never stored:
ready/blocked_byare recomputed fromdepends_on+ statuses after every mutation. completedtransitions are reported exactly once per task per mutation (completion_transitionscompares before/after).- A re-init that matches the current plan is a no-op on ids/dependencies —
stable
todoTaskIds survive re-planning of an identical plan. - The
todotool is available to orchestration children by default and is never removed by role ceilings (orchestration plumbing).
Related documentation
workflows.md— workflow planning/execution over the DAGorchestration.md—todoTaskIdownership and jobsgoals.md— the goal (why), distinct from todos (what)rpc-json.md—set_todos,todo_updated, steering
Orchestration: subagents, jobs, and IRC
The orchestration runtime (crates/pi-coding/src/orchestration/) lets the
main session delegate work to child coding sessions ("subagents"), supervise
them as jobs, and coordinate between them with IRC-style mailbox
messages. It powers the task, hub, yield, and goal tools, the
workflow executor (see workflows.md), and the TUI's agents
panel and Todo DAG view.
Enabling
Orchestration is opt-in. Set settings.orchestration.tasks = true (the
task tool gate; orchestration_enabled in
crates/pi-coding/src/settings.rs:1403-1409). The todo tool is on by
default and the process tool is gated by orchestration.process.
{
"orchestration": { "tasks": true }
}
The workflow runtime requires orchestration and gates it separately; see
workflows.md for the full orchestration settings block.
Agent catalog
Agents are Markdown definitions with YAML frontmatter, discovered from
<agent-dir>/agents/*.md and (when the project is trusted)
<workspace>/.pi/agents/*.md (AgentCatalog::discover in
orchestration/definitions.rs:138). The bundled task and researcher
agents are always available; user definitions win over bundled ones.
Frontmatter fields (AgentDefinition in orchestration/definitions.rs:66-88):
| Field | Meaning |
|---|---|
name | Identifier used by task/hub and by delegation. |
description | Shown in the task tool's available-agent list. |
tools | Child tool allowlist; settings.agents.<name>.tools overrides it. |
autoloadSkills | Skills autoloaded into the child's prompt. |
model | Model pattern list; settings.agents.<name>.model overrides it. |
thinkingLevel | Child reasoning level. |
maxTurns / maxToolCalls / timeoutSecs | Contract bounds: the child stops cleanly after the cap with a clear reason. |
disallowedTools | Tools the child must never receive. |
capabilityCeiling | Per-capability ceiling (read/write/exec); a role that sets only read: true gets a strictly read-only tool set. Orchestration plumbing (todo/process/task/hub/goal) is always kept so a read-only role can still delegate and be supervised. |
Model resolution precedence: settings override → first matching definition
pattern → parent session model (resolve_agent_model,
orchestration/definitions.rs:318-375). Settings entries can disable an
agent (settings.agents.<name>.enabled = false; /agents manages these),
and a child that declares tools outside the supported set is not blocked:
unknown declared tools are silently ignored (OMP-compatible) with a single
deduped warning, and an invalid model makes the agent unavailable with an
actionable error.
The task tool
task starts one or more independent child coding-session jobs and returns
immediately with stable job and agent ids; supervision happens through hub
(orchestration/tools.rs:39-67). A child that owns a canonical Todo DAG item
receives that item's stable id as todoTaskId.
task agent=researcher task="Study the persistence layer" todoTaskId=task-abc
task tasks=[{name: "w1", task: "Draft API"}, {name: "w2", task: "Write tests"}]
Delegation intent is also recognized from the main prompt: an English delegation verb ("Have researcher study this") or a conservative CJK construction ("你让researcher仔细调研…") escalates to an exact trusted agent name; informational mentions do not. Ambiguous mentions with explicit delegation intent are errors (actionable, naming the candidates).
The matcher is exact-token based (runtime.rs:4936-4977): the English
token set is have, ask, tell, get, let, make, please, delegate, assign, spawn, run, send, kick, dispatch (ENGLISH_DELEGATION_VERBS,
runtime.rs:4939-4941), matched as whole NFKC-lowercased tokens — so
researchers never matches researcher. The CJK token set is 让, 请, 叫, 派, 安排, 委托, 交给 (CJK_DELEGATION_TOKENS, runtime.rs:4948), matched
conservatively: the token must directly precede the agent name with no
intervening tokenizer boundary (a CJK script run like 你让researcher), using
the same word-boundary logic the selector applies (selector.rs:1107-1111).
A recognized verb or CJK construction plus an unambiguous agent-name mention
selects that agent; multiple distinct agent names with delegation intent is
an error listing the candidates.
Jobs and supervision
Every delegated child runs as a job (JobSnapshot in
orchestration/runtime.rs): queued → running → completed | failed | cancelled | aborted, with created_at/started_at/finished_at, a
description, an optional todoTaskId, a redacted result/error, and a
softBudgetExhausted marker.
- Concurrency: at most
orchestration.maxConcurrencychildren run at once (default 8, semaphore-bounded); recursion depth is bounded bymaxRecursionDepth(default 8). - Job retention: settled jobs and their artifact files
(
<agent-id>-<job-id>.mdoutputs,<agent-id>-<job-id>.history.jsontranscripts) are retained up toDEFAULT_MAX_RETAINED_JOBS = 256forDEFAULT_RETAINED_JOB_TTL_SECS = 24h, then pruned (JobRetentioninorchestration/runtime.rs:32-33). - Idle parking: idle non-main agents park after
DEFAULT_IDLE_TTL_SECS = 300sand are revived on demand (schedule_idle_park). - Soft budgets (
JobSoftBudgetinorchestration/runtime.rs:154-166, settingsorchestration.softBudget):maxRequests,maxTokens, andyieldAfterare all optional and default to unlimited (run-to-completion behavior). When a configured limit is reached the child is not failed: its run stops cleanly after the current turn, the job settlesCompletedwith the partial result, and bothTaskResultandJobSnapshotcarrysoftBudgetExhausted: trueso the parent can decide whether to continue the child. - Contract bounds:
maxTurns,maxToolCalls, andtimeoutSecsfrom the agent definition stop the child cleanly with a clear reason; a child that exceeds its timeout contract after an abort is reported. - Durable orchestration: child sessions are recorded to their own JSONL
transcripts under the durable child root; the runtime persists agent
snapshots, mailboxes, and job state to a sidecar so a restarted session
revives queued children and re-delivers their mailboxes
(
orchestration/persistence.rs).
The child's tool set is assembled per role: coding tools filtered by the
capability ceiling and disallowedTools, plus orchestration plumbing. The
child also receives the yield tool, the hub tool, and the goal tool.
The hub tool
hub coordinates with Main and child peers (orchestration/tools.rs:70-103):
hub send <to> <message>— deliver a mailbox message (subagent ⇄ subagent included). Delivery is durable: the message is committed to the recipient's mailbox before any revival claim, and the bounded delivered-message log (cap 200) keeps messages visible to the workflow page's Recent IRC even after consumption.hub wait [from] [timeoutMs]— block until a message arrives; registered waiters make the delivery bridge defer matching sends so the waiter drains them durably (MessageWaiter, RAII-unregistered on every return path).hub inbox [peek]— read queued messages without necessarily consuming.hub list— refresh the peer roster (spawn-time snapshot with<peer_roster>+<truncated />bounds).hub read_history <agent> [lines]— rendered transcript of a peer (default 50 lines, hard max 200, 32 KiB byte cap).hub jobs/hub cancel/hub wait— supervise child task jobs.
The roster a child sees is a spawn-time snapshot: hub list refreshes state
and hub send addresses exact ids.
The yield tool
yield is the explicit-delivery protocol for child sessions
(orchestration/tools.rs:104-119, YieldState in
orchestration/runtime.rs:791-836): a child calls it exactly once, passing
the full final deliverable as text; that payload becomes the job's delivered
output and the child's run terminates. It is wired to per-run delivery state
the run loop reads when the child settles, so the payload lands in
TaskResult.output. A child that exits without calling yield settles with
its natural final text plus the marker:
SYSTEM WARNING: Subagent exited without calling yield
The goal tool
Children receive the goal tool so a delegated worker can create, show,
pause, resume, complete, or drop a session goal — the same durable goal
state machine documented in goals.md. Orchestration plumbing
means role filters never remove it.
Live progress and activity
The runtime publishes ApplicationEvents (agent_start, agent_settled,
job_*, todo_updated, orchestration mailbox messages) that drive the TUI
agents panel and the workflow page. Each agent has a live AgentSnapshot
(display name, status, current task summary, todo ownership) and a bounded
per-agent activity feed; the workflow detail merges delegated task lifecycle
entries with IRC messages per agent (workflow/detail.rs). Everything shown
to the UI is redacted.
Concurrency gates and settings
orchestration.maxConcurrency(default 8) — child semaphore; a global workflow-scoped gate can additionally limit workflow children.orchestration.maxRecursionDepth(default 8) — thetasktool is only offered to a child whiledepth < maxRecursionDepth.orchestration.mailboxCapacity(default 1000) — per-agent mailbox bound.orchestration.maxToolsPerAgent(default 16) — tool-count ceiling for a child's coding tools.orchestration.sandboxed(default false) — run child process spawns in the Linux filesystem sandbox.orchestration.softBudget— per-child soft budget (see above).
All bounds are validated in OrchestrationConfig::validate
(orchestration/runtime.rs:368-392) and at settings load time
(settings.rs:2674-2697).
Invariants
- A child job is never failed by a soft budget — it settles
Completedwith the partial result and thesoftBudgetExhaustedmarker. yielddelivers exactly once; a missingyieldis observable to the parent viaMISSING_YIELD_WARNING.- Messages are durably committed to the mailbox before any revival claim, so a restart cannot lose a delivered IRC message.
- Mailbox waits are RAII: every return path (timeout, abort, shutdown, drop) unregisters the waiter so no stale claim strands a message.
- Role ceilings only ever remove tools; orchestration plumbing
(
todo/process/task/hub/goal) is preserved so restricted roles can still delegate and be supervised.
Related documentation
workflows.md— workflow lifecycle on top of this runtimetodos.md— the Todo DAG and its executiongoals.md— the durable goal state machineskills.md— agent definition format details
Isolated concurrent workflows
A workflow is a durable, isolated, self-contained run of the agent that
plans an objective into a canonical Todo DAG and then executes that DAG with
delegated subagents. Workflows are managed with /workflow and rendered live
in a dedicated TUI page (subagents, tasks, and IRC). The durable lifecycle
lives in crates/pi-coding/src/workflow/ (manager, store, supervisor) and the
isolation backends in crates/pi-coding/src/workflow_worktree/ (git worktrees)
and crates/pi-coding/src/workflow_worktree/overlay.rs (overlayfs).
Lifecycle
A workflow moves through a strict status machine
(WorkflowStatus in crates/pi-coding/src/workflow/mod.rs:53-64):
| Status | Meaning |
|---|---|
queued | Created, waiting for a runtime slot. |
planning | A bounded planning turn builds the canonical Todo DAG. |
running | The Todo DAG is being executed by delegated subagents. |
paused | Execution is suspended; resume continues it. |
integrating | Committed workflow changes are being merged back. |
completed | All tasks done; integration applied (or nothing to integrate). |
failed | Planning/execution/integration hit a hard error (WorkflowFailure). |
cancelled | Explicitly cancelled. |
conflicted | Integration produced merge conflicts. |
Lifecycle transitions are validated in two places: the manager rejects
actions that are not allowed for the current status (ensure_allowed in
workflow/manager.rs), and the supervisor projects only legal transitions
back (validate_status). Pause is legal from queued/planning/running;
resume is legal from paused/planning/running; cancel is legal from any
non-terminal status; integrate is legal from completed/paused/conflicted
(manager) and completes from completed/conflicted/failed (supervisor).
Transition table with reasons
Every transition below is enforced at workflow/manager.rs:517-531 (action
gates and runtime projection) and workflow/supervisor.rs:652-716
(supervisor-side handling); the reasons are the conditions that make each
transition legal.
| From | Action / event | To | Reason / condition |
|---|---|---|---|
queued | Start (runtime) | planning | The workflow leaves the queue and runs its bounded planning turn. |
queued | Pause | paused | User pause before planning starts. |
queued | Cancel | cancelled | User cancel; allowed from any non-terminal status. |
planning | Pause | paused | User pause mid-planning; the planning turn is aborted and later resume restarts the bounded flow. |
planning | plan committed / budget reached / timed out | running | A committed canonical Todo DAG is armed for execution (arm_plan_and_run / preserve_plan_and_run). |
planning | Cancel | cancelled | User cancel. |
planning | hard error | failed | Planning hit a WorkflowFailure. |
running | Pause | paused | User pause; backend parks the worktree and child jobs settle. |
running | Cancel | cancelled | User cancel; active workflow job ids are cancelled first (supervisor.rs:702-716). |
running | DAG settles completed | completed | All tasks done or abandoned (todo_dag_status = Settled + exactly complete, supervisor.rs:1228-1237). |
running | DAG settles failed / blocked | failed | Settled-but-incomplete, or the DAG is permanently blocked. |
paused | Resume | running / completed | Backend resumes execution; a DAG that finished while paused settles into completed and auto-integrates (manager.rs:438-444). |
paused | Integrate | integrating | Manual integration of a paused workflow is allowed. |
paused | Cancel | cancelled | User cancel. |
completed | Integrate (manual or auto) | integrating | Auto-integrates whenever the DAG settles into completed with no recorded integration (manager.rs:372-377); Conflicted outcomes land here for manual retry. |
integrating | merge applied | completed | Integration outcome Applied { strategy, result_commit }. |
integrating | merge conflicts | conflicted | Integration outcome Conflicted { conflicts } — manual /workflow integrate is required. |
integrating | merge error | failed | Hard integration error (e.g. DirtyBase refused). |
The supervisor may also project queued → completed/failed (restored
terminal outcomes) and planning → completed/failed; terminal statuses
(completed/failed/cancelled/conflicted) and paused/integrating
can never be re-entered from a runtime projection — a stale or regressing
projection is rejected (validate_runtime_projection).
/workflow command
Canonical usage (WORKFLOW_USAGE in crates/pi-cli/src/workflow_commands/mod.rs:14-15):
/workflow [list|show [id|name]|create <objective>|create <name> <objective>|pause|resume|cancel|integrate|remove]
- Bare
/workflowopens the dedicated workflows page in the TUI. /workflow listlists workflows (status · name · id · objective)./workflow show [id|name]shows one workflow's detail./workflow create <name> <objective>(or a single argument used as both) creates a workflow; the newly created workflow becomes the selection./workflow pause|resume|cancel|integrate|remove [id|name]operate on the selected workflow, or on the named one. Selectors accept the workflow id or an exact name.
Source: crates/pi-cli/src/workflow_commands/mod.rs (parse contract and
executor), crates/pi-cli/src/workflow_rpc.rs (RPC surface).
The RPC surface (WorkflowRpcCommand in workflow_rpc.rs:87-130) mirrors
the slash command on the JSONL control plane (see
rpc-json.md):
type | Fields | Notes |
|---|---|---|
workflow_create | name, objective; optional id | Name and objective must be non-empty. |
workflow_list | — | Returns { "workflows": [...] }. |
workflow_get | optional workflowId or name | One of the two is required. |
workflow_pause / workflow_resume / workflow_cancel / workflow_integrate / workflow_remove | workflowId | Selector ids resolve exactly; unknown or ambiguous names fail actionably. |
Wire snapshots project the durable WorkflowSnapshot (status, integration
none/applied:<commit>/conflicted:<paths>, failure message) and
workflow_updated / workflow_status_changed / workflow_removed events
stream status changes (workflow_rpc.rs:219-257).
Isolation backends
Each workflow runs in its own checkout of the source tree. The backend is
selected by settings.orchestration.isolation
(WorkflowIsolationSetting in crates/pi-coding/src/settings.rs:283-298):
| Setting | Backend | Notes |
|---|---|---|
worktree (default) | git worktree | Branch namespace rpi/workflow/<id>, managed root outside the source worktree, ownership catalog at <managed-root>/pi-workflow/worktrees.json. Integration fast-forwards or creates a merge commit. Source: workflow_worktree/git.rs, catalog.rs. |
overlayfs | overlayfs | The source repo is the read-only lower layer; each workflow gets a private writable upper layer. Integration commits the upper state as a single commit on the source branch — last-writer-wins, so Conflicted is never produced by this backend. Source: workflow_worktree/overlay.rs. |
none | none | No isolation: workflows operate directly on the source working tree (NoopWorkflowIsolation). |
The overlayfs backend tries, in order, kernel overlay (mount -t overlay),
fuse-overlayfs (unprivileged FUSE daemon), then recursive copy — the
first candidate that succeeds wins, and the chosen backend is persisted per
workflow so a restored workflow re-mounts exactly what it had
(default_backend_candidates in workflow_worktree/overlay.rs:68-75,
crates/pi-coding/src/isolate.rs).
Ownership is strict: every operation verifies that the live checkout exactly
matches the recorded identity (source root, common git dir, branch, HEAD
commit) before touching anything, and removal/prune only ever touch
manager-owned identities (WorktreeError::OwnershipMismatch). The managed
root must not be inside the source worktree, and the source base must be
clean before integration (DirtyBase is refused).
Integration strategies (IntegrateStrategy in
workflow_worktree/mod.rs:197-206): merge fast-forwards when possible and
otherwise creates a merge commit; rebase replays workflow commits onto the
current source HEAD. The outcome is Applied { strategy, result_commit } or
Conflicted { conflicts } — recorded on the durable snapshot as
WorkflowIntegration::None | Applied | Conflicted
(workflow/manager.rs:23-28).
Planning phase
A workflow starts in queued, then moves to planning: the supervisor agent
(running in the workflow's own Application context) receives a bounded
planning prompt (WorkflowSupervisorContract in workflow/supervisor.rs).
The planning contract requires the model to create the complete canonical
Todo DAG exactly once with the todo tool and then stop — no delegation, no
file edits, no waiting on jobs during planning. Explicit agent references in
the objective are validated against the workflow agent catalog before any
planning prompt runs (validate_delegation_agents).
Planning is bounded so a correcting model can never hold the workflow in Planning forever:
PLANNING_MAX_TURNS = 8assistant turns (workflow/supervisor.rs:35).PLANNING_MAX_TOOL_CALLS = 16— cap on Todo/tool calls in one planning prompt (workflow/supervisor.rs:37).PLANNING_DEFAULT_DEADLINE = 90 s— one wall-clock budget for the whole planning turn, measured from a single pinned sleep (a provider streaming keepalives can never stretch it into a deadline-of-silence) (workflow/supervisor.rs:41,1051-1061).PLANNING_IDENTICAL_FAILED_OP_LIMIT = 3— identical failed Todo operations with no Todo-state change terminate planning (workflow/supervisor.rs:44).PLANNING_CORRECTIONS_WITHOUT_PROGRESS_LIMIT = 6— semantic non-progress detection on the canonical Todo state (workflow/supervisor.rs:46-48).- A replan prompt gives a model that answered the first turn with plain text one chance to build the plan.
The outcome is one of Completed, PlanCommitted (plan accepted on the
first successful todo init, even while the model still has turns to emit),
PlanBudgetReached { reason }, or TimedOut. A committed plan (or a budget
stop with tasks present) preserves the plan and transitions to running
immediately (arm_plan_and_run / preserve_plan_and_run).
The supervisor's own planning turn is projected live into the workflow page as
a bounded activity feed (thinking chunks, tool calls, IRC progress) so
planning never reads as a static spinner (WorkflowSupervisorActivity,
workflow/supervisor.rs:85-110; planning_started_at_ms tracks the phase).
Execution phase
running re-arms Todo DAG execution over the committed plan: each task
in the canonical Todo state is executed by a delegated subagent (see
orchestration.md), with todoTaskId linking each job to
its task. The workflow's Todo mutations never auto-arm execution
(workflow/supervisor.rs:966-984); arming is explicit on the Planning →
Running transition and on resume of a restored running workflow, so a
parked DAG with open tasks is an actively worked workflow, not a Planning
one. Restored workflows never come back frozen: restored Queued/Planning
resume the planning flow, restored Running re-arms execution, restored
Paused stays paused until an explicit resume
(WorkflowSupervisor::continue).
When the DAG settles (all tasks completed or abandoned), the supervisor
projection moves the workflow to completed and the manager auto-integrates
when the status ends completed with no prior integration
(workflow/manager.rs:372-377, 439-444). Integration is also available
manually with /workflow integrate.
Live detail and workflow panel
The workflow page (crates/pi-cli/src/workflow_panel.rs) renders a live,
redacted projection of each workflow (WorkflowRuntimeDetail in
workflow/detail.rs:17-36):
todo— the canonical Todo DAG.supervisor— the planning/running supervisor row with its own activity feed; duringplanningthe (idle at the orchestration layer) supervisor reads as actively planning.subagents— one row per delegated worker: display name, agent type, status, current task summary, owningtodoTaskId, and a bounded per-agent activity feed (task lifecycle entries + IRC messages the agent sent or received, newest-last).jobs— delegated orchestration jobs with their todo ownership.irc— the workflow's recent IRC (subagent ⇄ subagent included), deduped by message id and capped at 50 (RECENT_IRC_LIMIT).
Everything in the live view is redacted (paths, ids, tokens), and a terminal
durable status always wins over a stale live projection — a Conflicted
integration that lands after the supervisor projected Completed is shown
as Conflicted (workflow/detail.rs:251-268). The durable workflow record
never persists activity; the activity feed is live-only.
The supervisor settles from the canonical Todo state and publishes
WorkflowEvents (StatusChanged, Updated, Removed) through the
WorkflowManager, which persists durable snapshots to the workflow store
(workflow/store.rs) and restores them on restart (WorkflowManager::reload).
Settings
{
"orchestration": {
"tasks": false,
"todo": true,
"maxConcurrency": 8,
"maxRecursionDepth": 8,
"mailboxCapacity": 1000,
"maxToolsPerAgent": 16,
"isolation": "worktree",
"sandboxed": false,
"softBudget": { "maxRequests": null, "maxTokens": null, "yieldAfter": null }
}
}
tasks— enables the orchestrationtasktool (defaultfalse).todo— thetodotool is on by default;falseopts out (settings.rs:1411-1420).maxConcurrency— max parallel child jobs (default 8, bounds 1..=64).maxRecursionDepth— max nested delegation depth (default 8, max 16).mailboxCapacity— per-agent message mailbox (default 1000, max 10000).maxToolsPerAgent— tool ceiling per child (default 16, max 64).isolation—worktree(default),overlayfs, ornone; unknown values fail deserialization (fail-closed).sandboxed— whentrue, orchestration subagent children run their process spawns (thebashtool) inside the Linux filesystem sandbox (workspace + agent dir +sandbox.allowedPathsvisible; deny-by-default otherwise). Seesandbox-isolation.md.softBudget— per-child job soft budget:maxRequests(LLM turns),maxTokens(cumulative usage),yieldAfter(return control to the parent after N requests regardless of remaining budget). A reached limit settles the childCompletedwithsoftBudgetExhausted: true, never failed. Seeorchestration.md.
Validation: maxConcurrency must be 1..=64, maxRecursionDepth at most 16,
mailboxCapacity 1..=10000, maxToolsPerAgent 1..=64, and soft-budget knobs
must be positive when set (settings.rs:2674-2697).
Invariants
- Workflows never execute without a committed plan:
runningimplies the Todo DAG was armed by the supervisor. - Planning is always bounded: 8 turns, 6 non-progress corrections, and a wall-clock deadline; a stuck provider cannot hold a workflow in Planning forever.
- Isolation is ownership-checked: every backend operation verifies the exact recorded identity before mutating; foreign checkouts fail closed.
- Integration refuses dirty sources and dirty workflows (
DirtyBase/DirtyWorktree), and overlayfs integration is last-writer-wins by design. - The durable snapshot is authoritative for terminal outcomes; the live
projection may never mask a
ConflictedorFailedstate. rewindis refused while any workflow is active (seesession-recovery.md).
Related documentation
orchestration.md— the subagent/job runtime that executes the DAGtodos.md— the canonical Todo DAG the workflow plans and executessandbox-isolation.md— filesystem sandbox and overlayfs backendscli-modes.md—/workflowin the slash-command surface
Session recovery: rewind, checkpoints, handoffs, and TTL
This page covers the session-level recovery and hygiene features: rewinding to an earlier point in the journal, named checkpoints, deterministic snap compaction, handoff summaries, doom-loop recovery, and startup TTL pruning of old session files.
/rewind and /checkpoint
/rewind # list entry indices and checkpoints to pick from
/rewind <entry-index> # roll the session back to before that entry
/rewind <checkpoint-name> # roll back to the named checkpoint
/checkpoint <name> # mark the current position as a named rewind target
/rewind truncates the session journal back to a target and archives the
dropped tail to a sidecar (the session store keeps every file; nothing is
lost). The bare command lists the last 20 records with first-line previews,
annotating checkpoints with the entry they target. /checkpoint <name> marks
the current position so /rewind <name> can return to it later; a checkpoint
named like a number is unreachable by design (numbers always parse as entry
indices).
Sources: crates/pi-cli/src/interactive_commands.rs
(parse_rewind_invocation, format_rewind_list, format_rewind_outcome),
crates/pi-coding/src/application.rs (rewind, set_checkpoint,
rewind_preview).
Safety: rewinding is refused while any orchestration job is queued or
running, while any workflow is active, or while bash is still executing —
truncating the journal under live work would orphan running jobs and corrupt
the state those jobs keep reading. The active turn is drained first so the
checks observe post-turn state, and the refusal message names the count of
live jobs and suggests orchestration /queue cancel or
workflow /workflow cancel (rewind_refusal in application.rs:2250-2264).
/snapcompact and /compact --snap
/snapcompact # deterministic archive, no LLM call
/compact --snap # same (trailing text after --snap is ignored)
/compact [instructions] # legacy LLM summarization path
compact_snap (in application.rs:2077-2080) replaces older turns with a
deterministic statistics block — per message-type counts, total archived
characters, timestamp span, sorted unique tool names, and a bounded list of
user-ask first lines (build_snapcompact_summary in
crates/pi-coding/src/compaction.rs:471) — and preserves the original
entries in a .snapcompact-<timestamp>.jsonl sidecar. The cut point never
splits a turn (find_snap_cut_point). Useless tool results (empty or
duplicate error text) are elided with a note.
The LLM path uses the structured checkpoint summary prompt
(SUMMARIZATION_PROMPT in compaction.rs:64-65) and rebuilds the view as a
CompactionSummary message followed by the recent entries
(apply_checkpoint). Automatic compaction triggers on context threshold
(should_compact with compaction.enabled/reserveTokens/
keepRecentTokens); see settings-trust.md.
/handoff
/handoff # deterministic envelope (copied to clipboard)
/handoff --prose # envelope + one bounded prose paragraph
A handoff is a concise, structured summary of the current session — what was
done (recent user asks), current state (active goal, todo counts, running
orchestration jobs), environment (cwd, git branch and dirtiness, model), and
deterministic next-step hints. The envelope is built entirely from session
state with no model call (handoff_envelope in
crates/pi-coding/src/handoff.rs:226); --prose adds one paragraph from the
existing summarization path, bounded to a single provider call with a hard
60-second timeout and a 600-token reserve (HANDOFF_SUMMARIZE_TIMEOUT,
HANDOFF_PROSE_RESERVE_TOKENS).
The rendered block (# Handoff) is copyable plain text, safe to paste into a
fresh session. Everything in it is redacted for credential-shaped text, and
only queued/running jobs appear (active_handoff_jobs). Hints are capped at
4 (HANDOFF_MAX_NEXT_STEP_HINTS).
Source: crates/pi-cli/src/interactive_commands.rs (/handoff builtin),
crates/pi-coding/src/handoff.rs.
Doom-loop recovery
The session turn loop detects a doom loop — the same tool failing with
the same error prefix DOOM_LOOP_THRESHOLD = 3 consecutive times in one
turn — and stops the turn with an actionable message instead of letting the
model retry the same failing call forever (DoomLoopTracker in
crates/pi-coding/src/session.rs:189-232, doom_loop_recovery at
session.rs:4553). Error prefixes are fingerprinted at
DOOM_LOOP_ERROR_PREFIX_CHARS = 80 characters; transient markers ("timed
out", network blips) never count toward the threshold. Once tripped, every
further tool outcome in the turn terminates with the same message so a
parallel batch cannot escape the stop, and the turn errors out with it.
Session TTL cleanup
At startup, rpi prunes old native session files:
- Default age:
DEFAULT_SESSION_TTL_DAYS = 30; override withsettings.sessionTtlDays(must be > 0). - Files modified within
SESSION_ACTIVE_GRACE = 1 hourare treated as possibly active and never pruned (the native store has no lock files). - Pruning touches only the native pi session tree (files at up to two directory levels below a root); foreign sources (codex/claude/grok) live in separate roots and are never walked.
- Never pruned: the current session's file, the live run's directory,
symlinks (never followed or deleted), and files with future/implausible
mtimes (
PRUNE_MTIME_FLOOR_SECS).
Source: crates/pi-coding/src/session_store.rs (prune_expired_sessions,
session_file_expired). The recorder self-heals if a prune removed its (as
yet empty) per-cwd directory before the first flush.
/fresh
/fresh (alias for /new) archives the current session in place and starts
a clean session — the session store keeps every file, so the old recorder
stays on disk. See export-share.md.
Invariants
- Rewind never loses data: the dropped tail is archived, and rewinding is refused while any live work (jobs, workflows, bash) could be orphaned.
- Snapcompact never calls the provider and never splits a turn; the original entries survive in a sidecar.
- Handoff envelopes are deterministic and redacted; prose is a single bounded provider call.
- Doom-loop recovery is per-turn and transparent (transient errors never count), and it cannot be escaped by a parallel tool batch.
- TTL pruning is conservative: grace-window, current-session, foreign, symlinked, and clock-skewed files are never deleted.
Related documentation
export-share.md—/export,/dump,/share --encrypt,/freshsettings-trust.md—compaction,sessionTtlDayssettingsgoals.md— goal journal entries participate in rewind
Live voice (/live)
/live is a minimal hold-to-talk realtime voice mode: microphone capture →
speech-to-text → composer draft (crates/pi-coding/src/live.rs). It is
modeled on Hyper's Codex Live but deliberately simple: press to record,
release to transcribe, and the final whole-utterance transcript lands in the
composer draft for the user to review before pressing Enter. It is never
auto-submitted.
Configuration
/live requires explicit configuration in settings.json
(LiveRuntimeSettings in crates/pi-coding/src/settings.rs:425-429):
{
"live": {
"enabled": true,
"sttBaseUrl": "https://your-whisper-host",
"sttApiKey": "<secret>",
"sttModel": "whisper-1",
"language": null,
"allowInsecure": false
}
}
| Key | Default | Meaning |
|---|---|---|
enabled | false | Master switch; /live fails with an actionable message when off. |
sttBaseUrl | (empty) | Base URL of an OpenAI-compatible speech-to-text service. |
sttApiKey | (empty) | Bearer key for that service (secret; never logged, never writable through /settings). |
sttModel | whisper-1 | Model name sent in the transcription request. |
language | (none) | Optional language hint. |
allowInsecure | false | Permit http:// endpoints (loopback/self-hosted); https:// is required otherwise. |
Validation (validate_live_settings in live.rs:97-129):
ws:///wss://URLs are always rejected —/livespeaks HTTP multipart to{base}/v1/audio/transcriptions, not WebSocket.- Plaintext
http://is refused unlessallowInsecureis explicitly true. - Errors name the exact
Settings.live.*key to fix.
Usage
In the TUI, /live opens a hold-to-talk session:
- Press (hold key) starts recording; a new press while recording or transcribing supersedes the previous session (its buffer is discarded so the old transcript cannot land in a new target).
- Release stops capture and transcribes the utterance.
- A 10-second no-speech watchdog stops capture with guidance; an
utterance shorter than 250 ms is discarded as accidental;
Esc/teardown aborts without transcribing.
Capture is mono 16 kHz i16 PCM (SAMPLE_RATE = 16_000), held in a bounded
backlog (30 s of audio), and the STT request runs with a 30-second client
timeout. The final transcript is delivered as a Transcript event into the
composer draft — the user reviews and presses Enter.
Microphone backend
Microphone capture uses cpal behind the live-capture feature. A build
without it fails with an actionable error:
Microphone capture is not compiled into this build. Rebuild with
--features pi-coding/live-capture (requires cpal and, on Linux,
libasound2-dev), or use a build that ships it
(open_capture in live.rs:189-201).
Delegation bridge
Hyper's Codex Live receives server-side delegation.created events; rpi's
generic STT endpoint never emits them, so the bridge detects delegation
intent client-side from the transcript: a coding verb (implement/fix/add/
test/refactor/write/create/update/change/remove/delete/debug/repair/migrate…)
paired with a code-domain signal (file path, extension, or code word)
(is_delegation_candidate in live.rs:681). The signal sets are exact
(live.rs:681-699): DELEGATION_VERBS = implement, fix, add, test, refactor, write, create, update, change, remove, delete, debug, repair, migrate, optimize, rewrite, extend, port; DELEGATION_CODE_WORDS covers
code-domain nouns (function, class, struct, module, api,
endpoint, cli, bug, test, config, …); DELEGATION_EXTENSIONS
covers .rs, .py, .ts, .js, .go, .c, .md, .sh, .json,
.toml, … — a bare config/path word or a slash-containing token also
counts as a code signal. The verb must appear as a whole word (boundary
matched) and the code signal must not be the verb itself
(has_code_signal, live.rs:730-739). The TUI offers the draft with a
⟦delegate⟧ hint and submits it through the standard prompt path — the
delegation is an ordinary agent turn: no separate task queue, and the reply
merges into the transcript like any other turn.
Invariants
- Transcripts are never auto-submitted: every utterance lands in the composer for review.
- STT credentials never leave the machine unencrypted: plaintext endpoints
are refused unless
allowInsecureis explicit, and server-echoed secrets are scrubbed from errors. - Capture is bounded: audio backlog, no-speech watchdog, utterance floor, and request timeout all have hard limits.
- A press supersedes the previous session and discards its buffer, so a stale transcript can never land in the new target.
Related documentation
settings-trust.md—settings.jsonand thelivekeycli-modes.md—/livein the slash-command surface
Models and custom providers
Built-in catalog
rpi ships with an embedded model catalog in
crates/pi-ai/src/models_catalog.json.
The catalog is loaded once on first use and restored for built-in provider ids
when a custom models.json is reloaded.
List available models with:
rpi models
rpi models claude
The filter is case-sensitive and matches provider name or model id. The list also reflects model entitlements from the resolved credential (e.g., GitHub Copilot OAuth model lists).
Supported API identifiers
A model's api field selects the wire protocol. The identifiers registered by
the built-in provider set are (source:
crates/pi-ai/src/types.rs / crates/pi-ai/src/providers/mod.rs):
| Identifier | Protocol |
|---|---|
openai-completions | OpenAI chat completions (/chat/completions) |
openai-responses | OpenAI Responses API (/responses) |
openai-codex-responses | OpenAI Codex Responses API |
azure-openai-responses | Azure OpenAI Responses API |
anthropic-messages | Anthropic Messages API |
bedrock-converse-stream | Amazon Bedrock Converse streaming API |
google-generative-ai | Google Gemini API |
google-vertex | Google Vertex AI API |
mistral-conversations | Mistral conversations API |
pi-messages | Dynamic Radius provider catalog |
faux | Deterministic test/example provider |
Model spec resolution
When you write rpi -m <spec> or use /model in the REPL, rpi resolves it
with the following algorithm (source: crates/pi-coding/src/resolve.rs):
- If the text before the first
/matches a known provider id (exactly, case-insensitively), the rest is treated as the model id within that provider:anthropic/claude-sonnet-4-5. - Otherwise the whole string is matched as a bare model id across all
providers. This allows OpenRouter-style ids that contain their own slashes,
e.g.
openrouter/ai21/jamba-large-1.7. - Case-insensitive substring matching on model
idandname. Aliases (ids ending in-latestor without a-YYYYMMDDdate suffix) are preferred over dated versions; otherwise the latest dated version by descending id is used. - If the provider is known but the id is not,
rpisynthesizes a custom-id model by cloning that provider's default template (defaultModelPerProvider), or the provider's first model if no default is defined. A warning is emitted.
A thinking-level suffix :off|minimal|low|medium|high|xhigh is parsed off the
end of the spec. On a custom-id fallback the suffix is stripped from the id
first. The CLI --thinking flag accepts off|minimal|low|medium|high|xhigh|max
(max is an alias for xhigh). If the resolved model does not have
reasoning: true, only off is available; a non-off suffix on a custom
fallback enables reasoning: true for that request.
When no spec is given, the default is anthropic/claude-sonnet-4-5.
Examples:
rpi -m anthropic/claude-sonnet-4-5
rpi -m claude-sonnet-4-5:high
rpi -m openrouter/ai21/jamba-large-1.7
rpi -m my-provider/my-custom-model:low
rpi -m llama.cpp/llama-3.1-8b
Custom providers via models.json
Place models.json in the agent directory ($PI_CODING_AGENT_DIR, or the
default agent directory under your home). If no explicit agent directory or
home directory is available, custom configuration is disabled and the
current working directory is never used as a fallback.
OpenAI-compatible proxy
{
"providers": {
"cliproxy": {
"baseUrl": "https://api.example.com/v1",
"api": "openai-completions",
"models": [
{ "id": "gpt-5.5", "maxTokens": 8192 }
]
}
}
}
Cloudflare AI Gateway
{
"providers": {
"cf-gateway": {
"baseUrl": "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai/",
"api": "openai-completions",
"apiKey": "$CLOUDFLARE_API_KEY",
"models": [
{ "id": "@cf/moonshotai/kimi-k2.6" }
]
}
}
}
The placeholders {CLOUDFLARE_ACCOUNT_ID} and {CLOUDFLARE_GATEWAY_ID} are
substituted from the environment at request time. See
environment-variables.md.
Anthropic with a bearer token
{
"providers": {
"anthropic": {
"apiKey": "$ANTHROPIC_AUTH_TOKEN",
"authHeader": true,
"models": []
}
}
}
A provider-level override with an empty models array keeps the built-in
Anthropic models unchanged but applies the provider-level apiKey,
authHeader, baseUrl, headers, or compat.
Override a built-in model
{
"providers": {
"openai": {
"models": [
{ "id": "gpt-5.5", "maxTokens": 16384 }
]
}
}
}
The per-model entry is merged with the built-in entry; maxTokens and other
fields override defaults.
models.json schema
Top level:
{
"providers": {
"<provider-id>": {
"name": "Display name",
"baseUrl": "https://...",
"api": "openai-completions",
"apiKey": "$VAR",
"authHeader": false,
"headers": { "x-foo": "$FOO" },
"compat": { "supportsStrictMode": true },
"models": [
{
"id": "model-id",
"name": "Display name",
"api": "openai-completions",
"baseUrl": "...",
"reasoning": true,
"thinkingLevelMap": { "off": null, "high": "high" },
"input": ["text", "image"],
"cost": { "input": 3.0, "output": 15.0, "cacheRead": 1.5, "cacheWrite": 0.5 },
"contextWindow": 200000,
"maxTokens": 8192,
"headers": { "x-model": "value" },
"compat": { "supportsReasoningEffort": false }
}
]
}
}
}
Rules:
- A provider must define at least one of
baseUrl,headers,compat,apiKey,authHeader, ormodels. apiandbaseUrlcan be set at provider level and overridden per model. Custom models require anapiand abaseUrlsomewhere in the fallback chain; otherwise loading fails closed.- Model-level fields override provider-level fields.
reasoning: trueenables theoff|minimal|low|medium|high|xhighlevel ladder; without it onlyoffis available.thinkingLevelMapcan disable a level ({ "xhigh": null }) or map a level name to a provider-specific value.compatis merged. NestedopenRouterRouting,vercelGatewayRouting, andchatTemplateKwargsobjects are merged deeply; all other compat keys are replaced.- For custom model entries, defaults are:
input["text"],contextWindow128000,maxTokens16384, emptycost, nocompat.
The llama.cpp provider
When a llama.cpp router is configured (rpi llama configure URL or
LLAMA_BASE_URL), live router models are merged into the catalog under the
provider id llama.cpp. Select them with:
rpi -m llama.cpp/llama-3.1-8b
At startup rpi refreshes the live router catalog unless PI_OFFLINE=1|true|yes
is set. If the router is unavailable, rpi falls back to the cached catalog
stored in the agent directory. Router configuration and the cache are written
atomically; the settings file must not be readable by group or other users on
Unix. See local-llama.md for router setup and GGUF
downloads.
GitHub Copilot
github-copilot is treated specially:
- It uses
COPILOT_GITHUB_TOKENfor bearer authentication. - Before each request it adds dynamic headers based on the context:
X-Initiator: userwhen the last message is from the user, otherwiseagent.Openai-Intent: conversation-edits.Copilot-Vision-Request: truewhen the context contains image content.
- Models are filtered by the entitlements returned for the resolved credential.
Capability flags in compat
Common flags used by the built-in providers:
| Flag | Effect |
|---|---|
supportsStrictMode | Enable JSON-schema strict tool sampling |
supportsOpenAIGrammarTools | Enable grammar-style constrained sampling |
supportsReasoningEffort | Map reasoning level to reasoning_effort |
supportsEagerToolInputStreaming | Anthropic eager tool-input streaming |
supportsLongCacheRetention | 24h prompt cache retention |
supportsCacheControlOnTools | Cache control on Anthropic tools |
supportsDeveloperRole | OpenAI Responses developer role |
supportsStore | Allow storing OpenAI Responses sessions |
allowEmptySignature | Allow empty thinking signatures |
forceAdaptiveThinking | Force adaptive thinking on Anthropic |
maxTokensField | Rename the max_tokens request field |
The full set of flags and defaults is defined in the provider source files
(crates/pi-ai/src/providers/*.rs).
Authentication
rpi resolves credentials in a strict, documented order and never logs them.
Credentials can be managed interactively with rpi login / rpi logout or
configured manually in auth.json or models.json.
Quick credential management
# Interactive login (choose provider and method)
rpi login
# Login to a specific provider
rpi login anthropic
# Remove stored credentials
rpi logout openai
login and logout operate on the agent's auth.json. Outside an
interactive terminal they require an explicit provider argument. auth.json is
written atomically with 0o600 file permissions and 0o700 directory
permissions on Unix.
Credential precedence (per provider)
When a request is built, rpi looks for a usable credential in this order
(source: crates/pi-cli/src/models_config.rs::resolve_model_request_auth and
resolve_model_request_auth_async):
- An explicit API key passed at call time, e.g.
--api-keyon the CLI (--api-keyrequires--modelor--models). - A runtime key set for the provider (the CLI stores
--api-keyas a runtime key for the resolved provider for the duration of the run). - A stored
auth.jsoncredential for the provider:api_keyentries are expanded and used as the key.- OAuth entries require async resolution;
rpirefreshes expired OAuth tokens automatically before use.
- The
apiKeyfield frommodels.jsonfor that provider. - A recognized provider environment variable (see table below).
- Anthropic-specific bearer handling: when the API-key slot is still empty,
ANTHROPIC_AUTH_TOKENis sent asAuthorization: Bearer <token>and is never treated as anx-api-keyvalue. - Provider-specific header-only auth configured in
models.jsonor model headers, e.g.authorization,x-goog-api-key, orcf-aig-authorization.
If none of the above produce a key or recognized auth header, the request fails closed with an error naming the provider.
All header-name lookups are case-insensitive. Header and credential values
support $VAR / ${VAR} template expansion (see
Template expansion).
Supported OAuth providers
rpi login can store OAuth credentials for:
anthropicopenai-codexgoogle-gemini-clixaiopenrouterkimi-coding
Other providers default to API-key authentication. OAuth tokens are refreshed
automatically when expired (with a 5-minute skew). OAuth credentials may carry
model entitlements (available_model_ids); models outside those entitlements
are hidden for that credential.
Environment variables by provider
| Provider(s) | Variable(s) |
|---|---|
anthropic | ANTHROPIC_API_KEY, ANTHROPIC_OAUTH_TOKEN, ANTHROPIC_AUTH_TOKEN |
github-copilot | COPILOT_GITHUB_TOKEN |
openai, openai-codex | OPENAI_API_KEY |
azure-openai-responses | AZURE_OPENAI_API_KEY |
google | GEMINI_API_KEY |
google-vertex | GOOGLE_CLOUD_API_KEY (or access-token/authorization header) |
groq | GROQ_API_KEY |
cerebras | CEREBRAS_API_KEY |
xai | XAI_API_KEY |
deepseek | DEEPSEEK_API_KEY |
openrouter | OPENROUTER_API_KEY |
nvidia | NVIDIA_API_KEY |
mistral | MISTRAL_API_KEY |
minimax, minimax-cn | MINIMAX_API_KEY, MINIMAX_CN_API_KEY |
moonshotai, moonshotai-cn | MOONSHOT_API_KEY |
huggingface | HF_TOKEN |
fireworks | FIREWORKS_API_KEY |
together | TOGETHER_API_KEY |
opencode, opencode-go | OPENCODE_API_KEY |
kimi-coding | KIMI_API_KEY |
cloudflare-workers-ai, cloudflare-ai-gateway | CLOUDFLARE_API_KEY |
amazon-bedrock | AWS_PROFILE, AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY, AWS_BEARER_TOKEN_BEDROCK, AWS_CONTAINER_CREDENTIALS_RELATIVE_URI, AWS_CONTAINER_CREDENTIALS_FULL_URI, AWS_WEB_IDENTITY_TOKEN_FILE |
ant-ling | ANT_LING_API_KEY |
qwen-token-plan, qwen-token-plan-cn | QWEN_TOKEN_PLAN_API_KEY, QWEN_TOKEN_PLAN_CN_API_KEY |
zai, zai-coding-cn | ZAI_API_KEY, ZAI_CODING_CN_API_KEY |
xiaomi, xiaomi-token-plan-cn, xiaomi-token-plan-ams, xiaomi-token-plan-sgp | XIAOMI_API_KEY, XIAOMI_TOKEN_PLAN_CN_API_KEY, XIAOMI_TOKEN_PLAN_AMS_API_KEY, XIAOMI_TOKEN_PLAN_SGP_API_KEY |
radius | RADIUS_API_KEY |
vercel-ai-gateway | AI_GATEWAY_API_KEY |
Provider-specific notes:
- Anthropic:
ANTHROPIC_OAUTH_TOKENwins overANTHROPIC_API_KEYwhen an API key is requested.ANTHROPIC_AUTH_TOKENis used as a bearer token in theAuthorizationheader only when the API-key slot is empty, and is never used as anx-api-keyvalue. - GitHub Copilot:
COPILOT_GITHUB_TOKENis required. The provider adds dynamic per-request headers (X-Initiator,Openai-Intent, andCopilot-Vision-Requestwhen images are present). - Amazon Bedrock: ambient AWS credentials are intentionally not read.
When one of the listed AWS credential sources is present, the resolved key
is the sentinel
<authenticated>and the AWS SDK signs the request. - Google Vertex:
GOOGLE_CLOUD_API_KEYsupplies an API key, orGOOGLE_CLOUD_ACCESS_TOKEN/ anauthorizationheader can supply an access token. The provider never reads Application Default Credential files or credential helpers. Vertex requests also requireGOOGLE_CLOUD_PROJECT(aliasGCLOUD_PROJECT) andGOOGLE_CLOUD_LOCATION.
auth.json
auth.json lives next to models.json in the agent directory
($PI_CODING_AGENT_DIR, or the default agent directory under your home).
Example:
{
"openai": {
"type": "api_key",
"key": "$OPENAI_API_KEY",
"env": {
"OPENAI_API_KEY": "<your-openai-key>"
}
}
}
Rules:
typeis"api_key"for manually edited entries.rpi loginmay write"oauth"entries; those should not be hand-edited.keyis the credential value or a$VAR/${VAR}template.envis an optional map of variables available only while resolving this credential.$$becomes a literal$;$!becomes a literal!.- Command-valued keys (
"key": "!some-command") are rejected.
models.json authentication
Custom and overridden providers in models.json can carry authentication
independently of auth.json:
{
"providers": {
"my-proxy": {
"baseUrl": "https://api.example.com/v1",
"api": "openai-completions",
"apiKey": "$MY_PROXY_KEY",
"authHeader": true,
"headers": {
"x-custom": "$CUSTOM_HEADER"
},
"models": [
{
"id": "my-model",
"maxTokens": 4096
}
]
}
}
}
Rules:
authHeader: truesendsAuthorization: Bearer <apiKey>. It requires a resolved API key; if none exists the request fails closed.- Provider-level
headersapply to every model of that provider; per-modelheadersoverride them. Merging is case-insensitive last-wins. apiKey,baseUrl, andheadervalues support$VAR/${VAR}expansion from the process environment.models.jsondoes not support a per-entryenvmap (onlyauth.jsoncredentials do).$$is a literal$and$!is a literal!. Unset variables produce an error.- Command-valued
apiKeyor header values ("!command") are rejected.
Template expansion
Both auth.json and models.json values use the same expander
(source: crates/pi-coding/src/auth.rs::expand_credential_value and
crates/pi-cli/src/models_config.rs::resolve_config_value_with_fallback):
| Form | Meaning |
|---|---|
$VAR | Expand a single variable. |
${VAR} | Explicit boundary. |
$$ | Literal $. |
$! | Literal !. |
For auth.json, variables are resolved from the request environment, then the
credential's own env map, then the process environment. Empty values are
treated as unset. Invalid braced names such as ${1bad} are left unchanged.
If a referenced variable is not set, rpi exits with an error that names the
variable and source file but never exposes the original template text.
Redaction and sensitive headers
The following headers are treated as sensitive and are marked with
set_sensitive(true) so they are hidden from request traces and logs:
authorizationx-api-keycf-aig-authorizationx-goog-api-key
When an error message is produced by pi_messages, radius, mistral, or
similar providers, the raw API key and any bearer-token values found in the
above headers are stripped from the message before it is surfaced. Error
messages name the missing variable or provider, never the key value.
Fail-closed behavior
rpi fails loudly rather than silently when authentication cannot be resolved:
- No API key, no OAuth credential, no recognized auth header, and no
authHeaderfallback → request fails with:no API key found for provider .... authHeader: truewithout a resolved API key → request fails.- A
$VARreference inauth.jsonormodels.jsonresolves to an unset variable → request fails. - Azure OpenAI requests with zero or more than one of
authorization/api-key→ request fails. - Google Vertex requests with both
authorizationandx-goog-api-keyin the same scope → request fails. - Amazon Bedrock requests with only one of
AWS_ACCESS_KEY_IDorAWS_SECRET_ACCESS_KEY→ request fails.
Do not commit auth.json or models.json containing real keys.
RPC JSONL protocol
rpi --mode rpc and the rpi rpc subcommand expose a long-lived,
LF-delimited JSONL control protocol on stdin/stdout. The protocol is not
JSON-RPC 2.0: every line is a single self-describing JSON object with a type
discriminator, and every command can carry an optional id that the matching
response echoes.
Launching the RPC mode
rpi --mode rpc [<other rpi flags>]starts the RPC server on stdin/stdout.rpi rpc [<other rpi flags>]is equivalent: the subcommand forces--mode rpc, so the server always enters RPC mode. A conflicting explicit--modeis rejected. On fatal initialization error the server writes a failure response line to stdout.
Both forms are headless and never prompt for project trust; pass --approve
when you need project-local .pi resources.
Framing
- Every record is exactly one JSON object followed by a single line feed (
\n, byte0x0A). There is no batching, length prefix, or JSON-RPC envelope. - The writer serializes each record with
serde_json::to_writer, appends\n, and flushes, so clients can read line-by-line. - The reader splits incoming bytes on
\n. A trailing carriage return (\r) is stripped before parsing, so\r\nsources are tolerated. - If stdin reaches EOF while the last partial line is non-empty, that partial line is delivered as the final frame.
Request envelope
Every command line is a JSON object containing at minimum:
{"type": "<command>", "id": "optional-correlation-id", ...}
type— required string, snake-cased command name (see the table below).id— optional string. If present it is echoed in the matching response. Commands that do not need correlation may omit it.
Response envelope
A response line has the fixed shape:
{
"id": "optional-correlation-id",
"type": "response",
"command": "<command>",
"success": true,
"data": { ... }
}
On failure:
{
"id": "optional-correlation-id",
"type": "response",
"command": "<command>",
"success": false,
"error": "human-readable message"
}
idmatches the requestidwhen one was provided; otherwise it is omitted.commandrepeats the request'stypevalue.datais present on success and isnullfor commands that return no payload.erroris present on failure.
Correlation and asynchronous events
Command/response pairs correlate by id. In addition to responses, the host
emits asynchronous ApplicationEvent records on the same stdout stream. Events
have their own type tag (e.g. session_started, agent_start, agent_settled,
todo_updated, process_started) and no id. A client should therefore read
all stdout lines, dispatch "response" records by id, and dispatch events by
type.
A minimal exchange looks like:
{"type":"session_started","data":{"version":3,"id":"...","timestamp":"...","cwd":"<workspace>"}}
{"type":"prompt","id":"1","message":"List Rust files"}
{"id":"1","type":"response","command":"prompt","success":true,"data":null}
{"type":"agent_settled"}
Malformed input and recovery
The RPC loop never aborts because of a bad line; it emits a failure response and continues reading.
- Invalid JSON →
{"type":"response","command":"parse","success":false,"error":"Failed to parse command: ..."}. Becauseserde_json::from_slicefails before the object is inspected, an invalid JSON line cannot preserve anid. - Missing
typefield → failure response with the preservedid. - Unknown
typevalue →{"id":"...","type":"response","command":"<type>","success":false,"error":"Unknown command: <type>"}. - Invalid fields for a known command → failure response with
commandset to the command name andidpreserved. - Invalid
extension_ui_response→ failure response with"command": "extension_ui_response".
Because lines are split before parsing, a malformed line never corrupts the following valid lines.
Extension UI requests and responses
When an extension asks to show UI, the host emits an extension_ui_request
record with a unique id and a flattened method object:
{
"type": "extension_ui_request",
"id": "ui-1",
"method": "confirm",
"title": "Approve destructive edit?",
"message": "This will overwrite src/main.rs"
}
Supported methods include select, confirm, input, editor, notify,
setStatus, setWidget, setTitle, and set_editor_text. The client replies
with an extension_ui_response carrying the same id and one of:
{"type":"extension_ui_response","id":"ui-1","confirmed":true}
{"type":"extension_ui_response","id":"ui-1","value":"typed answer"}
{"type":"extension_ui_response","id":"ui-1","cancelled":true}
Notification/status/widget requests are generated with a host-assigned id and are one-way; the client does not need to reply to them.
Stdout isolation
All protocol output goes through a single mutex-protected stdout writer; every record is produced by the same JSONL write path. No ordinary command handler, event path, or tool prints raw text to stdout. Runtime diagnostics and panics are routed to stderr, so stdout remains exclusively JSONL protocol records.
Command reference
The following table lists every current RpcCommand variant exactly once. All
commands accept an optional id. Required fields are shown without brackets;
optional fields are shown with [...] and their JSON key.
type | Fields | Example request line |
|---|---|---|
prompt | message; [images] array of ContentBlock; [streamingBehavior] "steer" or "followUp" | {"type":"prompt","message":"List Rust files"} |
steer | message; [images] | {"type":"steer","message":"Use async rust"} |
follow_up | message; [images] | {"type":"follow_up","message":"Any tests?"} |
abort | — | {"type":"abort"} |
new_session | [parentSession] path string | {"type":"new_session","parentSession":"<workspace>/parent.jsonl"} |
get_state | — | {"type":"get_state"} |
set_model | provider, modelId | {"type":"set_model","provider":"anthropic","modelId":"claude-sonnet-4-5"} |
cycle_model | — | {"type":"cycle_model"} |
get_available_models | — | {"type":"get_available_models"} |
set_thinking_level | level ("off", "minimal", "low", "medium", "high", "xhigh") | {"type":"set_thinking_level","level":"high"} |
cycle_thinking_level | — | {"type":"cycle_thinking_level"} |
get_available_thinking_levels | — | {"type":"get_available_thinking_levels"} |
set_steering_mode | mode ("all", "one-at-a-time") | {"type":"set_steering_mode","mode":"all"} |
set_follow_up_mode | mode ("all", "one-at-a-time") | {"type":"set_follow_up_mode","mode":"one-at-a-time"} |
compact | [customInstructions] | {"type":"compact","customInstructions":"Summarize recent context"} |
set_auto_compaction | enabled boolean | {"type":"set_auto_compaction","enabled":true} |
set_auto_retry | enabled boolean | {"type":"set_auto_retry","enabled":true} |
abort_retry | — | {"type":"abort_retry"} |
bash | command; [excludeFromContext] boolean | {"type":"bash","command":"pwd","excludeFromContext":true} |
abort_bash | — | {"type":"abort_bash"} |
get_session_stats | — | {"type":"get_session_stats"} |
export_html | [outputPath] | {"type":"export_html","outputPath":"<workspace>/out.html"} |
switch_session | sessionPath | {"type":"switch_session","sessionPath":"<workspace>/session.jsonl"} |
fork | entryId | {"type":"fork","entryId":"entry-1"} |
clone | — | {"type":"clone"} |
get_fork_messages | — | {"type":"get_fork_messages"} |
get_entries | [since] entry id | {"type":"get_entries","since":"entry-1"} |
get_tree | — | {"type":"get_tree"} |
get_last_assistant_text | — | {"type":"get_last_assistant_text"} |
set_session_name | name | {"type":"set_session_name","name":"demo"} |
get_messages | — | {"type":"get_messages"} |
get_commands | — | {"type":"get_commands"} |
set_todos | phases array of TodoPhase | {"type":"set_todos","phases":[]} |
todo_op | op ("init", "start", "done", "drop", "rm", "append", "add_dependency", "remove_dependency", "update_dependencies", "view") plus the op's fields: init: [list] array of {phase, items, [agents]} or [items]+[phase]; start/done/drop: [task] id-or-content + [phase]; rm: [task], [phase], [cascade]; append: phase, items; dependency ops: task, dependsOn | {"type":"todo_op","op":"append","phase":"Plan","items":["ship it"]} |
loop_create | interval, prompt; [fireImmediately] default true; [durable] default false | {"type":"loop_create","interval":"5m","prompt":"check","fireImmediately":true,"durable":false} |
loop_update | taskId; [interval]; [prompt] | {"type":"loop_update","taskId":"loop-1","interval":"10m","prompt":"check again"} |
loop_list | — | {"type":"loop_list"} |
loop_delete | taskId | {"type":"loop_delete","taskId":"loop-1"} |
loop_cancel | taskId | {"type":"loop_cancel","taskId":"loop-1"} |
process_spawn | spec (argv, cwd, [env], [tty], [terminalSize], [label], [timeoutMs], [outputBytes]) | {"type":"process_spawn","spec":{"argv":["printf","ok"],"cwd":"<workspace>","env":{},"tty":false}} |
process_list | — | {"type":"process_list"} |
process_describe | processId | {"type":"process_describe","processId":"00000000-0000-7000-8000-000000000000"} |
process_logs | processId; [cursor] default 0; [limitBytes] | {"type":"process_logs","processId":"00000000-0000-7000-8000-000000000000","cursor":0,"limitBytes":1024} |
process_write | processId, dataBase64 | {"type":"process_write","processId":"00000000-0000-7000-8000-000000000000","dataBase64":"b2s="} |
process_keys | processId, keys ("ENTER", "TAB", "ESCAPE", "CTRL_C", "CTRL_D", "UP", "DOWN", "LEFT", "RIGHT") | {"type":"process_keys","processId":"00000000-0000-7000-8000-000000000000","keys":["ENTER","CTRL_C"]} |
process_resize | processId, cols, rows | {"type":"process_resize","processId":"00000000-0000-7000-8000-000000000000","cols":80,"rows":24} |
process_signal | processId, signal ("SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT", "SIGKILL") | {"type":"process_signal","processId":"00000000-0000-7000-8000-000000000000","signal":"SIGTERM"} |
process_stop | processId | {"type":"process_stop","processId":"00000000-0000-7000-8000-000000000000"} |
process_wait | processId; [timeoutMs] | {"type":"process_wait","processId":"00000000-0000-7000-8000-000000000000","timeoutMs":500} |
Common payload notes
imagesitems areContentBlockobjects; an inline image looks like{"type":"image","data":"<base64>","mimeType":"image/png"}.QueueModevalues serialize as kebab-case:"all"and"one-at-a-time".ThinkingLevelvalues are lowercase:"off","minimal","low","medium","high","xhigh".ProcessSignalvalues serialize asSCREAMING_SNAKE_CASE.ProcessKeyvalues serialize as the variant names shown above.ProcessIdis a string (UUID v7 in generated ids), not a number.
Example RPC session
{"type":"prompt","id":"1","message":"List Rust files"}
{"type":"get_state","id":"2"}
{"id":"1","type":"response","command":"prompt","success":true,"data":null}
{"id":"2","type":"response","command":"get_state","success":true,"data":{"thinkingLevel":"medium","isStreaming":false,"sessionName":"demo"}}
{"type":"agent_settled"}
{"type":"bash","id":"3","command":"ls *.rs"}
{"id":"3","type":"response","command":"bash","success":true,"data":{"exitCode":0,"stdout":"main.rs\nlib.rs\n"}}
For a complete Rust client example, see
examples/src/bin/rpc_client.rs.
Web client (/web)
rpi --listen serves a web chat client at GET /web on the control-plane
listener. The frontend is a React + TypeScript app (crates/pi-cli/web/,
built with Vite); vite-plugin-singlefile inlines it into one self-contained
dist/index.html that build.rs embeds into the binary as an asset table
(path -> MIME/bytes). At runtime there is no framework, build step, or
external asset — the listener serves the embedded page, and the page drives
the existing WebSocket control plane in a browser.
Frontend development uses the Vite dev server:
$ rpi --listen 127.0.0.1:8765 --listen-token-file <workspace>/rpi-token
$ cd crates/pi-cli/web
$ npm install
$ RPI_LISTEN=http://127.0.0.1:8765 npm run dev # http://localhost:5173
The dev server proxies /ws and /rpc to the listener. npm run build
regenerates the committed dist/index.html; rebuilding the Rust binary
embeds the new bundle. RPI_WEB_DEV_DIR=<dir> makes the listener serve a
built page from disk instead, for iterating without recompiling Rust.
Starting the listener
rpi --listen is a headless Web-only backend. It never initializes terminal
raw mode, cursor probing, a TUI, or a line REPL. Standard input may be closed;
the service stays alive until Ctrl-C or SIGTERM, then flushes the active session
and shuts down its listener and runtime manager cleanly. Web prompts use the
normal session recorder, so restarting with --continue, --resume,
--session, or --session-id restores the recorded conversation.
Authentication is optional: a tokenless listener accepts browser connections directly, and a configured token makes authentication mandatory.
One-command local startup (loopback, no token — the browser auto-connects):
$ rpi --listen 127.0.0.1:8765
Control plane listening on http://127.0.0.1:8765 (loopback only)
Open http://127.0.0.1:8765/web in a browser; the page auto-connects with no token and is ready to use. This is the default for local single-user work.
Tokenless LAN access — bind a non-loopback address and opt into plaintext remote listening (still no token):
$ rpi --listen 0.0.0.0:8765 --listen-allow-insecure-remote
Open http://<host-lan-ip>:8765/web — or any hostname that routes to the
host — from another machine on the LAN; the page auto-connects with no
token. No --listen-advertised-origin is needed for ordinary /web,
/ws, or /rpc: the browser request is accepted when its Origin
authority equals the HTTP Host — an ordinary same-origin check that
rejects unrelated cross-origin pages, not authentication and not
DNS-rebinding protection. This is plaintext HTTP and WebSocket with no
authentication and no encryption: anyone reachable on the network can
drive the agent and observe traffic. Use loopback, or a TLS-terminating
proxy in front of the listener, unless that exposure is explicitly
acceptable.
Optional authenticated form — add --listen-token-file to make the token
mandatory on either bind:
$ rpi --listen 127.0.0.1:8765 --listen-token-file <workspace>/rpi-token
Control plane listening on http://127.0.0.1:8765 (loopback, authentication enabled)
Open http://127.0.0.1:8765/web, enter the token, and press Connect. For an authenticated LAN listener, combine the token file with the insecure-remote opt-in:
$ rpi --listen 0.0.0.0:8765 --listen-token-file <workspace>/rpi-token \
--listen-allow-insecure-remote
The token authenticates clients but provides no encryption: passive LAN observers can still capture the bearer token and control traffic.
Collaboration join links follow the same bind/advertise separation: wildcard
binds (0.0.0.0 or ::) require --listen-advertised-origin <URL> (a strict
http/https origin without credentials, path, query, or fragment) before
/collab — or collab_start without an explicit baseUrl — can print
reachable links; loopback binds advertise their local address automatically.
The page itself is always served without authentication: it carries no data,
and every command and event flows through the /rpc and /ws routes, which
are token-gated only when a token file is configured.
Authentication: the rpi-auth.<token> subprotocol
Browsers cannot set the Authorization header on WebSocket connections.
The listener therefore accepts the token as a WebSocket subprotocol request:
Sec-WebSocket-Protocol: rpi-auth.<token>
- The offered list is scanned for the first
rpi-auth.<token>entry whose token matches the configured token file, compared in constant time (the same comparison used forAuthorization: Bearer <token>). - On success the exact offered protocol is reflected in the upgrade response (RFC 6455 requires the server to select and echo one subprotocol), so the browser accepts the handshake.
- The
Authorizationheader path is unchanged. A token file makes the token mandatory on every bind; without one the listener is tokenless — browsers are accepted on loopback, and on a non-loopback bind with--listen-allow-insecure-remotewhen the request'sOriginauthority equals the HTTPHost(an ordinary same-origin check that rejects unrelated cross-origin pages, not authentication and not DNS-rebinding protection). Wrong, empty, or whitespace-containing candidates are rejected. A configured token authenticates clients but provides no encryption against passive network observers.
The token never appears in a URL or cookie; it is held only in the WebSocket
handshake header and kept in sessionStorage by the page.
v1 features
- Connection state + auto-reconnect — the status pill shows
connecting / connected / reconnecting / offline; unexpected disconnects retry with exponential backoff (1s → 15s cap). A manual Connect button reconnects after changing the token. - Prompt box — while idle, Enter and the primary Send button send
prompt; while a run is active, both switch to Steer and sendsteer, avoiding a rejected second prompt. Shift+Enter inserts a newline; Esc (or the active-only Abort button) stops the run. Redundant dedicated Steer and Follow up buttons are intentionally absent, leaving the textarea usable on phone-width screens. - Streaming transcript — assistant turns render live from the event
stream: text deltas, collapsible
thinkingblocks, compact tool-call cards, bash/tool-result blocks, and final markdown. Internaldisplay: falsesystem scaffolding is hidden like the TUI; bash output keeps the last 10 lines and other tool output keeps the last 6 with an omitted-line count. - Model / thinking switch — model and thinking-level dropdowns populated
from
get_available_models/get_available_thinking_levels, applyingset_modelandset_thinking_level; the session name comes fromget_state. - Status line — a pulsing "streaming" badge while a run is in flight and error toasts for failed commands, failed runs, and connection problems.
- Multi-session authoritative restore — switching sessions consumes the target runtime's backend snapshot; closed sessions resume from disk, and a listener restart rebinds before controls become active. Web prompts use the normal session recorder and remain available after restart.
- Panels — dedicated views for todo, goal, workflow, session tree, settings, subagent jobs, side chat, and maintenance, each driven by the same JSONL RPC control plane.
- Collaboration guest route —
/collab/ws/<roomId>serves the same embedded client for encrypted live-collaboration guests, reading the capability key from the URL fragment locally before opening the encrypted WebSocket.
Security
- Every model-derived string passes through a JavaScript port of the export
pipeline's
redact_secrets(credential shapes such assk-…,ghp_…,Bearer …,token=…, PEM private keys) before touching the DOM, and every string crossing intoinnerHTMLis additionally HTML-escaped (& < > " '). Streaming deltas usetextContent; model text is never injected as raw HTML. - Links are restricted to
http/https/mailtoand same-origin relative paths; images only render from base64data:URIs with whitelisted MIME types. - The token is never placed in a URL, and there is no cookie, so nothing to CSRF. Commands require the token only when one is configured; the page itself is static.
v1 limitations
- Remote approvals and overlay confirmations are intentionally unavailable
(
extension_ui_responseis hard-rejected on the wire by design); the page shows tool results but cannot answer interactive extension prompts. - No TLS: the listener is plain HTTP/WebSocket. Non-loopback access is an explicit plaintext opt-in (optionally authenticated), and passive network observers can capture any bearer token and control traffic.
Testing
- Frontend type-check + build:
cd crates/pi-cli/web && npm run build(type-check, focused transcript assertions, Vite bundle, and deterministic trim); the committeddist/index.htmlis what the binary embeds. - Rust:
cargo test -p pi-cli --lib(subprotocol unit tests) andcargo test -p pi-cli --test listen_control_plane(GET /web route, positive and negative subprotocol auth, existing routes unchanged). - Browser E2E (playwright-only hard gate):
bash E2E.d/web/run.shspawns the real binary with the loopback mock provider and runs 12 lanes: core, goal, xss, abort, reconnect, switch, mobile, auth, auth_tokenless, extras, sessions, and session_restore. The final lane proves loaded switching, close/resume from disk, and listener-restart history restoration. Most lanes use a token file; auth_tokenless starts without one. Playwright uses a system Chrome/Chromium binary or its bundled Chromium; an unavailable browser or failed assertion fails the lane with no skip or fallback.
E2E scenarios (user-perspective tmux tests)
This page is the scenario catalog for user-perspective, goal-driven E2E
testing of the rpi TUI. Every entry is written the way a user would experience
the product: a user goal, the concrete tmux interaction that pursues
it, the observable outcome on the real TUI, and the pass criteria that
prove the goal was achieved. The catalog covers every core feature; the
highest-value scenarios are implemented as runnable, deterministic tmux
scripts under E2E.d/ (see Implementation status).
How scenarios are executed
- The binary under test is
target/release-dist/rpi(RPI_BIN). - Scenarios that need no model tool calls use the built-in faux provider
(
--model faux/faux-1+PI_FAUX_RESPONSE): fully offline and deterministic. - Scenarios that must exercise tool calls (bash cards, todo DAG creation,
workflow planning/workers) use a loopback mock provider (an
OpenAI-compatible SSE server on
localhost), never a real model — still offline and deterministic. - Every script: starts tmux → launches rpi with the scenario's flags/model →
drives keystrokes → asserts on pane text (status line, panel rows, output)
and/or file side effects → captures evidence under
EVIDENCE_ROOT→ kills the session. - Scripts are self-contained, skip-guarded (
tmuxpresent,RPI_BINexecutable, faux model available), and use bounded wait-for-pattern polling instead of sleeps wherever possible. - Run a single scenario or the whole lane:
# whole lane
bash E2E.d/ci/user_scenarios.sh run
# one scenario
bash E2E.d/ci/user_scenarios.sh goal
# list
bash E2E.d/ci/user_scenarios.sh list
Evidence lands in ${TMPDIR:-/tmp}/rpi-e2e-evidence/<run-id>/user-<scenario>/.
Scenario catalog
Legend — script: implemented in this repo and runnable now; lane:
covered by an existing E2E lane (E2E.d/ci/*.sh or Rust tests/); manual:
needs hardware/credentials/network, runs by hand only.
1. Goal lifecycle (create / pin / pause / complete + budget + journal)
| User goal | Keep a long-running session focused on one objective with a token budget, and journal important constraints. |
| Interaction | /goal create --tokens 100 ship the widget → /goal → /goal pin keep the release checklist in scope → /goal pins → /goal pause → /goal resume → /goal complete → /goal. |
| Observable outcome | Status line reports Goal work started · active · 0/100 tokens · ship the widget; a Goal details block lands in the transcript (Objective: …, Status: active, Tokens: 0 / 100, Time spent: …); the composer header shows the 🎯 Goal 0/100 chip; pause flips the chip to ⏸ and the summary to paused · …; pins list as 1. …; complete flips to completed · …; show after drop reports no goal. |
| Pass criteria | Every lifecycle summary contains the exact lifecycle word (active/paused/completed) and budget fraction; the 🎯/⏸/✓ chip appears and changes; pin text survives pins; the goal persists to the agent-dir goal state file. |
| Status | script E2E.d/user/goal_lifecycle.sh |
2. Loop (create / cancel / delete / continue)
| User goal | Run a recurring prompt on an interval and own its lifecycle. |
| Interaction | /loop 1h slash keep-alive → /loops → /loop-update <id> 2h … → /loop-delete <id> / /loop-cancel <id>; bare /loop shows usage. |
| Observable outcome | /loop reports scheduled <task-id> and the loop fires immediately (faux reply appears); /loops lists the loop with its id and prompt; missing-argument forms print usage. |
| Pass criteria | A parseable loop id is printed and echoed by /loops; delete/cancel succeed without error text. |
| Status | lane ic.tui-loop (E2E.d/ci/interactive_commands.sh) + Rust goal_loop_e2e.rs |
3. Workflow (plan → todo → execute → integrate; isolation; restart)
| User goal | Delegate a complex objective to an isolated, supervised workflow that plans, executes, and integrates its own changes back. |
| Interaction | /workflow create ship-flow ship the widget → watch the planning feed → /todo (workflow-owned DAG rows) → /workflow integrate ship-flow → /workflow show ship-flow; concurrently create a second workflow and verify isolation; restart rpi and verify the workflow resumes. |
| Observable outcome | Creation prints the workflow detail block (Status: queued → live events move it through planning → running); the compact header counts Workflows · N active · M total; the supervisor's planning turns create real todo phases/tasks visible on the Todo DAG page; integration applies the workflow branch (Integration: applied · <commit>), status becomes completed; each workflow lives in its own git worktree; a restarted session restores non-terminal workflows. |
| Pass criteria | Two concurrent workflows never share a worktree or collide on task ids; integration produces a real merge commit on the source branch; pause/resume/cancel/remove transitions are reflected in show. |
| Status | script E2E.d/user/workflow_full_run.sh (create → plan → todo → execute → integrate → completed); lane workflow.goal-tmux, workflow.tmux, workflow.rpc, D11RestartRecovery |
4. Orchestration (subagents spawn/delegate, IRC, soft budgets, yield)
| User goal | Have the main session delegate work to named subagents, receive their progress over IRC, and park them on a soft budget. |
| Interaction | Natural-language delegation ("Have researcher study this") → observe the agent card and 👥 footer count → supervisor yield releases the turn; subagent completion lands in the transcript; IRC directives route to the owning workflow. |
| Observable outcome | The subagent card renders agent name/status; the footer shows 👥 N while agents run; ⟦N bg⟧ appears on the status line; yield tool parks the agent and returns control; a soft-budget-exhausted job surfaces in its card. |
| Pass criteria | Delegation selects the trusted agent by name; cards appear and resolve; yield observably ends the subagent turn without error. |
| Status | lane orchestration.rpc / orchestration.rust / orchestration.tmux (E2E.d/ci/orchestration.sh), D47YieldTool, T50SoftBudgets |
5. Todo DAG (list / detail / execute)
| User goal | Maintain a phased task list and inspect the dependency DAG before letting the model execute it. |
| Interaction | Seed markdown: /todo # Survey\n- [ ] map parser surface\n# Construct\n- [/] repair composer repaint → /todo opens the DAG page → Enter opens detail → Esc back → Esc closes. |
| Observable outcome | Overview shows Todo DAGs with a [main] row projecting open N active M blocked K; detail renders phases, tasks, ◌/● markers, in progress labels and counts; Esc steps detail → overview → closed; composer focus restored. |
| Pass criteria | Exact overview/detail chrome strings appear; the seeded task names and phase names render; panel closes without leaving overlay state. |
| Status | script E2E.d/user/todo_dag.sh; lane Rust core_tui_e2e.rs::pty_todo_overview_detail_navigation, orchestration.rpc |
6. /btw side chat
| User goal | Run a parallel side conversation without disturbing the main session. |
| Interaction | /btw opens the overlay → type a prompt, Esc closes (session kept) → reopen and confirm persistence → /btw new alpha → /btw list → /btw alpha → /btw close alpha. |
| Observable outcome | Status reports Side chat open · tab … · Esc closes overlay (session kept); tab create reports Side chat · tab alpha created · N of M tabs open; /btw list shows tabs with the active marked ▸; the side conversation persists across close/reopen. |
| Pass criteria | Overlay opens/closes with status transitions; tab list contains created names; the main composer still accepts input after close. |
| Status | script E2E.d/user/btw_side_chat.sh; lane Rust core_tui_e2e.rs::pty_btw_side_chat_open_paste_esc_reopen_persist |
7. /live voice (hold-to-talk)
| User goal | Dictate a prompt by holding a key. |
| Interaction | /live arms hold-to-talk; Ctrl+Space records; transcript lands in the composer for review before Enter. |
| Observable outcome | Status line shows ⟦live⟧ while armed; recorded text appears in the composer, not the transcript. |
| Pass criteria | Manual: requires a working microphone; the TUI never blocks without one. |
| Status | manual (no mic on CI); lane L1LiveVoice, docs/src/user-guide/live.md |
8. MCP (stdio server connect + tool call)
| User goal | Attach an external MCP stdio server and use its tools. |
| Interaction | Register an MCP server (config/--mcp), prompt the model to call its tool, verify the tool result card. |
| Observable outcome | Server connects (/mcp status), tool call appears as a card with the server-provided result. |
| Pass criteria | Tool result text from the MCP server lands in the transcript; disconnect cleans up. |
| Status | lane M1McpGateway, D44McpAcpTests |
9. ACP (agent stdio session)
| User goal | Drive rpi from an external agent over the ACP stdio protocol. |
| Interaction | Launch rpi under ACP (--mode acp), exchange initialize/session/… messages, approve a tool call via session/request_permission. |
| Observable outcome | Protocol handshake succeeds; tool approval round-trips; completion returns the model text. |
| Pass criteria | Deterministic envelope + tool approval exchange without host credentials. |
| Status | lane A1AcpProtocol, D44McpAcpTests, Rust acp_stdio_e2e.rs |
10. Extensions (overlay open, plugin install from dir/git, trust)
| User goal | Load a third-party extension, open its overlay, and install a plugin from a directory or git source. |
| Interaction | Launch with --extension <dir>; /run alpha hello and `/chain alpha one |
| Observable outcome | /run prints alpha:hello; /chain pipes outputs; the overlay opens over the TUI; installing an extension from a git URL lands in the extension dir. |
| Pass criteria | Command/chain outputs appear in the pane; untrusted installs fail closed without --approve. |
| Status | lane campaign.extension, ic overlay/plugin lanes, D45OverlayP0, D50GitPluginSource, G10ExtensionDesign |
11. /rewind + /snapcompact + /compact
| User goal | Roll a session back to a checkpoint and shrink its context without losing the archive. |
| Interaction | Run a few prompts → /checkpoint mid → /rewind (picker lists indices and [checkpoint mid -> …]) → /rewind <entry> → /snapcompact → /compact. |
| Observable outcome | The picker shows entry indices, types and checkpoint annotations; a rewind reports rewound to entry N (kept …, dropped … record(s)); archived tail to <path> and writes a .rewind-*.jsonl sidecar; /snapcompact reports Compacted X → Y estimated tokens on the status line (A→B) and writes a .snapcompact-*.jsonl archive; /compact (LLM summarizer) reports Compaction complete. |
| Pass criteria | Transcript before the rewind target disappears from the live view; the archived tail file exists and contains the dropped records; the A→B token status shows a strict decrease. |
| Status | script E2E.d/user/rewind_compact.sh (checkpoint, picker, rewind + sidecar, /snapcompact A→B status + sidecar); /compact LLM summarizer completion covered by unit + Rust REPL lanes (rewind_checkpoint_snapcompact_e2e.rs); T102RewindCheckpoint |
12. /handoff
| User goal | Produce a handoff summary another agent can act on. |
| Interaction | /handoff → /handoff --prose. |
| Observable outcome | A Handoff transcript block renders the deterministic envelope (# Handoff); the clipboard copy runs in the background; --prose in the TUI stays envelope-only with a hint that prose is a REPL/CLI surface. |
| Pass criteria | Envelope text appears in the pane; the summarizer is never invoked on the TUI event loop. |
| Status | script E2E.d/user/steering_queue_handoff.sh; lane Rust handoff_prose.rs, T99HandoffProse |
13. /fresh, /dump, /share --encrypt
| User goal | Start over cleanly, export the session, and share it privately. |
| Interaction | /fresh (alias /new) starts a new session; /dump [--jsonl] [path] writes an HTML/JSONL export; /share --encrypt [passphrase] writes an AES-256-GCM .jsonl.enc (never touches the network without a gist seam). |
| Observable outcome | /fresh prints Started a new session and /name reads (unnamed); the dump file exists on disk with the session content; the encrypted share file exists and its header identifies the cipher. |
| Pass criteria | File side effects verified on disk; /share honors a fake gh seam and never requires host credentials. |
| Status | lane ic.tui-name-new (/new), ic.tui-export (/export), ic.tui-share (/share + fake gh), T82FreshDumpShare, T38ExportFlagHardening |
14. Hooks / trust
| User goal | Run a trust hook that upgrades an untrusted project decision. |
| Interaction | Install a hooks config with a trust_decision handler; launch in an untrusted project; verify the hook's approve recommendation is applied and its veto is never (a hook cannot weaken a stored denial). |
| Observable outcome | The hook's approval lets the project resource load; a deny-only payload stays inert. |
| Pass criteria | Hook event fires and the decision changes exactly as the fail-open contract allows. |
| Status | lane T27HooksSystem, T91TrustHook, T98TrustWiring, docs/src/reference/settings-trust.md |
15. Sandbox (bash denied path)
| User goal | Ensure a denied filesystem path stays out of reach of the model's bash. |
| Interaction | Configure sandbox denials; prompt a bash call against the denied path; observe the tool error. |
| Observable outcome | The bash tool card renders the denial error; no file is created/read on the denied path. |
| Pass criteria | The tool result contains the denial reason; the denied path is untouched on disk. |
| Status | lane O1OsIsolation, D10SandboxOverlay, D15SandboxToolsE2e, Rust sandbox tests, docs/src/reference/sandbox-isolation.md |
16. Image generation (faux)
| User goal | Generate an image from a prompt. |
| Interaction | /image <prompt> with a faux-capable provider. |
| Observable outcome | The image placeholder card renders [Image #N, WxH] (or the real image under kitty graphics). |
| Pass criteria | API surface exercised against a faux generator (no real model); real-image rendering marked manual. |
| Status | manual (real generation); lane G1ImageGen, T109ImageInspect, Rust write_orchestration_png_fixture |
17. Eval / notebook (python cell)
| User goal | Run an inline python cell in the session. |
| Interaction | /run python3 -c … or the notebook overlay; verify cell output in the tool card. |
| Observable outcome | Cell stdout appears in the card; errors surface as tool errors, not TUI hangs. |
| Pass criteria | Output text lands in the pane; the composer stays responsive. |
| Status | lane E1EvalNotebook, T48OfficeNotebook |
18. Memory tool
| User goal | Persist a fact across sessions with the memory tool. |
| Interaction | Prompt the model to store a fact (mock tool call) → verify the memory file → new session reads it back. |
| Observable outcome | Memory file updated; a follow-up turn's context includes the stored fact. |
| Pass criteria | File side effect + cross-session retrieval. |
| Status | lane T74MemorySystem, T87SkillGoalPins, docs/src/reference/skills.md |
19. Ask tool
| User goal | Answer a mid-task question the model asks. |
| Interaction | Mock provider calls ask; the status line shows ⟦ask⟧ <question> ⟦esc⟧; the next submitted line is routed back as the answer. |
| Observable outcome | The pending question is visible above the composer and the answer arrives in the next model request. |
| Pass criteria | Status-line ask glyph appears; the answer text reaches the provider request. |
| Status | lane T51AskTool, Rust steering/ask unit tests |
20. Auto-mode
| User goal | Let the model classify the task and route it automatically. |
| Interaction | Submit a prompt in auto mode; observe the classification hint (Detected: code task — /todo …) and the routed workflow/todo behavior. |
| Observable outcome | The classifier hint renders on the status line; execution follows the routed path. |
| Pass criteria | Hint text appears; routing matches the fixture's classification. |
| Status | lane T76AutoMode, T53AutoIntegrate, U7WorkflowFixes |
21. /queue + doom-loop
| User goal | Keep steering the agent while it works, then clear the backlog. |
| Interaction | Submit a prompt (mock streams slowly) → type a second prompt while the turn is in flight (follow-up queued) → observe ⚙ N header count and Next: /queue · /goal suggestion → /queue lists Pending prompts: … steering, … follow-up — /queue cancel clears them → turn completes and the follow-up drains automatically → /queue → Queue is empty; repeat with /queue cancel. |
| Observable outcome | The pending count appears in the composer header (⚙ 1), the suggestion line advertises /queue, the queue view lists previews, and consumption clears the indicators without a restart. |
| Pass criteria | Counts appear while queued, drain after processing, and cancel empties the queue with Cancelled N queued prompts. |
| Status | script E2E.d/user/steering_queue_handoff.sh; lane Rust steering_rpc_binary_e2e.rs + TUI queue unit tests |
22. TUI chrome (theme, status line, bash cards, code frames, list colors, steering queue)
| User goal | Recognize and control the interface itself: visual theme, live status line, tool cards, and code frames. |
| Interaction | /theme / /theme next; drive a bash tool call (multi-line command with leading # comments) → card renders ╭── # comment ──╮ frame, $ command rows, Output separator; prompt a code fence that never closes → bottom border carries … (unclosed fence); queue a steering message → ⟦steering⟧ <preview> status line; check list/status colors under the active theme. |
| Observable outcome | Theme switch changes the rendered palette; the bash card keeps its 20-row budget with comment frame and $ rows; the open fence renders ╰── … (unclosed fence) ──╯ instead of a borderless tail; the steering preview sits above the input until the queue drains. |
| Pass criteria | Exact chrome strings (╭── #, $ , Output, … (unclosed fence)) appear in captures; theme cycles; steering glyph and preview appear/clear with queue lifecycle. |
| Status | script E2E.d/user/bash_card_fence.sh (bash card + unclosed fence) and E2E.d/user/steering_queue_handoff.sh (steering line); lane ic.tui-theme, K1ThemeAudit, F7ThemeListColors, T32NoColorFix |
Implementation status
| Scenario | Script | Model | Coverage |
|---|---|---|---|
| Goal lifecycle | E2E.d/user/goal_lifecycle.sh | faux | full lifecycle + budget chip + pins |
| Rewind / snapcompact / compact | E2E.d/user/rewind_compact.sh | faux | picker, checkpoint, sidecar, A→B status |
/btw side chat | E2E.d/user/btw_side_chat.sh | faux | open/type/esc/reopen, tabs, list, close |
Steering queue + /handoff | E2E.d/user/steering_queue_handoff.sh | mock (slow stream) | follow-up queue, ⚙/suggestion, drain, cancel, handoff envelope |
| Bash card + code fence | E2E.d/user/bash_card_fence.sh | mock (tool calls) | multi-line bash card, comment frame, unclosed fence marker |
| Todo DAG page | E2E.d/user/todo_dag.sh | faux | markdown seed, overview/detail chrome, Esc navigation |
| Workflow full run | E2E.d/user/workflow_full_run.sh | mock (planning) | create → plan → todo rows → integrate → completed, worktree |
All other features are covered by the lanes listed in the catalog; /live
voice and real image generation are manual.
Verification
First full green run (2026-08-08): bash E2E.d/ci/user_scenarios.sh run → exit 0.
Re-verified on the post-web-wave tree (2026-08-09, freshly rebuilt target/release-dist/rpi,
rust-toolchain 1.88.0): bash E2E.d/ci/user_scenarios.sh run → exit 0.
| Scenario | Result | Evidence |
|---|---|---|
| goal-lifecycle | pass | status/detail/chip captures in $EVIDENCE_ROOT/<run-id>/goal-lifecycle/ |
| rewind-compact | pass | rewind + snapcompact sidecars asserted on disk |
| btw-side-chat | pass | overlay chrome + tab lifecycle captures |
| steering-queue-handoff | pass | ⚙ counts, auto-drain, /queue cancel, handoff envelope in transcript + fake-xclip capture |
| bash-card-fence | pass | comment frame, $ rows, Output separator, … (unclosed fence) |
| todo-dag | pass | overview/detail chrome + Esc navigation |
| workflow-full-run | pass | create → plan → Todo DAG → workers → auto-integrate → completed; e2e plan commit + PLAN.e2e verified in git |
| project-authoring | pending | empty git workspace → multi-module Rust CLI written, planted marker-parse defect caught by failing cargo test, read+edit repair, passing tests (12/12), valid/invalid CLI runs, Todo DAG completed; driven by E2E.d/lib/project_authoring_mock.py |
Notes from verification:
- Scenario scripts must run against a current release-dist binary; the
previously shipped one (Aug 5) predated
/goal pinand reporteda current goal already existson pin. - The status text set by a command that starts a model turn
(
Goal work started …) is immediately superseded by the turn's busy label, so the scripts assert the details block, the header chip, and the post-turn statuses instead. /snapcompact'sCompacted A → B estimated tokensstatus can be overwritten by the session'sCompaction completeevent (event ordering); the scripts accept either and assert the sidecar archive durably.- The non-snap
/compactLLM summarizer requires> keepRecentTokensof context before it has anything to compact, which a short faux session cannot reach; that surface stays covered by unit tests and the Rust REPL lanes. - The workflow supervisor prompt reads
You plan workflow …; loopback mocks must classify on that wording, and multi-tool-call responses must stream onetool_callsindex per SSE delta.
Settings, configuration, and trust
Configuration directory
The runtime resolves the agent configuration directory (<agent-dir>) in this
order:
PI_CODING_AGENT_DIRenvironment variable.- The platform home directory (
HOMEon Unix,USERPROFILEon Windows) with/.pi/agentappended.
Source: crates/pi-coding/src/resources.rs:26-41.
Within <agent-dir> the CLI reads:
settings.json— global startup defaults and operational settings.models.json— custom providers and model overrides.auth.json— stored provider credentials.trust.json— persisted project trust decisions.sessions/— native Pi v3 JSONL session files.skills/,prompts/,themes/— global resources.
Project-local resources are loaded from <workspace>/.pi/ only when that project
is trusted. Source: crates/pi-coding/src/resource_manager.rs:49-70.
Project trust
By default, rpi asks before loading project-local .pi resources (skills,
prompts, themes, keybindings, extensions, agents, and package settings). Trust
decisions are stored in <agent-dir>/trust.json, versioned as
TRUST_STORE_VERSION = 1, and scoped to the canonical project path. The
resolver walks parent directories, so a decision at <workspace> covers
<workspace>/sub-project.
Source: crates/pi-coding/src/trust.rs:67-140 and
crates/pi-coding/src/trust.rs:180-216.
Default behavior is controlled by settings.json:
{
"defaultProjectTrust": "ask"
}
Allowed values:
"ask"— prompt for trust in interactive modes; treat as untrusted in headless modes (default)."always"— trust every project with a.pidirectory without asking."never"— never trust project-local resources.
One-run overrides:
rpi -a "hello" # --approve: trust this project's .pi for this run
rpi --no-approve "hello" # refuse project .pi for this run
Headless modes (--print, --mode json, --mode rpc) never prompt. In
headless mode an unset or "ask" decision is treated as untrusted, so use
--approve when you need project resources. If the project has no .pi
directory, it is implicitly trusted.
Source: crates/pi-coding/src/trust.rs:37-43 and
crates/pi-cli/src/args.rs:168-174.
settings.json
Global settings live at <agent-dir>/settings.json. Project settings live at
<workspace>/.pi/settings.json and are merged on top of global settings when
trusted. Unknown fields are retained across merges so other product modules can
use them.
Source: crates/pi-coding/src/settings.rs:666-669 and
crates/pi-coding/src/settings.rs:729-843.
{
"defaultProvider": "openai",
"defaultModel": "openai/gpt-5",
"defaultThinkingLevel": "medium",
"defaultProjectTrust": "ask",
"theme": "dark",
"compaction": {
"enabled": true,
"reserveTokens": 8192,
"keepRecentTokens": 4096
},
"terminal": {
"showImages": true,
"imageWidthCells": 80,
"clearOnShrink": false,
"showTerminalProgress": true
},
"images": {
"autoResize": true,
"blockImages": false
},
"retry": {
"enabled": true,
"maxRetries": 3,
"baseDelayMs": 2000,
"provider": {
"timeoutMs": 60000,
"maxRetries": 3,
"maxRetryDelayMs": 60000
}
},
"transport": "auto",
"timeoutMs": 60000,
"maxRetryDelayMs": 60000,
"temperature": 0.7,
"maxTokens": 4096,
"cacheRetention": "short",
"thinkingBudgets": {
"minimal": 1024,
"low": 2048,
"medium": 4096,
"high": 8192
},
"scopedModels": ["openai/*", "anthropic/*"],
"branchSummary": {
"reserveTokens": 16384,
"skipPrompt": false
},
"keybindings": {
"toggleTheme": "ctrl+t"
},
"quietStartup": false,
"showThinking": true,
"exposeSessionEnvironment": true,
"doubleEscapeAction": "tree",
"orchestration": {
"process": false,
"tasks": false,
"todo": false,
"maxConcurrency": 8,
"maxRecursionDepth": 8,
"mailboxCapacity": 1000,
"maxToolsPerAgent": 16
},
"packages": [
"git:github.com/owner/pi-my-tools",
{ "source": "./my-pi-tools", "extensions": ["*"] }
],
"extensions": ["ext-id"],
"skills": ["rust-review"],
"prompts": ["custom-prompt"],
"themes": ["solarized"]
}
Known fields
| Field | Purpose |
|---|---|
defaultProvider | Default provider id used when no model flag is given. |
defaultModel | Default model id used when no --model is given. |
defaultThinkingLevel | Default reasoning level (off, minimal, low, medium, high, xhigh, max). |
defaultProjectTrust | "ask", "always", or "never". |
approvalMode | Global host tool policy: yolo allows all tools, write confirms Exec, and ask confirms every tool call. Project settings cannot override it. |
steeringMode | Steering queue mode (all or one-at-a-time). |
followUpMode | Follow-up queue mode (all or one-at-a-time). |
sessionDir | Override the session storage directory. |
sessionImportSources | Allowed session import sources (omp, codex, claude, grok, droid). |
sessionTtlDays | Startup session TTL pruning age in days (default 30; must be > 0). |
authScope | Active credential scope label; PI_AUTH_SCOPE overrides it at runtime. |
theme | Initial TUI theme name. |
compaction | Context compaction limits (enabled, reserveTokens, keepRecentTokens). |
terminal | Legacy TUI rendering options (showImages, imageWidthCells, clearOnShrink, showTerminalProgress). |
images | Image display options (autoResize, blockImages) plus generation overrides (genModel, genBaseUrl, genApiKey). |
retry | Retry policy (enabled, maxRetries, baseDelayMs, plus optional provider overrides). |
autoRetry, maxRetries, baseDelayMs | Top-level legacy aliases for the same retry fields. |
transport | Stream transport (auto, sse, web-socket, web-socket-cached). |
timeoutMs | HTTP/stream timeout. |
httpIdleTimeoutMs | Idle HTTP connection timeout. |
websocketConnectTimeoutMs | WebSocket connect timeout. |
maxRetryDelayMs | Maximum delay between retries. |
temperature | Sampling temperature, finite and 0..=2. |
maxTokens | Maximum tokens per model request. |
cacheRetention | Cache retention (none, short, long). |
responsesStatefulChain | Opt-in stateful turn chaining for OpenAI Responses API models (default false); see configuration-profiles.md. |
thinkingBudgets | Per-level token budgets (minimal, low, medium, high). |
scopedModels / enabledModels | Alias for the same model-pattern allowlist. |
branchSummary | Branch-summary token reserve and skipPrompt. |
keybindings | Action-to-chord map (value is a string or array of strings). |
quietStartup | Suppress non-essential startup messages. |
showThinking / hideThinkingBlock | Control whether thinking blocks are shown. |
showImages, imageWidthCells, autoResizeImages | Top-level legacy aliases for terminal/image options. |
exposeSessionEnvironment | Forward session environment to tools. |
doubleEscapeAction | TUI action on double-escape (fork, tree, none). |
orchestration | Orchestration tool gates, concurrency limits, workflow isolation, and soft budgets (see orchestration.md and workflows.md). |
selector | Optional model-selector thresholds (advanced). |
agents | Per-agent runtime settings (settings.agents.<name>.enabled/model/tools); see orchestration.md. |
packages | Local/git package sources to install/load. |
extensions, skills, prompts, themes | Resource names to load from configured packages and discovered paths. |
sandbox | Opt-in Linux filesystem sandbox for bash (enabled, network, allowedPaths, deniedPaths, readOnly); see sandbox-isolation.md. |
live | Hold-to-talk voice configuration (enabled, sttBaseUrl, sttApiKey, sttModel, language, allowInsecure); see live.md. |
memory | Memory backend (backend: local/hindsight/off; Hindsight HTTP endpoint/token, plaintext opt-in, bank/scoping, recall policy, injection, and per-operation timeouts); see memory.md. |
hooks | Host hooks: external commands observing/gating session events; see hooks.md. |
permissionRules | Path-level permission rules evaluated before the approval mode; see security.md. |
mcpServers | MCP servers for the mcp tool (name, transport, command/args/env, url, disabled); see mcp.md. |
Source: crates/pi-coding/src/settings.rs:342-416,
crates/pi-coding/src/settings.rs:970-1096, and
crates/pi-coding/src/settings.rs:1104-1129.
Supported operational settings coverage
The following keys are the supported operational settings coverage used by the runtime. Any other field may exist but is either a startup default, a resource list, or module-specific configuration.
| Setting | Applied by |
|---|---|
approvalMode | session_run_blueprint host approval hook |
steeringMode | steering_mode |
followUpMode | follow_up_mode |
retry | retry_settings / apply_session_options |
compaction | apply_session_options |
transport | apply_session_options |
timeoutMs | apply_session_options |
maxRetryDelayMs | apply_session_options |
temperature | apply_session_options |
maxTokens | apply_session_options |
cacheRetention | apply_session_options |
thinkingBudgets | apply_session_options |
scopedModels | scoped_model_patterns |
enabledModels | scoped_model_patterns (legacy alias) |
terminal | tui_runtime |
images | tui_runtime |
theme | tui_runtime / resource validation |
keybindings | tui_runtime |
branchSummary | branch_summary_settings |
quietStartup | tui_runtime |
hideThinkingBlock | tui_runtime |
showThinking | tui_runtime |
exposeSessionEnvironment | expose_session_environment |
doubleEscapeAction | tui_runtime |
orchestration | orchestration tool gates |
Source: crates/pi-coding/src/settings.rs:302-330.
Model and thinking-level precedence
When the CLI starts without an explicit model:
--model/-mflag.- Resumed session's saved model (when
--resumeor--continueis used). settings.jsondefaultModel.- First authenticated model in the catalog.
Thinking level precedence:
--thinkflag.- Thinking level parsed from a
:levelmodel suffix (anthropic/claude-sonnet-4-5:high). - Resumed session's saved thinking level.
settings.jsondefaultThinkingLevel.medium.
The final level is clamped to the resolved model's supported levels.
Live reload semantics
Configuration and resources are loaded into an atomic snapshot.
rpi reloadvalidates the current settings/resource graph and prints a structured snapshot, includinggeneration,trust, discovered resources, and diagnostics. It always runs headlessly and emits JSON.SettingsManager::reload()re-reads global settings and, when the project is trusted, project settings, then recomputes the effective snapshot.ResourceManager::stage_reload()builds a new candidate snapshot without replacing the active one.commit_reload()swaps the active snapshot only after validation succeeds; any error leaves the previous generation in place.- Interactive TUI
/reloadand REPLreloadcall the same stage-and-commit path, so malformed files never silently replace a working configuration. - Settings writes performed through
update_globalorupdate_projectare atomic (temp file +fs::rename+ directory sync). Session-only overrides viaapply_overridesare never persisted.
Source: crates/pi-cli/src/commands.rs:145-180,
crates/pi-coding/src/settings.rs:729-843, and
crates/pi-coding/src/resource_manager.rs:139-253.
Trust boundaries
- No project-local configuration is loaded unless the project is trusted. Use
-a/--approveor setdefaultProjectTrustexplicitly. approvalModeis a global safety policy and is rejected in project.pi/settings.json;--approval-modeis the explicit one-run override. It is independent of--approve/--no-approve, which control only project resource trust.- Interactive approval runs before the existing host hook and extension reducer. Headless modes fail closed whenever the selected policy requires confirmation.
- Bash tool commands run in the configured working directory (
--cwd). - API keys are never printed in error messages or logs.
models.jsonandauth.jsonvalues may contain$VAR/${VAR}templates, which are expanded from the current process environment or an explicitenvmap. Command-valued values (!command) are rejected.
Custom config path for tests or isolation
export PI_CODING_AGENT_DIR="<workspace>/.my-pi-config"
mkdir -p "$PI_CODING_AGENT_DIR"
rpi --print "hello"
pi-coding 架构图(Mermaid)
来源:对
crates/pi-coding(约 128K 行 Rust)的结构分析。所有引用以<repo-root>/crates/...相对路径 + 行号给出,可执行验证:grep -n "<symbol>" crates/pi-coding/src/<file>.rs。
图 0 — 四层 crate 依赖
flowchart TB
subgraph L0["pi-cli — crates/pi-cli"]
TUI["TUI 面板 / REPL<br/>tui.rs · repl.rs"]
RPC["JSON-RPC / ACP 模式<br/>rpc.rs · acp.rs"]
CMD["子命令 · 会话编排<br/>interactive_commands.rs · session_run.rs"]
end
subgraph L1["pi-coding — crates/pi-coding(本图主体)"]
APP["Application 状态机<br/>application.rs"]
SESS["Session 回合执行<br/>session.rs"]
SUB["工具 · 编排 · 工作流<br/>扩展 · 配置 · 持久化"]
end
subgraph L2["pi-agent — crates/pi-agent"]
AGENT["Agent 循环<br/>agent.rs · loop_runtime.rs"]
end
subgraph L3["pi-ai — crates/pi-ai"]
PROV["providers/<br/>anthropic · openai · responses · codex · gemini..."]
CAT["模型目录 · 流式 · 重试 · 超时<br/>catalog.rs · stream.rs"]
end
TUI --> APP
RPC --> APP
CMD --> APP
APP --> SESS
SESS --> AGENT
AGENT --> PROV
PROV --> CAT
classDef lay fill:#eef7ff,stroke:#1565c0
class L0,L1,L2,L3 lay
依赖方向(AGENTS.md 约束):pi-cli → pi-coding → pi-agent → pi-ai,禁止跨层直取内部实现。
图 1 — pi-coding 内部模块全景
flowchart LR
subgraph CORE["回合核心"]
APP1["Application 状态机<br/>application.rs:335<br/>+ application/runtime.rs"]
SESS1["Session<br/>session.rs:717<br/>turn loop · retry · compaction"]
STORE["session_store.rs<br/>SessionRecorder:607 · start_session:1177<br/>resume:1551 · fork:1473"]
end
subgraph TOOLS["工具子系统 tools.rs + tools/*"]
CAT["工具目录<br/>tools.rs:537 create_all_tools"]
BASH["bash/brush · process/<br/>ProcessManager:23"]
EDIT["edit · write · ast_edit<br/>editdiff · editmatch"]
LSP1["lsp · lsp_client"]
BROWSER["browser · web_search"]
EVAL["eval · notebook · debug"]
IMG["image · image_gen · imageresize"]
MEMTOOL["memory · recall · retain · reflect"]
MCPTOOL["mcp_tool(McpRegistry)<br/>mcp.rs:554"]
end
subgraph AGENT2["子代理编排 orchestration/"]
ORCH["OrchestrationRuntime<br/>orchestration/runtime.rs:730"]
CHILD["ChildSession<br/>orchestration/runtime.rs:57"]
JOBS["jobs · persistence<br/>工具绑定"]
end
subgraph WF["工作流 workflow/ + workflow_worktree/"]
WFM["WorkflowManager<br/>workflow/manager.rs:196"]
WFS["WorkflowSupervisor<br/>workflow/supervisor.rs:283"]
WFT["worktree overlay<br/>workflow_worktree/mod.rs:280"]
end
subgraph EXT["扩展与集成"]
EXT1["extensions.rs<br/>ExtensionSpec:564 · 进程/QuickJS"]
QJS["quickjs_host.rs<br/>QuickJsExtensionHost:630"]
PLUGIN["plugin.rs 市场"]
PKG["packages.rs 包资源"]
HOOKS["hooks.rs HostHooks:71"]
end
subgraph CFG["配置 · 安全 · 隔离"]
SET["settings.rs · settings_catalog.rs<br/>RuntimeSettingsSnapshot:517"]
AUTH["auth.rs AuthManager:921"]
TRUST["trust.rs TrustStore:172<br/>resolve_project_trust:362"]
SANDBOX["sandbox.rs SandboxConfig:48"]
ENC["encrypt.rs · oauth.rs"]
end
subgraph CTX["上下文与选择"]
SEL["selector.rs SelectionPlan:321"]
RES["resources.rs · resource_manager.rs<br/>ResourceManager:317"]
MEM["memory.rs MemoryConfig:588"]
SYS["system_prompt.rs · prompt_templates.rs"]
end
subgraph DUR["持久化 · 生命周期"]
COMP["compaction.rs 压缩/回退"]
SCAT["session_catalog/ 会话树"]
LOOP1["loop_scheduler.rs LoopTask:97"]
GOAL["goal.rs · handoff.rs · todo.rs"]
end
APP1 --> SESS1
SESS1 --> STORE
SESS1 --> TOOLS
SESS1 --> CTX
SESS1 --> AGENT2
AGENT2 --> WF
APP1 --> EXT
EXT --> QJS
EXT --> PLUGIN
EXT --> PKG
APP1 --> CFG
CFG --> AUTH
CFG --> TRUST
CFG --> SANDBOX
SESS1 --> DUR
DUR --> COMP
DUR --> SCAT
图 2 — 单回合运行时流水线(Sequence)
sequenceDiagram
autonumber
actor U as 用户
participant AP as "Application<br/>application.rs:1402"
participant SE as "Session<br/>session.rs:3685 run()"
participant AG as "pi-agent Agent<br/>agent.rs / loop_runtime.rs"
participant PR as "pi-ai provider<br/>stream"
participant TL as "Tool 目录<br/>tools.rs"
U->>AP: prompt(text)
AP->>SE: run(prompt)
SE->>SE: run_messages(messages)
SE->>SE: inject_selection_messages()<br/>selector 选择 + memory 注入
SE->>SE: begin_run() → ClaimedRun
SE->>SE: execute_with_retries()<br/>session.rs:3853
SE->>AG: agent.prompt_messages()
AG->>AG: run_agent_loop → run_loop
loop 每个模型回合
AG->>PR: 流式请求(含工具定义)
PR-->>AG: assistant 消息 / tool_call
alt 有工具调用
AG->>TL: 执行 AgentTool.execute()
TL-->>AG: ToolResult
AG->>AG: 工具结果回合继续
else 无工具调用
AG-->>SE: 回合结束(stop_reason)
end
end
SE->>SE: finish_run() → 记录/事件
SE-->>AP: RunResult
AP-->>U: 渲染结果
图 3 — 回合失败恢复决策(重试 / 回退 / 压缩)
flowchart TD
A["execute_with_retries<br/>session.rs:3853"] --> B{"最后消息是<br/>Assistant 成功?"}
B -->|是| Z["返回成功<br/>finish_retry_success"]
B -->|否| C{"stop_reason == Error?"}
C -->|否| Z
C -->|是| D{"context overflow?"}
D -->|是| E{"已尝试过<br/>overflow 恢复?"}
E -->|是| F["报错终止<br/>Context overflow persisted"]
E -->|否| G["自动压缩<br/>perform_compaction(Overflow)"] --> H["继续 agent.continue_run()"] --> B
D -->|否| I{"retryable 错误<br/>且未超限?"}
I -->|是| J["重试(RetrySettings)<br/>session.rs RetrySettings:467"]
J --> B
I -->|否| K{"有回退链候选?<br/>retry_fallback.rs find_candidates:409"}
K -->|是| L["切换到回退模型<br/>format_retry_fallback_selector:112"]
L --> M["continue_run"] --> B
K -->|否| N["DoomLoopTracker 触发<br/>session.rs:228 → 终止"]
N --> F
图 4 — pi-agent 循环内部
flowchart TB
E["run_agent_loop<br/>loop_runtime.rs:45"] --> F["run_loop<br/>loop_runtime.rs:103"]
F --> G["取 steering / follow-up 消息<br/>PendingQueue"]
G --> H["组装 AgentContext<br/>types.rs:546"]
H --> I["调用 stream_fn(pi-ai 流式)"]
I --> J{"解析回复"}
J -->|tool_call| K["before_tool_call 钩子"]
K --> L["执行 AgentTool(AgentTool::execute<br/>types.rs:265)"]
L --> M["after_tool_call 钩子"]
M --> N["写入消息历史"] --> I
J -->|纯文本| O["AgentSettled 事件"]
J -->|Aborted/Error| O
O --> P["AgentEvent 广播<br/>types.rs:645"]
P --> Q["Session 订阅 → ApplicationEvent → TUI"]
图 5 — 工具子系统
flowchart LR
S["Session.get_active_tools<br/>session.rs:1942"] --> CAT["create_all_tools<br/>tools.rs:537"]
CAT --> T1["read / grep / find / glob / ls"]
CAT --> T2["bash(brush 引擎 + ProcessManager)<br/>tools/bash · process/"]
CAT --> T3["edit / write / ast_edit<br/>tools/ast_grep · editdiff · editmatch"]
CAT --> T4["lsp / browser / web_search"]
CAT --> T5["eval / notebook / debug"]
CAT --> T6["image / image_gen / imageresize"]
CAT --> T7["memory / recall / retain / reflect<br/>memory.rs"]
CAT --> T8["mcp_tool → McpRegistry<br/>mcp.rs:656"]
CAT --> T9["ask / todo / goal"]
subgraph INFRA["执行基础设施"]
I1["tool_presentation.rs<br/>结果渲染与卡片"]
I2["truncate.rs · redact.rs<br/>截断与脱敏"]
I3["tools/mutation_queue.rs<br/>写操作队列"]
I4["tools/framing.rs<br/>流式帧"]
end
T2 --> INFRA
T3 --> INFRA
CAT --> INFRA
图 6 — 子代理编排(Orchestration)
flowchart TB
APP2["Application / Session"] --> OR["OrchestrationRuntime<br/>orchestration/runtime.rs:730"]
OR --> CS["ChildSession<br/>runtime.rs:57<br/>(独立 Session 快照)"]
OR --> CHILDREQ["ChildSessionRequest<br/>runtime.rs:532<br/>(任务 + 预算 + 工具)"]
OR --> MAIL["MailboxMessage<br/>runtime.rs:618<br/>子代理消息信箱"]
OR --> DUR2["PreparedDurableBinding<br/>runtime.rs:920<br/>持久化绑定(fail-closed)"]
OR --> TOOLS2["orchestration/tools.rs<br/>子代理控制工具"]
CS --> SNAP["AgentSnapshot · AgentStatus<br/>runtime.rs:573"]
CS --> RESULT["TaskResult<br/>runtime.rs:709"]
TOOLS2 --> OR
DUR2 --> STORE2["session_store 持久子会话<br/>start_durable_child_session_in<br/>session_store.rs:1560"]
图 7 — 工作流(YAML DAG + worktree)
flowchart TB
WM["WorkflowManager<br/>workflow/manager.rs:196"] --> PARSE["解析 YAML DAG<br/>workflow/detail.rs · store.rs"]
PARSE --> TASKS["WorkflowTask DAG<br/>WorkflowStatus:54"]
TASKS --> SUP["WorkflowSupervisor<br/>workflow/supervisor.rs:283"]
SUP --> AGENTS3["按任务分配子代理<br/>(orchestration ChildSession)"]
SUP --> TOB["WorkflowSupervisorTodoObservation<br/>supervisor.rs:73<br/>观察 todo 完成度"]
SUP --> EV3["WorkflowEvent 广播"]
subgraph WT["worktree 隔离"]
WTM["WorkflowWorktreeManager<br/>workflow_worktree/mod.rs:280"]
OVER["overlay.rs 工作树叠加"]
GIT["git.rs 集成(IntegrateStrategy:209)"]
end
WM --> WTM
WTM --> OVER
OVER --> GIT
GIT -->|IntegrateOutcome| WM
图 8 — 扩展 / 钩子 / 信任 / 沙箱 / MCP
flowchart TB
APPH["ApplicationExtensionHost<br/>application.rs:3067"] --> EXTS["ExtensionSpec<br/>extensions.rs:564"]
EXTS --> RUNTIME{"runtime 类型"}
RUNTIME -->|process| PE["ProcessExtensionManifest<br/>extensions.rs:227<br/>子进程扩展"]
RUNTIME -->|quickjs| QE["QuickJsExtensionHost<br/>quickjs_host.rs:630"]
PE --> CAP["ExtensionCapabilityManifest<br/>extensions.rs:122"]
QE --> CAP
CAP --> PERM["ExtensionPermissionSet<br/>extensions.rs:154"]
PERM --> TRUSTDEC{"pre_trust_decision<br/>(宿主钩子先行)"}
TRUSTDEC -->|允许| LOAD["加载并执行扩展<br/>工具 · 事件钩子 · 渲染器"]
TRUSTDEC -->|拒绝/询问| ASK["Ask 升级为 Trusted<br/>(trust 只能升不能降)"]
TRUSTDEC --> SANDBOX2["sandbox.rs resolve:499<br/>沙箱配置(cap-std)"]
PERM --> HOOKS2["HostHooks 事件<br/>hooks.rs:71<br/>before_tool_call 等"]
PERM --> MCP2["McpRegistry 服务器<br/>mcp.rs:554<br/>stdio 子进程(可禁用)"]
LOAD --> TOOLP["扩展工具注入 Session 工具目录"]
图 9 — 会话持久化与生命周期
flowchart TB
START["start_session<br/>session_store.rs:1177"] --> REC["SessionRecorder<br/>session_store.rs:607<br/>(消息 + 工具调用 + 事件)"]
REC --> FILES["会话文件<br/><repo-digest>/<session-id>/"]
FILES --> RESUME["resume_session<br/>session_store.rs:1551"]
FILES --> BRANCH["create_branched_session<br/>session_store.rs:1237"]
FILES --> FORK["fork_session_in<br/>session_store.rs:1473"]
REC --> COMP["compaction.rs<br/>(阈值/手动/溢出压缩)"]
COMP --> REWIND["RewindTarget · RewindOutcome<br/>session.rs:589"]
COMP --> SNAP["compact_snap 快照压缩<br/>(先落盘+fsync 再引用)"]
FILES --> SCAT["session_catalog/<br/>会话树 · lineage"]
REC --> EVENTS["SessionEvent 广播<br/>session.rs:503 → ApplicationEvent:209"]
图 10 — 上下文装配管线(选择器 + 资源 + 系统提示词)
flowchart LR
REQ["用户请求"] --> SEL["selector.rs<br/>SelectionPlan:321<br/>(autoMode / 分类 / 技能选择)"]
SEL --> AUTO["PromptMode · AutoMode<br/>selector.rs:82"]
SEL --> SKILL["加载 skill:// 技能正文<br/>session.rs inject_selection_messages"]
REQ --> RES["resources.rs / resource_manager.rs<br/>ResourceSnapshot:259<br/>可信项目资源发现"]
RES --> RELOAD["reload_resources<br/>session.rs:1744"]
REQ --> MEM["memory.rs<br/>(hindsight 记忆注入)"]
MEM --> INJ["inject_hindsight_memory"]
REQ --> SYS["system_prompt.rs<br/>当前系统提示词 session.rs:1991"]
SYS --> TEMPLATES["prompt_templates.rs"]
AUTO --> SESS3["Session 回合开始"]
SKILL --> SESS3
INJ --> SESS3
SYS --> SESS3
关键事实索引(可执行验证)
| 符号 | 位置 |
|---|---|
Session::new | crates/pi-coding/src/session.rs:723 |
Session::run | crates/pi-coding/src/session.rs:3685 |
execute_with_retries | crates/pi-coding/src/session.rs:3853 |
Application::new / Application::prompt | crates/pi-coding/src/application.rs:486 / :1402 |
Agent(pi-agent) | crates/pi-agent/src/agent.rs:229 |
run_agent_loop / run_loop | crates/pi-agent/src/loop_runtime.rs:45 / :103 |
create_all_tools | crates/pi-coding/src/tools.rs:537 |
OrchestrationRuntime / ChildSession | crates/pi-coding/src/orchestration/runtime.rs:730 / :57 |
WorkflowManager / WorkflowSupervisor | crates/pi-coding/src/workflow/manager.rs:196 / supervisor.rs:283 |
WorkflowWorktreeManager | crates/pi-coding/src/workflow_worktree/mod.rs:280 |
ExtensionSpec / QuickJsExtensionHost | crates/pi-coding/src/extensions.rs:564 / quickjs_host.rs:630 |
McpRegistry / mcp_tool | crates/pi-coding/src/mcp.rs:554 / :656 |
SessionRecorder / start_session | crates/pi-coding/src/session_store.rs:607 / :1177 |
AuthManager / TrustStore / SandboxConfig | auth.rs:921 / trust.rs:172 / sandbox.rs:48 |
SelectionPlan / ResourceManager / MemoryConfig | selector.rs:321 / resource_manager.rs:317 / memory.rs:588 |
ProcessManager | crates/pi-coding/src/process/manager.rs:23 |
RuntimeSettingsSnapshot | crates/pi-coding/src/settings.rs:517 |
覆盖说明
- 全量阅读:
lib.rs模块导出、session.rs(回合/重试/压缩主路径)、application.rs(状态机与事件)、tools.rs目录、pi-agent循环、session_store.rs持久化、orchestration/runtime.rs、workflow/*、extensions.rs、auth/trust/sandbox核心类型。 - 摘要阅读(仅结构):
settings.rs、mcp.rs、selector.rs、resource_manager.rs、compaction.rs、plugin.rs、packages.rs、markdown/*。 - 未逐行展开:各
tools/*内部实现、tests.rs、session_catalog/tests.rs。
Configuration profiles, TOML settings, and scoped auth
This page covers the configuration surfaces beyond plain settings.json:
config profiles, TOML settings files, environment expansion inside settings,
scoped credentials, and opt-in stateful Responses chaining.
Config profiles (--profile)
--profile is a global flag that relocates the user base directory —
agent dir, sessions, settings, auth, memory, skills — to
<base>/profiles/<name>. default keeps the default base; PI_PROFILE is
honored when the flag is absent (crates/pi-cli/src/args.rs:87-92).
rpi --profile work sessions
rpi sessions --profile work # global flag: accepted after subcommands
PI_PROFILE=work rpi # environment equivalent
- Name rules: 1–64 ASCII letters, digits,
-, or_(defaultis valid and selects the default profile); whitespace is trimmed first. Anything else (slashes, dots, spaces, non-ASCII) fails with an actionable error (validate_profile_nameinargs.rs:231-249). - Each profile has its own
settings.json,auth.json,models.json,trust.json,skills/,sessions/, and resource trees — profile isolation is by directory, so credentials, trust decisions, and memory never leak between profiles.
TOML settings
Each scope's settings file is chosen deterministically by name, never by
content sniffing: a settings.toml sitting next to the canonical
settings.json wins when present; otherwise the canonical JSON file is used.
A .toml extension selects TOML parsing/serialization; any other extension
(or none) selects JSON (crates/pi-coding/src/settings.rs:2277-2299).
- TOML files round-trip through the typed
Settingsstruct; unknown fields are retained in theextramaps, so mixed-format documents survive a settings write. - Settings writes target whichever file the loader would read (JSON→JSON, TOML→TOML).
- TOML has no null: JSON nulls inside retained
extramaps are dropped on serialization; datetimes become their string form.
# settings.toml next to settings.json wins
defaultProvider = "openai"
defaultModel = "openai/gpt-5"
approvalMode = "write"
[orchestration]
tasks = true
maxConcurrency = 8
isolation = "worktree"
[memory]
backend = "local"
[mcp_servers.my-tools]
transport = "stdio"
command = "npx"
Environment expansion in settings
$NAME and ${NAME} references in every string value of the settings
document are replaced with the matching process-environment value,
recursively through nested objects and arrays (including retained unknown
fields). Keys and non-string values are never expanded
(expand_env_in_value_with in settings.rs:2375-2439).
Important properties:
- Expansion applies only when projecting the effective runtime view
(
SettingsManager::settings, consumed by sessions). The persisted layers keep the raw references, so writing settings never persists expanded secrets (e.g.mcpServers[].envtokens). - Unset names are left verbatim (fail-open) and reported once per name through the settings diagnostic channel; there is no default-value syntax.
- Expansion is recursive over the runtime view only; the persistence view keeps literals.
{
"mcpServers": [
{ "name": "local", "command": "$MCP_CMD", "args": ["-y", "${MCP_PKG}"] }
],
"sandbox": { "allowedPaths": ["$PROJECT_ROOT", "${CACHE_DIR}/pi"] }
}
Scoped credentials (--scope)
rpi login and rpi logout accept a scope label:
rpi login anthropic --scope work
rpi login anthropic --scope personal
rpi logout xai --scope personal
The active scope is PI_AUTH_SCOPE (environment) or
settings.authScope (settings.rs:911-915). A scoped credential is selected
over the unscoped default when the active scope matches its label, which
lets one machine hold multiple credentials per provider (work vs. personal)
and pick between them per project or per shell.
Opt-in stateful Responses chaining
responsesStatefulChain (settings.rs; default false) opts into
stateful turn chaining for OpenAI Responses API models
(crates/pi-ai/src/providers/responses.rs:30-43): the provider keeps the
previous response id per session and sends it as previous_response_id on
the next turn, sending only the new input items instead of the full
conversation history.
- While enabled, every response is stored server-side (
store: true) — both the seed response and each chained response — because the provider must resolveprevious_response_idfrom stored responses. Stateless mode (store: false) never chains. - The chain is per session in process memory (a session resumed from disk
starts fresh — the stored id would not match a foreign transcript). It
breaks (falls back to full history) after consecutive failures or when the
session transcript is replaced wholesale (compaction resets it,
reset_responses_chain). - Ignored by every other provider.
Invariants
- Profile names are validated before any use;
defaultand empty select the default base, and profile isolation is by directory (no cross-profile credential/trust leakage). - Settings file format is name-determined, never sniffed; a
.tomlextension always parses as TOML. - Environment expansion never mutates the persisted document — expanded secrets cannot be written back.
- Scoped credentials only ever add a labeled choice; the unscoped default remains for providers/sessions without a matching scope.
Related documentation
settings-trust.md—settings.jsonfields and trustauthentication.md— credential precedenceenvironment-variables.md—PI_PROFILE,PI_AUTH_SCOPE, and all env vars
Environment variables
This page lists every environment variable that rpi reads from source. Values
are never documented here; set them in your shell or agent configuration. The
lists are grouped by function rather than priority.
Installer / updater
| Variable | Default | Purpose |
|---|---|---|
PI_HOME | Auto-detected from the running executable layout | Binary install root used by the self-updater |
PI_UPDATE_BASE_URL | https://api.github.com/repos/0x8f701/rpi/releases | Release API endpoint for update checks and self-update |
GITHUB_TOKEN | (none) | Authenticate GitHub API calls when the updater hits api.github.com |
PI_OFFLINE | (none) | Disables updater networking, llama.cpp router refresh, and other non-essential network calls when set to 1, true, or yes |
PI_SKIP_VERSION_CHECK | (none) | Disables only the nonfatal interactive startup version check when set |
Runtime configuration
| Variable | Default | Purpose |
|---|---|---|
PI_CODING_AGENT_DIR | Default agent directory under the user's home | Agent config directory (models.json, auth.json, resources, llama cache) |
SESSIONS_HOME | $HOME | Relocates the native session subtree to $SESSIONS_HOME/.pi/agent/sessions (with the same ~ expansion and absolute-path handling as the catalog) |
PI_PACKAGE_DIR | (none) | Overrides upstream-style package/docs path discovery |
PI_SHARE_VIEWER_URL | (none) | Viewer URL template for shared sessions; {url} substitutes the gist URL |
PI_CODING_AGENT_DIR is the only way to relocate the agent tree; the current
working directory is never trusted as a fallback. SESSIONS_HOME relocates
only the session subtree — agent configuration, skills, and resources remain
under PI_CODING_AGENT_DIR (or $HOME) when only SESSIONS_HOME is set.
Precedence for the session store root is PI_CODING_AGENT_DIR >
$SESSIONS_HOME/.pi/agent > $HOME/.pi/agent.
Local model configuration
| Variable | Purpose |
|---|---|
LLAMA_BASE_URL | llama.cpp router base URL (without /v1) |
LLAMA_API_KEY | Optional llama.cpp router bearer token |
HF_TOKEN | Hugging Face token for GGUF search/download |
HF_TOKEN_PATH | Path to a file containing a Hugging Face token |
HF_HOME | Hugging Face cache home; token is read from token inside it |
HF_ENDPOINT | Override the Hugging Face base URL |
XDG_CACHE_HOME | Cache home; token is read from huggingface/token inside it |
HOME / USERPROFILE | User home directory; used for cache and agent directory fallbacks |
Tool environment
The bash tool receives the parent process environment with these additions:
| Variable | Purpose |
|---|---|
PI_PROVIDER | Resolved provider id |
PI_MODEL | Resolved model id |
PI_REASONING_LEVEL | Current thinking level name |
PI_SESSION_ID | Current session id |
PI_SESSION_FILE | Path to the current session file |
Parent-process values for these keys are stripped before the additions are applied, so stale values never leak into a child.
Provider API keys
These variables are read by rpi to authenticate provider requests. Empty values
are treated as unset.
| Variable(s) | Provider |
|---|---|
ANTHROPIC_API_KEY, ANTHROPIC_OAUTH_TOKEN, ANTHROPIC_AUTH_TOKEN | Anthropic |
COPILOT_GITHUB_TOKEN | GitHub Copilot |
OPENAI_API_KEY | OpenAI, OpenAI Codex |
AZURE_OPENAI_API_KEY | Azure OpenAI Responses |
GEMINI_API_KEY | Google Gemini |
GOOGLE_CLOUD_API_KEY | Google Vertex (API-key auth) |
GOOGLE_CLOUD_ACCESS_TOKEN | Google Vertex (access-token auth) |
GROQ_API_KEY | Groq |
CEREBRAS_API_KEY | Cerebras |
XAI_API_KEY | xAI |
DEEPSEEK_API_KEY | DeepSeek |
OPENROUTER_API_KEY | OpenRouter |
NVIDIA_API_KEY | NVIDIA |
MISTRAL_API_KEY | Mistral |
MINIMAX_API_KEY, MINIMAX_CN_API_KEY | MiniMax |
MOONSHOT_API_KEY | Moonshot |
HF_TOKEN | Hugging Face |
FIREWORKS_API_KEY | Fireworks |
TOGETHER_API_KEY | Together |
OPENCODE_API_KEY | OpenCode |
KIMI_API_KEY | Kimi coding |
CLOUDFLARE_API_KEY | Cloudflare Workers AI / AI Gateway |
AWS_PROFILE | Amazon Bedrock |
AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY | Amazon Bedrock (SigV4) |
AWS_SESSION_TOKEN | Amazon Bedrock session token |
AWS_BEARER_TOKEN_BEDROCK | Amazon Bedrock bearer auth |
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI | Amazon Bedrock container credentials |
AWS_CONTAINER_CREDENTIALS_FULL_URI | Amazon Bedrock container credentials |
AWS_WEB_IDENTITY_TOKEN_FILE | Amazon Bedrock web-identity credentials |
ANT_LING_API_KEY | Ant Ling |
QWEN_TOKEN_PLAN_API_KEY, QWEN_TOKEN_PLAN_CN_API_KEY | Qwen token-plan |
ZAI_API_KEY, ZAI_CODING_CN_API_KEY | ZAI |
XIAOMI_API_KEY, XIAOMI_TOKEN_PLAN_CN_API_KEY, XIAOMI_TOKEN_PLAN_AMS_API_KEY, XIAOMI_TOKEN_PLAN_SGP_API_KEY | Xiaomi |
RADIUS_API_KEY | Radius |
AI_GATEWAY_API_KEY | Vercel AI Gateway |
Precedence notes:
- Anthropic:
ANTHROPIC_OAUTH_TOKENwins overANTHROPIC_API_KEY.ANTHROPIC_AUTH_TOKENis used as a bearer header and is never returned as an API key. - Amazon Bedrock: ambient AWS credentials (shared config files, instance metadata, etc.) are intentionally not read. Only the listed environment variables are considered.
- Google Vertex: ADC files and credential helpers are not read. Use
GOOGLE_CLOUD_API_KEY,GOOGLE_CLOUD_ACCESS_TOKEN, or anauthorizationheader.
Azure OpenAI configuration
In addition to AZURE_OPENAI_API_KEY:
| Variable | Purpose |
|---|---|
AZURE_OPENAI_API_VERSION | API version, default v1 |
AZURE_OPENAI_BASE_URL | Base URL override |
AZURE_OPENAI_RESOURCE_NAME | Resource name; builds https://{name}.openai.azure.com/openai/v1 |
AZURE_OPENAI_DEPLOYMENT_NAME_MAP | Comma-separated modelId=deploymentName mappings |
AWS Bedrock configuration
| Variable | Purpose |
|---|---|
AWS_REGION | Bedrock region |
AWS_DEFAULT_REGION | Fallback region |
AWS_BEDROCK_SKIP_AUTH | Set to 1 to skip auth (test/local only) |
AWS_BEDROCK_FORCE_CACHE | Set to 1 to force prompt caching |
See the provider API-keys table above for the credential variables.
Google Vertex configuration
| Variable | Purpose |
|---|---|
GOOGLE_CLOUD_PROJECT | Required project id (alias GCLOUD_PROJECT) |
GOOGLE_CLOUD_LOCATION | Required location |
GOOGLE_CLOUD_ACCESS_TOKEN | Short-lived access token |
GOOGLE_CLOUD_API_KEY | API key (sent as x-goog-api-key) |
Anthropic cache retention
| Variable | Purpose |
|---|---|
PI_CACHE_RETENTION | Set to long to request 24h prompt-cache retention for Anthropic and OpenAI Responses models |
Cloudflare placeholders
For Cloudflare Workers AI and AI Gateway models.json baseUrl values may
contain:
{CLOUDFLARE_ACCOUNT_ID} — from env CLOUDFLARE_ACCOUNT_ID
{CLOUDFLARE_GATEWAY_ID} — from env CLOUDFLARE_GATEWAY_ID
They are replaced at request time before the URL is used. If a placeholder is present and the matching variable is unset, the request fails.
OAuth tuning
| Variable | Purpose |
|---|---|
PI_OAUTH_CALLBACK_HOST | Override the OAuth redirect callback bind address (default 127.0.0.1) |
KIMI_CODE_OAUTH_HOST | Override the Kimi Code OAuth host |
KIMI_OAUTH_HOST | Fallback override for the Kimi OAuth host |
Extension host
| Variable | Purpose |
|---|---|
PI_EXTENSION_ID | Extension id passed to extensions (internal) |
PI_EXTENSION_ENTRY | Extension entry path (internal) |
PI_EXTENSION_PACKAGE_ID | Package id for the extension (internal) |
PI_EXTENSION_CAPABILITIES | JSON capabilities list (internal) |
PI_EXTENSION_UI_CAPABILITIES | JSON UI capabilities list (internal) |
PI_EXTENSION_MAX_FRAME_BYTES | Max extension IPC frame size (internal) |
PI_EXTENSION_PROTOCOL_VERSION | Extension protocol version (internal) |
Session import
| Variable | Purpose |
|---|---|
CODEX_HOME | Codex directory under the user's home; session import source |
CLAUDE_CONFIG_DIR | Claude config directory under the user's home; project import source |
Editor / clipboard / display
| Variable | Purpose |
|---|---|
VISUAL | Preferred external editor |
EDITOR | Fallback external editor |
DISPLAY | X11 display detection for clipboard |
WAYLAND_DISPLAY | Wayland display detection |
XDG_SESSION_TYPE | Session type detection (e.g. wayland) |
COLORFGBG | Terminal background-color hint for theme selection |
XDG_CONFIG_HOME | Used to locate git/ignore and other config fallbacks |
Template expansion
auth.json and models.json values may use these forms:
| Form | Meaning |
|---|---|
$VAR | Expand a single variable from the process environment |
${VAR} | Explicit boundary |
$$ | Literal $ |
$! | Literal ! |
For auth.json credentials, variables are also resolved from the optional
per-credential env map. models.json values use only the process
environment (and, for OAuth-stored credentials, the credential's own env).
Unset variables produce an error; there is no default-value syntax.
Command-valued values (!command) are rejected for API keys and headers.
Sandbox and overlayfs isolation
This page documents the two Linux confinement/isolation layers: the filesystem sandbox for process spawns (bash, process extensions, and orchestration subagents) and the overlayfs isolation backend used by workflow checkouts.
Filesystem sandbox
The sandbox (crates/pi-coding/src/sandbox.rs) is an opt-in Linux filesystem
sandbox for process spawns. It is confinement, not isolation: the command
still runs as the same user with the same privileges, but inside fresh Linux
namespaces (unshare) so that
- only the configured allowed paths are visible (bind-mounted read-write, or
read-only when
sandbox.readOnlyis set), - everything else on the host filesystem is denied (tmpfs root +
pivot_root), - the command gets a private, empty
HOMEandTMPDIRunder the sandbox root instead of the host home (codex/claude parity), - the network is off by default (fresh net namespace, loopback only),
/procreflects only the sandbox's own PID namespace.
System binaries and libraries under /usr, /bin, /sbin, /lib, /lib64
are bind-mounted read-only so commands can execute; user data under those
roots is still hidden. Denied paths are overlaid with an empty tmpfs mount so
they are invisible even when nested inside an allowed path.
Requirements: Linux and the unshare command (util-linux). Unprivileged
users additionally need user namespaces (kernel.unprivileged_userns_clone
or equivalent); the wrapper maps the caller to root inside the namespaces
only — there is no privilege escalation on the host. On non-Linux targets
every sandbox entry point returns an explicit "sandbox unsupported on this
platform" error.
Settings
{
"sandbox": {
"enabled": true,
"network": false,
"readOnly": false,
"allowedPaths": ["<workspace>", "<data-root>"],
"deniedPaths": ["<workspace>/secrets"]
}
}
| Key | Default | Meaning |
|---|---|---|
enabled | (off) | Master switch for sandboxed spawns. |
network | false | Share the host network; default is a fresh net namespace with loopback only. |
readOnly | false | Bind allowed paths read-only. |
allowedPaths | [cwd, agent_dir] | Paths visible inside the sandbox. Relative paths resolve from cwd. |
deniedPaths | (none) | Paths overlaid with an empty tmpfs (invisible even inside allowed paths). |
deniedPaths wins over allowedPaths (denied overlays come last in the
mount order). The working directory must live inside an allowed path,
otherwise its inode would be detached after pivot_root.
What the sandbox covers
- bash tool — when
settings.sandbox.enabledis set,bashcommands run throughrun_in_sandbox(streaming merged output, timeout, abort; the whole process group is killed on either so namespaced descendants cannot linger). - Process extensions — long-lived protocol children can be spawned with
spawn_piped(same fail-closed validation and allowed/denied path semantics). - Orchestration subagents — with
settings.orchestration.sandboxed = true, every process a child spawns (its bash tool) is confined to the workspace, the agent directory, andsandbox.allowedPaths(settings.rs:318-325).
The environment inside the sandbox is fully controlled by the caller (env is
applied after env_clear); the sandbox path lists are appended last so a
host environment can never spoof them.
Invariants
- Every sandboxed spawn validates its configuration fail-closed before the
unsharewrapper is constructed. - Setup is PID 1 of the fresh namespaces and fails closed: every step exits with a distinct code and an actionable message on stderr.
- The sandbox never escalates privileges: the caller is mapped to root only inside the user namespace, and the host uid is unchanged.
Overlayfs isolation
crates/pi-coding/src/isolate.rs provides an overlayfs isolation backend
(OMP pi-iso parity): a writable "merged" view over a read-only "lower" tree
without a deep copy. OverlayfsIsolation::start materializes merged as the
union of lower (read-only) and upper (private, writable copy-on-write);
stop detaches the mount (umount -l) and cleans the upper/work
directories.
Backend fallback chain, tried in order until one succeeds
(OverlayBackend):
- Kernel overlay —
mount -t overlay -o lowerdir=,upperdir=,workdir=. Requires mount privilege: real root, or a user namespace that owns the process mount namespace. - fuse-overlayfs — PATH lookup; runs as a FUSE daemon, works unprivileged and is visible to every process.
- rcopy — recursive copy of
lowerintomerged; no mount needed.
Backends never degrade silently: the first candidate that succeeds wins and every later candidate is skipped; the copy fallback guarantees callers always get a usable writable view. The chosen backend is serialized by the workflow isolation manager so a restored workflow re-establishes exactly the backend it had before a restart (rcopy must never be re-run over an existing upper, which would clobber the workflow's changes). Timeouts bound hung mounts: 30 s per mount command, 5 s fuse readiness.
Use in workflows
settings.orchestration.isolation = "overlayfs" selects the overlayfs
workflow backend (workflow_worktree/overlay.rs): each workflow gets a
private overlay whose read-only lower layer is the live source tree and whose
writable upper layer is a private per-workflow dir. Integration commits the
upper state as a single commit on the source branch — no merge history, no
merge-conflict detection; overlayfs integration is last-writer-wins by
design, and Conflicted is never produced by this backend. See
workflows.md for the isolation options
(worktree default, overlayfs, none).
Related documentation
security.md— path containment and process ownership hardeningworkflows.md— workflow isolation backendssettings-trust.md—sandboxandorchestration.sandboxedsettings
Hooks and trust hooks
Host hooks are external commands that observe (and, for two events, gate)
session activity. They are configured under settings.hooks and run by the
HostHooks runtime (crates/pi-coding/src/hooks.rs). Extensions additionally
receive lifecycle events — including a trust_decision event that can
recommend approving a tentative project trust decision.
Host hooks
{
"hooks": [
{ "event": "pre_tool_call", "matcher": "read", "command": ["/opt/hooks/guard", "--strict"], "timeoutMs": 1200, "failClosed": true },
{ "event": "session_end", "command": ["/opt/hooks/bye"] }
]
}
HookConfig fields (settings.rs:731-757):
| Field | Meaning |
|---|---|
event | pre_tool_call, post_tool_call, session_start, session_end, turn_start, turn_end, or pre_trust_decision. Unknown events are rejected at deserialize time. |
matcher | Exact or substring match on the event subject (tool name, message role, or canonical project path). Absent matchers fire for every subject. |
command | Command argv run without a shell (command[0] is the executable, the rest are argv); must be non-empty. |
timeoutMs | Per-hook timeout; defaults to 5000, capped at 60000. A timed-out hook's process group is killed. |
enabled | Set false to skip the entry without removing it. |
failClosed | Only meaningful for pre_tool_call and pre_trust_decision: when the hook errors or times out, fail closed (block the tool / deny the trust decision) instead of the default fail-open (allow). |
Hooks are external commands run without a shell. The event payload is
written as JSON on stdin; stdout (capped) is parsed as JSON for
pre_tool_call and pre_trust_decision decisions. The envelope carries
event, cwd, sessionId, timestamp, and subject — no secrets.
Blocking semantics
Only pre_tool_call and pre_trust_decision can block: a
{"decision":"block","reason":"..."} response prevents the tool from running
or denies the tentative trust decision. Every other event is advisory
(logged). Hook failures (spawn error, non-zero exit, timeout, malformed JSON)
fail open for the two blocking events unless the entry sets
failClosed: true, in which case the tool is blocked (or the trust decision
denied) instead.
pre_tool_callpayloads include the toolnameand a text rendering ofarguments.pre_trust_decisionpayloads carry the canonical projectpath, the tentativedecision(trusted/untrusted/ask), andisNew— the same spelling the extensiontrust_decisionevent uses (hooks.rs:333-351).
Host approval runs before the existing host hooks and extension reducers; a denial skips later hooks.
Extension trust hook
Extensions registered for the event_hooks capability receive a
trust_decision event ({path, decision, isNew}) and may recommend approval
with {approve: true} (ExtensionTrustDecisionReduction in
extensions.rs:2187-2196). The contract is fail-open by design:
- The event never carries a deny surface — an extension can only recommend approval.
- The recommendation can only upgrade an undecided (
ask) tentative decision to trusted; the host applies it viacrate::trust::apply_trust_hook_outcomes, so a stored denial is never weakened.
Project-trust extensions also receive a project_trust event
(ExtensionProjectTrustReduction), and untrusted project extension manifests
are refused at load/execute time (extensions.rs:413-418, 623-625).
Where hooks are applied
- Session lifecycle:
session_start/session_endfire around the session. - Turn lifecycle:
turn_start/turn_endfire around each agent turn. - Tool lifecycle:
pre_tool_call/post_tool_callobserve (and gate) each tool invocation; the post hook observes the final result after extension reduction, and its output never mutates the tool result (except for doom-loop recovery, which replaces the result with an actionable stop message — seesession-recovery.md). - Trust:
pre_trust_decisionfires for a tentative trust decision before the stored decision is consulted/recorded.
Invariants
- Hooks never run through a shell; argv is passed directly.
- Payloads are bounded (stdout capped), redacted (no secrets), and carry a millisecond timestamp.
- Blocking is opt-in per event: only
pre_tool_callandpre_trust_decisioncan block, and only with an explicitfailClosedentry do errors turn into blocks. - Disabled entries never fire; empty commands are skipped with a diagnostic.
- Extension trust recommendations can only upgrade an
askto trusted — never weaken a stored decision.
Related documentation
security.md— approval modes, trust boundaryextensions.md— extension lifecycle events and capabilitiessettings-trust.md—hookssettings and trust resolution
Extensions and process protocol
rpi supports long-lived process extensions over the strict versioned LF JSONL
protocol. A manifest can launch an existing executable, or run a JavaScript
entry through the in-process QuickJS runtime (.js/.mjs only). QuickJS
is embedded in rpi; no external JavaScript runtime is required. The in-process
runtime speaks the same extension protocol over in-memory channels, so
executable and QuickJS extensions share the same host machinery (handshake,
load, invocation, cancellation, shutdown).
Manifest (pi-extension.json)
Every extension needs an explicit manifest. Existing executable manifests stay valid unchanged:
{
"schemaVersion": 1,
"id": "pi-weather",
"executable": "./main",
"arguments": [],
"capabilities": ["tools"],
"uiCapabilities": []
}
The equivalent explicit process form adds "runtime": "process". A QuickJS
extension instead uses the discriminated runtime/entry form:
{
"schemaVersion": 1,
"id": "pi-weather",
"runtime": "quickjs",
"entry": "./index.mjs",
"capabilities": ["commands", "tools", "event_hooks", "session_actions", "ui"],
"uiCapabilities": ["notify", "status"]
}
Fields:
schemaVersion— must be1.id— alphanumeric plus_,-, or..runtime— optionalprocessfor executables, or requiredquickjsfor script entries. Omitting it preserves the original executable manifest shape.executable— process-only path relative to the manifest directory.arguments— optional process-only argv; QuickJS manifests reject it.entry— QuickJS-only contained relative.jsor.mjsfile. TypeScript (.ts) is not supported by the in-process runtime.capabilities— one or more ofcommands,tools,event_hooks,message_renderers,provider_metadata,session_actions,ui,overlays. QuickJS entries rejectmessage_renderersandprovider_metadatabecause their factories cannot cross the extension protocol.uiCapabilities— required whenuiis listed; one or more ofselect,confirm,input,editor,notify,status,widget,title,set_editor_text,overlay.
The manifest is validated before launch. Absolute paths, parent traversal,
symlink escapes, missing files, mixed executable/entry fields, unsupported
script extensions, and untrusted project manifests fail closed.
QuickJS extension API
The entry must default-export an OMP-style factory:
export default function (pi) {
pi.registerCommand("hello", {
description: "Say hello",
handler: async (_args, ctx) => {
ctx.ui.notify("Hello from QuickJS", "info");
},
});
}
The in-process runtime supports the full protocol-backed surface:
- the 33 lifecycle hook names documented by the OMP ExtensionAPI
pi.registerCommand,pi.registerTool, and the in-processpi.eventsbus- invocation context metadata such as mode, cwd, trust, cancellation, and model/thinking state when supplied by the Rust host
- synchronous snapshot getters
getSessionName,getThinkingLevel,getActiveTools,getAllTools, andgetCommands - with
session_actions,sendMessage,sendUserMessage,appendEntry,setSessionName,setLabel,setActiveTools,setModel, andsetThinkingLevel, plus contextabort,shutdown,compact,reload, andwaitForIdle - UI methods
select,confirm,input,editor,notify,setStatus,setWidgetwith string arrays,setTitle,setEditorText, plus the query methodsgetEditorText,getAllThemes,getTheme,setTheme, andgetToolsExpanded
Handlers receive AbortSignal cancellation for tools. Tool updates,
cancellation, shutdown, UI requests, and hook return values translate onto the
same protocol used by executable extensions.
Sandbox guarantees:
- Each QuickJS extension runs in its own dedicated OS thread with its own runtime/context, a 64 MiB memory limit, and a host-timeout interrupt handler that bounds runaway bytecode.
- The runtime exposes no
process,require,fetch,console, or other Node/Bun-style host globals; extension code has no process, filesystem, or network access. Any host capability must go through thepiAPI. - Outbound frames (responses, registrations, updates, actions) are bounded by
max_frame_byteslike every process extension.
See examples/quickjs_extension.mjs for
commands, a tool, an event hook, session actions, and UI actions.
Packaging
Extensions are distributed through rpi packages (local or git). Place the
manifest and executable in a package under extensions/ and reference the
package in settings.json:
{
"packages": [
{ "source": "git:github.com/owner/pi-weather", "extensions": ["pi-weather"] }
]
}
Project extensions are only loaded when the project is trusted.
Protocol overview
The host spawns the extension with environment variables:
PI_EXTENSION_PROTOCOL_VERSION=1PI_EXTENSION_ID=<id>PI_EXTENSION_PACKAGE_ID=<package-id>PI_EXTENSION_ENTRY=<canonical-entry>(QuickJS only)PI_EXTENSION_CAPABILITIES=<JSON-array>(QuickJS only)PI_EXTENSION_UI_CAPABILITIES=<JSON-array>(QuickJS only)PI_EXTENSION_MAX_FRAME_BYTES=<bytes>(QuickJS only)
The extension reads JSON frames from stdin and writes JSON frames to stdout.
Host frames (host → extension)
{
"type": "hello",
"protocolVersion": 1,
"instance": { "extensionId": "pi-weather", "generation": 1 },
"cwd": "<workspace>",
"mode": "tui",
"projectTrusted": true
}
{
"type": "request",
"id": "req-1",
"generation": 1,
"request": { "kind": "initialize" }
}
{
"type": "request",
"id": "req-2",
"generation": 1,
"request": {
"kind": "invoke",
"invocation": {
"kind": "tool",
"name": "weather",
"callId": "call-1",
"arguments": { "city": "London" }
}
}
}
{
"type": "shutdown",
"reason": "session ended"
}
Extension frames (extension → host)
{
"type": "hello",
"protocolVersion": 1,
"manifest": {
"id": "pi-weather",
"name": "Weather",
"version": "0.1.0",
"capabilities": ["tools"],
"uiCapabilities": []
}
}
{
"type": "register",
"registration": {
"kind": "tool",
"tool": {
"name": "weather",
"label": "Weather",
"description": "Get current weather",
"parameters": {
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"]
}
}
}
}
{
"type": "response",
"id": "req-2",
"result": {
"status": "success",
"value": {
"content": [
{ "type": "text", "text": "15 °C, cloudy" }
]
}
}
}
Tool results use the same shape as AgentToolResult.
Capabilities
| Capability | What the extension can do |
|---|---|
tools | Register tools the agent can call |
commands | Register slash commands |
event_hooks | Receive agent lifecycle events |
message_renderers | Render custom message types |
provider_metadata | Provide extra model metadata |
session_actions | Read invocation snapshots and request session/model/tool actions |
ui | Show select/confirm/input/editor/status widgets |
overlays | Register pi.registerOverlay overlays (rows + optional interactive input) |
Custom tool fallback
If you only need a one-off custom tool and don't want to write a full
extension, library users can register additional pi_agent::AgentTools when
constructing a pi_coding::Session. See
examples/src/bin/custom_tool.rs.
Timeout defaults
The host uses these timeouts unless overridden by the extension launcher:
- Handshake: 5s
- Load: 10s
- Initialize: 15s
- Invocation: 60s
- Hook: 10s
- Shutdown: 2s
Security
- The child process starts with
env_clear()and receives only the explicitly declared manifest environment entries plus the requiredPI_EXTENSION_*protocol variables. - Process executables must be regular files and executable on Unix.
- Process executable and QuickJS entry paths are resolved relative to the manifest and must remain inside the package directory.
- QuickJS entries run only from explicit trusted manifests inside the
in-process sandbox: a dedicated thread, a 64 MiB memory limit, an interrupt
deadline, and no
process/require/fetch/consoleglobals. - On Unix the child runs in its own process group (
process_group(0)); on termination the host sendsSIGKILLto the entire group so descendant processes are cleaned up, then falls back to killing the immediate child. - Every JSONL frame is bounded by
max_frame_bytes(default 1 MiB); frames that exceed the limit, blank lines, and CRLF terminators are rejected. - QuickJS entries reject the
message_renderersandprovider_metadatacapabilities because their factories cannot cross the protocol boundary. - Extension-initiated runtime actions, including
reload, are unavailable during the registration-only load phase and can be rejected when thesession_actionscapability is missing or the host cannot process the action. - Project extensions are only launched when the project is trusted.
kill_on_dropand runtime invalidation terminate process children; QuickJS instances are joined and torn down on shutdown.
Skills
Skills are Markdown files with YAML frontmatter that provide specialized instructions for specific tasks. They are listed in the system prompt and loaded on demand by the model through the read tool using skill:// URIs.
For how skills differ from prompt templates, agents, and context files, see prompt-templates.md.
In this document <agent-dir> means the resolved agent configuration directory (PI_CODING_AGENT_DIR or the platform default), and <workspace> means the current working directory passed to the session.
File format and frontmatter
A skill is usually a directory containing SKILL.md:
<agent-dir>/skills/rust-review/
└── SKILL.md
---
name: rust-review
description: Review Rust code for idioms and safety.
globs:
- "*.rs"
- "src/**/*.rs"
alwaysApply: false
hidden: false
disable-model-invocation: false
---
# Rust review
Check for unnecessary clones, panic paths, and async cancellation safety.
Fields:
| Field | Required | Purpose |
|---|---|---|
name | No | Skill identifier. Defaults to the directory name. Must be lowercase a-z, 0-9, and hyphens; ≤ 64 UTF-16 code units; must not start/end with a hyphen or contain --. |
description | Yes | Used in the system prompt and selector. Must be non-empty and ≤ 1,024 UTF-16 code units. A skill with no description is dropped. |
globs | No | Path patterns that trigger the selector when the user's request mentions matching files. YAML list or comma-separated string. |
alwaysApply / always-apply | No | When the YAML boolean true, the skill is autoloaded into the context. |
hide / hidden | No | When the YAML boolean true, the skill is excluded from the <available_skills> block and selector. |
disable-model-invocation | No | When the YAML boolean true, the skill is hidden from the system prompt. Quoted "true" is treated as the string true and does not hide the skill. |
Only the plain YAML boolean true counts for the boolean fields; quoted values are strings.
Discovery
Skills are discovered in these locations, in precedence order:
<agent-dir>/skills/— global user skills, always scanned.<workspace>/.pi/skills/— project-local skills, scanned only when the project is trusted.- Settings
skillsand packageskillsresources. - Explicit
--skill PATHarguments.
Discovery rules from load_skills_from_dir in crates/pi-coding/src/resources.rs:
- A directory containing
SKILL.mdis a skill root; its children are not scanned further. - At a root without
SKILL.md, direct.mdchildren are loaded and subdirectories are scanned recursively forSKILL.md. node_modules, hidden directories, and entries matched by.gitignore,.ignore, or.fdignoreare skipped.- Symlinks are followed, but real paths are de-duplicated to avoid loops.
Global skills are marked source: User and trusted. Project skills are source: Project and trusted only when the project is trusted. Explicit skills are source: Explicit and trusted. Package skills use PackageGlobal or PackageProject source and follow the package trust rules from packages.md.
During default directory scanning, the first skill with a given name wins: global skills are loaded before project skills, so a global skill shadows a project skill of the same name. After configured, package, and explicit skills are merged in, any remaining duplicate name is a hard error.
Trust gates and CLI flags
Project-local .pi/skills/ are loaded only when the resolved project trust decision allows project resources. Trust is resolved by crates/pi-coding/src/trust.rs:
- One-run
--approve/--no-approve. - Persisted decision in
<agent-dir>/trust.json. defaultProjectTrustin settings (ask,always,never).- In headless modes, an unset decision is treated as untrusted.
If a project has no .pi directory, it is trusted by default.
rpi --skill ./my-skills/rust-review.md --print "review src/main.rs"
rpi --no-skills --print "hello" # disables discovered/configured skills, explicit paths still load
rpi --no-context-files --print "hello" # also skip AGENTS.md / CLAUDE.md
Safe path containment
Explicit skill paths are canonicalized and validated before loading:
- Relative paths are resolved against
--cwdand must not escape it through symlinks. - Paths inside a project
.pi/directory require project trust. - Missing paths are a hard error.
When a skill is loaded, base_dir is set to the directory containing SKILL.md. The skill resolver in crates/pi-coding/src/selector.rs (resolve_skill_uri) restricts skill://<name>/<relative-path> reads to that base directory:
.., absolute paths,?,#, and backslashes are rejected.- The resolved path is canonicalized and must be inside
base_dir. - The declared
file_pathitself must also be insidebase_dir. - Only trusted skills can be resolved.
Selection and resolver
The deterministic selector in crates/pi-coding/src/selector.rs ranks skills for each request. Hidden skills and skills with disable-model-invocation: true are excluded from ranking. The score is built from:
| Signal | Points | Notes |
|---|---|---|
| Exact name match | 1,600 | Request tokens equal skill name phrase. |
| Name phrase contained in request | 1,000 | e.g. request contains "rust review". |
| Name token overlap | 220 each | Stopwords are ignored. |
| Description token overlap | 60 each | Stopwords are ignored. |
| Description phrase (≥ 2 tokens) | 90 per token | Longest shared phrase. |
alwaysApply: true | +2,000 | Also makes the skill autoload. |
Matching globs path | +800 per matched pattern | Patterns are globset globs matched against request paths. |
| Source precedence | +precedence | User 50, Project 40, PackageGlobal 30, PackageProject 20, Explicit 10. |
Only skills scoring at least min_score (default 60) are returned, up to max_results (default 5).
An optional classifier can re-rank the deterministic candidates when selector.classifier.enabled: true in settings. The classifier is never allowed to invent new skill names; it only reorders candidates already produced by the deterministic pass.
Selection recommendations are rendered into the system prompt as <selection_recommendations>. They augment model judgment; they do not replace it. The system prompt explicitly tells the model:
Use the
readtool withskill://<name>to load a skill when the task matches its description. When a skill file references a relative path, read it asskill://<name>/<relative-path>; the resolver confines access to that skill's base directory.
Autoload skills
Some skills are loaded automatically:
- Skills with
alwaysApply: true(if trusted and not disabled). - High-scoring skills that also have
alwaysApply: true. - Skills named in a selected agent's
autoloadSkillsfrontmatter.
Autoloaded skill bodies are read from disk and inserted into the prompt context.
Usage in the prompt
The <available_skills> block is appended to the system prompt only when the read tool is selected. It contains every visible, trusted skill:
<available_skills>
<skill>
<name>rust-review</name>
<description>Review Rust code for idioms and safety.</description>
<location>skill://rust-review</location>
</skill>
</available_skills>
Untrusted, hidden, and disabled skills are excluded. The model decides when to load a skill; deterministic recommendations are advisory.
Interactive skill commands
When enable_skill_commands is true (the default), the REPL and TUI register a /skill:<name> command for each visible skill. Typing:
/skill:rust-review review src/lib.rs
expands the skill body into the user prompt. This is a convenience for manually loading a skill; the model can still load any skill via read on its own.
Agents
Agents are defined in <agent-dir>/agents/*.md and, when trusted, <workspace>/.pi/agents/*.md. They look similar to skills but are used for a different purpose:
| Skill | Agent | |
|---|---|---|
| Location | skills/ | agents/ |
| Frontmatter | name, description, globs, alwaysApply, disable-model-invocation | name, description, tools, autoloadSkills, model, thinkingLevel |
| Use | Loaded by the current session via skill:// | Spawned as a subagent by the orchestration runtime |
| System prompt | Listed in <available_skills> | Becomes the child agent's full system prompt |
The bundled task agent is always available. See crates/pi-coding/src/orchestration/definitions.rs for the agent definition format.
SDK behavior
The AgentSessionBuilder in crates/pi-coding/src/sdk.rs defaults to ResourceDiscovery::Disabled, so programmatic sessions do not auto-discover skills. To opt in, call discover_resources(ResourceManagerOptions); project-local skills still require trust.
#![allow(unused)] fn main() { use pi_ai::Model; use pi_coding::{AgentSessionBuilder, AgentSession, ResourceManagerOptions}; async fn discovery_session(model: Model) -> Result<AgentSession, Box<dyn std::error::Error>> { let session = AgentSessionBuilder::new(model, "<workspace>") .discover_resources(ResourceManagerOptions::new("<workspace>", "<agent-dir>"))? .build() .await?; Ok(session) } }
This makes skill discovery explicit and fail-closed: no project skills are loaded unless the caller opts in and the project is trusted.
Example: create and use a skill
Create a project skill:
mkdir -p .pi/skills/rust-review
cat > .pi/skills/rust-review/SKILL.md <<'EOF'
---
name: rust-review
description: Review Rust code for safety, idioms, and clarity.
globs:
- "*.rs"
---
# Rust review
Check for unnecessary clones, panic paths, and async cancellation safety.
EOF
Run with project trust:
rpi --approve --print "Review src/lib.rs using the rust-review skill"
Or in an interactive session type:
/skill:rust-review review src/lib.rs
The resolver ensures that any relative path inside the skill (for example a referenced checklist file) is read from .pi/skills/rust-review/, never from outside the project.
Packages
rpi supports local-directory and git-based packages. The npm backend is
deliberately deferred and cannot be installed.
Source: crates/pi-coding/src/packages.rs:1-5 and
crates/pi-coding/src/packages.rs:842-845.
Package sources
Valid package sources:
- A plain filesystem path, relative to the current working directory or
absolute. The stored identity becomes
local:<canonical path>.- Example:
./my-pi-tools
- Example:
- A git URL using
https,http,ssh, orgitas the scheme.- Examples:
https://github.com/owner/repo,git@github.com:owner/repo,ssh://git@github.com/owner/repo,git:https://github.com/owner/repo
- Examples:
- A
git:shorthand using a host with a domain orlocalhost:git:github.com/owner/repo. The shorthand must include host, owner, and repository. - A git ref pinned with
@ref:git:github.com/owner/repo@v1.2. npm:package[@version]— not implemented; rejected with a clear error.
Source: crates/pi-coding/src/packages.rs:810-903.
Install, remove, list, config, update
# Install globally (stored in agent settings)
rpi install git:github.com/owner/pi-my-tools
# Install into project settings (requires project trust)
rpi install ./my-pi-tools --local
# Remove a configured package
rpi remove git:github.com/owner/pi-my-tools
# List configured packages and their install status
rpi list
# Toggle enabled package resources for global or project scope
rpi config
rpi config -l # project scope; requires project trust
# Update rpi itself (default when no package flags are given)
rpi update
rpi update --self
# Update every configured package (also accepts --all)
rpi update --extensions
rpi update --all
# Update one configured package by source identity
rpi update git:github.com/owner/pi-my-tools
rpi update github.com/owner/pi-my-tools
# Update packages and rpi itself
rpi update --self --extensions
# Reinstall self-update even when version and checksum match
rpi update --self --force
Source: crates/pi-cli/src/args.rs:244-285 and
crates/pi-cli/src/lib.rs:62-120.
rpi list shows the scope (global or project), source, status
(installed, missing, or unsupported), whether a git source is pinned to
a ref, and the on-disk path for installed packages.
Source: crates/pi-cli/src/package_commands.rs:34-66 and
crates/pi-coding/src/packages.rs:777-800.
rpi config discovers resources declared by each package's
package.json#pi manifest and lets you enable or disable individual extensions,
skills, prompts, and themes. In a headless environment (stdout is not a TTY) it
prints deterministic JSON and never blocks. Project scope is refused when the
project is not trusted.
Source: crates/pi-cli/src/package_config.rs:1-16 and
crates/pi-cli/src/package_config.rs:542-550.
rpi update --extensions reconciles every configured git/local package. Pinned
git refs are fetched and reset to the configured ref; unpinned sources follow the
remote default branch. npm: entries are skipped. rpi update PACKAGE reconciles
one configured package by identity; matching npm: sources produce the deferred
error.
Source: crates/pi-coding/src/packages.rs:495-545.
Git packages are cloned into a content-addressed directory under the scope root
(<agent-dir>/git/... for global packages, <workspace>/.pi/git/... for
project packages). Local packages are referenced by path. Atomic checkout
swapping, serialized operations, and atomic settings/state writes prevent
partial installs.
Source: crates/pi-coding/src/packages.rs:57-77,
crates/pi-coding/src/packages.rs:408-530,
crates/pi-coding/src/packages.rs:711-770, and
crates/pi-coding/src/packages.rs:1867-1954.
settings.json packages field
The packages array holds either a source string or a filtered object:
{
"packages": [
"git:github.com/owner/pi-my-tools",
{
"source": "git:github.com/owner/pi-skills",
"autoload": true,
"extensions": ["pi-my-ext"],
"skills": ["rust-review"],
"prompts": ["custom-prompt"],
"themes": ["solarized"]
}
]
}
Source: crates/pi-coding/src/settings.rs:34-45.
autoload: trueenables all resources from the package by default.extensions,skills,prompts,themesare resource-filter lists. They may contain exact resource names, glob patterns,!exclusions, and+/-force-include/exclude tokens used byrpi config.- Project entries win over global entries with the same package identity.
Source: crates/pi-coding/src/packages.rs:524-550 and
crates/pi-cli/src/package_config.rs:1-16.
Package manifest (package.json#pi)
A package root may contain a package.json whose pi field declares its
resources:
{
"name": "pi-my-tools",
"pi": {
"schemaVersion": 1,
"extensions": ["extensions/*"],
"skills": ["skills/**/*.md"],
"prompts": ["prompts/**/*.md"],
"themes": ["themes/*.json"]
}
}
Source: crates/pi-coding/src/packages.rs:139-165.
Without a manifest, the package manager discovers resources under standard subdirectories:
extensions/— files namedpi-extension.json.skills/—SKILL.mdor.mdfiles.prompts/—.mdfiles.themes/—.jsonfiles.
Hidden entries, symlinks, and node_modules are ignored during discovery.
Source: crates/pi-coding/src/packages.rs:1303-1453.
Trust and scope
- Global packages are always loaded.
- Project packages are loaded only when the project is trusted.
- Project resources win over global resources with the same name.
Source: crates/pi-coding/src/packages.rs:524-550.
Plugin marketplace (rpi plugin)
Separate from rpi install packages, the plugin marketplace
(crates/pi-coding/src/plugin.rs) installs standalone extension packages —
a directory whose root carries a validated pi-extension.json manifest (the
same schema the extension runtime loads). Installed plugins land in
<agent_dir>/extensions/<name>/ and are picked up by the resource scan, so
an installed plugin is loadable through the normal extension pipeline.
rpi plugin list # name, version, runtime, trust state
rpi plugin list --updates # check the marketplace index for updates
rpi plugin install <SOURCE> # install and record explicit Trusted consent
rpi plugin remove <NAME> # remove and clear the stored trust decision
rpi plugin update <NAME> # re-stage from the index entry, atomic swap
install/update accept:
- a local directory,
- a local or remote
.tgz/.tar.gz/.tararchive, - an
owner/repoGitHub reference, - an
npm:<name>[@<version>]reference — resolved through the npm registry to the package'sdist.tarball, with the tarball's content authenticated againstdist.integrity(sha512-<base64>; any other algorithm, a missing integrity field, or a digest mismatch fails closed), - a git URL (
git+https://host/owner/repo,git+ssh://git@host/owner/repo.git,https://host/owner/repo.git,ssh://git@host/owner/repo.git,git@host:owner/repo.git).
Bounded everywhere: 256 MiB package cap, 1 MiB manifest cap, 2 MiB npm metadata cap, 60 s HTTP fetch timeout, 120 s git-clone timeout, and URL-credential redaction in every error.
Trust model: install records the user's explicit consent as a Trusted
decision for the plugin path; remove clears it. A plugin whose canonical
path resolves to anything other than Trusted is listed but not loaded
by marketplace_extension_resources, so untrusted plugins never execute.
update preserves the stored trust decision.
The marketplace index is a JSON list of {name, repo, version, description?}
entries fetched from the pluginMarketplace setting (a URL or a local index
file) or the embedded default. npm: sources are supported here even though
the rpi install package manager defers them.
What is not supported
npm:sources (for therpi installpackage manager; the plugin marketplace accepts them viarpi plugin install).package.jsonlifecycle hooks or registry metadata.- A
local:scheme prefix. Local packages are written as plain filesystem paths.
Memory
rpi has a memory subsystem with three backends, selected by
settings.memory.backend (crates/pi-coding/src/memory.rs):
| Backend | Tools | Behavior |
|---|---|---|
local (default) | memory | Built-in JSONL note store: learn, recall, list, forget entries that survive across sessions. Ordinary sessions use a repository namespace; personas use their durable persona-local store. |
hindsight | recall, retain, reflect | Calls an explicitly configured Hindsight HTTP API. |
off | (none) | Every memory tool is hidden. |
A missing config falls back to local. Backend selection is inherited by future
ordinary and persona child sessions; local persona memory remains rooted at the
persona directory.
Settings
{
"memory": {
"backend": "hindsight",
"hindsightApiUrl": "https://memory.example.test",
"hindsightApiToken": "$HINDSIGHT_API_TOKEN",
"hindsightBankId": "rpi",
"hindsightScoping": "per-project-tagged",
"hindsightInjection": false
}
}
| Key | Default | Meaning |
|---|---|---|
memory.backend | local | local, hindsight, or off. |
memory.hindsightApiUrl | unset | Explicit Hindsight HTTP API base URL. Required for hindsight. |
memory.hindsightApiToken | unset | Optional bearer token. Secret settings views always redact it. |
memory.hindsightAllowInsecure | false | Explicitly permit plaintext HTTP endpoints and redirect hops for a trusted self-hosted service. Secure mode is HTTPS-only for the initial URL and every redirect. |
memory.hindsightBankId | rpi | Base bank id. |
memory.hindsightBankIdPrefix | unset | Optional bank-id prefix. |
memory.hindsightScoping | per-project-tagged | global, per-project, or per-project-tagged. |
memory.hindsightBankMission | unset | Optional reflect mission applied while ensuring the bank exists. |
memory.hindsightRetainMission | unset | Optional retain mission applied while ensuring the bank exists. |
memory.hindsightInjection | false | Inject bounded recall for the latest ask as hidden context. |
memory.hindsightRecallBudget | mid | Hindsight recall/reflect budget: low, mid, or high. |
memory.hindsightRecallMaxTokens | 1024 | Maximum tokens requested from recall. |
memory.hindsightRecallTypes | world, experience | Memory types included in recall. |
memory.hindsightRequestTimeoutMs | 30000 | Default HTTP request deadline. |
memory.hindsightRecallTimeoutMs | 30000 | Recall deadline. |
memory.hindsightRetainTimeoutMs | 60000 | Retain deadline. |
memory.hindsightReflectTimeoutMs | 120000 | Reflect deadline. |
Local backend
Ordinary sessions store entries at:
<agent-dir>/memory/<repo-digest>/entries.jsonl
Persona sessions store entries at:
<persona-root>/memory/entries.jsonl
The public local behavior remains unchanged:
- Entries are capped at 1 MiB.
- At most 100 entries are retained per namespace;
learnevicts the oldest. recalldefaults to 10 entries and clamps at 50.- At most 20 tags are stored per entry.
- Output is bounded and secrets are redacted.
- Switching away from local never migrates, mirrors, deletes, or rewrites JSONL.
Hindsight backend
Hindsight uses the source-verified HTTP wire contract:
POST /v1/default/banks/{bank}/memories/recallPOST /v1/default/banks/{bank}/memoriesPOST /v1/default/banks/{bank}/reflectPUT /v1/default/banks/{bank}before retain/reflect to idempotently create or update the bank and apply optional missions. Any non-success, timeout, or authentication failure stops retain/reflect instead of being ignored.
Recall and reflect requests include the selected budget, recall types, token limit, and namespace tags; retain requests include the item content and tags. The client methods themselves reject empty required query/content fields and bound every query, content, and optional context to 1 MiB, including direct turn-start injection calls that bypass tool argument validation.
Responses are capped at 256 KiB before JSON decoding; rendered tool and
injection output remains capped at 32 KiB. Every operation has an explicit
timeout. HTTP errors include the operation and status while redacting credential
shapes in the error path. The bearer token is sent only in the Authorization
header. MemoryConfig debug output renders a redaction marker instead of the
token.
Plaintext HTTP endpoints are rejected unless hindsightAllowInsecure is
explicitly true. In secure mode, the HTTP client is HTTPS-only and a redirect
policy checks each hop before following it: HTTPS-to-HTTP and every other
plaintext redirect fail before a request reaches the plaintext target, so the
Authorization header cannot be forwarded there. The explicit insecure opt-in
permits both an HTTP base URL and HTTP redirect hops for trusted self-hosted
deployments; the normal redirect limit still applies.
Turn-start injection is advisory and fail-open: an unavailable Hindsight service never prevents an ordinary agent turn from running. Explicit tool calls return contextual errors and never silently fall back to local.
Namespaces
global: one bank, no project tag filter.per-project: bank id is<base>-<sanitized-label>-<digest-prefix>.per-project-tagged: one bank; retains carryproject:<sanitized-label>-<digest-prefix>and recalls use that tag withtags_match: any, which also permits untagged global memory.- The project identity hashes the canonical repository anchor when present, otherwise the canonical working directory. The readable basename is retained, but same-named repositories remain isolated by the digest prefix.
- Future child sessions resolve the parent's live backend configuration. Local
persona children still use
<persona-root>/memory/entries.jsonl; Hindsight persona children use the explicitly configured bank/scoping contract.
Invariants
- Exactly one backend is active; no dual writes or automatic fallback.
offremoves the complete reserved memory tool family in parent and child sessions.- Local JSONL paths and model-visible local tool behavior are unchanged.
- External endpoints, auth, HTTPS-only redirect enforcement, timeouts, request/ response bounds, and plaintext opt-in are explicit.
- Turn-start context is hidden (
display: false) and never auto-submitted.
Related documentation
tools.md— the full tool catalog including memorysettings-trust.md— settings precedence and trust
Extended tool catalog
Beyond the core coding tools (read, bash, edit, write, grep,
find, glob, ls), rpi ships a catalog of extended tools
(TOOL_NAMES in crates/pi-coding/src/tools.rs:153). Each tool below is a
built-in; some are session-scoped (one live child process per session), and
all bound their output and redact secrets.
lsp
Query a language server over the Language Server Protocol (JSON-RPC 2.0 with
Content-Length framing, lsp-types message shapes). Actions: hover,
definition, references, diagnostics, symbols, rename, code actions; one
server is spawned per invocation (crates/pi-coding/src/tools/lsp_client.rs).
initialize runs against the workspace root; a dedicated
wait_for_diagnostics waits for the push targeted at a specific document
URI. ContentModified errors are retried. Server stderr is captured
(bounded) and redacted in error messages. Timeouts: 30 s per request, 15 s
diagnostics wait.
browser
Headless Chromium/Chrome automation over the Chrome DevTools Protocol
(crates/pi-coding/src/tools/browser.rs). Actions: navigate, click,
fill, screenshot, extract, list_tabs, close. Each call spawns a
fresh headless browser with a temporary profile, performs exactly one action,
then tears it down — state does not persist between calls; non-navigate
actions accept an optional url to navigate first. Chrome is discovered via
CHROME_PATH, PATH, or standard install locations; missing binaries are
rejected actionably. Output is bounded (16 KiB extract cap). Timeout/abort
kills the whole browser process group, so a compromised page can only act
within one action's window.
github
GitHub API access via the gh CLI (preferred — uses the user's own gh auth;
rpi never touches or prints a token) with a GH_TOKEN/reqwest fallback
(crates/pi-coding/src/tools/github.rs). Actions: search_issues,
get_issue, list_issues, create_issue, comment_issue, list_prs,
get_pr, list_commits, view_file, search_code. Requests are
argv-built (gh api --method … -f key=value, no shell interpolation);
GitHub JSON is parsed into bounded plain text (32 KiB cap; code search
renders file:line:snippet). All surfaced error text is redacted.
ask
Ask the user a question mid-task and receive the typed answer as the tool
result (crates/pi-coding/src/ask.rs). One pending question at a time;
publishes SessionEvent::AskUser so the frontend renders the prompt, and
resolves the awaiting call when the answer arrives. Interactive sessions
only: print/JSON/RPC/REPL never arm the interactive flag, so the tool
rejects up front instead of hanging. Answer-wait bound: 60 s default; abort
or cancel resolves the slot.
eval
Evaluate code in a persistent, session-scoped language kernel
(crates/pi-coding/src/tools/eval.rs): python (a real python3
subprocess with the full standard library — an execution tool, not a
sandbox) or js (the embedded QuickJS engine with no require/import, no
network, and no filesystem access). Globals persist between calls
(cross-cell state); a cell timeout (default 30 s, max 300 s) kills the
kernel and the next call respawns it. Output is bounded (64 KiB per stream),
errors are classified (syntax/runtime/timeout), and results are redacted.
notebook
Read, execute, and edit Jupyter notebooks (.ipynb)
(crates/pi-coding/src/tools/notebook.rs): read lists cells (8 MiB file
cap, 200-cell preview), execute runs code cells through the same
session-scoped Python kernel as eval (outputs written back only with
write=true; unknown fields preserved), edit appends a markdown/code/raw
cell. Per-action capability gating: read→Read, execute→Exec,
edit→Write, enforced before any dispatch — a read-only role can read
notebooks without gaining edit/execute.
debug
Session-scoped Debug Adapter Protocol (DAP) client over stdio
(crates/pi-coding/src/tools/debug.rs). Adapters: gdb, lldb-dap,
debugpy. Actions: launch, set_breakpoint (1-based file:line),
continue_, pause, step_over/step_in/step_out, stack_trace,
variables, evaluate, threads, terminate. The program stays paused
before start until the first continue_ sends configurationDone; one
adapter per session; terminate (or drop) kills the whole process group,
debuggee included. Bounded: 30 s request timeout, 50 frames, 200 variables,
32 KiB rendered output; adapter stderr is redacted in launch failures.
generate_image and inspect_image
generate_image— bounded image generation through the provider subsystem (pi_ai::generate_image, OpenAI-compatibleimages/generations). The model must declare image capability (active model, an explicitmodelargument, orsettings.images.genModel);images.genBaseUrl/genApiKeyoverride the endpoint/credential for self-hosted services. Prompts capped at 4096 characters,nat 4, sizes whitelisted to 256/512/1024 square, decoded output capped at 128 MiB per image with a 16 MP header pre-check, save path workspace-contained. Returns file paths plus a bounded prompt echo — image bytes never enter the transcript.inspect_image— deterministic metadata + statistics for an image file without rendering it: format, dimensions, decoded color type, file size, EXIF orientation (JPEG/WebP), mean/stddev 8-bit luma brightness, and a coarse 8-bin RGB dominant-color histogram. Refuses files over 32 MiB from metadata before reading; decoded allocation capped at 128 MiB; output bounded to a few KiB. No OCR, no ML, no vision-model call.
web_search, ast_grep, ast_edit
web_search— DuckDuckGo Instant Answer API search (disabled whilePI_OFFLINEis set).ast_grep— structural code search with ast-grep patterns ($-metavariables, tree-sitter).ast_edit— single-file structural rewrite with ast-grep pattern→rewrite.
Memory tools
memory (local JSONL store) and recall/retain/reflect (Hindsight
backend) are documented in memory.md. mcp, debug, eval,
notebook, lsp, browser, and github above plus the orchestration
tools (task, hub, yield, goal — see
orchestration.md) and the todo tool
(see todos.md) make up the full built-in set.
Tool schemas and example calls
Every tool validates its arguments against a JSON-schema-style parameters
object before execution; unknown keys and invalid values fail with actionable
messages. Schemas below are verbatim from the tool factories; required
lists the parameters the schema marks as required (all others are optional).
Nullable-but-required parameters (the todo tool, orchestration tools) must
still be present in the call object, but may be null when unused.
| Tool | Required | Optional | Example call |
|---|---|---|---|
read | path | — | read path=src/main.rs |
bash | command | timeout, excludeFromContext, cwd, env | bash command="cargo test" timeout=120 |
browser | action | url, selector, text, path | browser action=fill url=https://example.com selector=#name text=world (tools/browser.rs:96-115) |
github | action | repo, query, number, title, body, path, state, ref | github action=view_file repo=octocat/Hello-World path=README (tools/github.rs:97-131) |
lsp | action | path, query/symbol, line, character, end_line, end_character, new_name, lang | lsp action=definition path=src/main.rs line=10 character=4 (tools/lsp.rs:221-275) |
eval | language, code | timeout | eval language=python code="x = 1" (tools/eval.rs:1049-1065) |
notebook | action, path | cell, write, cell_type, source, timeout | notebook action=execute path=demo.ipynb write=true (tools/notebook.rs:128-157) |
debug | action | adapter, program, args, cwd, launch_args, adapter_args, file, line, thread, variables_reference, expression, frame_id, wait_ms, levels | debug launch adapter=gdb program=./bin → debug set_breakpoint file=src/main.rs line=42 → debug continue_ (tools/debug.rs:759-823) |
mcp | action | server, tool, args | mcp call server=my-tools tool=weather args={"city":"London"} (mcp.rs:667-690) |
ask | question | — | ask question="Should I proceed?" (schema {question: string} in pi_agent::create_ask_tool) |
web_search | query | — | web_search query="Rust async cancellation safety" (tools/web_search.rs:39-50) |
ast_grep | pattern | path, lang | ast_grep pattern='fn $FNAME() {}' path=src lang=rust (tools/ast_grep.rs:63-84) |
ast_edit | pattern, rewrite, path | lang | ast_edit pattern='Some($A)' rewrite='Option::Some($A)' path=src/lib.rs (tools/ast_edit.rs:66-93) |
memory | op | content, tags, query, limit, tag, id | memory learn content="…" tags=["rust"]; memory recall query="async" limit=5; memory forget id=<id> (memory.rs:451-468) |
generate_image | prompt | model, size, n, path | generate_image prompt="a cat" size=1024 n=1 path=out.png (tools/image_gen.rs:81-121) |
inspect_image | path | — | inspect_image path=screenshot.png (tools/image.rs:82-90) |
todo | op, list, task, phase, items, dependsOn, cascade | (all nullable) | todo op=init list=[{phase:"Plan",items:["A","B"]}]; todo op=start task=task-abc; todo op=view (tools.rs:671-708) |
task | agent/task, or tasks[] (each item needs task; name/agent/todoTaskId nullable) | name, todoTaskId, context | task agent=researcher task="Study the persistence layer" todoTaskId=task-abc (orchestration/tools.rs:497-513) |
hub | op | to, message, replyTo, await, from, timeoutMs, peek, ids, agentId, lines | hub send to=w1 message="…"; hub wait from=w1 timeoutMs=30000; hub read_history agentId=w1 lines=50 (orchestration/tools.rs:536-606) |
yield | text | — | yield text="<full final deliverable>" (orchestration/tools.rs:136-140) |
goal | op | — | goal op=get / goal op=pause / goal op=complete (application.rs:3491-3508) |
Action enums are enforced by the schema: browser = navigate, click, fill, screenshot, extract, list_tabs, close (tools/browser.rs:91); github =
search_issues, get_issue, list_issues, create_issue, comment_issue, list_prs, get_pr, list_commits, view_file, search_code (tools/github.rs:40-50); lsp
= hover, definition, references, diagnostics, symbols, rename, code_actions, capabilities, status, reload (tools/lsp.rs:64); notebook = read, execute, edit (tools/notebook.rs:62); debug = launch, set_breakpoint, continue_, pause, step_over, step_in, step_out, stack_trace, variables, evaluate, threads, terminate (tools/debug.rs:70); mcp = list_servers, list_tools, call; memory = learn, recall, list, forget
(memory.rs:447-450); todo = init, start, done, drop, rm, append, add_dependency, remove_dependency, update_dependencies, view
(tools.rs:674-684); hub = send, wait, inbox, list, jobs, cancel, read_history (orchestration/tools.rs:544); goal = get, pause, complete (application.rs:3493-3498).
All extended tools share the same validation contract: arguments are
validated against the schema (additionalProperties: false on the
orchestration/todo schemas), capabilities gate execution, and results are
bounded and redacted.
Capabilities
Each tool declares a ToolCapability used by role ceilings and approval
modes: read tools (read, grep, find, glob, ls, web_search,
ast_grep, lsp, inspect_image, hub, recall, reflect), write
tools (write, edit, github, memory, retain, mcp, debug,
notebook's edit action), and exec tools (bash, browser, eval,
notebook's execute action, task). Unknown or legacy tool metadata
defaults to Exec capability.
Invariants
- Every tool result is bounded (per-tool byte caps) and passes through the secret redactor.
- Session-scoped tools (eval kernels, debug adapter, MCP servers, browser processes) are killed on drop — no leaked children, no orphaned processes.
- Workspace-relative tools resolve paths through the same containment as
read/bash(resolve_scoped_path); seesecurity.md. - Tool arguments are validated against their schemas before execution, and unknown actions/arguments fail with actionable messages.
Related documentation
memory.md— memory backendsmcp.md— MCP clientorchestration.md—task/hub/yield/goaltodos.md— thetodotool
Model Context Protocol (MCP) client
rpi embeds a Model Context Protocol (MCP) client: session-scoped
JSON-RPC 2.0 over a stdio child process (Content-Length framing, mirroring
the LSP client). The mcp tool discovers servers and calls their tools;
servers are declared under settings.mcpServers in a Grok-compatible
[mcp_servers.<name>] shape.
Source: crates/pi-coding/src/mcp.rs.
Configuration
{
"mcpServers": [
{
"name": "my-tools",
"transport": "stdio",
"command": "npx",
"args": ["-y", "my-mcp-server"],
"env": { "TOKEN": "$MY_TOKEN" },
"disabled": false
}
]
}
| Key | Meaning |
|---|---|
name | Server name used by mcp list_tools <server> / mcp call <server>. |
transport | stdio (a child process from command/args/env) or sse (an http(s) endpoint at url). The client transport in this build is stdio; an sse entry parses and round-trips (so Grok/Claude configs survive a settings write) but call/list_tools against it report the limitation explicitly. |
command/args | stdio server command line. |
env | Extra environment for the stdio child. Never echoed into tool output; $VAR/${VAR} references expand from the process environment. |
url | SSE endpoint (accepted, not connected in this build). |
disabled | true (Cursor-compatible) filters the server out at configure time: it never spawns, has no session slot, and never appears in mcp list_servers. |
Configured env values are never echoed into tool output, and a server's
stderr tail is only surfaced in initialize-failure diagnostics with secret
patterns redacted first.
The mcp tool
mcp list_servers # configured servers (live sessions marked)
mcp list_tools <server> # list a server's tools with descriptions
mcp call <server> <tool> [args] # invoke a server tool (JSON object or JSON string)
list_serversrenders name, transport (stdio command line or sse URL), and a live marker for servers with a spawned session.list_toolspages throughtools/list(up to 512 tools across 32 pages) and renders a name+description table.callvalidates the tool name client-side against the known list (or via the server'stools/searchextension when advertised) and renderstools/callresults as bounded text (32 KiB cap).argsmust be a JSON object or a JSON string that parses to one.
Tool capability: Write (servers may mutate external state — the prompt
guidelines tell the model to review call arguments before invoking).
Lifecycle
- Session-scoped, spawn on first use: the registry holds configured
servers plus one live client per server, spawned lazily on the first tool
call and killed on drop — the session owns the registry and
Dropnever leaks a child process. - Fast-start gate: the first tool call against a server waits up to 250 ms (holding the session lock) so sibling calls issued in the same turn batch into a single spawn instead of N sequential spawns.
- Reconnects: transport failures (spawn, framing, io) are retried with capped exponential backoff (100 ms → 1 s) up to 3 attempts per call, then surface an actionable error naming the server. JSON-RPC protocol errors and request timeouts are not retried — the wedged session is dropped so the next call respawns a fresh server.
- Progressive tool discovery: when a server advertises the
search_toolextension (capabilities.tools.search_toolor the older experimental location),tools/callprobes with atools/searchrequest and caches the definition instead of loading the full list.
Protocol: MCP 2024-11-05 requested on initialize; the negotiated version is
stored per client. Per-request timeout is 30 s; shutdown gets 5 s and exit 2 s
before the child is killed. Protocol framing is shared with the LSP and DAP
clients (crates/pi-coding/src/tools/framing.rs).
Invariants
- A disabled server is never spawned and never listed.
- Reconnect never reuses a wedged session: JSON-RPC errors and timeouts drop the client so the next call starts fresh.
- Server output is bounded (32 KiB results, 64 KiB stderr cap) and redacted; configured env values never appear in tool output.
- Spawn/framing failures are retried only when marked transport-level; a JSON-RPC refusal (e.g. a server that rejects initialize) fails immediately.
Related documentation
settings-trust.md—mcpServerssettings and env expansionenvironment-variables.md—$VARexpansionextensions.md— the in-process extension protocol (the alternative to MCP for adding tools)
Agent Client Protocol (ACP) mode
rpi implements the stable Agent Client Protocol v1 (ACP,
agentclientprotocol.com) — a JSON-RPC 2.0 protocol that lets ACP-speaking
editors embed coding agents. It is exposed as rpi agent stdio and
rpi agent serve (crates/pi-cli/src/modes/acp.rs).
Transports
- stdio —
rpi agent stdiospeaks Content-Length framed JSON-RPC 2.0 on stdin/stdout (the same framing as the LSP server), so an editor spawns it the way it spawns a language server. - serve —
rpi agent servespeaks the same messages as WebSocket text frames (frames capped at 16 MiB). The server is loopback-only (plaintext WebSocket cannot safely carry the bearer token off the local host; TLS is tracked for a later release).
Methods
Client → Agent requests:
| Method | Purpose |
|---|---|
initialize | Negotiate the protocol version and capabilities; advertises the rpi-auth auth method. |
authenticate | Acknowledge rpi's configured-credential auth (methodId: "rpi-auth"). rpi never collects secrets over ACP — credentials come from auth.json/provider env keys, and session/new performs the real gate (fails with auth_required when no authenticated model exists). |
session/new | Create a session rooted at a client-supplied absolute cwd; returns sess_<uuid>. Each session builds an independent rpi Application and records to the normal session store unless --no-session, so resumed rpi runs see the same conversation. |
session/prompt | Run a turn; the assistant response streams back as session/update notifications and the request resolves with a stopReason. |
session/cancel | Abort the active turn (request or notification form); the pending session/prompt resolves with stopReason: "cancelled". |
session/close | Cancel ongoing work and release the session. |
logout | Acknowledge (rpi's credential state is process-wide). |
Agent → Client reverse requests:
session/request_permission— the tool-approval gate. When the session's approval mode (--approval-modeflag or theapprovalModesetting) requires confirmation, the agent asks the client for anallow-once/reject-oncedecision before the tool executes. Evaluation order: path-levelpermissionRulesfirst (same evaluation as the interactive host hook), then the capability-wide approval mode, then the ACP reverse round trip (acp_approval_before_tool_callinacp.rs:330-364). The decision feeds the tool call; a timeout (600 s) or client disconnect blocks the tool with an actionable message.
Agent → Client notifications:
session/update—user_message_chunk,agent_message_chunk,agent_thought_chunk,tool_call,tool_call_update, andusage_updatevariants, projected from rpi'sApplicationEvents.
Prompt content
session/prompt accepts a ContentBlock[] array: baseline text and
resource_link (file contents embedded up to 2 MiB), plus image and
resource blocks (advertised via promptCapabilities). An empty prompt is
rejected with invalid_params.
Capabilities advertised
initialize returns agentCapabilities with loadSession: false,
promptCapabilities: { image: true, audio: false, embeddedContext: true },
sessionCapabilities: { close: {} }, and auth: { logout: {} }.
session/load, session/resume, session/list, and session/delete are
not advertised.
Concurrency and isolation
- One prompt turn per session at a time; concurrent prompts on the same session fail with an actionable error. Different sessions run concurrently, each with its own permission-bridge session id (concurrent turns never cross permission requests).
- Cancelling a prompt turn resolves its pending permission requests with the
cancelledoutcome. mcpServersinsession/neware accepted but not connected yet (documented limitation).
Example
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}
{"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"/path/to/project"}}
{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"sess_…","prompt":[{"type":"text","text":"List the Rust files"}]}}
Related documentation
rpc-json.md— the rpi-native JSONL control plane (different protocol, same Application runtime)security.md— approval modes and permission rulessettings-trust.md—approvalMode,permissionRules
Prompt templates and system prompt assembly
This page covers three related things:
- Prompt templates — reusable slash-command expansions (
/name) for the user prompt. - Project context files —
AGENTS.md/CLAUDE.mdinstructions injected into the system prompt. - System prompt assembly — how the final system prompt is built from the role text, tool snippets, guidelines, context, and skills.
For how skills and agents differ from prompt templates, see skills.md.
In this document <agent-dir> means the resolved agent configuration directory. The runtime resolves it from the PI_CODING_AGENT_DIR environment variable when set, otherwise from the platform default (see settings-trust.md).
Prompt templates
A prompt template is a Markdown file with optional YAML frontmatter. The file stem is the command name, the frontmatter describes it, and the body is the expansion text.
Templates live in:
<agent-dir>/prompts/— global templates, always discovered.<workspace>/.pi/prompts/— project-local templates, discovered only when the project is trusted.- Any path passed with
--prompt-template PATH.
Settings can also reference named templates from configured packages:
{
"prompts": ["custom-prompt"]
}
File format
---
description: Review code for common issues
argument-hint: "[files...]"
---
Review the following files for bugs, style, and performance:
$ARGUMENTS
Fields:
| Field | Purpose |
|---|---|
description | Shown in the interactive command list and used for the template table. If omitted, the first non-empty line of the body is truncated to 60 characters with .... |
argument-hint | Optional hint shown in command completion. |
The template name is the file stem (review.md → /review). Duplicate names across discovery sources are an error at load time.
How templates differ from skills, agents, and context files
| Concept | What it is | Where it appears |
|---|---|---|
| Prompt template | A reusable user-prompt fragment | Expanded into the user message when you type /name in the REPL or TUI |
| Skill | Specialized instructions loaded on demand | Listed in the system prompt under <available_skills>; the model loads it via skill://<name> |
| Agent | A subagent definition with its own system prompt | Used by the orchestration runtime to spawn child sessions (see skills.md) |
| Context file | Project-specific instructions | Injected verbatim into the system prompt under <project_context> |
Discovery order and precedence
The ResourceManager loads prompt templates from these sources, in order:
- Global default directory:
<agent-dir>/prompts/. - Project default directory:
<workspace>/.pi/prompts/(only when trusted). - Settings
promptsand packagepromptsresources. - Explicit
--prompt-template PATHarguments.
If two loaded templates have the same name, loading fails with a collision error.
Trust gates
Project-local .pi/prompts/ are loaded only when the resolved project trust decision allows project resources. Trust is resolved by crates/pi-coding/src/trust.rs using, in order:
- The one-run CLI flag
--approve/--no-approve. - A persisted decision in
<agent-dir>/trust.json. defaultProjectTrustfrom settings (ask,always,never).- In headless modes (
--print,--mode json,--mode rpc), an unset decision is treated as untrusted.
If a project has no .pi directory at all, it is trusted by default because there are no project-local resources to gate.
CLI flags:
rpi --prompt-template ./prompts/review.md # load an explicit template for interactive use
rpi --no-prompt-templates --print "hello" # disables discovered/configured templates, explicit paths still load
Safe path containment
Explicit template paths are canonicalized and validated before loading:
- Relative paths are resolved against
--cwdand must not escape it through symlinks. - Paths inside a project
.pi/directory require project trust. - Paths that do not exist are a hard error.
This is implemented in validate_explicit_project_paths in crates/pi-coding/src/resource_manager.rs.
Template expansion syntax
In the REPL or TUI, type /name arg1 arg2 to expand a template. Expansion is performed by expand_prompt_template / substitute_args in crates/pi-coding/src/prompt_templates.rs.
Supported placeholders:
| Placeholder | Meaning |
|---|---|
$1, $2, … | Positional argument (1-based). Missing arguments expand to empty. |
$@, $ARGUMENTS | All arguments joined by a single space. |
${n:-default} | Positional argument n or default if empty. |
${@:-default} | All arguments or default if empty. |
${@:start:length} | Slice of the argument list (1-based start, optional length). |
Important properties:
- Arguments are parsed with the same quoting rules as the original Go implementation: unquoted whitespace splits arguments; single or double quotes group arguments.
- Substitution is a single regex pass. Values inserted by a substitution are never scanned again, so a literal
$1in an argument or default stays literal. - If no template matches the
/namecommand, the original text is returned unchanged.
Example: create and use a template
mkdir -p "$PI_CODING_AGENT_DIR/prompts"
cat > "$PI_CODING_AGENT_DIR/prompts/review.md" <<'EOF'
---
description: Review code for common issues
argument-hint: "[files...]"
---
Review the following files for bugs, style, and performance:
$ARGUMENTS
EOF
In an interactive rpi session:
/review src/main.rs src/lib.rs
That expands to the template body with $ARGUMENTS replaced by src/main.rs src/lib.rs.
Project context files
The CLI discovers AGENTS.md, AGENTS.MD, CLAUDE.md, and CLAUDE.MD in two places:
- The agent config directory (
<agent-dir>). - Each ancestor directory of the working directory, from the filesystem root down to
--cwd.
Global context is always loaded. Ancestor/project context is loaded only when the project is trusted (see Trust gates). The CLI skips git-worktree-shadowed files so the same tracked context file is not loaded twice.
Context files are wrapped as:
<project_context>
Project-specific instructions and guidelines:
<project_instructions path="AGENTS.md">
...
</project_instructions>
</project_context>
Disable context-file discovery with --no-context-files.
System prompt files
In addition to context files, the CLI can load full system-prompt overrides from:
<agent-dir>/SYSTEM.md<workspace>/.pi/SYSTEM.md(project, trusted)
And append-only blocks from:
<agent-dir>/APPEND_SYSTEM.md<workspace>/.pi/APPEND_SYSTEM.md(project, trusted)
CLI flags take precedence:
--system-prompt TEXT_OR_PATH(or--system) replaces the default role section.--append-system-prompt TEXT_OR_PATHappends text after the role section.
When a path is given to --system-prompt or --append-system-prompt, it is canonicalized and a relative path that escapes --cwd through symlinks is rejected.
System prompt assembly
The final system prompt is assembled by build_system_prompt in crates/pi-coding/src/system_prompt.rs using the active ResourceSnapshot from the ResourceManager.
With the default role section, the prompt contains:
- The role description.
Available tools:— one-line snippets for each selected tool.Guidelines:— de-duplicated prompt guidelines from built-in tools andprompt_guidelines, plus:Use bash for file operations like ls, rg, findwhenbashis present withoutgrep/find/ls.Be concise in your responses.Show file paths clearly when working with files.
- A block pointing to the rpi docs and examples paths.
- The
--append-system-prompttext, if any. - The
<project_context>block, if any context files are loaded. - The
<available_skills>block, only when thereadtool is selected. Current working directory: <cwd>.
When --system-prompt is provided, the custom text replaces the default role section, tool list, guidelines, and rpi documentation block. Any --append-system-prompt, <project_context>, and <available_skills> blocks are still appended.
The read tool supports skill://<name> and skill://<name>/<relative-path> paths. The skill resolver in crates/pi-coding/src/selector.rs confines access to the skill's base directory.
Programmatic use and SDK behavior
The low-level Session::new defaults to trusted-project resource discovery. The higher-level AgentSessionBuilder in crates/pi-coding/src/sdk.rs defaults to no discovery:
#![allow(unused)] fn main() { use pi_ai::Model; use pi_coding::{AgentSessionBuilder, AgentSession, ResourceDiscovery}; async fn no_discovery_session(model: Model) -> Result<AgentSession, Box<dyn std::error::Error>> { let session = AgentSessionBuilder::new(model, "<workspace>") .resource_discovery(ResourceDiscovery::Disabled) // default .build() .await?; Ok(session) } }
To opt into discovery, call discover_resources(ResourceManagerOptions). It still requires project trust for project-local resources:
#![allow(unused)] fn main() { use pi_ai::Model; use pi_coding::{AgentSessionBuilder, AgentSession, ResourceManagerOptions}; async fn discovery_session(model: Model) -> Result<AgentSession, Box<dyn std::error::Error>> { let session = AgentSessionBuilder::new(model, "<workspace>") .discover_resources(ResourceManagerOptions::new("<workspace>", "<agent-dir>"))? .build() .await?; Ok(session) } }
This explicit, opt-in design means SDK consumers do not accidentally load project instructions, skills, or prompts.
You can also build a system prompt directly without any discovery:
#![allow(unused)] fn main() { use pi_coding::{build_system_prompt, BuildSystemPromptOptions, ContextFile, Skill, SkillSource}; use std::collections::HashMap; let mut snippets = HashMap::new(); snippets.insert("read".into(), "Read file contents".into()); let prompt = build_system_prompt(BuildSystemPromptOptions { custom_prompt: "You are a Rust reviewer.".into(), selected_tools: vec!["read".into()], tool_snippets: snippets, cwd: "<workspace>".into(), context_files: vec![ContextFile { path: "AGENTS.md".into(), content: "Be concise.".into(), }], skills: vec![Skill { name: "rust-review".into(), description: "Review Rust code for idioms and safety.".into(), file_path: "<workspace>/.pi/skills/rust-review/SKILL.md".into(), base_dir: "<workspace>/.pi/skills/rust-review".into(), globs: vec!["*.rs".into()], always_apply: false, hidden: false, disable_model_invocation: false, source: SkillSource::Project, trusted: true, }], ..BuildSystemPromptOptions::default() }); }
See also skills.md for skill frontmatter, selection, and resolver details.
Local / self-hosted models with llama.cpp
rpi has first-class support for a local llama.cpp
router. When a router is configured, its live models are merged into the
catalog under the llama.cpp provider and can be selected like any other
model.
Configure a router
rpi llama configure http://localhost:8080 [--api-key TOKEN]
Source: crates/pi-cli/src/args.rs:307-314,
crates/pi-cli/src/llama_commands.rs:14-27.
rpi llama configure validates the router by calling its /v1/models endpoint
before persisting the settings. The base URL is normalized: a trailing /v1 is
stripped, query strings and fragments are removed, embedded credentials are
rejected, and only http/https schemes are allowed. The configuration is
persisted in the agent directory under the llama data directory.
Source: crates/pi-ai/src/llama.rs:38-50,
crates/pi-ai/src/llama.rs:359-377,
crates/pi-coding/src/llama.rs:173-194.
You can also set LLAMA_BASE_URL and optionally LLAMA_API_KEY to skip
explicit configuration. On Unix, the persisted settings file must not be
readable by group or other users.
Source: crates/pi-coding/src/llama.rs:130-159.
Use local models
Once configured, local models appear as llama.cpp/<MODEL_ID>:
rpi -m llama.cpp/<model-id> --print "Hello"
Router models are refreshed automatically at startup unless PI_OFFLINE is
set. If the router is unreachable, rpi falls back to the cached catalog with a
warning and continues using the last successfully observed snapshot.
Source: crates/pi-cli/src/session_run.rs:284-299.
Manage the router
| Subcommand | Purpose |
|---|---|
rpi llama status | Show configured router and live models |
rpi llama status --reload | Ask the router to rescan its model directory |
rpi llama refresh | Refresh live models; fall back to cache |
rpi llama load MODEL | Load a model through the router |
rpi llama unload MODEL | Unload a model through the router |
Source: crates/pi-cli/src/args.rs:316-332,
crates/pi-cli/src/llama_commands.rs:24-69.
status prints each router model on its own line with a status flag such as
loaded, loading, unloaded, or sleeping.
Source: crates/pi-cli/src/llama_commands.rs:24-37.
load and unload request the router to change its active model, then refresh
the live catalog and persist the new snapshot atomically.
Source: crates/pi-coding/src/llama.rs:247-290.
In the TUI or REPL, /llama accepts the same operations:
/llama status
/llama refresh
/llama load llama-3.1-8b
/llama unload llama-3.1-8b
/llama configure http://localhost:8080 [TOKEN]
Source: crates/pi-cli/src/interactive_commands.rs:260-264,
crates/pi-cli/src/llama_commands.rs:156-222.
Download GGUF models from Hugging Face
# Search
rpi llama search "meta-llama/Llama-3.1-8B"
# List quantizations and file checksums
rpi llama details meta-llama/Llama-3.1-8B-GGUF
# Download a quantization (or the first available one if -q is omitted)
rpi llama download meta-llama/Llama-3.1-8B-GGUF -q Q4_K_M
# List local downloads
rpi llama installed
Source: crates/pi-cli/src/args.rs:333-353,
crates/pi-cli/src/llama_commands.rs:66-107.
search returns repository ids and download counts. details lists each
quantization, the files it contains, their sizes, and SHA-256 checksums when
available. download installs the selected quantization into the agent's llama
models directory.
Source: crates/pi-ai/src/llama.rs:494-525,
crates/pi-ai/src/llama.rs:526-633,
crates/pi-coding/src/llama.rs:294-413.
Downloads are:
- Atomic: written to a
.partfile and renamed into place only after the checksum succeeds. - Resumable: a partial
.partfile is reused with an HTTPRangerequest. - Verifiable: each file is checked against the SHA-256 from Hugging Face when a checksum is provided.
- Cancellable: pressing Ctrl-C cancels the in-progress download cleanly.
Source: crates/pi-coding/src/llama.rs:439-638.
Authentication uses the HF_TOKEN environment variable or an already-configured
Hugging Face token. A custom Hugging Face endpoint can be set with HF_ENDPOINT.
Authentication
If your router requires a bearer token, pass it with --api-key during
configuration or set LLAMA_API_KEY. The key is sent as an
Authorization: Bearer header on router management and inference requests.
Source: crates/pi-coding/src/llama.rs:130-159,
crates/pi-cli/src/models_config.rs:295-308.
Environment variables
| Variable | Purpose |
|---|---|
LLAMA_BASE_URL | Router base URL (skips rpi llama configure) |
LLAMA_API_KEY | Router bearer token |
HF_TOKEN | Hugging Face token for GGUF search/download |
HF_ENDPOINT | Custom Hugging Face API endpoint |
PI_OFFLINE | Skip router refresh at startup |
PI_CODING_AGENT_DIR | Override the agent directory that stores llama settings and downloads |
Security
This page describes the trust boundaries and hardening that are implemented in
the current rpi source. Every protection below is source-grounded; unsupported
surfaces are called out explicitly rather than with vague limitations.
- Trust boundary and project-local resources
- Credential storage and redaction
- Path and symlink containment
- Structured stdout and protocol isolation
- Extension manifest, environment, and process isolation
- Process ownership, capability gating, and log bounds
- Image decode and display limits
- Parent-process hardening
- Installer and update checksums / atomicity
- Reporting issues
Trust boundary and project-local resources
The runtime keeps global configuration in the agent directory
(<agent-dir>, resolved from $PI_CODING_AGENT_DIR or the platform default).
Project-local resources live under $CWD/.pi/ and are only loaded when that
project is trusted. See settings-trust.md for the full
configuration model.
Trust decisions are stored in $PI_CODING_AGENT_DIR/trust.json, versioned as
TRUST_STORE_VERSION = 1, and keyed by canonical project path. The resolver
walks parents so a decision at <workspace> covers <workspace>/project.
Source: crates/pi-coding/src/trust.rs.
Effective trust is resolved by resolve_project_trust in this order:
- One-run
--approve/--no-approveoverride. - If the project has no
.pidirectory at all, treat it as trusted for that run (there is nothing local to load). - A persisted decision from
trust.json. settings.json#defaultProjectTrust(ask,always,never).
Headless modes (--print, --mode json, --mode rpc) never prompt. In
headless mode an unset "ask" decision is treated as untrusted, so pass
-a / --approve when you need project-local resources:
rpi --mode json --approve "what does this project do?"
Trust-gated project-local resources include skills, prompts, themes,
keybindings, extensions, packages, and .pi/settings.json. Global resources
and the built-in model catalog are always loaded. AGENTS.md / CLAUDE.md
files discovered in ancestors of the cwd are loaded only when the project is
trusted and are included as plain-text instructions, not parsed as
configuration. Sources: crates/pi-coding/src/trust.rs,
crates/pi-coding/src/resources.rs, crates/pi-coding/src/resource_manager.rs.
Tool execution policy is separate from project trust. Global
settings.json#approvalMode and the one-run --approval-mode flag accept
yolo, write, or ask: yolo allows all capabilities, write confirms
Exec tools, and ask confirms every tool call. A project-local settings file
cannot lower this global policy. When confirmation is required outside the TUI,
the host fails closed instead of silently allowing the tool. Host approval runs
before existing host hooks and extension reducers; a denial skips later hooks.
Host hooks and the pre_trust_decision/extension trust_decision surfaces
are documented in hooks.md. Unknown or legacy tool metadata
defaults to Exec capability.
Credential storage and redaction
API keys and subscriptions are resolved in the precedence order described in
authentication.md. The final value may come from an
environment variable, auth.json, models.json, or the --api-key flag.
Storage
auth.json stores credentials as a Credential enum (ApiKey or OAuth).
Writes go through write_credentials_atomic:
- The parent directory is created and set to
0o700on Unix. - A temporary file is created with
0o600permissions. - The JSON is serialized, synced, and moved into place with
fs::rename. - The final file is set to
0o600and the parent directory is synced.
Command-valued stored values (!command) are rejected during parsing.
$VAR / ${VAR} templates are expanded from the request env map, the
credential's own env map, and then the process environment; empty values are
treated as unset. Expansion errors name the missing variable and source only
and never include the resolved secret. Source: crates/pi-coding/src/auth.rs.
Redaction
CredentialandRequestAuthDebugimpls only expose the credential type, whether a key is present, and counts of headers/env entries.--api-keyis documented as "never logged". Source:crates/pi-cli/src/args.rs.- The HTTP headers
authorization,x-api-key,x-goog-api-key, andcf-aig-authorizationare marked sensitive withreqwest'sHeaderValue::set_sensitive(true). Source:crates/pi-ai/src/providers/common.rs. - Provider implementations redact those values from error bodies before they
are returned or logged, replacing secrets with
[REDACTED]. - Custom model headers are merged case-insensitively and kept single-valued so
duplicate
authorizationlines cannot leak a stale value.
Do not commit auth.json or models.json containing real keys.
Path and symlink containment
All built-in file tools operate relative to the configured --cwd.
crates/pi-coding/src/tools/paths.rs::resolve_scoped_pathresolves the path, rejects traversal outside the lexical working directory, canonicalizes the nearest existing ancestor, and checks that the canonical path still starts with the canonical cwd. This prevents symlink escapes.crates/pi-cli/src/file_args.rs::resolve_contained_fileapplies the same containment to@filearguments in the prompt: absolute paths,.., root components, and prefix components are rejected; the joined path is canonicalized and verified to stay inside the cwd. Text@fileinputs are capped at 8 MiB and image@fileinputs at 20 MiB.- System-prompt file paths (
--system-prompt,--append-system-prompt) use the same symlink-escape check. Source:crates/pi-cli/src/session_run.rs.
Extension manifest executable and QuickJS entry paths are resolved relative to
the manifest directory and must remain inside it. Source:
crates/pi-coding/src/extensions.rs::resolve_manifest_path.
Package sources are validated before any git command runs:
npm:sources are rejected at every parse/install/remove entry point with the exact error below. Configurednpm:entries are also skipped during discovery so they can never reach installed state.- Git refs are validated in
validate_git_reference: leading-,/, trailing/or.,..,//,@{, backslash, colon,?,*,[,^,~, spaces, control characters, and.locksuffixes are all rejected. - Git hosts and repository paths are normalized and percent-decoded before the
check. Source:
crates/pi-coding/src/packages.rs.
The self-updater rejects symlinks for the install root, update state, versioned
binary, and active executable, and verifies that the running binary matches the
managed update state. Source: crates/pi-cli/src/self_update.rs.
Structured stdout and protocol isolation
rpi keeps structured output channels separate from interactive terminal
controls.
--mode jsonemits one JSON line per application event and flushes after each record. Source:crates/pi-cli/src/modes/json.rs.--mode rpcreads LF-terminated JSONL commands from stdin and writes LF-terminated JSONL responses/events to stdout. Malformed frames produce a JSON error response, not raw panic text. Source:crates/pi-cli/src/modes/rpc.rs.- Process extensions speak a strict LF JSONL protocol over stdin/stdout. The
host rejects CRLF, blank lines, and frames larger than the configured maximum
(default 1 MiB, minimum 1,024 bytes). Source:
crates/pi-coding/src/extensions.rs. - QuickJS extensions run in-process with no stdout channel: the runtime has no
console,process,require, orfetchglobals, so extension code cannot emit protocol noise or escape the sandbox. Source:crates/pi-coding/src/quickjs_host.rs. - Terminal image escape sequences are only emitted by the interactive TUI's
terminal guard. JSON, RPC, print mode, logs, and ratatui buffers never receive
raw graphics protocol bytes. Source:
crates/pi-cli/src/terminal_images.rsandcrates/pi-cli/src/tui.rs. --listenselects a headless Web-only service around one liveApplication; it never acquires the terminal or reads stdin, and stops on Ctrl-C/SIGTERM. It is rejected with positional prompts, subcommands, print, JSON/RPC, or model-listing exits. HTTP and WebSocket messages are capped at 4 MiB, ordinary commands at 16 concurrent operations, pre-auth connections at 64 tasks, and outbound WebSocket delivery uses a bounded queue. Recovery commands such as abort and process stop can bypass saturated ordinary-work slots.--listenis loopback-only (127.0.0.0/8 or ::1) by default. A non-loopback or wildcard bind requires the explicit--listen-allow-insecure-remoteopt-in; a token file is optional there (strongly recommended). The opt-in does not encrypt plaintext HTTP/WebSocket: passive LAN observers can capture control traffic and, when a token is configured, the bearer token.agent serveremains strictly loopback-only with no remote opt-in and no tokenless browser access. On loopback, the bounded regular token file is optional; a configured token must be presented exactly asAuthorization: Bearer <token>or the constant-timerpi-auth.<token>WebSocket subprotocol on every bind. Without a token, the listener is tokenless: native clients withoutOriginare always accepted, and a browser (which always sendsOrigin) is accepted only when itsOriginauthority equals the request's HTTPHost— an ordinary same-origin check that rejects unrelated cross-origin pages, not authentication and not DNS-rebinding protection. This works on any bind, including a wildcard address (0.0.0.0 or::), with no--listen-advertised-originrequired for/web,/ws, or/rpc.--listen-advertised-origin <URL>(a strict http/https origin with no credentials, path, query, or fragment) is only for collaboration-link generation (/collab,collab_startwithout an explicitbaseUrl) and the reachable/webURL printed at startup; loopback and other specific binds advertise their bound address automatically, and a wildcard bind without it prints no reachable URL while/collabfails closed instead of synthesizing links from an unreachable wildcard. Interactive extension dialogs remain exclusively owned by the local TUI: remote clients cannot observe or answer them.
Extension manifest, environment, and process isolation
Every extension needs an explicit pi-extension.json manifest. See
extensions.md for the full format.
- The manifest must declare
schemaVersion: 1and usesdeny_unknown_fields, so extra fields fail closed. Theidis validated as an identifier up to 128 bytes. Source:crates/pi-coding/src/extensions.rs. runtimeis discriminated:processusesexecutable+ optionalarguments;quickjsusesentryand rejectsarguments. QuickJS entries must end in.jsor.mjs.- UI capabilities require the
uicapability; QuickJS extensions rejectmessage_renderersandprovider_metadatabecause those factories cannot cross the protocol boundary. - Package extensions are only loaded when the project is trusted. Untrusted
project manifests are refused. Source:
crates/pi-coding/src/extensions.rs::extension_spec_from_package_resource.
When an extension process is spawned:
- The command starts with
env_clear(); only the extension's configured environment and a small set of host variables are passed through. - Process extensions may additionally run inside the opt-in Linux filesystem
sandbox (
spawn_pipedwith aSandboxConfig); seesandbox-isolation.md. - Every process extension receives
PI_EXTENSION_PROTOCOL_VERSION=1,PI_EXTENSION_ID, andPI_EXTENSION_PACKAGE_ID. - QuickJS extensions additionally receive
PI_EXTENSION_ENTRY,PI_EXTENSION_CAPABILITIES,PI_EXTENSION_UI_CAPABILITIES, andPI_EXTENSION_MAX_FRAME_BYTES. - The child is placed in its own process group (
process_group(0)) and haskill_on_drop(true), so it is terminated when the host drops it. - stdin/stdout/stderr are piped; stdout is protocol-only. Up to 16 KiB of
stderr is retained for crash diagnostics. Source:
crates/pi-coding/src/extensions.rs.
In-process QuickJS extensions never spawn: each runs on its own dedicated
thread with a 64 MiB set_memory_limit, a set_interrupt_handler deadline,
and no process/require/fetch/console globals. Source:
crates/pi-coding/src/quickjs_host.rs.
Extension instances are invalidated when the runtime reloads, the session ends,
or the protocol breaks. The host does not keep orphaned extension children
alive. Source: crates/pi-coding/src/extensions.rs::spawn_discarded_instances.
Process ownership, capability gating, and log bounds
The ProcessManager owns long-lived subprocesses and enforces per-owner
boundaries.
- Every spawned process is tagged with a
ProcessOwnerId.list,describe,logs,write,keys,resize,signal,stop, andwaitall require the caller'sowner_idto match the session's owner. Source:crates/pi-coding/src/process/manager.rs. - Default limits: at most 16 active processes, 1 MiB of retained output per
process, 30-minute idle timeout with 30-second scans, 1-second termination
grace, and 256 KiB per log read. Source:
crates/pi-coding/src/process/mod.rs. validate_spawn_specrejects empty argv, NUL bytes in argv or env, non- absolute or non-directory cwd, env keys containing=or NUL, labels over 64 bytes, andoutput_bytesabove the manager maximum. Source:crates/pi-coding/src/process/manager.rs.- Subprocesses are spawned with
env_clear()plus explicit overrides, their own process group, andkill_on_drop(true). On Unix, stdout and stderr are merged into a single combined stream so the host sees all output. Source:crates/pi-coding/src/process/backend.rs. ProcessLogkeeps a bounded ring buffer: when the retained bytes exceed the configured capacity, old bytes are dropped and the read response reports how much was lost. Source:crates/pi-coding/src/process/log.rs.- The bash tool's
OutputAccumulatorbounds memory with default limits of 2,000 lines / 50 KiB, keeps a rolling tail of at most 2× the byte limit, and streams overflow to a temp file. Source:crates/pi-coding/src/tools/bash.rsandcrates/pi-coding/src/truncate.rs.
Extension capabilities are enforced at registration time: the runtime builds
an ExtensionPermissionSet from the manifest and rejects any registration that
requests a capability or UI capability not in that set. Source:
crates/pi-coding/src/extensions.rs::ExtensionPermissionSet::validate_manifest.
Image decode and display limits
Image data is bounded before decoding and before being sent to the model or the terminal.
crates/pi-cli/src/image_pipeline.rsenforces:- 20 MiB compressed input (
MAX_IMAGE_BYTES). - 128 MiB decoded allocation budget (
MAX_DECODED_IMAGE_BYTES). - Maximum decoded dimensions capped by that budget at 8 bytes per pixel.
- Maximum display width/height of 2,000 pixels.
- Inline base64 cap of ~4.5 MiB.
- 20 MiB compressed input (
- Supported formats are PNG, JPEG, GIF, and WebP. The coding-side image tool
additionally converts BMP to PNG before sending. Source:
crates/pi-coding/src/tools/imageresize.rs. - Terminal display reuses the same validation. The TUI caches at most 256
decoded metadata entries, clamps the cell reservation to the viewport, and only
emits Kitty or iTerm2 graphics protocol bytes. Sixel is detected but not
emitted because there is no bounded safe encoder yet. Source:
crates/pi-cli/src/terminal_images.rs.
Parent-process hardening
Before any dispatch, rpi runs best-effort parent-process hardening
(harden_process in crates/pi-cli/src/lib.rs:64-84, invoked from
crates/pi-cli/src/main.rs:12-17):
- On Linux, the process is made non-dumpable and ptrace attach (plus
/proc/<pid>/memaccess) is denied even to same-user debuggers. - The call is cfg-guarded and failure-ignored: hardening must never break startup on unsupported platforms.
Loader variables (LD_PRELOAD, LD_LIBRARY_PATH) are consumed by the
dynamic loader before main runs and cannot be sanitized after the fact;
child processes already rebuild their environments (tools and extensions),
so no child inherits the hardened parent's load state.
Installer and update checksums / atomicity
install.sh, install.ps1, and rpi update --self all follow the same
pattern. The full update mechanics are documented in update.md;
the security-relevant parts are:
- Every release archive is paired with a
SHA256SUMSfile. The installer downloads both, enforces a 1 MiB limit on the manifest and a 1 GiB limit on the archive, requires exactly one valid 64-character hex digest for the platform asset, recomputes the digest locally, and aborts on mismatch. - A smoke test runs the staged binary with
--versionand requires the output to be exactlyrpi <version>before activation — exit status alone is not proof of identity. - Unix: the versioned binary is placed with a single
rename(2), then the activebin/rpisymlink is swapped with anotherrename(2). The active path is never missing. The prior symlink target is captured so rollback restores exactly what was live. Source:install.shandcrates/pi-cli/src/self_update.rs. - Windows:
MoveFileExwithMOVEFILE_REPLACE_EXISTINGperforms an atomic same-volume replace. If the runningrpi.exeis locked, the installer fails with a clear message instead of leaving a window where the executable is absent. A preemptive backup allows rollback to restore the previous binary. Source:install.ps1andcrates/pi-cli/src/self_update.rs. update-state.jsonis written atomically (temp file + rename) and is rolled back if activation or smoke testing fails. On Windows, activation is deferred to a short-lived PowerShell process that runs after the currentrpiprocess exits; the deferred activation re-verifies that the moved binary prints exactlyrpi <version>and restores the previous binary on any mismatch. Source:crates/pi-cli/src/self_update.rs.- Concurrent installs are serialized:
install.shuses a PID-based lockfile;install.ps1uses a named mutex;self_update.rsacquires an install lock. - Package updates use the same staging/rollback model: git checkouts are
prepared next to the live checkout and activated with an atomic directory
swap, and package state/settings files are written with temp-file + rename
and rolled back on failure. Source:
crates/pi-coding/src/packages.rs.
The stable /releases/latest endpoint is never used for prereleases: the
installer defaults to /releases/latest, and self_update.rs rejects draft or
prerelease releases from that endpoint. Prerelease-aware users must explicitly
request a prerelease tag. Source: install.sh and
crates/pi-cli/src/self_update.rs::select_release.
npm packages
npm: package sources are deliberately not supported. Every entry point that
accepts a package source rejects npm: with the exact error:
npm package sources are not supported yet; use a local path or git source
There is no partial npm backend, npm package cache, or registry integration.
Supported package sources are local: directories below the project and git:
repositories. Source: crates/pi-coding/src/packages.rs::npm_deferred_error
and packages.md.
Bash filesystem sandbox (opt-in, Linux)
The bash tool can run inside an opt-in filesystem sandbox (settings.sandbox
or the per-call sandboxed parameter; default off). It is confinement, not
isolation: the command still runs as the same user with the same host
privileges — nothing is escalated — but inside fresh Linux namespaces created
with unshare (--mount --pid --fork --mount-proc, plus --net unless
sandbox.network is true). Source: crates/pi-coding/src/sandbox.rs.
What the sandbox does:
- Builds a tmpfs root, bind-mounts the configured
sandbox.allowedPaths(default: the session working directory plus the agent directory), andpivot_roots so the host root is detached. Filesystem reads and writes are confined to the allowed paths (read-write). - System binaries and libraries under
/usr,/bin,/sbin,/lib,/lib64are bind-mounted read-only so commands can execute; user data under those roots is not readable. sandbox.deniedPathsentries are hidden with an empty overlay even when nested inside an allowed path.- Network is loopback-only by default (fresh net namespace);
curl, DNS, and other non-loopback traffic fail with "Network is unreachable". /procreflects only the sandbox's own PID namespace.
Limitations to be aware of:
- Confinement, not isolation: the command keeps the caller's uid and can write to the bind-mounted allowed paths (the same inodes the host sees). A malicious command can still consume CPU/memory or abuse host binaries.
- Requires Linux, the util-linux
unsharebinary, and (for unprivileged users) permission to create user namespaces (kernel.unprivileged_userns_cloneor an equivalent AppArmor profile). Missing prerequisites produce actionable errors; on non-Linux platforms the sandbox is rejected with an explicit "unsupported on this platform" error. - The sandbox root is minimal: no
/etc(so/etc/passwdis not visible, and DNS/NSS lookups fail), a private/tmp, and only the essential device nodes (/dev/null,/dev/zero,/dev/full,/dev/random,/dev/urandom,/dev/tty). No/dev/pts,/run, or$HOMEunless explicitly allowed. deniedPathshides existing content but cannot prevent a command from creating a new entry at a denied path inside a read-write allowed path.sandboxedandbackgroundare mutually exclusive; the supervised process manager runs outside the sandbox.- Only paths are passed to the wrapper command line / environment — never
secrets. Wrapper construction is deterministic and unit-tested
(
sandbox.rs), and the real namespace behavior is smoke-tested when the host supports it (crates/pi-coding/tests/sandbox_smoke.rs).
Reporting issues
If you find a security issue in rpi, please open a private issue or contact
the maintainers before disclosing publicly.
Export and share
rpi can export a session to a self-contained HTML file or to a Pi v3 JSONL
branch, and it can share a session as a secret GitHub gist. The export step
needs no model, auth, or network access; only gist sharing requires the gh
CLI.
Export a session from the CLI
# Export a session file to a self-contained HTML file
rpi export <agent-dir>/sessions/--cwd--/timestamp_id.jsonl
# Export to a specific path
rpi export session.jsonl --output report.html
# Export the current branch as JSONL (suitable for --resume)
rpi export session.jsonl --jsonl
# JSONL output with an explicit path
rpi export session.jsonl --jsonl --output backup.jsonl
Source: crates/pi-cli/src/args.rs:232-247,
crates/pi-cli/src/commands.rs:129-145.
--output sets the destination path. Without it, the output path is derived
from the session file by swapping the extension to .html or .jsonl. Writes
are atomic: a temporary file is created in the same directory and renamed into
place.
Source: crates/pi-coding/src/export/mod.rs:91-145,
crates/pi-coding/src/export/mod.rs:693-732.
HTML export
HTML export produces a single self-contained .html file with inline CSS and
JavaScript and no external dependencies. The rendered transcript is the full
chronological record from the session file, including compaction markers. All
user, model, and tool content is HTML-escaped at render time, so arbitrary
markup cannot escape into the page.
Source: crates/pi-coding/src/export/mod.rs:1-10,
crates/pi-coding/src/export/mod.rs:188-204.
JSONL export
JSONL export writes only the current branch (root → leaf) of the session and
produces a valid Pi v3 session file. It is suitable for archiving or for passing
to --resume.
Source: crates/pi-coding/src/export/mod.rs:107-132.
Export during a session
In the TUI or line REPL, use the /export slash command:
/export # write HTML to the default path
/export report.html # write HTML to the named file
/export backup.jsonl # write the current branch as JSONL
If the argument ends in .jsonl (case-insensitive), the export is JSONL;
otherwise it is HTML. The path is printed to the REPL or shown as a TUI status
message.
Source: crates/pi-cli/src/repl.rs, crates/pi-cli/src/tui.rs,
crates/pi-cli/src/interactive_commands.rs.
/dump is the same export surface with an explicit format flag:
/dump # write HTML to the default path (session-file derived)
/dump --jsonl # write the current branch as JSONL to cwd/<name>.jsonl
/dump report.html # write HTML to the named file
/dump --jsonl backup # write JSONL to the named file
--jsonl forces JSONL; a .jsonl output path does too (matching /export).
Without --jsonl and without a path, HTML is written next to the session
file. JSONL with no path defaults to <session-stem>.jsonl in the session
working directory, since the session-dir default would collide with the
.jsonl source file.
Source: crates/pi-cli/src/interactive_commands.rs
(parse_dump_invocation / execute_dump).
RPC export
In RPC mode, send:
{"type":"export_html","outputPath":"report.html"}
The response contains { "path": "..." }. RPC export always produces HTML.
Source: crates/pi-cli/src/modes/rpc.rs:138-141,
crates/pi-cli/src/modes/rpc.rs:894-896.
Start a fresh session
/fresh
/fresh (alias for /new) starts a new, clean session: the current one is
archived in place — the session store keeps every session file, so the old
recorder stays on disk with a new recorder getting a fresh id. The TUI resets
the transcript view to the new session.
Source: crates/pi-cli/src/interactive_commands.rs,
crates/pi-cli/src/tui.rs ("new" | "fresh" dispatch arm).
Share a session as a gist
/share
/share is available in the TUI, the REPL, and through the application API. It
exports the current session to HTML and uploads it as a secret GitHub gist
using gh gist create --desc "rpi session export". The command intentionally
omits visibility flags so GitHub CLI's secret-gist default applies.
Source: crates/pi-cli/src/interactive_commands.rs,
crates/pi-cli/src/repl.rs, crates/pi-coding/src/share.rs.
Requirements:
ghmust be installed.gh auth loginmust have completed successfully.
If either check fails, the command returns an actionable error such as
"gh CLI not found; install it from https://cli.github.com/" or
"gh is not authenticated; run gh auth login to sign in".
Source: crates/pi-coding/src/share.rs:40-60.
Encrypted share (/share --encrypt)
/share --encrypt # prompt for a passphrase (hidden input)
/share --encrypt <passphrase> # passphrase on the command line
--encrypt exports the current session branch as JSONL and encrypts it with
the passphrase using AES-256-GCM, writing <name>.jsonl.enc. The plaintext
JSONL is staged in the system temp directory and removed after encryption.
When the gh CLI is available and authenticated the ciphertext is also
uploaded to a secret gist (non-fatal if gh is missing).
The passphrase is never stored or logged. The scheme is documented for
interoperability in crates/pi-coding/src/encrypt.rs:
- key = SHA-256(passphrase) (32 bytes)
- nonce = 12 fresh random bytes per encryption
- file layout:
nonce (12 bytes) || AES-256-GCM ciphertext(ciphertext includes the 16-byte authentication tag)
Decrypt by splitting the 12-byte nonce prefix and authenticating the tag with
the same passphrase; a wrong passphrase or any tampering fails tag
verification.
Source: crates/pi-coding/src/encrypt.rs,
crates/pi-coding/src/share.rs
(encrypt_session_share_to_file / share_session_encrypted),
crates/pi-cli/src/interactive_commands.rs
(parse_share_invocation / execute_encrypted_share).
By default the returned URL is the raw gist URL, which renders on
gist.github.com. You can substitute a custom viewer by setting
PI_SHARE_VIEWER_URL. If the value contains {url}, the gist URL is substituted
into the template; otherwise the value is used verbatim.
Source: crates/pi-coding/src/share.rs:20-24,
crates/pi-coding/src/share.rs:67-89.
Copy to clipboard
The last assistant response can be copied to the system clipboard:
- Slash command:
/copy - Default keybindings:
ctrl+xorctrl+shift+c(action nameapp.message.copy/copy_last_assistant)
Source: crates/pi-cli/src/repl.rs:350,
crates/pi-cli/src/interactive_commands.rs:175-179,
crates/pi-cli/src/keybindings.rs:53-59,
crates/pi-cli/src/keybindings.rs:128-135,
crates/pi-cli/src/keybindings.rs:396-401.
Clipboard writing is implemented per platform: PowerShell on Windows,
pbcopy on macOS, and xclip/xsel on Linux. The TUI and REPL also support
pasting clipboard images with ctrl+v / alt+v (action app.clipboard.pasteImage
/ clipboard_paste).
Source: crates/pi-cli/src/clipboard.rs:52-112.
Manual portability
Native Pi v3 session files are plain JSONL and live under:
<agent-dir>/sessions/--<encoded-cwd>--/<timestamp>_<id>.jsonl
You can copy, move, or archive these files directly. A session file may contain
multiple branches linked by parentId; the active branch is followed from the
leaf entry. The JSONL export writes only that active branch.
Environment variables
| Variable | Purpose |
|---|---|
PI_SHARE_VIEWER_URL | Viewer URL template; use {url} to substitute the gist URL |
Update safety
rpi can update itself from GitHub releases and reconcile configured rpi packages.
Both paths are designed so a failed update never leaves the installation in an
unusable state.
Update notifications
When you start an interactive session (TUI or REPL), rpi checks GitHub
releases in the background. If a newer release exists, a non-fatal status
message is shown:
Update available: current v0.2.6, latest v0.2.7 — summary — URL (run `rpi update --self`)
Source: crates/pi-cli/src/self_update.rs:187-213.
Two environment variables control this check:
PI_OFFLINE=1|true|yesdisables all updater networking.PI_SKIP_VERSION_CHECKdisables only the interactive startup version check.
Self-update
rpi update # update rpi itself (default when no package flags are given)
rpi update --self # explicit
rpi update --self --force # reinstall even when version and checksum match
Source: crates/pi-cli/src/args.rs:270-288, crates/pi-cli/src/lib.rs:85-94.
rpi update --self downloads the latest GitHub release for the current platform,
verifies it, smoke-tests it, and activates it atomically. It fails early if
PI_OFFLINE is enabled. The updater expects a managed install layout rooted at
$PI_HOME (default ~/.rpi on Unix, %USERPROFILE%\.rpi on Windows; the
self-updater otherwise derives the root from the running executable's location)
with an update-state.json file.
Source: crates/pi-cli/src/self_update.rs:211-223, crates/pi-cli/src/self_update.rs:480-524.
What the self-update does
- Selects the release. Stable versions query
/releases/latest. Prerelease versions pick the newest published prerelease. Drafts and unpublished releases are rejected. - Locates the platform archive (
rpi-<version>-<triple>.tar.gzor.zip) and the release'sSHA256SUMSfile. - Enforces size limits: archives are capped at 1 GiB and
SHA256SUMSat 1 MiB. - Downloads
SHA256SUMS, looks up the expected digest, and skips the download when the installed digest already matches (unless--forceis used). - Downloads the archive, verifies its SHA-256 digest against
SHA256SUMS, and extracts the binary to a staged path. - Runs a smoke test: the staged binary must print exactly
rpi <version>from--version. - Atomically installs the versioned binary and swaps the active symlink, then
writes
update-state.jsonatomically.
Sources: crates/pi-cli/src/self_update.rs:223-304,
crates/pi-cli/src/self_update.rs:705-723,
crates/pi-cli/src/self_update.rs:725-770,
crates/pi-cli/src/self_update.rs:795-875,
crates/pi-cli/src/self_update.rs:874-889.
Safety guarantees
- Checksum verification — every archive is checked against the release's
SHA256SUMSmanifest. - Size limits — archives and extracted binaries are capped at 1 GiB;
SHA256SUMSis capped at 1 MiB. - Smoke test — the downloaded binary must print exactly
rpi <version>from--versionbefore activation; exit status alone is not proof of identity. - Atomic activation — the active symlink is swapped with
rename(2)on Unix andMoveFileExon Windows, so the activerpipath is never missing during an update. - Rollback — if smoke testing, activation, or state writing fails, the
previous active symlink and
update-state.jsonare restored. - Serialized installs — a lockfile prevents concurrent installers from
racing on the same
PI_HOME. - No partial install — a failed transaction removes staged files and leaves the previous binary active.
Source: crates/pi-cli/src/self_update.rs:250-253,
crates/pi-cli/src/self_update.rs:280-284,
crates/pi-cli/src/self_update.rs:601-728,
crates/pi-cli/src/self_update.rs:705-770,
crates/pi-cli/src/self_update.rs:795-875,
crates/pi-cli/src/self_update.rs:897-929,
crates/pi-cli/src/self_update.rs:1081-1147.
Windows deferred activation
On Windows the running executable cannot be replaced while it is executing, so
the self-updater writes a deferred activation script and a
last-update-result.json status file. The new binary is moved into place by a
short-lived PowerShell process after the current rpi process exits. The
deferred activation then re-verifies that the moved binary prints exactly
rpi <version> and restores the previous binary on any mismatch or rollback
failure.
Source: crates/pi-cli/src/self_update.rs:798-875,
crates/pi-cli/src/self_update.rs:987-1025.
Update state
After a successful install the updater writes $PI_HOME/update-state.json. It
records the installed version, asset name, archive digest, versioned binary
path, and install timestamp. On the next update the digest is used to detect
republished tags that point to a different archive.
Source: crates/pi-cli/src/self_update.rs:62-73,
crates/pi-cli/src/self_update.rs:285-295.
Update packages
rpi update --extensions # reconcile every configured package (--all is an alias)
rpi update OWNER/REPO # update one configured git or local package
rpi update local:./my-tools # update a configured local package
rpi update --self --extensions # update packages, then update rpi itself
Source: crates/pi-cli/src/args.rs:270-288,
crates/pi-cli/src/package_commands.rs:69-94.
rpi update --extensions re-clones or checks out every configured git package
and re-discovers every configured local package. Git packages are checked out
into a content-addressed directory under the agent directory. Pinned git refs
are honored; unpinned sources follow the remote's default branch. Local
packages are validated from their configured paths.
Source: crates/pi-coding/src/packages.rs:470-708,
crates/pi-coding/src/packages.rs:1118-1253.
Package updates use the same safety patterns as install:
- Operations are serialized with a per-scope lock.
- Git is invoked directly with an argv vector, never through a shell.
- New checkouts are staged next to the existing one and activated with an atomic directory swap. If validation fails, the swap is rolled back.
- Settings and package state files are written atomically (temp file + rename) and rolled back if either write fails.
Source: crates/pi-coding/src/packages.rs:656-708,
crates/pi-coding/src/packages.rs:1874-1955,
crates/pi-coding/src/packages.rs:2033-2133.
npm: package sources are deliberately not supported. They are rejected with a
clear error (npm package sources are not supported yet; use a local path or git source). See packages.md for the supported package sources and
manifest format.
Source: crates/pi-coding/src/packages.rs:948-954.
Update environment variables
| Variable | Default | Purpose |
|---|---|---|
PI_HOME | ~/.rpi (Unix) / %USERPROFILE%\.rpi (Windows) | Install root for the binary and update state |
PI_UPDATE_BASE_URL | https://api.github.com/repos/0x8f701/rpi/releases | Release API base (must match the installer scripts) |
GITHUB_TOKEN | (none) | Authenticate GitHub API calls for release metadata |
PI_OFFLINE | (none) | Disables all updater networking |
PI_SKIP_VERSION_CHECK | (none) | Disables only the nonfatal interactive startup version check |
Source: crates/pi-cli/src/self_update.rs:1-13,
crates/pi-cli/src/self_update.rs:95-103.
Release policy
- Tags must be semantic versions of the form
vX.Y.Z(optionally with+buildmetadata). - Prerelease tags (
vX.Y.Z-alpha.N) are published as prereleases and are not made "latest", so the default/releases/latestendpoint never points to them. - The release workflow refuses to overwrite an already-published release and verifies the asset inventory before publishing.