Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

User guide

Reference

Quick start

First-run workflow

  1. Install rpi:

    curl -fsSL https://raw.githubusercontent.com/0x8f701/rpi/master/install.sh | sh
    

    See install.md for Windows, pinned binary releases, manual archive verification, and rollback behavior.

  2. Verify the binary:

    rpi --version
    
  3. Configure one provider credential. For example:

    rpi login anthropic
    

    For 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, see authentication.md.

  4. 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"
    
  5. Start an interactive session:

    rpi -m anthropic/claude-sonnet-4-5
    

    The CLI opens the normal-screen inline TUI when both stdin and stdout are terminals; otherwise it uses the line REPL. See cli-modes.md for 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 stdin and stdout are 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

Installation

Supported platforms

The release workflow builds five native targets:

  • aarch64-apple-darwin
  • aarch64-unknown-linux-gnu (glibc 2.31 baseline, Ubuntu 20.04)
  • x86_64-apple-darwin
  • x86_64-pc-windows-msvc
  • x86_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) or install.ps1 (Windows); recommended for users.
  • Prebuilt GitHub Release asset — download the matching .tar.gz / .zip and SHA256SUMS, then verify and extract manually.
  • Self-update — rpi update --self after 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

  1. Detects the host OS/architecture.
  2. Fetches release metadata from the GitHub API.
  3. Downloads the archive and SHA256SUMS.
  4. Verifies the archive digest.
  5. Extracts the rpi / rpi.exe binary.
  6. Smoke-tests the binary with --version.
  7. Writes it to a content-addressed path under PI_HOME/downloads and atomically swaps PI_HOME/bin/rpi to that path on Unix. Windows atomically replaces PI_HOME/bin/rpi.exe because a running executable cannot be a symlink target.
  8. Records the installed identity in PI_HOME/update-state.json.
  9. On Unix, removes a legacy installer-managed PI_HOME/bin/pi symlink 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

VariableDefaultPurpose
PI_HOME~/.rpi (Unix) / %USERPROFILE%\.rpi (Windows)Install root for the binary and update state
PI_UPDATE_BASE_URLhttps://api.github.com/repos/0x8f701/rpi/releasesRelease 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 default titanium palette: electric blue chrome, green success/readouts, gold highlights, and dark titanium surfaces (DARK in crates/pi-cli/src/theme.rs).
  • light — a high-contrast palette for light terminals (LIGHT in theme.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 .pi directory under the user's home directory: HOME on Unix, USERPROFILE on Windows, resolved by home_dir() in crates/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

ChordActionStable ID (config name)
enterSubmit messagetui.input.submit
shift+enter, ctrl+jInsert newlinetui.input.newLine
backspaceDelete character backwardtui.editor.deleteCharBackward
deleteDelete character forwardtui.editor.deleteCharForward
left, ctrl+bMove cursor lefttui.editor.cursorLeft
right, ctrl+fMove cursor righttui.editor.cursorRight
upMove cursor / previous history linetui.editor.cursorUp
downMove cursor / next history linetui.editor.cursorDown
alt+left, ctrl+left, alt+bWord lefttui.editor.cursorWordLeft
alt+right, ctrl+right, alt+fWord righttui.editor.cursorWordRight
home, ctrl+aStart of linetui.editor.cursorLineStart
end, ctrl+eEnd of linetui.editor.cursorLineEnd
ctrl+]Jump forwardtui.editor.jumpForward
ctrl+alt+]Jump backwardtui.editor.jumpBackward
pageupScroll transcript up one pagetui.editor.pageUp
pagedownScroll transcript down one pagetui.editor.pageDown
ctrl+w, alt+backspaceDelete word backwardtui.editor.deleteWordBackward
alt+d, alt+deleteDelete word forwardtui.editor.deleteWordForward
ctrl+uClear the entire composertui.editor.clear
ctrl+kDelete to line endtui.editor.deleteToLineEnd
ctrl+yYanktui.editor.yank
alt+yYank poptui.editor.yankPop
ctrl+-Undotui.editor.undo
escAbort in-flight run, or clear input when idleapp.interrupt
ctrl+cClear inputapp.clear
ctrl+dQuit (when input is empty)app.exit
tabAccept slash-command completiontui.input.tab
ctrl+v, alt+vPaste from clipboardapp.clipboard.pasteImage
ctrl+x, ctrl+shift+cCopy last assistant messageapp.message.copy
ctrl+gOpen external editorapp.editor.external
ctrl+zSuspend (yield terminal)app.suspend
shift+tabCycle thinking levelapp.thinking.cycle
ctrl+tToggle thinking block visibilityapp.thinking.toggle
ctrl+pCycle model forwardapp.model.cycleForward
ctrl+shift+pCycle model backwardapp.model.cycleBackward
ctrl+lOpen model selectorapp.model.select
ctrl+oExpand/collapse tool detailsapp.tools.expand
alt+enterQueue follow-upapp.message.followUp
alt+upDequeue last promptapp.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):

ChordAction
ctrl+nToggle named-only filter (app.session.toggleNamedFilter)
ctrl+pToggle path display (app.session.togglePath)
ctrl+sToggle sort (app.session.toggleSort)
ctrl+rRename selected session (app.session.rename)
ctrl+dDelete selected session (app.session.delete)
ctrl+backspaceDelete without exiting selector (app.session.deleteNoninvasive)

Scoped model selector (/scoped-models):

ChordAction
ctrl+sSave scoped patterns (app.models.save)
ctrl+aEnable all (app.models.enableAll)
ctrl+xClear all (app.models.clearAll)
ctrl+pToggle provider (app.models.toggleProvider)
alt+upMove selected up (app.models.reorderUp)
alt+downMove selected down (app.models.reorderDown)

Session tree / fork panel (/tree, /fork):

ChordAction
leftFold the current node, or move up (app.tree.foldOrUp)
rightUnfold the current node, or move down (app.tree.unfoldOrDown)
alt+shift+lEdit node label (app.tree.editLabel)
alt+shift+tToggle label timestamps (app.tree.toggleLabelTimestamp)
ctrl+dDefault filter (app.tree.filter.default)
ctrl+tNo-tools filter (app.tree.filter.noTools)
ctrl+uUser-only filter (app.tree.filter.userOnly)
ctrl+lLabeled-only filter (app.tree.filter.labeledOnly)
ctrl+aAll entries filter (app.tree.filter.all)
ctrl+oCycle filter forward (app.tree.filter.cycleForward)
ctrl+shift+oCycle 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 .pi directory 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+l or /model with no argument): searchable list of configured providers and models (open_model_panel in tui.rs:1963).
  • Thinking level selector (/settings → "Thinking level" or /theme not used): one entry per level (open_thinking_panel in tui.rs:1984).
  • Settings panel (/settings): toggles thinking level, theme, and automatic compaction (open_settings_panel in tui.rs:2027).
  • Trust panel (/trust): choose Trusted, Untrusted, or Ask for the current project (open_trust_panel in tui.rs:2080). Saved session selector (/sessions, /resume with 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_key in tui.rs:2281).
  • Scoped model selector (/scoped-models): enable/disable models for ctrl+p/ctrl+shift+p cycling, save the scope, and reorder enabled models (handle_scoped_model_selector_key in tui.rs:2387).
  • Session tree (/tree): browse the branching message tree, fold/unfold, apply filters, and edit node labels (handle_tree_panel_key in tui.rs:2143).
  • Fork panel (/fork): a tree filtered to user messages; selecting one and pressing Enter copies the active path up to that message into a new session (TreePanelMode::Fork in tree_panel.rs:9, open_fork_panel in tui.rs:2066).

Extension dialogs are raised when an extension requests interactive UI (ExtensionDialog in tui.rs:127). They include:

  • Select: Up/Down to choose, Enter to accept, Esc to cancel.
  • Confirm: arrow keys or Tab to swap the default; y/Y or n/N or Enter to accept; Esc to cancel.
  • Input / Editor: full editor keys from the keybinding table, Enter (tui.input.submit) to accept, Esc to 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, or TERM_PROGRAM equal to wezterm/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.app and ITERM_SESSION_ID present.
  • Sixel: TERM contains sixel. 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_images in terminal_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.

CommandDescription
/settingsInspect settings or open the settings page
/model [provider/model]Select or switch model
/branchCreate a branch from a previous message
/resume [path, id, or prefix]Resume native or discovered foreign sessions
/forkFork 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)
/agentsManage agent definitions and model overrides
/roleManage 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
/psList supervised processes
/loop [interval] <prompt>Run a recurring prompt
/goalManage the durable session goal
/workflowManage 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)
/liveHold-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-review overlays are TUI-only. In the REPL, /scoped-models and /theme report that they require the TUI, /tree prints JSON, /fork with no argument prints candidate messages, and /model accepts 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:

  1. --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.
  2. --mode json or --mode rpc → headless structured I/O.
  3. -p / --print → print mode.
  4. A non-empty positional prompt with non-terminal stdin or stdout → print mode.
  5. Both stdin and stdout are terminals → TUI, with positional prompts submitted as initial turns.
  6. 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

