← Corpus / agent-harnesses / profile
pi
- Path
- profiles/Profile__Pi.md
Pi — Profile
A profile of Pi as it lives in this study (studies/agent-harnesses/pi/). Cites pinned paths so you can jump to source rather than trust paraphrase. Read alongside Profile__Opencode.md (same study, same five deep-dive questions) and the sibling harnesses goose (Rust, extensions-as-MCP-wrapper) and aider (git-native repo-map/diff-based, no MCP) — Pi is the study’s “deliberately minimal core, capability-as-extension” corner, and its own README explicitly stakes out that ground.
TL;DR
Pi’s own docs are unusually blunt about what it refuses to be. packages/coding-agent/docs/usage.md:307 states the core design boundary in one line: “It intentionally does not include built-in MCP, sub-agents, permission popups, plan mode, to-dos, or background bash.” The README doubles down with a direct rebuttal to the industry-standard MCP pattern (packages/coding-agent/README.md:493): “No MCP. Build CLI tools with READMEs (see Skills), or build an extension that adds MCP support.” — linking out to the maintainer’s own blog post arguing MCP is unnecessary overhead for a CLI-native agent. CONTRIBUTING.md:7-11 gives the philosophy driving both refusals: “pi’s core is minimal… If your feature does not belong in the core, it should be an extension.”
The monorepo shape is a real, five-package npm workspace, verified via each package’s own package.json, not the four-package guess a prior pass assumed: packages/ai → @earendil-works/pi-ai (provider abstraction — 38 distinct provider integrations, see Deep dive 1’s provider-abstraction note below), packages/tui → @earendil-works/pi-tui (terminal rendering), packages/agent → @earendil-works/pi-agent-core (session/harness engine, depends on pi-ai), packages/coding-agent → @earendil-works/pi-coding-agent (the actual CLI, depends on all three), and a fifth, packages/orchestrator → @earendil-works/pi-orchestrator, explicitly labeled "experimental orchestrator package for pi" in its own package.json:4. All five sit at version 0.80.6. The package manager is npm, not pnpm or Bun — confirmed by a root package-lock.json ("lockfileVersion": 3) and the total absence of pnpm-lock.yaml or bun.lockb anywhere in the checkout, a real divergence from OpenCode’s Bun-only stance.
Despite the “no MCP, no sub-agents” party line, both features exist in this checkout in a specific, deliberate way: not vendored in core, but shipped as real, maintained example extensions and mentioned nowhere as vendored core packages. This is the single most important nuance a shallow read of the README would miss, and it’s covered in full in Deep dives 4 and 5 below.
Session persistence is the loudest counter-example to “minimal”: Pi ships a genuine git-like, tree-structured, branchable session format — /tree, /fork, /clone are real, shipped, iterated-on commands, not aspirational docs (packages/coding-agent/CHANGELOG.md:3730-3736, v0.31.0: “This release introduces session trees for in-place branching… navigate to any previous point with /tree, continue from there, and switch between branches while preserving all history in a single file.”). Pi’s mental model is: keep the agent loop and tool surface deliberately small and auditable, but make the session itself a serious, append-only, branchable, git-adjacent data structure — and push MCP, sub-agents, and anything else “big” out to an extension SDK the project dogfoods on itself via its own .pi/ directory.
The project’s own packages/coding-agent/CHANGELOG.md is 4,947 lines, structured in Keep-a-Changelog form (## [Unreleased] at the top, with ### Breaking Changes / ### New Features / ### Added subsections, e.g. lines 1-14) — this is not a token gesture at release notes but an actively maintained ship-log spanning hundreds of point releases (git describe --tags on this pinned commit resolves to v0.80.6-24-g0e6909f0, i.e. 24 commits past the v0.80.6 tag), which is itself evidence that the “session trees” and “renamed /branch to /fork” claims cited above are real historical events, not retrofitted documentation.
Why this exists
Pi frames itself, in its own philosophy documents, as a reaction against feature-creep in coding-agent harnesses. packages/coding-agent/docs/usage.md:307-309 lists what’s deliberately absent — MCP, sub-agents, permission popups, plan mode, to-dos, background bash — and for each, points at an escape hatch: build it as an extension, spawn separate pi processes via tmux, or install a package that does it your way. SECURITY.md:37-56 extends the same posture to its threat model: there is no built-in permission system, and “prompt injection,” “malicious trusted extension/skill,” and “AGENTS.md prompt injection” are explicitly named as out of scope for security reports — containment is pushed to external sandboxing (a Gondolin micro-VM example extension, plain Docker, or OpenShell, per README.md:37-46), not solved in-process. CONTRIBUTING.md:7-11’s “core minimal, capability as extension” line is the load-bearing sentence for every design decision downstream of it. AGENTS.md (repo root, 163 lines) is the flip side — a genuinely detailed operating manual the maintainers hold their own contributing agents to, forbidding destructive git operations for concurrent-session safety (AGENTS.md:57-59) and mandating npm run check after any change (AGENTS.md:28).
Deep dive 1 — file/session organization: ~/.pi/agent/, no XDG, project-local .pi/ gated on trust
Pi does not use the XDG Base Directory spec. A targeted search across packages/*/src for XDG_CONFIG_HOME/XDG_DATA_HOME/any XDG_* variable found exactly one hit, and it’s unrelated to config paths: packages/coding-agent/src/utils/clipboard-image.ts:23 checks env.XDG_SESSION_TYPE === "wayland" purely to detect a Wayland compositor for clipboard-image handling. Every config/session path instead hangs off a single global root, resolved in packages/coding-agent/src/config.ts:514-521:
export function getAgentDir(): string {
const envDir = process.env[ENV_AGENT_DIR];
if (envDir) return expandTildePath(envDir);
return join(homedir(), CONFIG_DIR_NAME, "agent");
}
CONFIG_DIR_NAME defaults to .pi but is actually read from the CLI package’s own manifest — packages/coding-agent/package.json:6-8 declares "piConfig": {"configDir": ".pi"} — and ENV_AGENT_DIR is computed as ${APP_NAME.toUpperCase()}_CODING_AGENT_DIR (config.ts:495), i.e. PI_CODING_AGENT_DIR by default, or a different prefix entirely for a white-labeled binary. So the canonical global root is ~/.pi/agent/, and everything else is derived from it: getModelsPath(), getAuthPath(), getSettingsPath(), getToolsDir(), getBinDir() (managed fd/rg binaries), getPromptsDir(), getCustomThemesDir(), getSessionsDir() (config.ts:523-559).
Session files live at a formulaic path documented directly in packages/coding-agent/docs/session-format.md:7-11: ~/.pi/agent/sessions/--<cwd-with-slashes-as-dashes>--/<timestamp>_<uuid>.jsonl, implemented in packages/coding-agent/src/core/session-manager.ts via resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-"). Each session is one JSONL file — one JSON object per line — versioned (current version 3, enforced at packages/agent/src/harness/session/jsonl-storage.ts:70: if (header.version !== 3) throw invalidSession(...)), with a header shape of {"type":"session","version":3,"id":"uuid","timestamp":"ISO8601","cwd":"/path","parentSession":"optional-path"} (session-format.md:190-198, matching the SessionHeader interface at jsonl-storage.ts:8-16). getDebugLogPath() (config.ts:563-566) puts a per-app debug log alongside the agent dir as ${APP_NAME}-debug.log — the same white-labeling mechanism (APP_NAME from piConfig.name, config.ts:489-490) that lets a distributor rebrand .pi and pi- prefixes wholesale without touching source.
The three env vars that actually govern this layout are documented verbatim in packages/coding-agent/docs/usage.md:294-300: PI_CODING_AGENT_DIR (“Override config directory; default is ~/.pi/agent”), PI_CODING_AGENT_SESSION_DIR (“Override session storage directory; overridden by --session-dir”), and PI_PACKAGE_DIR (“Override package directory, useful for Nix/Guix store paths” — a concrete acknowledgment that Pi expects to run from read-only Nix-store installs, not just a mutable node_modules). Two more, PI_OFFLINE and PI_SKIP_VERSION_CHECK (usage.md:297-298), gate every network call the CLI makes at startup — including the pi.dev latest-version check — behind explicit opt-outs, a detail that matters for anyone running Pi in an air-gapped or CI environment.
Settings precedence: global vs. project, lock-protected, deep-merged
packages/coding-agent/src/core/settings-manager.ts makes the global/project precedence concrete rather than aspirational. FileSettingsStorage’s constructor (settings-manager.ts:187-196) resolves exactly two files — join(resolvedAgentDir, "settings.json") (global, i.e. ~/.pi/agent/settings.json) and join(resolvedCwd, CONFIG_DIR_NAME, "settings.json") (project, i.e. <cwd>/.pi/settings.json) — and every write goes through withLock(scope, fn) (settings-manager.ts:224-225), which acquires a real file lock via the proper-lockfile package with a synchronous retry loop (acquireLockSyncWithRetry, lines 199-221, up to 10 attempts at 20ms) before mutating either file — a concrete safeguard against two concurrent Pi processes corrupting settings.json. The merge itself is deepMergeSettings(base, overrides) (settings-manager.ts:131-155): nested objects merge key-by-key recursively, but primitives and arrays are replaced wholesale by the override — documented in the function’s own comment as “project/overrides take precedence, nested objects merge recursively.”
Provider abstraction: 38 integrations behind one interface, browser-safe by construction
packages/ai is not a thin Anthropic-plus-OpenAI wrapper — packages/ai/src/providers/ contains 38 distinct non-.models.ts provider modules (verified via find … -type f -name "*.ts" ! -name "*.models.ts" ! -name "all.ts" | wc -l), covering the obvious majors (anthropic.ts, openai.ts, google.ts, google-vertex.ts, amazon-bedrock.ts, mistral.ts, azure-openai-responses.ts) alongside a long tail of gateways and regional variants (openrouter.ts, together.ts, fireworks.ts, groq.ts, cerebras.ts, cloudflare-workers-ai.ts, cloudflare-ai-gateway.ts, vercel-ai-gateway.ts, github-copilot.ts, huggingface.ts, nvidia.ts, deepseek.ts, moonshotai.ts + moonshotai-cn.ts, minimax.ts + minimax-cn.ts, zai.ts + zai-coding-cn.ts, xiaomi.ts plus three region-specific xiaomi-token-plan-{ams,cn,sgp}.ts variants, kimi-coding.ts, ant-ling.ts, xai.ts, opencode.ts + opencode-go.ts). All 38 are imported and composed in one aggregator, packages/ai/src/providers/all.ts:5-30, each exporting a *Provider object consumed by createModels() (all.ts:3).
A small but telling detail: packages/ai/src/env-api-keys.ts:1-23 deliberately avoids top-level node:fs/node:os/node:path imports — the file comment reads “NEVER convert to top-level imports - breaks browser/Vite builds” (env-api-keys.ts:1) — and instead dynamically import()s them only if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) (lines 13-23). This confirms pi-ai is engineered to run inside a browser bundle as well as Node/Bun, with Node-only environment-variable API-key lookup silently no-op’ing outside those runtimes. Auth material itself resolves through getAuthPath() (packages/coding-agent/src/config.ts:534-536), joining the same global agent dir to auth.json — one file, alongside settings.json and models.json, all siblings under ~/.pi/agent/.
Managed fd/rg binaries
getBinDir()’s own doc comment (config.ts:547-550) is explicit: “Get path to managed binaries directory (fd, rg)”, resolving to ~/.pi/agent/bin. packages/coding-agent/src/utils/tools-manager.ts backs this with a real downloader: a TOOLS registry (lines 29-53+) maps fd → GitHub repo sharkdp/fd and rg → BurntSushi/ripgrep, each with a getAssetName(version, platform, arch) function that picks the right release tarball per OS/arch, a systemBinaryNames fallback list (e.g. ["fd", "fdfind"]) to prefer an already-installed system binary before downloading, and an offline guard — isOfflineModeEnabled() (lines 14-17) checks PI_OFFLINE before attempting any network fetch, with NETWORK_TIMEOUT_MS = 10_000 and DOWNLOAD_TIMEOUT_MS = 120_000 as hard ceilings (lines 10-11). This is a real, working binary-management layer, not a shell-out-and-hope wrapper.
Project-local resources live under <cwd>/.pi/ but are gated on project trust, not loaded unconditionally the way OpenCode’s directory walk-up is. resource-loader.ts:966-967 reads join(this.cwd, CONFIG_DIR_NAME, "SYSTEM.md") only if (this.settingsManager.isProjectTrusted() && existsSync(projectPath)); the same gate applies to APPEND_SYSTEM.md (lines 979-991). The bootstrap sequence is deliberately two-pass: loadProjectTrustExtensions() first force-disables project trust (resource-loader.ts:330-336, this.settingsManager.setProjectTrusted(false)) to load only global/user + CLI-temporary resources, and only after a resolveProjectTrust callback decides to trust the project does the real reload() proceed (lines 338-350) — a real, code-level defense against an untrusted repo’s .pi/ silently executing extension code on first open. AGENTS.md/CLAUDE.md discovery, by contrast, is unconditional and walks upward from cwd to filesystem root (resource-loader.ts:85-120), globally first, then root-to-cwd ancestor order.
Deep dive 2 — memory/state persistence: session trees, and /tree//fork//clone are real, not aspirational
This is the headline finding: session rewind/branching is real, shipped, and iterated on across multiple releases — not a docs-only claim. The core primitive is packages/agent/src/harness/session/session.ts (338 lines): every append (appendMessage, appendThinkingLevelChange, appendModelChange, appendCompaction, appendCustomEntry, appendLabel, appendSessionName) constructs a tree node whose parentId is await this.storage.getLeafId() (e.g. session.ts:203-211). The literal “rewind” operation is moveTo(entryId, summary?) (session.ts:318-337): it calls this.storage.setLeafId(entryId), moving the active tree pointer to any prior entry, and optionally appends a branch_summary entry recording where the abandoned branch was left — invoked from packages/agent/src/harness/agent-harness.ts:804 during /tree navigation, with an LLM-generated summary via generateBranchSummary (agent-harness.ts:763).
Crucially, branching is additive, never destructive: packages/agent/src/harness/session/jsonl-storage.ts:247-265, JsonlSessionStorage.setLeafId(), appends a LeafEntry ({type:"leaf", id, parentId, targetId}) to the same JSONL file rather than truncating or rewriting it; getPathToRoot(leafId) (lines 296-309) walks parentId links backward to reconstruct whichever branch is currently active. Three user-facing slash commands expose this, registered at packages/coding-agent/src/core/slash-commands.ts:31-33: /fork (“Create a new fork from a previous user message”), /clone (“Duplicate the current session at the current position”), /tree (“Navigate session tree (switch branches)”). Double-Escape on an empty editor triggers tree/fork navigation per a configurable doubleEscapeAction (packages/coding-agent/src/modes/interactive/interactive-mode.ts:2540-2559).
This is not retrofitted marketing copy — the CHANGELOG shows real naming churn typical of an evolving feature: CHANGELOG.md:3730-3736 (v0.31.0) announces session trees directly: “Sessions now use a tree structure with id/parentId fields. This enables in-place branching.” CHANGELOG.md:3080 records that the command was originally called /branch and was later renamed to /fork (issue #641); CHANGELOG.md:1171 documents /clone being added afterward specifically so /fork could narrow its meaning to “fork from a prior user message.” packages/coding-agent/docs/sessions.md:69-139 gives a full ASCII-art worked example and a /tree vs /fork vs /clone comparison table.
Deep dive 3 — tool-call tracing: a real typed hook/event system, but no OTLP or distributed tracing
There is no OpenTelemetry SDK usage anywhere in Pi’s own source. @opentelemetry/api@1.9.0 appears only as a dependency in packages/ai/package.json:59, almost certainly a transitive requirement of @aws-sdk/client-bedrock-runtime; a direct grep for opentelemetry across packages/ai/src/**/*.ts returned zero matches — it is never imported or instrumented against. What the codebase itself labels “telemetry” is narrower still: packages/coding-agent/src/core/telemetry.ts:8-12 defines isInstallTelemetryEnabled(), gated by env var PI_TELEMETRY — this is an anonymous install/update ping, not tool-call tracing (packages/coding-agent/docs/usage.md’s env-var table confirms: “Override install/update telemetry and provider attribution headers”).
What does exist, and is real: a plain in-process EventEmitter (packages/coding-agent/src/core/event-bus.ts:1-33, createEventBus() wraps Node’s node:events, with per-handler error-swallowing at lines 19-25), plus a genuinely typed, per-tool hook API for extension authors in packages/coding-agent/src/core/extensions/types.ts. The ToolCallEvent union, read verbatim (extensions/types.ts:890-898):
export type ToolCallEvent =
| BashToolCallEvent
| ReadToolCallEvent
| EditToolCallEvent
| WriteToolCallEvent
| GrepToolCallEvent
| FindToolCallEvent
| LsToolCallEvent
| CustomToolCallEvent;
fires before a tool executes and can block/mutate its input; a mirrored ToolResultEvent union (lines 949-957) fires after, with per-tool details typed (e.g. a BashToolDetails shape carrying exit code/stdout/stderr). This is exactly the mechanism the project’s own dogfooded extension .pi/extensions/tps.ts:13,17 uses — hooking agent_start/agent_end to compute and display tokens-per-second and cache-read/cache-write token counts after each turn. It is a real, well-typed local hook system an extension could wire into an external exporter, but Pi core ships no tracer, no span format, and no OTLP integration itself. Session-analysis scripts (scripts/cost.ts, scripts/tool-stats.ts, scripts/stats.ts) exist too, but they post-process JSONL session files after the fact — not live telemetry.
Deep dive 4 — tool/MCP scoping: MCP is referenced-only, and deliberately absent by design
MCP is not vendored anywhere in this checkout. find . -iname "*mcp*" (excluding node_modules) returns zero directory or file matches — there is no packages/pi-mcp-adapter, no packages/mcp, no client or server implementation. A whole-repo case-insensitive grep for “mcp” turns up exactly four non-implementation hits: (1) packages/coding-agent/README.md:388, MCP listed as an example of what an extension could add; (2) the explicit refusal at README.md:493, quoted in the TL;DR, with a link to the maintainer’s blog post arguing against needing MCP; (3) packages/coding-agent/test/settings-manager-bug.test.ts:45,52-53, where the literal string "npm:pi-mcp-adapter" appears purely as an arbitrary fixture value in a SettingsManager unit test about preserving user edits to settings.json — the test exercises no MCP logic and the string is interchangeable with any package name; (4) an unrelated OAuth scope string "user:mcp_servers" in packages/ai/src/utils/oauth/anthropic.ts:38, which is Anthropic’s own Console OAuth scope, not Pi’s. Verdict: referenced-only, and explicitly, deliberately absent by design — not a package that exists but wasn’t checked out; it simply isn’t implemented here, by philosophy.
Tools themselves are registered through a straightforward built-in + extension model rather than an MCP-mediated one. There is no single central “tool registry array” the way OpenCode’s ToolRegistry.Service composes one — instead, each built-in tool lives in its own file under packages/coding-agent/src/core/tools/ (bash.ts, read.ts, edit.ts, write.ts, grep.ts, find.ts, ls.ts), each exporting a create<Name>Tool()/create<Name>ToolDefinition() factory pair, and all barrel-re-exported through packages/coding-agent/src/core/tools/index.ts:1-60 (confirmed by direct read: the file is a flat sequence of export { create*Tool, create*ToolDefinition, type *ToolInput, ... } from "./*.ts" blocks, one per tool). packages/coding-agent/src/core/system-prompt.ts:90 shows the actual default selection at the point tools are assembled into a turn: const tools = selectedTools || ["read", "bash", "edit", "write"] — a plain string-array default, not a schema-driven permission object. Extensions add to this surface via registerTool(...), the same API the example subagent extension uses (see Deep dive 5), exposed through the typed hook infrastructure in packages/coding-agent/src/core/extensions/loader.ts and extensions/types.ts.
There is no wildcard-permission-rule engine analogous to OpenCode’s Permission.evaluate — SECURITY.md:37-56 states plainly that Pi has no built-in permission system at all; scoping/sandboxing is pushed entirely to the process/OS boundary (tmux panes, Docker, the Gondolin micro-VM example extension) rather than an in-process ruleset.
Deep dive 5 — skills/subagents/custom-commands: .md-file loaders, and subagents as a real example extension (not core)
AGENTS.md/CLAUDE.md loading: packages/coding-agent/src/core/resource-loader.ts:67-83, loadContextFileFromDir(dir), checks candidates in order ["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"] — both conventions are recognized, first match per directory wins. loadProjectContextFiles() (lines 85-120) loads the global agent-dir’s context file first, then walks upward from cwd to filesystem root collecting one per ancestor, ordered global-first then root-to-cwd. A --no-context-files/-nc CLI flag disables this entirely (packages/coding-agent/src/cli/args.ts:270). Separately, packages/coding-agent/src/core/tools/read.ts:37 gives AGENTS.md/CLAUDE.md special compaction/truncation treatment when read via the read tool.
Prompt templates / slash commands: packages/coding-agent/src/core/prompt-templates.ts implements a .md-file-as-slash-command system — loadTemplateFromFile() (lines 103-132) derives the command name from the filename and reads description/argument-hint frontmatter; substituteArgs() (lines 69-101) supports bash-style $1/$@/$ARGUMENTS/${N:-default}/${@:N} substitution. loadPromptTemplates() (lines 193-262) loads from agentDir/prompts/ (global) and cwd/.pi/prompts/ (project-local) plus any CLI-supplied paths. This checkout’s own .pi/prompts/{cl,is,pr,sa,wr}.md are real, working examples the maintainers use on themselves — e.g. .pi/prompts/wr.md (“Wrap it”) sequences changelog-update, PR-comment, commit, push, and issue-close, gated on the branch being main.
The /share slash command (registered in slash-commands.ts, described as “Share session as a secret GitHub gist”) is implemented at packages/coding-agent/src/modes/interactive/interactive-mode.ts:5513-5522: it spawns gh gist create --public=false <tmpFile> (line 5522) as a real child process, streams its stdout/stderr, and parses the returned https://gist.github.com/<user>/<id> URL to extract the gist ID (lines 5544-5548) — this is the mechanism the .pi/extensions/import-repro.ts dogfooded extension (Deep dive 3 above) consumes on the receiving end, closing the loop between “one maintainer shares a repro session” and “another maintainer’s Pi imports and replays it locally.”
Skills: packages/coding-agent/src/core/skills.ts (487 lines) is a parallel .md-file loader — respects .gitignore/.ignore/.fdignore while scanning (IGNORE_FILE_NAMES, line 15), enforces MAX_NAME_LENGTH = 64/MAX_DESCRIPTION_LENGTH = 1024 (lines 11, 14), and supports a disable-model-invocation frontmatter flag. This checkout’s own .pi/skills/add-llm-provider.md is a real dogfooded example: a 7-step checklist for adding a new LLM provider (core types → provider implementation → lazy registration → model generation → tests → coding-agent wiring → docs), citing packages/ai/src/types.ts for step 1 and, at step 3 (add-llm-provider.md:31), “Register the provider in packages/ai/src/providers/register-builtins.ts via lazy loader wrappers.” Worth flagging precisely rather than repeating uncritically: the only register-builtins.ts that actually exists in this checkout is packages/ai/src/providers/images/register-builtins.ts (image-model registration) — the skill file’s own path for the general-provider registrar is slightly stale relative to this pinned commit, a small real-world instance of the exact kind of doc/code drift this profile is trying to avoid.
The full built-in slash-command roster, read directly from packages/coding-agent/src/core/slash-commands.ts:24-45 (BUILTIN_SLASH_COMMANDS, 22 entries), spans session/model management (/settings, /model, /scoped-models — “Enable/disable models for Ctrl+P cycling”), the branching trio (/fork, /clone, /tree), export/import/sharing (/export — “HTML default, or specify path: .html/.jsonl”, /import, /share, /copy), trust and auth (/trust — “Save project trust decision for future sessions”, /login, /logout), and lifecycle (/new, /compact, /resume, /reload — “Reload keybindings, extensions, skills, prompts, themes, and context files”, /quit). That /reload entry is itself a small confirming data point for this profile’s config-discovery claims above: it explicitly re-runs discovery for all five resource types plus context files, meaning none of them are cached for the life of the process — they’re designed to be hot-reloadable mid-session.
Subagents: core explicitly excludes them (docs/usage.md:307,309: “No sub-agents… Spawn pi instances via tmux, or build your own with extensions”) — grep across packages/*/src for a built-in registerTool("subagent", ...) finds none. But a complete, real, maintained implementation ships as a first-party example extension: packages/coding-agent/examples/extensions/subagent/index.ts registers a tool literally named "subagent" (line 462, description at line 465: “Delegate tasks to specialized subagents with isolated context.”), spawning a separate pi process per invocation (doc comment, lines 2-12) into an isolated scratch dir (fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-")), line 240), with named personas at subagent/agents/{planner,reviewer,scout,worker}.md and orchestration prompts at subagent/prompts/{implement,implement-and-review,scout-and-plan}.md. It has real iteration history in the CHANGELOG (parallel-mode diagnostics, Bun-vfs path-leak fixes, agent-dir resolution fixes — CHANGELOG.md:681,1347,2128,2268,3636,4293-4309) and is cross-listed in packages/coding-agent/docs/extensions.md:2872. The repo’s own analysis tooling independently reuses the same spawn-a-pi-process pattern (scripts/session-transcripts.ts:78, runSubagent()). Verdict: core has zero built-in subagent tool, but a complete, first-party, maintained example extension implements the concept fully — precisely the “core minimal, capability as extension” philosophy in action.
Non-interactive/scripting mode and the TUI package
Pi is not TUI-only. packages/coding-agent/src/cli/args.ts:140-145 implements a real --print/-p flag: “Non-interactive mode: process prompt and exit” (help text at args.ts:244), which greedily consumes the next positional argument as the prompt message unless it looks like a flag or an @-file reference (args.ts:141-144). Paired with it is a genuine output-mode selector — export type Mode = "text" | "json" | "rpc" (args.ts:10), surfaced as --mode <mode> (“text (default), json, or rpc”, args.ts:243) — the "rpc" mode is the concrete mechanism for driving Pi as a subprocess from another program (exactly the pattern the example subagent extension and the maintainers’ own scripts/session-transcripts.ts both use to spawn and control child pi processes). The README’s own usage example at args.ts:296 (${APP_NAME} "Read package.json" "What dependencies do we have?") shows the same flag family used for a one-shot, scriptable invocation.
packages/tui/src/ is a from-scratch terminal-UI toolkit, not a wrapper around an existing TUI framework: tui.ts (the core render loop), keys.ts/keybindings.ts/native-modifiers.ts (input handling), editor-component.ts (the multi-line prompt editor), autocomplete.ts and fuzzy.ts (completion), undo-stack.ts/kill-ring.ts (Emacs-style text-editing primitives), word-navigation.ts, terminal-image.ts/terminal-colors.ts (image/color rendering), and stdin-buffer.ts (raw input capture) — all confirmed present via direct directory listing. This is the same kind of bespoke terminal-rendering investment OpenCode makes with its @opentui/* stack, just under a different package name and without a matching desktop/web surface in this monorepo.
What’s inside this submodule
pi/
├── AGENTS.md # 163-line contributor-agent operating manual; forbids destructive git ops, mandates `npm run check`
├── CONTRIBUTING.md # "core minimal, capability as extension" philosophy (lines 7-11); PR/issue auto-close gate
├── SECURITY.md # states: no built-in permission system; prompt injection explicitly out of scope
├── README.md # "No MCP." / "No sub-agents." stance with linked rationale
├── LICENSE # MIT, copyright Mario Zechner (original badlogic/pi-mono author)
├── package.json # root workspace manifest; npm (package-lock.json, lockfileVersion 3); no pnpm/bun lockfiles anywhere
├── .npmrc # save-exact=true, min-release-age=2
├── biome.json # lint/format config; stale exclude for deleted packages/mom (see note below)
├── .pi/ # the repo dogfooding its own extensibility surface
│ ├── extensions/ # import-repro.ts, prompt-url-widget.ts, redraws.ts, tps.ts — 4 real working TS extensions
│ ├── prompts/ # cl.md, is.md, pr.md, sa.md, wr.md — 5 slash-command templates (changelog audit, issue triage, PR review, security advisory, "wrap it" ship flow)
│ ├── skills/add-llm-provider.md # 1 skill: checklist for adding an LLM provider to packages/ai
│ ├── git/.gitignore, npm/.gitignore # cache dirs for git-/npm-sourced extension packages (placeholder-only in VCS)
├── scripts/ # 25 files: cost.ts, tool-stats.ts, stats.ts, session-transcripts.ts (spawns subagent pi processes), release tooling
├── packages/
│ ├── ai/ # @earendil-works/pi-ai — provider abstraction; 38 provider modules aggregated in providers/all.ts; browser-safe (env-api-keys.ts dynamic-imports Node builtins); leaf package
│ ├── tui/ # @earendil-works/pi-tui — terminal rendering; leaf package
│ ├── agent/ # @earendil-works/pi-agent-core — session/harness engine; depends on pi-ai
│ │ └── src/harness/session/
│ │ ├── session.ts # Session class; append*/moveTo (the rewind primitive)
│ │ └── jsonl-storage.ts # JsonlSessionStorage; setLeafId/getPathToRoot; version-3 header enforcement
│ ├── coding-agent/ # @earendil-works/pi-coding-agent — the actual CLI; depends on agent+ai+tui
│ │ └── src/core/
│ │ ├── config.ts # getAgentDir(), all path getters, piConfig-driven CONFIG_DIR_NAME
│ │ ├── resource-loader.ts # AGENTS.md/CLAUDE.md discovery, project-trust gate, merge order
│ │ ├── prompt-templates.ts # .md slash-command loader + $1/$@/${N:-default} substitution
│ │ ├── skills.ts # .md skill loader, gitignore-aware
│ │ ├── slash-commands.ts # built-in commands incl. /fork, /clone, /tree
│ │ ├── event-bus.ts # thin EventEmitter wrapper for extension hooks
│ │ ├── extensions/types.ts # ToolCallEvent / ToolResultEvent typed hook unions
│ │ ├── telemetry.ts # install/update ping only — NOT tool-call tracing
│ │ └── session-manager.ts # session file path formula (~/.pi/agent/sessions/--cwd--/ts_uuid.jsonl)
│ │ └── examples/extensions/subagent/ # real sub-agent implementation as an EXAMPLE, not core
│ │ ├── index.ts # registers "subagent" tool; spawns child `pi` processes
│ │ ├── agents/{planner,reviewer,scout,worker}.md
│ │ └── prompts/{implement,implement-and-review,scout-and-plan}.md
│ │ └── docs/
│ │ ├── session-format.md # JSONL header/entry schema, version history v1→v3
│ │ ├── sessions.md # /tree vs /fork vs /clone worked examples
│ │ ├── usage.md # "intentionally does not include..." design-boundary statement
│ │ └── extensions.md # example-extensions index table
│ └── orchestrator/ # @earendil-works/pi-orchestrator — "experimental orchestrator package for pi"; depends on coding-agent
└── .github/workflows/ # 9 CI workflows; .husky/pre-commit hook
Note: biome.json:35 still excludes packages/mom/data/**/*, but no packages/mom directory exists — git log --all -- packages/mom shows a commit 0ed0d434 "remove mom and pods packages"; packages/orchestrator is a separate, still-present “experimental” package, not a renamed survivor of the deleted mom/pods. A second, independent instance of the same drift: .husky/pre-commit still special-cases packages/web-ui/* (triggering a browser-smoke check when any staged file matches packages/ai/*|packages/web-ui/*|package.json|package-lock.json), but packages/web-ui no longer exists in this checkout — git log --all --oneline surfaces the removal directly: b141e1fa "chore: remove web-ui workspace". Both stale references are minor and harmless (an unused biome exclude, an unreachable hook branch), but they’re honest signals that this is an actively-refactored monorepo where package boundaries move faster than every cross-reference gets cleaned up.
Mental model for using it well
- Read
docs/usage.md’s exclusion list first. MCP, sub-agents, permission popups, plan mode, to-dos, and background bash are all deliberately absent from core — if you need any of them, the answer is “build or install an extension,” not “wait for it to ship.” - Sessions are a real branchable tree, not a linear log.
/fork,/clone, and/treeoperate on a JSONL file withid/parentIdlinks; nothing is destroyed on rewind —setLeafIdjust moves the active pointer and appends aleafmarker. - Project-local
.pi/is trust-gated, global~/.pi/agent/is not. An untrusted repo’s.pi/extensions/*.tswon’t execute until you explicitly trust the project; don’t assume opening a new repo silently runs its extensions. - The example
subagent/extension is the reference implementation to copy, not a toy. It spawns real, isolatedpichild processes with scratch dirs and abort propagation — readexamples/extensions/subagent/index.tsbefore rolling your own multi-agent orchestration. - MCP has to be built by you. If a workflow needs MCP tool discovery, either wrap
@modelcontextprotocol/sdkyourself inside an extension, or accept the CLI-tool-with-README pattern the maintainers propose as the substitute. - Tool-call events are real and typed, but there’s no built-in exporter.
ToolCallEvent/ToolResultEventgive you everything needed to build your own tracing extension (as.pi/extensions/tps.tsdoes for tokens/sec); don’t expect OTLP or a trace file out of the box. - This checkout’s own
.pi/directory is the best worked example of every extensibility mechanism at once — prompts, skills, and extensions, all real and exercised by the maintainers on their own release process.
When NOT to reach for this
- You need first-class MCP support out of the box. There is no vendored MCP client or server anywhere in this checkout — you will be building that integration yourself as an extension.
- You need a built-in permission/sandboxing layer.
SECURITY.mdstates plainly there isn’t one; prompt injection and malicious-extension risk are explicitly out of scope for the project’s own security process. If you need in-process containment, look elsewhere or bolt on your own (Docker/tmux/Gondolin are the suggested patterns, not a built-in). - You want sub-agent orchestration as a first-class, core-supported feature. It exists only as an example extension you must install/adapt yourself; core ships nothing under that name.
- You want distributed tracing or OTLP out of the box.
@opentelemetry/apiis present only as an unused transitive dependency; there’s no tracer or exporter wired up anywhere in the source. - You need XDG-spec-compliant config paths (e.g., for packaging on a Linux distro that enforces XDG conventions). Pi hardcodes
~/.pi/agent/(configurable only by literal env-var/package-name override), not$XDG_CONFIG_HOME. - You’re looking for a stable, frozen public API to build long-term integrations against. The presence of a stale
biome.jsonexclude for a deletedpackages/mom, and an “experimental” fifth package (pi-orchestrator), both signal an actively-shifting internal package boundary.
How this compares to the study’s other harnesses
| Axis | Pi | OpenCode | goose | aider |
|---|---|---|---|---|
| Language/runtime | TypeScript, npm workspaces (Node ≥22.19.0); no Bun, no Go | TypeScript, Bun-only (no Go anywhere in the tree) | Rust | Python |
| Shape | 5-package npm monorepo: pi-ai, pi-tui (leaves) → pi-agent-core → pi-coding-agent (CLI) → pi-orchestrator (experimental) | CLI + TUI + server + desktop (Electron) + web app, one shared core | CLI agent with extensions | CLI, git-native |
| Tool/MCP model | No vendored MCP anywhere (verified: zero *mcp* files/dirs outside docs/test-fixture mentions); tools are built-ins + extension-registered, no permission-rule engine | Full MCP client (local stdio + remote HTTP/SSE, OAuth); tools scoped per-agent via wildcard permission rules | Extensions are themselves MCP-wrapper shaped — “extensions-as-MCP-wrapper” harness | No MCP; tools are the diff/edit protocol plus repo-map context, not a discoverable tool registry |
| Session persistence | JSONL tree-of-entries per session (~/.pi/agent/sessions/--cwd--/ts_uuid.jsonl), version-3 header; real, shipped /fork//clone//tree branching via additive leaf-pointer moves | SQLite (Drizzle ORM, WAL mode, 38 migrations) for sessions/messages/parts; legacy JSON-file store with its own migration chain | (not verified in this profile — see goose’s own profile) | Git commits are the persistence layer; no separate session database |
| Tracing | Typed in-process ToolCallEvent/ToolResultEvent hook API for extensions; no OTLP, no tracer, no exporter shipped in core (@opentelemetry/api present but unused) | Real OTLP export (@effect/opentelemetry, gated on env var) + Effect.withSpan on every tool execution carrying session/message/call IDs | (see goose profile) | (see aider profile — no built-in OTel; diffs and commits are the audit trail) |
| Skills/agents/commands | .md-file skills (skills.ts) + .md-file prompt templates with bash-style arg substitution (prompt-templates.ts); subagents real only as a first-party example extension, not core | Skills read from .claude/skills, .agents/skills, .opencode/skill(s), config paths, and remote URLs; converge into one command namespace with MCP prompts and static commands | Extensions play the analogous role, wrapped as MCP | No formal skill/agent-definition system; behavior is driven by the repo-map + prompt |
| Config layering | Global ~/.pi/agent/ (unconditionally trusted) + project-local <cwd>/.pi/ (trust-gated, two-pass bootstrap); no XDG support anywhere | Global (~/.config/opencode/opencode.json) + project (walked-up .opencode/opencode.json) + env-var overrides | (see goose profile) | .aider.conf.yml + CLI flags; no MCP-server config surface to layer |
| Best fit | Teams wanting a small, auditable core with a genuinely serious branchable-session model, who are willing to build MCP/sub-agent/permission layers themselves as extensions | Teams wanting a database-backed, observable, multi-surface (TUI/server/desktop) coding agent with first-class MCP and skill ecosystems | Teams wanting a Rust-native agent where every capability is naturally MCP-shaped | Teams wanting a minimal, git-native pair-programming loop without a tool-call/MCP layer at all |
The crucial axis Pi owns in this study is session-tree seriousness paired with deliberate core minimalism: it is the only entry that ships real, additive, git-like session branching (/fork, /clone, /tree backed by parent-linked JSONL entries) as a core feature, while simultaneously refusing — by explicit, documented policy — to vendor MCP, sub-agents, or a permission system, pushing all three into an extension SDK it then dogfoods on its own repository via .pi/.
One-line summary
Pi is an npm-based (not Bun/pnpm) TypeScript monorepo —
pi-ai/pi-tuias leaves,pi-agent-corefor the session/harness engine,pi-coding-agentas the CLI, plus an experimentalpi-orchestrator— whose core deliberately excludes MCP, sub-agents, permission popups, and a built-in tracer (docs/usage.md:307), yet ships a genuinely serious, additive, JSONL-backed session tree with real/fork//clone//treebranching (CHANGELOG.md:3730-3736) and a typed tool-call hook API that extensions (not core) turn into tracing, MCP, or sub-agent orchestration, most concretely demonstrated by the first-partyexamples/extensions/subagent/package that spawns isolated childpiprocesses exactly the way the project’s own philosophy prescribes.