FlagShortValueMeaning
--model <SPEC>-me.g. anthropic/claude-sonnet-4-5Model to use.
--provider <PROVIDER>provider idProvider used with --model.
--models <PATTERNS>comma-separated patternsScope interactive model cycling.
--print-pForce print mode.
--mode <text|json|rpc>protocolOutput protocol. json streams one-shot events; rpc reads LF-delimited JSON commands on stdin; text uses normal terminal selection.
--continue-cResume the newest native Pi session for this directory. Native-only; foreign sessions are never selected.
--resume <PATH_OR_ID>-rpath or idResume 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 idOpen a session by file path, exact id, or unambiguous prefix.
--session-id <ID>exact idOpen an exact project session id, creating it when absent.
--fork <PATH_OR_ID>path or idFork a session by file path, exact id, or unambiguous prefix.
--session-dir <DIR>directoryOverride the directory used for session storage and id lookup.
--no-sessionDo not persist a session file for this run.
--name <NAME>-ndisplay nameSet the session display name.
--system-prompt <TEXT_OR_PATH>--systemtext or fileOverride the system prompt with text or an existing file.
--append-system-prompt <TEXT_OR_PATH>text or fileAppend to the system prompt; repeatable.
--cwd <DIR>-CdirectoryWorking directory for this run and global subcommands.
--add-dir <DIR>directoryAdd a directory to scoped tools and @file expansion; repeatable.
--thinking <LEVEL>--thinkoff|minimal|low|medium|high|xhigh|maxInitial reasoning level.
--api-key <KEY>secretOverride the API key for the resolved model's provider.
--tools <TOOLS>-tcomma-separated namesAllowlist applied after tool assembly.
--exclude-tools <TOOLS>-xtcomma-separated namesDenylist applied after the allowlist.
--no-tools-ntDisable all built-in, extension, orchestration, and custom tools.
--no-builtin-tools-nbtDisable built-in tools while preserving others.
--extension <PATH>-efile or directoryLoad an explicit extension manifest; repeatable. --extensions is an alias.
--no-extensions-neDisable discovered/configured extensions while retaining explicit --extension paths.
--skill <PATH>file or directoryLoad an explicit skill; repeatable.
--no-skills-nsDisable discovered/configured skills while retaining explicit --skill paths.
--prompt-template <PATH>file or directoryLoad an explicit prompt template; repeatable.
--no-prompt-templates-npDisable discovered/configured prompt templates while retaining explicit paths.
--theme <PATH>file or directoryLoad an explicit theme; repeatable.
--no-themesDisable discovered/configured themes while retaining explicit paths.
--no-context-files-ncDisable AGENTS.md and CLAUDE.md discovery.
--list-models [SEARCH]optional filterList models and exit.
--offlineDisable nonessential startup networking such as catalog refreshes and update checks.
--verboseForce verbose startup diagnostics.
--approve-aTrust project-local .pi settings/resources for this run only.
--no-approveRefuse project-local .pi settings/resources for this run only.
--approval-mode <MODE>yolo|write|askHost 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:8765Start 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 fileOptional 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-remotePermit 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) originAdvertised 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, -VPrint version and exit.
--help-hPrint 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:

  • pi
  • omp
  • codex
  • claude
  • grok
  • droid

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 managed rpi installation.
  • --extensions: reconcile every configured package.
  • --self --extensions or --all: update packages, then update rpi.
  • --models: refresh dynamic model catalogs.
  • --extension SOURCE or positional PACKAGE: update one configured package.
  • positional self or rpi: 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:

SubcommandPurpose
configure URL [--api-key KEY]Configure and validate a router
status [--reload]Show router catalog
refreshRefresh live models, fall back to cached catalog
load MODEL / unload MODELLoad/unload a model
search QUERYSearch Hugging Face for GGUF repos
details OWNER/REPOList quantizations and checksums
download OWNER/REPO [-q QUANT]Download one quantization atomically
installedList local downloads

Interactive and service modes

  1. Web-only listener — when --listen is passed. This service is headless and signal-driven; standard input is not an input or lifetime owner.
  2. Headless JSON/RPC — when --mode json or --mode rpc is passed.
  3. Print mode — when -p / --print is set, or a positional prompt is supplied while stdin or stdout is not a terminal.
  4. TUI — when both stdin and stdout are terminals.
  5. Line REPL — fallback for non-terminal text sessions.

All share the same session engine; only the input and rendering differ.

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.

CommandDescription
/settingsInspect settings (REPL) or open the settings panel (TUI)
/model [spec]Switch model without clearing the transcript
/branchCreate a new branch from a previous message
/resume [path|id|prefix]List or resume native and discovered foreign sessions
/forkFork 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)
/agentsManage agent definitions and model overrides
/roleManage 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
/psList supervised processes
/loop [interval] <prompt>Run a prompt on a recurring interval
/goalCreate and manage the durable session goal
/workflowCreate 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)
/liveHold-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:

CommandDescription
/think <level>Set reasoning level (off, minimal, low, medium, high, xhigh, max)
!commandRun a bash command recorded in context
!!commandRun 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

CommandDescription
/theme [name|next|prev]Show or switch the active theme
/scoped-modelsEnable 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.

KeyAction
EnterSubmit the current message
Shift+Enter or Ctrl+JInsert a newline
Ctrl+D (on empty input)Quit
EscCancel the in-flight run, or clear input when idle
Ctrl+CClear input
Ctrl+UClear the entire composer
TabAccept the selected slash-command completion
Up / DownMove the cursor, select a completion, or navigate submitted prompt history
Left / RightMove cursor
Backspace / DeleteEdit text
Home / EndMove to start/end of line
Ctrl+V / Alt+VPaste from clipboard
Ctrl+X / Ctrl+Shift+CCopy last assistant message
Ctrl+GOpen external editor
Ctrl+LOpen model selector
Ctrl+P / Ctrl+Shift+PCycle model forward/backward
Ctrl+TToggle thinking-block visibility
Shift+TabCycle thinking level
Ctrl+OExpand 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-C aborts the in-flight turn and the CLI exits.
  • In the REPL, Ctrl-C aborts the current turn and returns to the prompt.
  • In the TUI, Esc aborts the in-flight run.

Exit codes

  • 0 — success (--help and --version also exit 0).
  • 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 export on a missing session) surface through this same path.
  • 2 — argument/usage errors, reported by clap's parser (error.exit() in crates/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).

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):

LifecycleMeaning
activeThe goal is current and its work may continue.
pausedThe goal exists but its work is suspended.
completedThe objective is done.
droppedThe 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 kindPayloadMeaning
created—A goal was created (must be the journal's first event).
fork_clonedsourceA forked session cloned this goal from a source snapshot.
pausedreason (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_updateddeltaToken/time usage accumulated while active.
pins_updatedpinsA 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 version GOAL_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 (see session-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.

  • todos.md — the task-level plan, distinct from the goal
  • orchestration.md — the goal tool in child sessions
  • session-recovery.md — the goal appears in handoff envelopes (HandoffGoal in crates/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 (stable task-<uuid>), content, status, depends_on (dependency task ids), ready, blocked_by (derived blocked-reasons with the blocking task's content and status), and an optional agent (a typed routing contract naming the agent that must execute the task).
  • TodoPhase — name + tasks.
  • TodoState — phases + storage (Session — persisted in the session file — or Memory).

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):

OpFieldsEffect
initlist (phases with items and optional parallel agents), or items/phaseCreate the plan. A re-init describing exactly the current plan preserves ids/dependencies/statuses; otherwise it replaces the plan.
appendphase, itemsAdd tasks to a phase.
starttaskMark a task in_progress.
donetask or phaseMark task(s) completed.
droptask or phaseMark task(s) abandoned.
rmtask or phase, cascadeRemove tasks/phase; cascade removes dependents too.
add_dependencytask, dependsOnAdd dependency edges (cycle-checked).
remove_dependencytask, dependsOnRemove dependency edges.
update_dependenciestask, dependsOnReplace 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: task calls pass a todoTaskId when 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. See workflows.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_by are recomputed from depends_on + statuses after every mutation.
  • completed transitions are reported exactly once per task per mutation (completion_transitions compares 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 todo tool is available to orchestration children by default and is never removed by role ceilings (orchestration plumbing).

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):

FieldMeaning
nameIdentifier used by task/hub and by delegation.
descriptionShown in the task tool's available-agent list.
toolsChild tool allowlist; settings.agents.<name>.tools overrides it.
autoloadSkillsSkills autoloaded into the child's prompt.
modelModel pattern list; settings.agents.<name>.model overrides it.
thinkingLevelChild reasoning level.
maxTurns / maxToolCalls / timeoutSecsContract bounds: the child stops cleanly after the cap with a clear reason.
disallowedToolsTools the child must never receive.
capabilityCeilingPer-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.maxConcurrency children run at once (default 8, semaphore-bounded); recursion depth is bounded by maxRecursionDepth (default 8).
  • Job retention: settled jobs and their artifact files (<agent-id>-<job-id>.md outputs, <agent-id>-<job-id>.history.json transcripts) are retained up to DEFAULT_MAX_RETAINED_JOBS = 256 for DEFAULT_RETAINED_JOB_TTL_SECS = 24h, then pruned (JobRetention in orchestration/runtime.rs:32-33).
  • Idle parking: idle non-main agents park after DEFAULT_IDLE_TTL_SECS = 300s and are revived on demand (schedule_idle_park).
  • Soft budgets (JobSoftBudget in orchestration/runtime.rs:154-166, settings orchestration.softBudget): maxRequests, maxTokens, and yieldAfter are 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 settles Completed with the partial result, and both TaskResult and JobSnapshot carry softBudgetExhausted: true so the parent can decide whether to continue the child.
  • Contract bounds: maxTurns, maxToolCalls, and timeoutSecs from 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) — the task tool is only offered to a child while depth < 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 Completed with the partial result and the softBudgetExhausted marker.
  • yield delivers exactly once; a missing yield is observable to the parent via MISSING_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.
  • workflows.md — workflow lifecycle on top of this runtime
  • todos.md — the Todo DAG and its execution
  • goals.md — the durable goal state machine
  • skills.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):

StatusMeaning
queuedCreated, waiting for a runtime slot.
planningA bounded planning turn builds the canonical Todo DAG.
runningThe Todo DAG is being executed by delegated subagents.
pausedExecution is suspended; resume continues it.
integratingCommitted workflow changes are being merged back.
completedAll tasks done; integration applied (or nothing to integrate).
failedPlanning/execution/integration hit a hard error (WorkflowFailure).
cancelledExplicitly cancelled.
conflictedIntegration 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.

FromAction / eventToReason / condition
queuedStart (runtime)planningThe workflow leaves the queue and runs its bounded planning turn.
queuedPausepausedUser pause before planning starts.
queuedCancelcancelledUser cancel; allowed from any non-terminal status.
planningPausepausedUser pause mid-planning; the planning turn is aborted and later resume restarts the bounded flow.
planningplan committed / budget reached / timed outrunningA committed canonical Todo DAG is armed for execution (arm_plan_and_run / preserve_plan_and_run).
planningCancelcancelledUser cancel.
planninghard errorfailedPlanning hit a WorkflowFailure.
runningPausepausedUser pause; backend parks the worktree and child jobs settle.
runningCancelcancelledUser cancel; active workflow job ids are cancelled first (supervisor.rs:702-716).
runningDAG settles completedcompletedAll tasks done or abandoned (todo_dag_status = Settled + exactly complete, supervisor.rs:1228-1237).
runningDAG settles failed / blockedfailedSettled-but-incomplete, or the DAG is permanently blocked.
pausedResumerunning / completedBackend resumes execution; a DAG that finished while paused settles into completed and auto-integrates (manager.rs:438-444).
pausedIntegrateintegratingManual integration of a paused workflow is allowed.
pausedCancelcancelledUser cancel.
completedIntegrate (manual or auto)integratingAuto-integrates whenever the DAG settles into completed with no recorded integration (manager.rs:372-377); Conflicted outcomes land here for manual retry.
integratingmerge appliedcompletedIntegration outcome Applied { strategy, result_commit }.
integratingmerge conflictsconflictedIntegration outcome Conflicted { conflicts } — manual /workflow integrate is required.
integratingmerge errorfailedHard 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 /workflow opens the dedicated workflows page in the TUI.
  • /workflow list lists 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):

typeFieldsNotes
workflow_createname, objective; optional idName and objective must be non-empty.
workflow_list—Returns { "workflows": [...] }.
workflow_getoptional workflowId or nameOne of the two is required.
workflow_pause / workflow_resume / workflow_cancel / workflow_integrate / workflow_removeworkflowIdSelector 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):

SettingBackendNotes
worktree (default)git worktreeBranch 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.
overlayfsoverlayfsThe 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.
nonenoneNo 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 = 8 assistant 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; during planning the (idle at the orchestration layer) supervisor reads as actively planning.
  • subagents — one row per delegated worker: display name, agent type, status, current task summary, owning todoTaskId, 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 orchestration task tool (default false).
  • todo — the todo tool is on by default; false opts 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, or none; unknown values fail deserialization (fail-closed).
  • sandboxed — when true, orchestration subagent children run their process spawns (the bash tool) inside the Linux filesystem sandbox (workspace + agent dir + sandbox.allowedPaths visible; deny-by-default otherwise). See sandbox-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 child Completed with softBudgetExhausted: true, never failed. See orchestration.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: running implies 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 Conflicted or Failed state.
  • rewind is refused while any workflow is active (see session-recovery.md).

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 with settings.sessionTtlDays (must be > 0).
  • Files modified within SESSION_ACTIVE_GRACE = 1 hour are 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.

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
  }
}
KeyDefaultMeaning
enabledfalseMaster 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).
sttModelwhisper-1Model name sent in the transcription request.
language(none)Optional language hint.
allowInsecurefalsePermit http:// endpoints (loopback/self-hosted); https:// is required otherwise.

Validation (validate_live_settings in live.rs:97-129):

  • ws:///wss:// URLs are always rejected — /live speaks HTTP multipart to {base}/v1/audio/transcriptions, not WebSocket.
  • Plaintext http:// is refused unless allowInsecure is 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 allowInsecure is 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.

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):

IdentifierProtocol
openai-completionsOpenAI chat completions (/chat/completions)
openai-responsesOpenAI Responses API (/responses)
openai-codex-responsesOpenAI Codex Responses API
azure-openai-responsesAzure OpenAI Responses API
anthropic-messagesAnthropic Messages API
bedrock-converse-streamAmazon Bedrock Converse streaming API
google-generative-aiGoogle Gemini API
google-vertexGoogle Vertex AI API
mistral-conversationsMistral conversations API
pi-messagesDynamic Radius provider catalog
fauxDeterministic 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):

  1. 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.
  2. 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.
  3. Case-insensitive substring matching on model id and name. Aliases (ids ending in -latest or without a -YYYYMMDD date suffix) are preferred over dated versions; otherwise the latest dated version by descending id is used.
  4. If the provider is known but the id is not, rpi synthesizes 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, or models.
  • api and baseUrl can be set at provider level and overridden per model. Custom models require an api and a baseUrl somewhere in the fallback chain; otherwise loading fails closed.
  • Model-level fields override provider-level fields.
  • reasoning: true enables the off|minimal|low|medium|high|xhigh level ladder; without it only off is available.
  • thinkingLevelMap can disable a level ({ "xhigh": null }) or map a level name to a provider-specific value.
  • compat is merged. Nested openRouterRouting, vercelGatewayRouting, and chatTemplateKwargs objects are merged deeply; all other compat keys are replaced.
  • For custom model entries, defaults are: input ["text"], contextWindow 128000, maxTokens 16384, empty cost, no compat.

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_TOKEN for bearer authentication.
  • Before each request it adds dynamic headers based on the context:
    • X-Initiator: user when the last message is from the user, otherwise agent.
    • Openai-Intent: conversation-edits.
    • Copilot-Vision-Request: true when 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:

FlagEffect
supportsStrictModeEnable JSON-schema strict tool sampling
supportsOpenAIGrammarToolsEnable grammar-style constrained sampling
supportsReasoningEffortMap reasoning level to reasoning_effort
supportsEagerToolInputStreamingAnthropic eager tool-input streaming
supportsLongCacheRetention24h prompt cache retention
supportsCacheControlOnToolsCache control on Anthropic tools
supportsDeveloperRoleOpenAI Responses developer role
supportsStoreAllow storing OpenAI Responses sessions
allowEmptySignatureAllow empty thinking signatures
forceAdaptiveThinkingForce adaptive thinking on Anthropic
maxTokensFieldRename 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):

  1. An explicit API key passed at call time, e.g. --api-key on the CLI (--api-key requires --model or --models).
  2. A runtime key set for the provider (the CLI stores --api-key as a runtime key for the resolved provider for the duration of the run).
  3. A stored auth.json credential for the provider:
    • api_key entries are expanded and used as the key.
    • OAuth entries require async resolution; rpi refreshes expired OAuth tokens automatically before use.
  4. The apiKey field from models.json for that provider.
  5. A recognized provider environment variable (see table below).
  6. Anthropic-specific bearer handling: when the API-key slot is still empty, ANTHROPIC_AUTH_TOKEN is sent as Authorization: Bearer <token> and is never treated as an x-api-key value.
  7. Provider-specific header-only auth configured in models.json or model headers, e.g. authorization, x-goog-api-key, or cf-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:

  • anthropic
  • openai-codex
  • google-gemini-cli
  • xai
  • openrouter
  • kimi-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)
anthropicANTHROPIC_API_KEY, ANTHROPIC_OAUTH_TOKEN, ANTHROPIC_AUTH_TOKEN
github-copilotCOPILOT_GITHUB_TOKEN
openai, openai-codexOPENAI_API_KEY
azure-openai-responsesAZURE_OPENAI_API_KEY
googleGEMINI_API_KEY
google-vertexGOOGLE_CLOUD_API_KEY (or access-token/authorization header)
groqGROQ_API_KEY
cerebrasCEREBRAS_API_KEY
xaiXAI_API_KEY
deepseekDEEPSEEK_API_KEY
openrouterOPENROUTER_API_KEY
nvidiaNVIDIA_API_KEY
mistralMISTRAL_API_KEY
minimax, minimax-cnMINIMAX_API_KEY, MINIMAX_CN_API_KEY
moonshotai, moonshotai-cnMOONSHOT_API_KEY
huggingfaceHF_TOKEN
fireworksFIREWORKS_API_KEY
togetherTOGETHER_API_KEY
opencode, opencode-goOPENCODE_API_KEY
kimi-codingKIMI_API_KEY
cloudflare-workers-ai, cloudflare-ai-gatewayCLOUDFLARE_API_KEY
amazon-bedrockAWS_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-lingANT_LING_API_KEY
qwen-token-plan, qwen-token-plan-cnQWEN_TOKEN_PLAN_API_KEY, QWEN_TOKEN_PLAN_CN_API_KEY
zai, zai-coding-cnZAI_API_KEY, ZAI_CODING_CN_API_KEY
xiaomi, xiaomi-token-plan-cn, xiaomi-token-plan-ams, xiaomi-token-plan-sgpXIAOMI_API_KEY, XIAOMI_TOKEN_PLAN_CN_API_KEY, XIAOMI_TOKEN_PLAN_AMS_API_KEY, XIAOMI_TOKEN_PLAN_SGP_API_KEY
radiusRADIUS_API_KEY
vercel-ai-gatewayAI_GATEWAY_API_KEY

Provider-specific notes:

  • Anthropic: ANTHROPIC_OAUTH_TOKEN wins over ANTHROPIC_API_KEY when an API key is requested. ANTHROPIC_AUTH_TOKEN is used as a bearer token in the Authorization header only when the API-key slot is empty, and is never used as an x-api-key value.
  • GitHub Copilot: COPILOT_GITHUB_TOKEN is required. The provider adds dynamic per-request headers (X-Initiator, Openai-Intent, and Copilot-Vision-Request when 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_KEY supplies an API key, or GOOGLE_CLOUD_ACCESS_TOKEN / an authorization header can supply an access token. The provider never reads Application Default Credential files or credential helpers. Vertex requests also require GOOGLE_CLOUD_PROJECT (alias GCLOUD_PROJECT) and GOOGLE_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:

  • type is "api_key" for manually edited entries. rpi login may write "oauth" entries; those should not be hand-edited.
  • key is the credential value or a $VAR / ${VAR} template.
  • env is 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: true sends Authorization: Bearer <apiKey>. It requires a resolved API key; if none exists the request fails closed.
  • Provider-level headers apply to every model of that provider; per-model headers override them. Merging is case-insensitive last-wins.
  • apiKey, baseUrl, and header values support $VAR / ${VAR} expansion from the process environment. models.json does not support a per-entry env map (only auth.json credentials do). $$ is a literal $ and $! is a literal !. Unset variables produce an error.
  • Command-valued apiKey or 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):

FormMeaning
$VARExpand 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:

  • authorization
  • x-api-key
  • cf-aig-authorization
  • x-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 authHeader fallback → request fails with: no API key found for provider ....
  • authHeader: true without a resolved API key → request fails.
  • A $VAR reference in auth.json or models.json resolves 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 authorization and x-goog-api-key in the same scope → request fails.
  • Amazon Bedrock requests with only one of AWS_ACCESS_KEY_ID or AWS_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 --mode is 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, byte 0x0A). 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\n sources 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"
}
  • id matches the request id when one was provided; otherwise it is omitted.
  • command repeats the request's type value.
  • data is present on success and is null for commands that return no payload.
  • error is 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: ..."}. Because serde_json::from_slice fails before the object is inspected, an invalid JSON line cannot preserve an id.
  • Missing type field → failure response with the preserved id.
  • Unknown type value → {"id":"...","type":"response","command":"<type>","success":false,"error":"Unknown command: <type>"}.
  • Invalid fields for a known command → failure response with command set to the command name and id preserved.
  • 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.

typeFieldsExample request line
promptmessage; [images] array of ContentBlock; [streamingBehavior] "steer" or "followUp"{"type":"prompt","message":"List Rust files"}
steermessage; [images]{"type":"steer","message":"Use async rust"}
follow_upmessage; [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_modelprovider, 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_levellevel ("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_modemode ("all", "one-at-a-time"){"type":"set_steering_mode","mode":"all"}
set_follow_up_modemode ("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_compactionenabled boolean{"type":"set_auto_compaction","enabled":true}
set_auto_retryenabled boolean{"type":"set_auto_retry","enabled":true}
abort_retry—{"type":"abort_retry"}
bashcommand; [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_sessionsessionPath{"type":"switch_session","sessionPath":"<workspace>/session.jsonl"}
forkentryId{"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_namename{"type":"set_session_name","name":"demo"}
get_messages—{"type":"get_messages"}
get_commands—{"type":"get_commands"}
set_todosphases array of TodoPhase{"type":"set_todos","phases":[]}
todo_opop ("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_createinterval, prompt; [fireImmediately] default true; [durable] default false{"type":"loop_create","interval":"5m","prompt":"check","fireImmediately":true,"durable":false}
loop_updatetaskId; [interval]; [prompt]{"type":"loop_update","taskId":"loop-1","interval":"10m","prompt":"check again"}
loop_list—{"type":"loop_list"}
loop_deletetaskId{"type":"loop_delete","taskId":"loop-1"}
loop_canceltaskId{"type":"loop_cancel","taskId":"loop-1"}
process_spawnspec (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_describeprocessId{"type":"process_describe","processId":"00000000-0000-7000-8000-000000000000"}
process_logsprocessId; [cursor] default 0; [limitBytes]{"type":"process_logs","processId":"00000000-0000-7000-8000-000000000000","cursor":0,"limitBytes":1024}
process_writeprocessId, dataBase64{"type":"process_write","processId":"00000000-0000-7000-8000-000000000000","dataBase64":"b2s="}
process_keysprocessId, 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_resizeprocessId, cols, rows{"type":"process_resize","processId":"00000000-0000-7000-8000-000000000000","cols":80,"rows":24}
process_signalprocessId, signal ("SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT", "SIGKILL"){"type":"process_signal","processId":"00000000-0000-7000-8000-000000000000","signal":"SIGTERM"}
process_stopprocessId{"type":"process_stop","processId":"00000000-0000-7000-8000-000000000000"}
process_waitprocessId; [timeoutMs]{"type":"process_wait","processId":"00000000-0000-7000-8000-000000000000","timeoutMs":500}

Common payload notes

  • images items are ContentBlock objects; an inline image looks like {"type":"image","data":"<base64>","mimeType":"image/png"}.
  • QueueMode values serialize as kebab-case: "all" and "one-at-a-time".
  • ThinkingLevel values are lowercase: "off", "minimal", "low", "medium", "high", "xhigh".
  • ProcessSignal values serialize as SCREAMING_SNAKE_CASE.
  • ProcessKey values serialize as the variant names shown above.
  • ProcessId is 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 for Authorization: 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 Authorization header 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-remote when the request's Origin authority equals the HTTP Host (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 send steer, 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 thinking blocks, compact tool-call cards, bash/tool-result blocks, and final markdown. Internal display: false system 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, applying set_model and set_thinking_level; the session name comes from get_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 as sk-…, ghp_…, Bearer …, token=…, PEM private keys) before touching the DOM, and every string crossing into innerHTML is additionally HTML-escaped (& < > " '). Streaming deltas use textContent; model text is never injected as raw HTML.
  • Links are restricted to http/https/mailto and same-origin relative paths; images only render from base64 data: 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_response is 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 committed dist/index.html is what the binary embeds.
  • Rust: cargo test -p pi-cli --lib (subprotocol unit tests) and cargo 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.sh spawns 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 (tmux present, RPI_BIN executable, 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 goalKeep 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 outcomeStatus 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 criteriaEvery 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.
Statusscript E2E.d/user/goal_lifecycle.sh

2. Loop (create / cancel / delete / continue)

User goalRun 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 criteriaA parseable loop id is printed and echoed by /loops; delete/cancel succeed without error text.
Statuslane ic.tui-loop (E2E.d/ci/interactive_commands.sh) + Rust goal_loop_e2e.rs

3. Workflow (plan → todo → execute → integrate; isolation; restart)

User goalDelegate 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 outcomeCreation 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 criteriaTwo 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.
Statusscript 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 goalHave the main session delegate work to named subagents, receive their progress over IRC, and park them on a soft budget.
InteractionNatural-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 outcomeThe 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 criteriaDelegation selects the trusted agent by name; cards appear and resolve; yield observably ends the subagent turn without error.
Statuslane orchestration.rpc / orchestration.rust / orchestration.tmux (E2E.d/ci/orchestration.sh), D47YieldTool, T50SoftBudgets

5. Todo DAG (list / detail / execute)

User goalMaintain a phased task list and inspect the dependency DAG before letting the model execute it.
InteractionSeed 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 outcomeOverview 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 criteriaExact overview/detail chrome strings appear; the seeded task names and phase names render; panel closes without leaving overlay state.
Statusscript E2E.d/user/todo_dag.sh; lane Rust core_tui_e2e.rs::pty_todo_overview_detail_navigation, orchestration.rpc

6. /btw side chat

User goalRun 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 outcomeStatus 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 criteriaOverlay opens/closes with status transitions; tab list contains created names; the main composer still accepts input after close.
Statusscript 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 goalDictate 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 outcomeStatus line shows ⟦live⟧ while armed; recorded text appears in the composer, not the transcript.
Pass criteriaManual: requires a working microphone; the TUI never blocks without one.
Statusmanual (no mic on CI); lane L1LiveVoice, docs/src/user-guide/live.md

8. MCP (stdio server connect + tool call)

User goalAttach an external MCP stdio server and use its tools.
InteractionRegister an MCP server (config/--mcp), prompt the model to call its tool, verify the tool result card.
Observable outcomeServer connects (/mcp status), tool call appears as a card with the server-provided result.
Pass criteriaTool result text from the MCP server lands in the transcript; disconnect cleans up.
Statuslane M1McpGateway, D44McpAcpTests

9. ACP (agent stdio session)

User goalDrive rpi from an external agent over the ACP stdio protocol.
InteractionLaunch rpi under ACP (--mode acp), exchange initialize/session/… messages, approve a tool call via session/request_permission.
Observable outcomeProtocol handshake succeeds; tool approval round-trips; completion returns the model text.
Pass criteriaDeterministic envelope + tool approval exchange without host credentials.
Statuslane A1AcpProtocol, D44McpAcpTests, Rust acp_stdio_e2e.rs

10. Extensions (overlay open, plugin install from dir/git, trust)

User goalLoad a third-party extension, open its overlay, and install a plugin from a directory or git source.
InteractionLaunch 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 criteriaCommand/chain outputs appear in the pane; untrusted installs fail closed without --approve.
Statuslane campaign.extension, ic overlay/plugin lanes, D45OverlayP0, D50GitPluginSource, G10ExtensionDesign

11. /rewind + /snapcompact + /compact

User goalRoll a session back to a checkpoint and shrink its context without losing the archive.
InteractionRun a few prompts → /checkpoint mid → /rewind (picker lists indices and [checkpoint mid -> …]) → /rewind <entry> → /snapcompact → /compact.
Observable outcomeThe 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 criteriaTranscript 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.
Statusscript 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 goalProduce a handoff summary another agent can act on.
Interaction/handoff → /handoff --prose.
Observable outcomeA 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 criteriaEnvelope text appears in the pane; the summarizer is never invoked on the TUI event loop.
Statusscript E2E.d/user/steering_queue_handoff.sh; lane Rust handoff_prose.rs, T99HandoffProse

13. /fresh, /dump, /share --encrypt

User goalStart 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 criteriaFile side effects verified on disk; /share honors a fake gh seam and never requires host credentials.
Statuslane ic.tui-name-new (/new), ic.tui-export (/export), ic.tui-share (/share + fake gh), T82FreshDumpShare, T38ExportFlagHardening

14. Hooks / trust

User goalRun a trust hook that upgrades an untrusted project decision.
InteractionInstall 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 outcomeThe hook's approval lets the project resource load; a deny-only payload stays inert.
Pass criteriaHook event fires and the decision changes exactly as the fail-open contract allows.
Statuslane T27HooksSystem, T91TrustHook, T98TrustWiring, docs/src/reference/settings-trust.md

15. Sandbox (bash denied path)

User goalEnsure a denied filesystem path stays out of reach of the model's bash.
InteractionConfigure sandbox denials; prompt a bash call against the denied path; observe the tool error.
Observable outcomeThe bash tool card renders the denial error; no file is created/read on the denied path.
Pass criteriaThe tool result contains the denial reason; the denied path is untouched on disk.
Statuslane O1OsIsolation, D10SandboxOverlay, D15SandboxToolsE2e, Rust sandbox tests, docs/src/reference/sandbox-isolation.md

16. Image generation (faux)

User goalGenerate an image from a prompt.
Interaction/image <prompt> with a faux-capable provider.
Observable outcomeThe image placeholder card renders [Image #N, WxH] (or the real image under kitty graphics).
Pass criteriaAPI surface exercised against a faux generator (no real model); real-image rendering marked manual.
Statusmanual (real generation); lane G1ImageGen, T109ImageInspect, Rust write_orchestration_png_fixture

17. Eval / notebook (python cell)

User goalRun an inline python cell in the session.
Interaction/run python3 -c … or the notebook overlay; verify cell output in the tool card.
Observable outcomeCell stdout appears in the card; errors surface as tool errors, not TUI hangs.
Pass criteriaOutput text lands in the pane; the composer stays responsive.
Statuslane E1EvalNotebook, T48OfficeNotebook

18. Memory tool

User goalPersist a fact across sessions with the memory tool.
InteractionPrompt the model to store a fact (mock tool call) → verify the memory file → new session reads it back.
Observable outcomeMemory file updated; a follow-up turn's context includes the stored fact.
Pass criteriaFile side effect + cross-session retrieval.
Statuslane T74MemorySystem, T87SkillGoalPins, docs/src/reference/skills.md

19. Ask tool

User goalAnswer a mid-task question the model asks.
InteractionMock provider calls ask; the status line shows ⟦ask⟧ <question> ⟦esc⟧; the next submitted line is routed back as the answer.
Observable outcomeThe pending question is visible above the composer and the answer arrives in the next model request.
Pass criteriaStatus-line ask glyph appears; the answer text reaches the provider request.
Statuslane T51AskTool, Rust steering/ask unit tests

20. Auto-mode

User goalLet the model classify the task and route it automatically.
InteractionSubmit a prompt in auto mode; observe the classification hint (Detected: code task — /todo …) and the routed workflow/todo behavior.
Observable outcomeThe classifier hint renders on the status line; execution follows the routed path.
Pass criteriaHint text appears; routing matches the fixture's classification.
Statuslane T76AutoMode, T53AutoIntegrate, U7WorkflowFixes

21. /queue + doom-loop

User goalKeep steering the agent while it works, then clear the backlog.
InteractionSubmit 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 outcomeThe 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 criteriaCounts appear while queued, drain after processing, and cancel empties the queue with Cancelled N queued prompts.
Statusscript 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 goalRecognize 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 outcomeTheme 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 criteriaExact chrome strings (╭── #, $ , Output, … (unclosed fence)) appear in captures; theme cycles; steering glyph and preview appear/clear with queue lifecycle.
Statusscript 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

ScenarioScriptModelCoverage
Goal lifecycleE2E.d/user/goal_lifecycle.shfauxfull lifecycle + budget chip + pins
Rewind / snapcompact / compactE2E.d/user/rewind_compact.shfauxpicker, checkpoint, sidecar, A→B status
/btw side chatE2E.d/user/btw_side_chat.shfauxopen/type/esc/reopen, tabs, list, close
Steering queue + /handoffE2E.d/user/steering_queue_handoff.shmock (slow stream)follow-up queue, ⚙/suggestion, drain, cancel, handoff envelope
Bash card + code fenceE2E.d/user/bash_card_fence.shmock (tool calls)multi-line bash card, comment frame, unclosed fence marker
Todo DAG pageE2E.d/user/todo_dag.shfauxmarkdown seed, overview/detail chrome, Esc navigation
Workflow full runE2E.d/user/workflow_full_run.shmock (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.

ScenarioResultEvidence
goal-lifecyclepassstatus/detail/chip captures in $EVIDENCE_ROOT/<run-id>/goal-lifecycle/
rewind-compactpassrewind + snapcompact sidecars asserted on disk
btw-side-chatpassoverlay chrome + tab lifecycle captures
steering-queue-handoffpass⚙ counts, auto-drain, /queue cancel, handoff envelope in transcript + fake-xclip capture
bash-card-fencepasscomment frame, $ rows, Output separator, … (unclosed fence)
todo-dagpassoverview/detail chrome + Esc navigation
workflow-full-runpasscreate → plan → Todo DAG → workers → auto-integrate → completed; e2e plan commit + PLAN.e2e verified in git
project-authoringpendingempty 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 pin and reported a current goal already exists on 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's Compacted A → B estimated tokens status can be overwritten by the session's Compaction complete event (event ordering); the scripts accept either and assert the sidecar archive durably.
  • The non-snap /compact LLM summarizer requires > keepRecentTokens of 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 one tool_calls index per SSE delta.

Settings, configuration, and trust

Configuration directory

The runtime resolves the agent configuration directory (<agent-dir>) in this order:

  1. PI_CODING_AGENT_DIR environment variable.
  2. The platform home directory (HOME on Unix, USERPROFILE on Windows) with /.pi/agent appended.

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 .pi directory 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

FieldPurpose
defaultProviderDefault provider id used when no model flag is given.
defaultModelDefault model id used when no --model is given.
defaultThinkingLevelDefault reasoning level (off, minimal, low, medium, high, xhigh, max).
defaultProjectTrust"ask", "always", or "never".
approvalModeGlobal host tool policy: yolo allows all tools, write confirms Exec, and ask confirms every tool call. Project settings cannot override it.
steeringModeSteering queue mode (all or one-at-a-time).
followUpModeFollow-up queue mode (all or one-at-a-time).
sessionDirOverride the session storage directory.
sessionImportSourcesAllowed session import sources (omp, codex, claude, grok, droid).
sessionTtlDaysStartup session TTL pruning age in days (default 30; must be > 0).
authScopeActive credential scope label; PI_AUTH_SCOPE overrides it at runtime.
themeInitial TUI theme name.
compactionContext compaction limits (enabled, reserveTokens, keepRecentTokens).
terminalLegacy TUI rendering options (showImages, imageWidthCells, clearOnShrink, showTerminalProgress).
imagesImage display options (autoResize, blockImages) plus generation overrides (genModel, genBaseUrl, genApiKey).
retryRetry policy (enabled, maxRetries, baseDelayMs, plus optional provider overrides).
autoRetry, maxRetries, baseDelayMsTop-level legacy aliases for the same retry fields.
transportStream transport (auto, sse, web-socket, web-socket-cached).
timeoutMsHTTP/stream timeout.
httpIdleTimeoutMsIdle HTTP connection timeout.
websocketConnectTimeoutMsWebSocket connect timeout.
maxRetryDelayMsMaximum delay between retries.
temperatureSampling temperature, finite and 0..=2.
maxTokensMaximum tokens per model request.
cacheRetentionCache retention (none, short, long).
responsesStatefulChainOpt-in stateful turn chaining for OpenAI Responses API models (default false); see configuration-profiles.md.
thinkingBudgetsPer-level token budgets (minimal, low, medium, high).
scopedModels / enabledModelsAlias for the same model-pattern allowlist.
branchSummaryBranch-summary token reserve and skipPrompt.
keybindingsAction-to-chord map (value is a string or array of strings).
quietStartupSuppress non-essential startup messages.
showThinking / hideThinkingBlockControl whether thinking blocks are shown.
showImages, imageWidthCells, autoResizeImagesTop-level legacy aliases for terminal/image options.
exposeSessionEnvironmentForward session environment to tools.
doubleEscapeActionTUI action on double-escape (fork, tree, none).
orchestrationOrchestration tool gates, concurrency limits, workflow isolation, and soft budgets (see orchestration.md and workflows.md).
selectorOptional model-selector thresholds (advanced).
agentsPer-agent runtime settings (settings.agents.<name>.enabled/model/tools); see orchestration.md.
packagesLocal/git package sources to install/load.
extensions, skills, prompts, themesResource names to load from configured packages and discovered paths.
sandboxOpt-in Linux filesystem sandbox for bash (enabled, network, allowedPaths, deniedPaths, readOnly); see sandbox-isolation.md.
liveHold-to-talk voice configuration (enabled, sttBaseUrl, sttApiKey, sttModel, language, allowInsecure); see live.md.
memoryMemory backend (backend: local/hindsight/off; Hindsight HTTP endpoint/token, plaintext opt-in, bank/scoping, recall policy, injection, and per-operation timeouts); see memory.md.
hooksHost hooks: external commands observing/gating session events; see hooks.md.
permissionRulesPath-level permission rules evaluated before the approval mode; see security.md.
mcpServersMCP 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.

SettingApplied by
approvalModesession_run_blueprint host approval hook
steeringModesteering_mode
followUpModefollow_up_mode
retryretry_settings / apply_session_options
compactionapply_session_options
transportapply_session_options
timeoutMsapply_session_options
maxRetryDelayMsapply_session_options
temperatureapply_session_options
maxTokensapply_session_options
cacheRetentionapply_session_options
thinkingBudgetsapply_session_options
scopedModelsscoped_model_patterns
enabledModelsscoped_model_patterns (legacy alias)
terminaltui_runtime
imagestui_runtime
themetui_runtime / resource validation
keybindingstui_runtime
branchSummarybranch_summary_settings
quietStartuptui_runtime
hideThinkingBlocktui_runtime
showThinkingtui_runtime
exposeSessionEnvironmentexpose_session_environment
doubleEscapeActiontui_runtime
orchestrationorchestration tool gates

Source: crates/pi-coding/src/settings.rs:302-330.

Model and thinking-level precedence

When the CLI starts without an explicit model:

  1. --model / -m flag.
  2. Resumed session's saved model (when --resume or --continue is used).
  3. settings.json defaultModel.
  4. First authenticated model in the catalog.

Thinking level precedence:

  1. --think flag.
  2. Thinking level parsed from a :level model suffix (anthropic/claude-sonnet-4-5:high).
  3. Resumed session's saved thinking level.
  4. settings.json defaultThinkingLevel.
  5. 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 reload validates the current settings/resource graph and prints a structured snapshot, including generation, 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 /reload and REPL reload call the same stage-and-commit path, so malformed files never silently replace a working configuration.
  • Settings writes performed through update_global or update_project are atomic (temp file + fs::rename + directory sync). Session-only overrides via apply_overrides are 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/--approve or set defaultProjectTrust explicitly.
  • approvalMode is a global safety policy and is rejected in project .pi/settings.json; --approval-mode is 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.json and auth.json values may contain $VAR / ${VAR} templates, which are expanded from the current process environment or an explicit env map. 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::newcrates/pi-coding/src/session.rs:723
Session::runcrates/pi-coding/src/session.rs:3685
execute_with_retriescrates/pi-coding/src/session.rs:3853
Application::new / Application::promptcrates/pi-coding/src/application.rs:486 / :1402
Agent(pi-agent)crates/pi-agent/src/agent.rs:229
run_agent_loop / run_loopcrates/pi-agent/src/loop_runtime.rs:45 / :103
create_all_toolscrates/pi-coding/src/tools.rs:537
OrchestrationRuntime / ChildSessioncrates/pi-coding/src/orchestration/runtime.rs:730 / :57
WorkflowManager / WorkflowSupervisorcrates/pi-coding/src/workflow/manager.rs:196 / supervisor.rs:283
WorkflowWorktreeManagercrates/pi-coding/src/workflow_worktree/mod.rs:280
ExtensionSpec / QuickJsExtensionHostcrates/pi-coding/src/extensions.rs:564 / quickjs_host.rs:630
McpRegistry / mcp_toolcrates/pi-coding/src/mcp.rs:554 / :656
SessionRecorder / start_sessioncrates/pi-coding/src/session_store.rs:607 / :1177
AuthManager / TrustStore / SandboxConfigauth.rs:921 / trust.rs:172 / sandbox.rs:48
SelectionPlan / ResourceManager / MemoryConfigselector.rs:321 / resource_manager.rs:317 / memory.rs:588
ProcessManagercrates/pi-coding/src/process/manager.rs:23
RuntimeSettingsSnapshotcrates/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 _ (default is valid and selects the default profile); whitespace is trimmed first. Anything else (slashes, dots, spaces, non-ASCII) fails with an actionable error (validate_profile_name in args.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 Settings struct; unknown fields are retained in the extra maps, 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 extra maps 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[].env tokens).
  • 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 resolve previous_response_id from 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; default and 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 .toml extension 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.

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

VariableDefaultPurpose
PI_HOMEAuto-detected from the running executable layoutBinary install root used by the self-updater
PI_UPDATE_BASE_URLhttps://api.github.com/repos/0x8f701/rpi/releasesRelease 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

VariableDefaultPurpose
PI_CODING_AGENT_DIRDefault agent directory under the user's homeAgent config directory (models.json, auth.json, resources, llama cache)
SESSIONS_HOME$HOMERelocates 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

VariablePurpose
LLAMA_BASE_URLllama.cpp router base URL (without /v1)
LLAMA_API_KEYOptional llama.cpp router bearer token
HF_TOKENHugging Face token for GGUF search/download
HF_TOKEN_PATHPath to a file containing a Hugging Face token
HF_HOMEHugging Face cache home; token is read from token inside it
HF_ENDPOINTOverride the Hugging Face base URL
XDG_CACHE_HOMECache home; token is read from huggingface/token inside it
HOME / USERPROFILEUser home directory; used for cache and agent directory fallbacks

Tool environment

The bash tool receives the parent process environment with these additions:

VariablePurpose
PI_PROVIDERResolved provider id
PI_MODELResolved model id
PI_REASONING_LEVELCurrent thinking level name
PI_SESSION_IDCurrent session id
PI_SESSION_FILEPath 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_TOKENAnthropic
COPILOT_GITHUB_TOKENGitHub Copilot
OPENAI_API_KEYOpenAI, OpenAI Codex
AZURE_OPENAI_API_KEYAzure OpenAI Responses
GEMINI_API_KEYGoogle Gemini
GOOGLE_CLOUD_API_KEYGoogle Vertex (API-key auth)
GOOGLE_CLOUD_ACCESS_TOKENGoogle Vertex (access-token auth)
GROQ_API_KEYGroq
CEREBRAS_API_KEYCerebras
XAI_API_KEYxAI
DEEPSEEK_API_KEYDeepSeek
OPENROUTER_API_KEYOpenRouter
NVIDIA_API_KEYNVIDIA
MISTRAL_API_KEYMistral
MINIMAX_API_KEY, MINIMAX_CN_API_KEYMiniMax
MOONSHOT_API_KEYMoonshot
HF_TOKENHugging Face
FIREWORKS_API_KEYFireworks
TOGETHER_API_KEYTogether
OPENCODE_API_KEYOpenCode
KIMI_API_KEYKimi coding
CLOUDFLARE_API_KEYCloudflare Workers AI / AI Gateway
AWS_PROFILEAmazon Bedrock
AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEYAmazon Bedrock (SigV4)
AWS_SESSION_TOKENAmazon Bedrock session token
AWS_BEARER_TOKEN_BEDROCKAmazon Bedrock bearer auth
AWS_CONTAINER_CREDENTIALS_RELATIVE_URIAmazon Bedrock container credentials
AWS_CONTAINER_CREDENTIALS_FULL_URIAmazon Bedrock container credentials
AWS_WEB_IDENTITY_TOKEN_FILEAmazon Bedrock web-identity credentials
ANT_LING_API_KEYAnt Ling
QWEN_TOKEN_PLAN_API_KEY, QWEN_TOKEN_PLAN_CN_API_KEYQwen token-plan
ZAI_API_KEY, ZAI_CODING_CN_API_KEYZAI
XIAOMI_API_KEY, XIAOMI_TOKEN_PLAN_CN_API_KEY, XIAOMI_TOKEN_PLAN_AMS_API_KEY, XIAOMI_TOKEN_PLAN_SGP_API_KEYXiaomi
RADIUS_API_KEYRadius
AI_GATEWAY_API_KEYVercel AI Gateway

Precedence notes:

  • Anthropic: ANTHROPIC_OAUTH_TOKEN wins over ANTHROPIC_API_KEY. ANTHROPIC_AUTH_TOKEN is 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 an authorization header.

Azure OpenAI configuration

In addition to AZURE_OPENAI_API_KEY:

VariablePurpose
AZURE_OPENAI_API_VERSIONAPI version, default v1
AZURE_OPENAI_BASE_URLBase URL override
AZURE_OPENAI_RESOURCE_NAMEResource name; builds https://{name}.openai.azure.com/openai/v1
AZURE_OPENAI_DEPLOYMENT_NAME_MAPComma-separated modelId=deploymentName mappings

AWS Bedrock configuration

VariablePurpose
AWS_REGIONBedrock region
AWS_DEFAULT_REGIONFallback region
AWS_BEDROCK_SKIP_AUTHSet to 1 to skip auth (test/local only)
AWS_BEDROCK_FORCE_CACHESet to 1 to force prompt caching

See the provider API-keys table above for the credential variables.

Google Vertex configuration

VariablePurpose
GOOGLE_CLOUD_PROJECTRequired project id (alias GCLOUD_PROJECT)
GOOGLE_CLOUD_LOCATIONRequired location
GOOGLE_CLOUD_ACCESS_TOKENShort-lived access token
GOOGLE_CLOUD_API_KEYAPI key (sent as x-goog-api-key)

Anthropic cache retention

VariablePurpose
PI_CACHE_RETENTIONSet 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

VariablePurpose
PI_OAUTH_CALLBACK_HOSTOverride the OAuth redirect callback bind address (default 127.0.0.1)
KIMI_CODE_OAUTH_HOSTOverride the Kimi Code OAuth host
KIMI_OAUTH_HOSTFallback override for the Kimi OAuth host

Extension host

VariablePurpose
PI_EXTENSION_IDExtension id passed to extensions (internal)
PI_EXTENSION_ENTRYExtension entry path (internal)
PI_EXTENSION_PACKAGE_IDPackage id for the extension (internal)
PI_EXTENSION_CAPABILITIESJSON capabilities list (internal)
PI_EXTENSION_UI_CAPABILITIESJSON UI capabilities list (internal)
PI_EXTENSION_MAX_FRAME_BYTESMax extension IPC frame size (internal)
PI_EXTENSION_PROTOCOL_VERSIONExtension protocol version (internal)

Session import

VariablePurpose
CODEX_HOMECodex directory under the user's home; session import source
CLAUDE_CONFIG_DIRClaude config directory under the user's home; project import source

Editor / clipboard / display

VariablePurpose
VISUALPreferred external editor
EDITORFallback external editor
DISPLAYX11 display detection for clipboard
WAYLAND_DISPLAYWayland display detection
XDG_SESSION_TYPESession type detection (e.g. wayland)
COLORFGBGTerminal background-color hint for theme selection
XDG_CONFIG_HOMEUsed to locate git/ignore and other config fallbacks

Template expansion

auth.json and models.json values may use these forms:

FormMeaning
$VARExpand 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.readOnly is set),
  • everything else on the host filesystem is denied (tmpfs root + pivot_root),
  • the command gets a private, empty HOME and TMPDIR under the sandbox root instead of the host home (codex/claude parity),
  • the network is off by default (fresh net namespace, loopback only),
  • /proc reflects 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"]
  }
}
KeyDefaultMeaning
enabled(off)Master switch for sandboxed spawns.
networkfalseShare the host network; default is a fresh net namespace with loopback only.
readOnlyfalseBind 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.enabled is set, bash commands run through run_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, and sandbox.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 unshare wrapper 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):

  1. Kernel overlay — mount -t overlay -o lowerdir=,upperdir=,workdir=. Requires mount privilege: real root, or a user namespace that owns the process mount namespace.
  2. fuse-overlayfs — PATH lookup; runs as a FUSE daemon, works unprivileged and is visible to every process.
  3. rcopy — recursive copy of lower into merged; 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).

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):

FieldMeaning
eventpre_tool_call, post_tool_call, session_start, session_end, turn_start, turn_end, or pre_trust_decision. Unknown events are rejected at deserialize time.
matcherExact or substring match on the event subject (tool name, message role, or canonical project path). Absent matchers fire for every subject.
commandCommand argv run without a shell (command[0] is the executable, the rest are argv); must be non-empty.
timeoutMsPer-hook timeout; defaults to 5000, capped at 60000. A timed-out hook's process group is killed.
enabledSet false to skip the entry without removing it.
failClosedOnly 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_call payloads include the tool name and a text rendering of arguments.
  • pre_trust_decision payloads carry the canonical project path, the tentative decision (trusted/untrusted/ask), and isNew — the same spelling the extension trust_decision event 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 via crate::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_end fire around the session.
  • Turn lifecycle: turn_start / turn_end fire around each agent turn.
  • Tool lifecycle: pre_tool_call / post_tool_call observe (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 — see session-recovery.md).
  • Trust: pre_trust_decision fires 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_call and pre_trust_decision can block, and only with an explicit failClosed entry do errors turn into blocks.
  • Disabled entries never fire; empty commands are skipped with a diagnostic.
  • Extension trust recommendations can only upgrade an ask to trusted — never weaken a stored decision.

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 be 1.
  • id — alphanumeric plus _, -, or ..
  • runtime — optional process for executables, or required quickjs for 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 .js or .mjs file. TypeScript (.ts) is not supported by the in-process runtime.
  • capabilities — one or more of commands, tools, event_hooks, message_renderers, provider_metadata, session_actions, ui, overlays. QuickJS entries reject message_renderers and provider_metadata because their factories cannot cross the extension protocol.
  • uiCapabilities — required when ui is listed; one or more of select, 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-process pi.events bus
  • 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, and getCommands
  • with session_actions, sendMessage, sendUserMessage, appendEntry, setSessionName, setLabel, setActiveTools, setModel, and setThinkingLevel, plus context abort, shutdown, compact, reload, and waitForIdle
  • UI methods select, confirm, input, editor, notify, setStatus, setWidget with string arrays, setTitle, setEditorText, plus the query methods getEditorText, getAllThemes, getTheme, setTheme, and getToolsExpanded

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 the pi API.
  • Outbound frames (responses, registrations, updates, actions) are bounded by max_frame_bytes like 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=1
  • PI_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

CapabilityWhat the extension can do
toolsRegister tools the agent can call
commandsRegister slash commands
event_hooksReceive agent lifecycle events
message_renderersRender custom message types
provider_metadataProvide extra model metadata
session_actionsRead invocation snapshots and request session/model/tool actions
uiShow select/confirm/input/editor/status widgets
overlaysRegister 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 required PI_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/console globals.
  • On Unix the child runs in its own process group (process_group(0)); on termination the host sends SIGKILL to 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_renderers and provider_metadata capabilities 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 the session_actions capability is missing or the host cannot process the action.
  • Project extensions are only launched when the project is trusted.
  • kill_on_drop and 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:

FieldRequiredPurpose
nameNoSkill 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 --.
descriptionYesUsed in the system prompt and selector. Must be non-empty and ≤ 1,024 UTF-16 code units. A skill with no description is dropped.
globsNoPath patterns that trigger the selector when the user's request mentions matching files. YAML list or comma-separated string.
alwaysApply / always-applyNoWhen the YAML boolean true, the skill is autoloaded into the context.
hide / hiddenNoWhen the YAML boolean true, the skill is excluded from the <available_skills> block and selector.
disable-model-invocationNoWhen 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:

  1. <agent-dir>/skills/ — global user skills, always scanned.
  2. <workspace>/.pi/skills/ — project-local skills, scanned only when the project is trusted.
  3. Settings skills and package skills resources.
  4. Explicit --skill PATH arguments.

Discovery rules from load_skills_from_dir in crates/pi-coding/src/resources.rs:

  • A directory containing SKILL.md is a skill root; its children are not scanned further.
  • At a root without SKILL.md, direct .md children are loaded and subdirectories are scanned recursively for SKILL.md.
  • node_modules, hidden directories, and entries matched by .gitignore, .ignore, or .fdignore are 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:

  1. One-run --approve / --no-approve.
  2. Persisted decision in <agent-dir>/trust.json.
  3. defaultProjectTrust in settings (ask, always, never).
  4. 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 --cwd and 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_path itself must also be inside base_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:

SignalPointsNotes
Exact name match1,600Request tokens equal skill name phrase.
Name phrase contained in request1,000e.g. request contains "rust review".
Name token overlap220 eachStopwords are ignored.
Description token overlap60 eachStopwords are ignored.
Description phrase (≥ 2 tokens)90 per tokenLongest shared phrase.
alwaysApply: true+2,000Also makes the skill autoload.
Matching globs path+800 per matched patternPatterns are globset globs matched against request paths.
Source precedence+precedenceUser 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 read tool with skill://<name> to load a skill when the task matches its description. When a skill file references a relative path, read it as skill://<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 autoloadSkills frontmatter.

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:

SkillAgent
Locationskills/agents/
Frontmattername, description, globs, alwaysApply, disable-model-invocationname, description, tools, autoloadSkills, model, thinkingLevel
UseLoaded by the current session via skill://Spawned as a subagent by the orchestration runtime
System promptListed 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
  • A git URL using https, http, ssh, or git as 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
  • A git: shorthand using a host with a domain or localhost: 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: true enables all resources from the package by default.
  • extensions, skills, prompts, themes are resource-filter lists. They may contain exact resource names, glob patterns, ! exclusions, and + / - force-include/exclude tokens used by rpi 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 named pi-extension.json.
  • skills/ — SKILL.md or .md files.
  • prompts/ — .md files.
  • themes/ — .json files.

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 / .tar archive,
  • an owner/repo GitHub reference,
  • an npm:<name>[@<version>] reference — resolved through the npm registry to the package's dist.tarball, with the tarball's content authenticated against dist.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 the rpi install package manager; the plugin marketplace accepts them via rpi plugin install).
  • package.json lifecycle 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):

BackendToolsBehavior
local (default)memoryBuilt-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.
hindsightrecall, retain, reflectCalls 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
  }
}
KeyDefaultMeaning
memory.backendlocallocal, hindsight, or off.
memory.hindsightApiUrlunsetExplicit Hindsight HTTP API base URL. Required for hindsight.
memory.hindsightApiTokenunsetOptional bearer token. Secret settings views always redact it.
memory.hindsightAllowInsecurefalseExplicitly 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.hindsightBankIdrpiBase bank id.
memory.hindsightBankIdPrefixunsetOptional bank-id prefix.
memory.hindsightScopingper-project-taggedglobal, per-project, or per-project-tagged.
memory.hindsightBankMissionunsetOptional reflect mission applied while ensuring the bank exists.
memory.hindsightRetainMissionunsetOptional retain mission applied while ensuring the bank exists.
memory.hindsightInjectionfalseInject bounded recall for the latest ask as hidden context.
memory.hindsightRecallBudgetmidHindsight recall/reflect budget: low, mid, or high.
memory.hindsightRecallMaxTokens1024Maximum tokens requested from recall.
memory.hindsightRecallTypesworld, experienceMemory types included in recall.
memory.hindsightRequestTimeoutMs30000Default HTTP request deadline.
memory.hindsightRecallTimeoutMs30000Recall deadline.
memory.hindsightRetainTimeoutMs60000Retain deadline.
memory.hindsightReflectTimeoutMs120000Reflect 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; learn evicts the oldest.
  • recall defaults 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/recall
  • POST /v1/default/banks/{bank}/memories
  • POST /v1/default/banks/{bank}/reflect
  • PUT /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 carry project:<sanitized-label>-<digest-prefix> and recalls use that tag with tags_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.
  • off removes 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.

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-compatible images/generations). The model must declare image capability (active model, an explicit model argument, or settings.images.genModel); images.genBaseUrl/genApiKey override the endpoint/credential for self-hosted services. Prompts capped at 4096 characters, n at 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 while PI_OFFLINE is 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.

ToolRequiredOptionalExample call
readpath—read path=src/main.rs
bashcommandtimeout, excludeFromContext, cwd, envbash command="cargo test" timeout=120
browseractionurl, selector, text, pathbrowser action=fill url=https://example.com selector=#name text=world (tools/browser.rs:96-115)
githubactionrepo, query, number, title, body, path, state, refgithub action=view_file repo=octocat/Hello-World path=README (tools/github.rs:97-131)
lspactionpath, query/symbol, line, character, end_line, end_character, new_name, langlsp action=definition path=src/main.rs line=10 character=4 (tools/lsp.rs:221-275)
evallanguage, codetimeouteval language=python code="x = 1" (tools/eval.rs:1049-1065)
notebookaction, pathcell, write, cell_type, source, timeoutnotebook action=execute path=demo.ipynb write=true (tools/notebook.rs:128-157)
debugactionadapter, program, args, cwd, launch_args, adapter_args, file, line, thread, variables_reference, expression, frame_id, wait_ms, levelsdebug launch adapter=gdb program=./bin → debug set_breakpoint file=src/main.rs line=42 → debug continue_ (tools/debug.rs:759-823)
mcpactionserver, tool, argsmcp call server=my-tools tool=weather args={"city":"London"} (mcp.rs:667-690)
askquestion—ask question="Should I proceed?" (schema {question: string} in pi_agent::create_ask_tool)
web_searchquery—web_search query="Rust async cancellation safety" (tools/web_search.rs:39-50)
ast_greppatternpath, langast_grep pattern='fn $FNAME() {}' path=src lang=rust (tools/ast_grep.rs:63-84)
ast_editpattern, rewrite, pathlangast_edit pattern='Some($A)' rewrite='Option::Some($A)' path=src/lib.rs (tools/ast_edit.rs:66-93)
memoryopcontent, tags, query, limit, tag, idmemory learn content="…" tags=["rust"]; memory recall query="async" limit=5; memory forget id=<id> (memory.rs:451-468)
generate_imagepromptmodel, size, n, pathgenerate_image prompt="a cat" size=1024 n=1 path=out.png (tools/image_gen.rs:81-121)
inspect_imagepath—inspect_image path=screenshot.png (tools/image.rs:82-90)
todoop, 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)
taskagent/task, or tasks[] (each item needs task; name/agent/todoTaskId nullable)name, todoTaskId, contexttask agent=researcher task="Study the persistence layer" todoTaskId=task-abc (orchestration/tools.rs:497-513)
hubopto, message, replyTo, await, from, timeoutMs, peek, ids, agentId, lineshub send to=w1 message="…"; hub wait from=w1 timeoutMs=30000; hub read_history agentId=w1 lines=50 (orchestration/tools.rs:536-606)
yieldtext—yield text="<full final deliverable>" (orchestration/tools.rs:136-140)
goalop—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); see security.md.
  • Tool arguments are validated against their schemas before execution, and unknown actions/arguments fail with actionable messages.

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
    }
  ]
}
KeyMeaning
nameServer name used by mcp list_tools <server> / mcp call <server>.
transportstdio (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/argsstdio server command line.
envExtra environment for the stdio child. Never echoed into tool output; $VAR/${VAR} references expand from the process environment.
urlSSE endpoint (accepted, not connected in this build).
disabledtrue (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_servers renders name, transport (stdio command line or sse URL), and a live marker for servers with a spawned session.
  • list_tools pages through tools/list (up to 512 tools across 32 pages) and renders a name+description table.
  • call validates the tool name client-side against the known list (or via the server's tools/search extension when advertised) and renders tools/call results as bounded text (32 KiB cap). args must 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 Drop never 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_tool extension (capabilities.tools.search_tool or the older experimental location), tools/call probes with a tools/search request 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.

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 stdio speaks 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 serve speaks 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:

MethodPurpose
initializeNegotiate the protocol version and capabilities; advertises the rpi-auth auth method.
authenticateAcknowledge 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/newCreate 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/promptRun a turn; the assistant response streams back as session/update notifications and the request resolves with a stopReason.
session/cancelAbort the active turn (request or notification form); the pending session/prompt resolves with stopReason: "cancelled".
session/closeCancel ongoing work and release the session.
logoutAcknowledge (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-mode flag or the approvalMode setting) requires confirmation, the agent asks the client for an allow-once / reject-once decision before the tool executes. Evaluation order: path-level permissionRules first (same evaluation as the interactive host hook), then the capability-wide approval mode, then the ACP reverse round trip (acp_approval_before_tool_call in acp.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, and usage_update variants, projected from rpi's ApplicationEvents.

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 cancelled outcome.
  • mcpServers in session/new are 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"}]}}
  • rpc-json.md — the rpi-native JSONL control plane (different protocol, same Application runtime)
  • security.md — approval modes and permission rules
  • settings-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.md instructions 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:

FieldPurpose
descriptionShown 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-hintOptional 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

ConceptWhat it isWhere it appears
Prompt templateA reusable user-prompt fragmentExpanded into the user message when you type /name in the REPL or TUI
SkillSpecialized instructions loaded on demandListed in the system prompt under <available_skills>; the model loads it via skill://<name>
AgentA subagent definition with its own system promptUsed by the orchestration runtime to spawn child sessions (see skills.md)
Context fileProject-specific instructionsInjected verbatim into the system prompt under <project_context>

Discovery order and precedence

The ResourceManager loads prompt templates from these sources, in order:

  1. Global default directory: <agent-dir>/prompts/.
  2. Project default directory: <workspace>/.pi/prompts/ (only when trusted).
  3. Settings prompts and package prompts resources.
  4. Explicit --prompt-template PATH arguments.

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:

  1. The one-run CLI flag --approve / --no-approve.
  2. A persisted decision in <agent-dir>/trust.json.
  3. defaultProjectTrust from settings (ask, always, never).
  4. 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 --cwd and 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:

PlaceholderMeaning
$1, $2, …Positional argument (1-based). Missing arguments expand to empty.
$@, $ARGUMENTSAll 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 $1 in an argument or default stays literal.
  • If no template matches the /name command, 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:

  1. The agent config directory (<agent-dir>).
  2. 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_PATH appends 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:

  1. The role description.
  2. Available tools: — one-line snippets for each selected tool.
  3. Guidelines: — de-duplicated prompt guidelines from built-in tools and prompt_guidelines, plus:
    • Use bash for file operations like ls, rg, find when bash is present without grep/find/ls.
    • Be concise in your responses.
    • Show file paths clearly when working with files.
  4. A block pointing to the rpi docs and examples paths.
  5. The --append-system-prompt text, if any.
  6. The <project_context> block, if any context files are loaded.
  7. The <available_skills> block, only when the read tool is selected.
  8. 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

SubcommandPurpose
rpi llama statusShow configured router and live models
rpi llama status --reloadAsk the router to rescan its model directory
rpi llama refreshRefresh live models; fall back to cache
rpi llama load MODELLoad a model through the router
rpi llama unload MODELUnload 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 .part file and renamed into place only after the checksum succeeds.
  • Resumable: a partial .part file is reused with an HTTP Range request.
  • 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

VariablePurpose
LLAMA_BASE_URLRouter base URL (skips rpi llama configure)
LLAMA_API_KEYRouter bearer token
HF_TOKENHugging Face token for GGUF search/download
HF_ENDPOINTCustom Hugging Face API endpoint
PI_OFFLINESkip router refresh at startup
PI_CODING_AGENT_DIROverride 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

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:

  1. One-run --approve / --no-approve override.
  2. If the project has no .pi directory at all, treat it as trusted for that run (there is nothing local to load).
  3. A persisted decision from trust.json.
  4. 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 0o700 on Unix.
  • A temporary file is created with 0o600 permissions.
  • The JSON is serialized, synced, and moved into place with fs::rename.
  • The final file is set to 0o600 and 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

  • Credential and RequestAuth Debug impls only expose the credential type, whether a key is present, and counts of headers/env entries.
  • --api-key is documented as "never logged". Source: crates/pi-cli/src/args.rs.
  • The HTTP headers authorization, x-api-key, x-goog-api-key, and cf-aig-authorization are marked sensitive with reqwest's HeaderValue::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 authorization lines cannot leak a stale value.

Do not commit auth.json or models.json containing real keys.

All built-in file tools operate relative to the configured --cwd.

  • crates/pi-coding/src/tools/paths.rs::resolve_scoped_path resolves 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_file applies the same containment to @file arguments 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 @file inputs are capped at 8 MiB and image @file inputs 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. Configured npm: 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 .lock suffixes 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 json emits one JSON line per application event and flushes after each record. Source: crates/pi-cli/src/modes/json.rs.
  • --mode rpc reads 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, or fetch globals, 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.rs and crates/pi-cli/src/tui.rs.
  • --listen selects a headless Web-only service around one live Application; 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.
  • --listen is loopback-only (127.0.0.0/8 or ::1) by default. A non-loopback or wildcard bind requires the explicit --listen-allow-insecure-remote opt-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 serve remains 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 as Authorization: Bearer <token> or the constant-time rpi-auth.<token> WebSocket subprotocol on every bind. Without a token, the listener is tokenless: native clients without Origin are always accepted, and a browser (which always sends Origin) is accepted only when its Origin authority equals the request's HTTP Host — 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-origin required 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_start without an explicit baseUrl) and the reachable /web URL printed at startup; loopback and other specific binds advertise their bound address automatically, and a wildcard bind without it prints no reachable URL while /collab fails 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: 1 and uses deny_unknown_fields, so extra fields fail closed. The id is validated as an identifier up to 128 bytes. Source: crates/pi-coding/src/extensions.rs.
  • runtime is discriminated: process uses executable + optional arguments; quickjs uses entry and rejects arguments. QuickJS entries must end in .js or .mjs.
  • UI capabilities require the ui capability; QuickJS extensions reject message_renderers and provider_metadata because 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_piped with a SandboxConfig); see sandbox-isolation.md.
  • Every process extension receives PI_EXTENSION_PROTOCOL_VERSION=1, PI_EXTENSION_ID, and PI_EXTENSION_PACKAGE_ID.
  • QuickJS extensions additionally receive PI_EXTENSION_ENTRY, PI_EXTENSION_CAPABILITIES, PI_EXTENSION_UI_CAPABILITIES, and PI_EXTENSION_MAX_FRAME_BYTES.
  • The child is placed in its own process group (process_group(0)) and has kill_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, and wait all require the caller's owner_id to 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_spec rejects empty argv, NUL bytes in argv or env, non- absolute or non-directory cwd, env keys containing = or NUL, labels over 64 bytes, and output_bytes above the manager maximum. Source: crates/pi-coding/src/process/manager.rs.
  • Subprocesses are spawned with env_clear() plus explicit overrides, their own process group, and kill_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.
  • ProcessLog keeps 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 OutputAccumulator bounds 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.rs and crates/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.rs enforces:
    • 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.
  • 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>/mem access) 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 SHA256SUMS file. 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 --version and requires the output to be exactly rpi <version> before activation — exit status alone is not proof of identity.
  • Unix: the versioned binary is placed with a single rename(2), then the active bin/rpi symlink is swapped with another rename(2). The active path is never missing. The prior symlink target is captured so rollback restores exactly what was live. Source: install.sh and crates/pi-cli/src/self_update.rs.
  • Windows: MoveFileEx with MOVEFILE_REPLACE_EXISTING performs an atomic same-volume replace. If the running rpi.exe is 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.ps1 and crates/pi-cli/src/self_update.rs.
  • update-state.json is 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 current rpi process exits; the deferred activation re-verifies that the moved binary prints exactly rpi <version> and restores the previous binary on any mismatch. Source: crates/pi-cli/src/self_update.rs.
  • Concurrent installs are serialized: install.sh uses a PID-based lockfile; install.ps1 uses a named mutex; self_update.rs acquires 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), and pivot_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, /lib64 are bind-mounted read-only so commands can execute; user data under those roots is not readable.
  • sandbox.deniedPaths entries 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".
  • /proc reflects 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 unshare binary, and (for unprivileged users) permission to create user namespaces (kernel.unprivileged_userns_clone or 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/passwd is 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 $HOME unless explicitly allowed.
  • deniedPaths hides existing content but cannot prevent a command from creating a new entry at a denied path inside a read-write allowed path.
  • sandboxed and background are 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:

  • gh must be installed.
  • gh auth login must 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+x or ctrl+shift+c (action name app.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

VariablePurpose
PI_SHARE_VIEWER_URLViewer 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|yes disables all updater networking.
  • PI_SKIP_VERSION_CHECK disables 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

  1. Selects the release. Stable versions query /releases/latest. Prerelease versions pick the newest published prerelease. Drafts and unpublished releases are rejected.
  2. Locates the platform archive (rpi-<version>-<triple>.tar.gz or .zip) and the release's SHA256SUMS file.
  3. Enforces size limits: archives are capped at 1 GiB and SHA256SUMS at 1 MiB.
  4. Downloads SHA256SUMS, looks up the expected digest, and skips the download when the installed digest already matches (unless --force is used).
  5. Downloads the archive, verifies its SHA-256 digest against SHA256SUMS, and extracts the binary to a staged path.
  6. Runs a smoke test: the staged binary must print exactly rpi <version> from --version.
  7. Atomically installs the versioned binary and swaps the active symlink, then writes update-state.json atomically.

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 SHA256SUMS manifest.
  • Size limits — archives and extracted binaries are capped at 1 GiB; SHA256SUMS is capped at 1 MiB.
  • Smoke test — the downloaded binary must print exactly rpi <version> from --version before activation; exit status alone is not proof of identity.
  • Atomic activation — the active symlink is swapped with rename(2) on Unix and MoveFileEx on Windows, so the active rpi path is never missing during an update.
  • Rollback — if smoke testing, activation, or state writing fails, the previous active symlink and update-state.json are 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

VariableDefaultPurpose
PI_HOME~/.rpi (Unix) / %USERPROFILE%\.rpi (Windows)Install root for the binary and update state
PI_UPDATE_BASE_URLhttps://api.github.com/repos/0x8f701/rpi/releasesRelease 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 +build metadata).
  • Prerelease tags (vX.Y.Z-alpha.N) are published as prereleases and are not made "latest", so the default /releases/latest endpoint never points to them.
  • The release workflow refuses to overwrite an already-published release and verifies the asset inventory before publishing.