Files
Codeman/docs/pi-integration-plan.md
Codeman maintainer c5b59633d8 feat(pi): add Pi (pi.dev) as a sixth CLI run mode (#206)
SessionMode gains 'pi', a first-class backend alongside Claude Code,
OpenCode, Codex, Gemini and Antigravity: its own PTY, tmux session, rose
tab identity, welcome button, run-mode entry, cron agentType, Docker and
remote-SSH command defaults, and clone-repo Brain option.

Pi is a different shape of CLI from the other four, and three decisions
follow from that:

- It has NO permission prompts and no sandbox, so there is no
  --dangerously-skip-permissions analog and none was invented. The
  privilege-shaped knob is the tri-state approveProjectTrust, which makes
  pi load and EXECUTE repo-local .pi/extensions TypeScript and install
  missing project packages. clampExternalCliBypassForOwner() therefore
  puts pi in the MATERIALIZE branch: a non-granted multi-user owner gets
  --no-approve even when no config was sent, because pi's own default is
  a prompt the session user could answer themselves. That helper had zero
  test coverage; it now has coverage for all four CLIs.
- Only the PI_ prefix joins the env allowlist. Pi's ~34 provider key vars
  share no prefix and ALLOWED_ENV_PREFIXES is one global list with no mode
  context, so admitting them would widen the allowlist for every mode at
  once. Auth goes through pi's /login or the server's own environment.
  --api-key is deliberately never wired: it would put a provider secret on
  the spawn command line.
- pi stays OUT of isAltScreenStripMode(). Its default TUI renders into the
  main screen with terminal-owned scrollback, and its 0.84.0 fullscreen
  mode is runtime-switchable via /settings; that flip was measured to put
  the pane into the alt screen, which the strip would have corrupted.

pi-cli-resolver.ts additionally sanity-probes `pi --version` and requires
semver-shaped output, because `pi` is a short generic name a stray binary
can shadow; GET /api/pi/status surfaces path and version so a
misresolution is diagnosable rather than presenting as a broken mode.

Docker installs pi in its own --ignore-scripts step so that flag cannot
affect the other four CLIs, and seeds its credentials per-file rather than
whole-dir (~/.pi/agent also holds sessions, extensions and package trees).

Verified end to end against pi 0.84.1 on an isolated instance: resolver
search-dir fallback, flag construction, piConfig persistence across a full
server restart, the trust prompt and its --no-approve suppression, the
rose Run button on the default daylight-blue skin (the nested skin block
eats per-mode gradients unless the rule lives inside it), and the buffer
local-echo policy, which pi tolerates where codex did not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 13:54:47 +02:00

58 KiB
Raw Permalink Blame History

Pi (pi.dev) Run Mode: Implementation Plan

Tracking issue: #206 "Plans to support pi.dev?"

Status: IMPLEMENTED 2026-08-13 (see docs/pi-integration.md for the user-facing guide). Everything below is the design record; the open questions were resolved empirically against pi 0.84.1 and the answers are recorded inline as RESULT notes. Originally reworked 2026-08-06; rechecked 2026-08-13 against master @ f39beb3 (v1.17.0), and every line anchor below was re-verified at that commit (the 1.11.2-era anchors drifted heavily: six releases landed in between, including the settings-surface overhaul and the codex predictive-echo work, both of which added new pi touchpoints, §2.10 and the Brain picker in Phase 3). Upstream facts verified against @earendil-works/pi-coding-agent v0.84.1 (npm latest, published 2026-08-07) and the earendil-works/pi repo (cite that name: upstream docs still contain stale pi-mono links from a repo rename). Line numbers are anchors for orientation, not contracts; they drift.


1. What Pi is

Pi (MIT) is a minimal, extensible coding-agent harness. Facts below are verified against the upstream docs in packages/coding-agent/docs/.

Property Value
Binary pi (bin: { pi: 'dist/cli.js' })
npm package @earendil-works/pi-coding-agent, latest 0.84.1 (2026-08-07; 0.84.0 was 2026-08-06); legacy-node20 dist-tag at 0.74.2
Install npm install -g --ignore-scripts @earendil-works/pi-coding-agent, or curl -fsSL https://pi.dev/install.sh | sh (the curl installer also goes through global npm, so both uninstall via npm)
Config dir ~/.pi/agent (override: PI_CODING_AGENT_DIR). Holds auth.json, trust.json, settings.json, models.json (user-defined providers), models-store.json (cached catalogs), keybindings.json, extensions/, skills/, prompts/, themes/, AGENTS.md, SYSTEM.md, and the package trees npm/ + git/
Sessions ~/.pi/agent/sessions/--<cwd with / replaced by ->--/<timestamp>_<uuid>.jsonl, tree-structured (id/parentId), format v3. Overrides: PI_CODING_AGENT_SESSION_DIR, --session-dir
Credentials ~/.pi/agent/auth.json (OAuth subscriptions + API keys, auto-refresh), plus ~34 provider env vars with no common prefix. 0.84.1 adds pi auth check (auth preflight with optional credential output)
TUI Default: main screen with terminal-owned scrollback. Since 0.84.0 an experimental fullscreen mode exists, selectable via --tui-mode fullscreen or at runtime through /settings; the default remains the main-screen mode
Providers 15+ (Anthropic, OpenAI, Google, Azure, Bedrock, Mistral, Groq, xAI, OpenRouter, Copilot, Baseten since 0.84.0, ...). OAuth subscription login via /login for six: ChatGPT Plus/Pro, Claude Pro/Max, GitHub Copilot, xAI, OpenRouter, Radius
Permission model No permission prompts at all. No built-in sandbox, no MCP (none planned), no sub-agents, no plan mode, no to-dos, no background bash. Tools run with the user's own permissions
Trust model "Project trust" gates loading of project-local .pi/ config/extensions/skills and installing missing project packages, not tool execution. Triggered only when the cwd (or an ancestor) contains .pi/settings.json, .pi/extensions|skills|prompts|themes, .pi/SYSTEM.md/.pi/APPEND_SYSTEM.md, or .agents/skills; a bare .pi/ directory does NOT prompt. Global defaultProjectTrust: ask (default) / always / never

Three consequences shape the whole integration:

  1. There is no --dangerously-skip-permissions analog and none is needed. Pi never prompts for tool approval. The Claude/Codex/Gemini/Antigravity pattern of "send the bypass flag so the session is not stuck on a modal" does not apply. Codeman must not invent a flag here.
  2. The one privileged knob is --approve / -a (trust project-local files for this run), which makes pi load and execute project .pi/extensions TypeScript and run an npm install of missing project packages. That is the field the multi-user clamp has to cover. Its explicit inverse -na / --no-approve exists, which lets the clamp force-deny rather than merely omit (§3, §5.2).
  3. Provider keys cannot ride the env allowlist. Pi's provider key vars (ANTHROPIC_API_KEY, OPENAI_API_KEY, DEEPSEEK_API_KEY, HF_TOKEN, BASETEN_API_KEY, ...) share no prefix, so there is no way to admit them through ALLOWED_ENV_PREFIXES without widening the list for every mode (§2.4).

2. Design decisions

2.1 Mode identity

SessionMode gains 'pi'. Not a location overlay (unlike Docker/remote-SSH cases), not a web tab: a real sixth CLI backend with its own PTY, tmux session and respawn behaviour, exactly like antigravity. Append pi after antigravity in every enum/list to keep ordering consistent.

Surface Value
SessionMode 'pi'
Display label Pi
Tab badge pi (two-letter lowercase, like sh/oc/cx/gm/ag)
Run button label Run PI (short-label ternary in _applyRunMode, pattern Run AG)
Kill-menu label Kill Tmux & Pi
Identity color #f472b6 (rose-400). Verified free: live computed values on the default skin are claude #38b6f0, opencode #44b993, codex #2b8fd9, gemini #8ab4f8, antigravity #22d3ee, shell #98a2b1, web #38bdf8; purple is codex's base hex and amber reads as the shell tab badge, so pink/rose (or orange #fb923c) are the only genuinely free hues. No pi CSS identifier collides anywhere (mode-pi, .tab-mode.pi, .run-mode-dot.pi all grep clean, re-checked at f39beb3)
Env prefix PI_
Dependency id pi
Status endpoint GET /api/pi/status

2.2 isExternalCliMode() yes, isAltScreenStripMode() no

Pi joins isExternalCliMode() (session.ts:164-167): its own TUI, its own output format, so the Ralph tracker, BashToolParser, token/CLI-info scraping and the ❯ readiness probe all stay off (gates at session.ts:1100, :1701, :2000, :2103), and readiness falls back to the output stabilization used by the other external CLIs.

Pi stays out of isAltScreenStripMode() (session.ts:197-199, currently codex/claude/gemini; antigravity and opencode are deliberately excluded). Pi's default TUI renders into the main screen with terminal-owned scrollback, so there is nothing to strip. The fullscreen mode shipped in 0.84.0 and is runtime-switchable via /settings, so Codeman cannot assume a pi session stays main-screen for its lifetime; staying out of the strip list is exactly what makes that safe (the alt screen is load-bearing when the user flips to fullscreen, as it is for opencode). Putting pi IN the strip list would corrupt fullscreen sessions. Three mirrors must stay consistent (all unchanged for pi, i.e. pi appears in none of them): the replay-side strip in session-routes.ts:2275, the live-stream twin in session.ts, and the frontend _sessionUsesServerMouseStrip() in terminal-ui.js (usages :3432, :3697).

2.3 tmux required, no direct-PTY fallback, no per-mode configurator

Same rule as the other external CLIs: pi mode throws if tmux is unavailable. Add a fourth block to the guard chain at session.ts:1751-1768 (antigravity's is :1765-1768).

No _configurePi() is needed. Opencode/codex/gemini each have a tmux-setenv configurator (tmux-manager.ts:1709-1727), but antigravity has none: it relies entirely on the generic applyEnvOverrides() (tmux-manager.ts:1643, VALID_KEY = /^[A-Z_][A-Z0-9_]*$/), which runs for every mode in both create (:1880) and respawn (:2107) and injects via socket-scoped tmux setenv, never the spawn command line. Pi follows the antigravity precedent: PI_* overrides flow through applyEnvOverrides() and nothing else.

Pi joins the truecolor branches: buildEnvExports() (tmux-manager.ts:1604-1609, export COLORTERM=truecolor + unset NO_COLOR for codex/gemini/antigravity) and the attach-env condition at session.ts:1400-1402 (buildMuxAttachEnv(...), whose comment says it must mirror buildEnvExports). Add || mode === 'pi' to both, or the tmux session and the attach client disagree about color depth.

2.4 Env prefix: PI_ only

Add 'PI_' to ALLOWED_ENV_PREFIXES (schemas.ts:125) and to the prose error message at :163 (two edits: the message hardcodes the list, and since 1.12+ it also names the exact-key allowlist, currently ...ANTIGRAVITY_* keys and CLAUDE_CONFIG_DIR are allowed.; there is now a separate ALLOWED_ENV_KEYS exact-key set alongside the prefix list, which pi does not need to touch). That covers every documented variable pi reads: PI_CODING_AGENT_DIR, PI_CODING_AGENT_SESSION_DIR, PI_PACKAGE_DIR, PI_OFFLINE, PI_SKIP_VERSION_CHECK, PI_TELEMETRY, PI_CACHE_RETENTION, PI_SHARE_VIEWER_URL, PI_HARDWARE_CURSOR, PI_EXPERIMENTAL (whose meaning 0.84.0 extended to strict JSON-schema tool sampling). (Pi also sets PI_CODING_AGENT=true and AI_AGENT=pi in child processes; those are output markers, not inputs, and need nothing from us.)

Deliberately not added: ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, XAI_API_KEY, GROQ_API_KEY, MISTRAL_API_KEY and the other ~28 provider keys. ALLOWED_ENV_PREFIXES is a single global list applied by one Zod refine with no mode context (safeEnvOverridesSchema, schemas.ts:153-165), so allowlisting bare provider keys for pi would widen the allowlist for every mode at once, violating the multi-CLI prefix discipline in CLAUDE.md. Users authenticate pi through /login (stored in ~/.pi/agent/auth.json, auto-refreshed) or by exporting the key in the Codeman server process's own environment.

Making the allowlist mode-aware is the clean fix, listed as a follow-up in §9. Do not smuggle it into this change.

2.5 Docker credential policy: seed files, not the whole dir

CRED_STORES (docker-hosts.ts:597-605; file unchanged since the 2026-08-06 verification) gets a .pi/agent entry. Nested rel paths already work (.config/gcloud maps to seed name .config-gcloud via the replace(/\//g, '-') at :620). Unlike antigravity, which needed no entry (agy nests all state under ~/.gemini/antigravity-cli/, already covered by the .gemini policy, per the comment at :599-602), pi has its own top-level dir and needs its own entry. Use seedFiles, not seedWhole:

{ rel: '.pi/agent', seedFiles: ['auth.json', 'settings.json', 'trust.json', 'models.json', 'models-store.json'] },

Rationale: ~/.pi/agent also contains sessions/, extensions/, skills/ and the installed package trees (npm/, git/), which on an active host is easily gigabytes; seedWhole would cp -a all of it into every container start. The five seeded files are what pi needs to authenticate and behave consistently: models.json is in the list because it holds user-defined custom providers, and omitting it would silently strip those inside containers. Seeding (RO mount then copy) also means the in-container pi never writes refreshed OAuth tokens back to the host, which is the whole point of the seeding policy, and bind mounts stay excluded from docker commit so exports remain secret-free.

Trade-off to accept and document: in-container pi sessions are not visible host-side, so pi -c inside a Docker case only sees that container's own history. Codex shares sessions/ RW precisely because Codeman reads it host-side for the response viewer; there is no such reader for pi yet (the response-viewer follow-up in §9 would justify flipping this).

2.6 The pi binary name is generic

Unlike agy/codex/gemini, pi is a short, common name (Raspberry Pi tooling, personal scripts, $PATH accidents). The resolver must not blindly trust a hit. None of the existing external-CLI resolvers execute their binary (only claude-cli-resolver.ts does, via the cached getClaudeCliVersion(), skipped under vitest), so the sanity check is new ground: model it on getClaudeCliVersion(). Run pi --version once via execFileSync, cache the result module-level, skip under VITEST, and require output matching /^\d+\.\d+\.\d+/; on mismatch treat the binary as unavailable and log the rejected path. Surface { available, path, version } from GET /api/pi/status so a misresolution is diagnosable from the UI (additive relative to the sibling endpoints' { available, path }). The dependency-registry entry carries versionArg: '--version' for codeman doctor.

2.7 tmux extended keys (a real pi-specific footgun)

Pi documents (docs/tmux.md, verified verbatim) that without

set -g extended-keys on
set -g extended-keys-format csi-u

tmux collapses Shift+Enter and Ctrl+Enter into a plain \r (and Alt+Enter into \x1b\r), and pi's editor uses those for newline vs submit. extended-keys-format requires tmux 3.5+; tmux 3.2-3.4 works with extended-keys on alone (pi then falls back to xterm modifyOtherKeys). Codeman's own browser input path sends \r for submit, so basic use works unconfigured, but newline-in-editor is degraded both for a user typing in an attached terminal (sc) and potentially for the browser Shift+Enter path.

Upstream recommends ~/.tmux.conf and notes the setting may need a full tmux kill-server restart to take effect. Codeman must NEVER run kill-server on its socket (it would kill every live session, including w1/w2/w3). Action: attempt to set both options server-scoped on Codeman's own socket only (tmux -L codeman set -s ..., never -g on the user's default socket) at the point the tmux server is first started, verify with tmux -L codeman show-options -s and an empirical Shift+Enter test which scope actually takes for the installed tmux version, and fall back to a documented manual step in docs/pi-integration.md (a ~/.tmux.conf snippet plus the kill-server caveat) if it cannot be applied safely to an already-running server. Upstream does not discuss socket- or server-scoped configuration at all, so this verification is original work, not a doc lookup.

RESULT (measured, tmux 3.4 + pi 0.84.1): tmux -L <socket> set -s extended-keys on takes effect on an already-running server with no kill-server — pi's own startup warning (Warning: tmux extended-keys is off…, a convenient in-band probe) disappears for the next session started afterwards. extended-keys-format does not exist on tmux 3.4 and errors with invalid option: extended-keys-format, so the two options must be issued independently rather than chained. Decision: Codeman does not set this itself — it is a server-wide tmux option affecting every session of every backend, so silently changing key encoding is not Codeman's call. It is documented as a user step in docs/pi-integration.md instead, carrying the measured facts.

2.8 The completeness trap: which mode tables fail loud vs silent

Adding 'pi' to the SessionMode union makes some omissions compile errors and leaves others silent. The plan calls this out so review can focus on the silent ones.

Loud (typecheck fails until edited): getModeLabel() (session.ts:168-183, exhaustive switch with no default), defaultDockerCommandForMode and defaultRemoteCommandForMode (both typed Record<...CommandMode, string>), but only after RemoteCommandMode (types/session.ts:48-51) and DockerCommandMode (:157-161) are widened: both are Extract<SessionMode, '...'> with every member spelled out, so forgetting the Extract lists keeps tsc green while docker/remote pi cases silently fall back to exec bash -l via the || commands.shell on the lookup. Edit union + both Extract lists + both Record literals together.

Silent (compiles clean, mode just doesn't work):

  • appendResumeFlag() (tmux-manager.ts:1030-1042) has a default: arm; a missing case 'pi' silently drops docker resume.
  • buildSpawnCommand() (:770-825) and buildPathExport() (:1680-1707) are if-chains with fallthrough returns; a missing branch spawns pi as a login shell / with no PATH augmentation.
  • isExternalCliMode() / isAltScreenStripMode() are boolean chains.
  • The runMode accessor's setter whitelist (session-ui.js:2949-2960) coerces any unknown mode to 'claude'. Omitting pi there makes the mode unselectable while every other edit appears to work: this is the single most deceptive omission in the frontend.
  • window.__codemanCliAvailable (injected by renderIndexHtml, server.ts:1375-1407): the client treats a missing key as available (isCliAvailable in settings-ui.js), so forgetting the injection un-gates pi on boxes without the CLI instead of hiding it.

2.9 The Daylight skin cascade eats per-mode run-button colors

A finding that changes the CSS work (verified empirically with computed styles on the live instance, re-confirmed at f39beb3): styles.css:13681 opens a nested skin block, html:not([data-skin="og"]) { ... }, and the default skin is daylight-blue, not og, so the block is live for every default-skin user. Inside it, .btn-toolbar.btn-run is re-declared generically and per-mode only for claude/opencode/codex (codex at :13787). CSS nesting adds the wrapper's specificity (the nested rules resolve to (0,3,1) vs (0,3,0) for .btn-toolbar.btn-run.mode-X), so gemini's and antigravity's toolbar gradients are dead on the default skin: both render the generic claude gradient today, still unfixed as of f39beb3. The base-sheet rules (gemini/antigravity at :4406/:4420) only ever render on the og skin. Since 1.12+ styles.css itself documents this trap in comments (:9214, :11091), which confirms the mechanism.

Consequences for pi:

  • The toolbar gradient needs two rules: one in the base sheet (:4420 area, for og), and one inside the 13681 block next to codex's (:13787 area), using the block's own idiom (or the color is invisible to the average user).
  • mobile.css phone-toolbar colors need !important on background/border-color/color, exactly as the CLAUDE.md gotcha prescribes. Antigravity's phone block (mobile.css:895-910, inside the @media (max-width: 430px) opened at :338) has no !important and is dead on the default skin; do not copy that mistake.
  • Three surfaces work from base rules alone (verified): run-mode dots (list at :4506-4516; the skin block overrides only claude/opencode/codex/shell dots, so a base-sheet .run-mode-dot.pi renders as authored), tab badges, and the welcome button (the skin block overrides only claude/opencode/tunnel welcome buttons).
  • Optional, separate cleanup (not this change): gemini/antigravity could get the same in-block treatment to resurrect their colors.

2.10 Local-echo policy: pi lands on the buffer overlay by default

New since the first draft of this plan: the codex predictive-echo work (1.13+) introduced a per-session echo policy in _updateLocalEchoState() (terminal-ui.js, _localEchoPolicy set at :2837): codex → 'predict' (write-through predictive echo), shell → 'off', everything else → 'buffer' (the LocalEchoOverlay that buffers typed text until Enter). Pi therefore gets the buffer overlay on touch devices with zero edits, via the fallthrough.

That default is a real open question, not a freebie: the codex history (issues #218/#219/#220/#222) shows that a composer which re-renders per keystroke (live-filtering slash picker, server-side cursor movement, wrap-as-you-type) is starved by buffer-until-Enter, and pi's editor is exactly such a composer. Decision for v1: ship with the default 'buffer' policy but make phone-profile typing an explicit E2E gate (§7 step 4); if pi's editor mis-renders under the overlay, the cheap fallback is forcing 'off' for pi (one branch in _updateLocalEchoState), and teaching the predict path pi's composer row is a follow-up, not a v1 requirement. test/local-echo-codex-gating.test.ts pins the per-mode policy via it.each(['claude', 'gemini', 'opencode']) lists (:193, :376); add 'pi' to those lists once the buffer decision is confirmed (or pin the 'off' branch if that is the outcome).

RESULT (measured, pi 0.84.1, iPhone 14 Pro profile + a PTY-level A/B): the buffer policy holds; codex's failure mode does not reproduce. Pi's slash picker re-filters on the whole composer content, not on per-keystroke deltas: a one-shot literal write of /set (what the overlay flush does) filters the picker to settings identically to sending / s e t as five separate keystrokes, and the delayed \r then selects it and opens the settings menu. Prose prompts buffer correctly (pendingText right, nothing on the PTY before Enter), flush on Enter, and are accepted as a single prompt. 'pi' was added to both it.each lists. The 'off' fallback stays documented but unused.


3. Config surface: PiConfig to CLI flags

/** Pi CLI session configuration */
export interface PiConfig {
  /** Model pattern or ID. Supports `provider/id` and a `:<thinking>` suffix (e.g. `sonnet:high`). Passed via --model. */
  model?: string;
  /** Provider name (anthropic, openai, google, ...). Passed via --provider. */
  provider?: string;
  /** Reasoning level. Passed via --thinking. */
  thinking?: 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
  /** Continue the most recent session (-c). Per-cwd scoping is strongly implied upstream but not documented; treat as probable. */
  continueSession?: boolean;
  /** Resume a specific session by ID or partial UUID (--session). Codeman deliberately accepts ids only, never paths. */
  resumeSessionId?: string;
  /**
   * Tri-state project trust (repo-local `.pi/` settings/extensions/skills, plus installing
   * missing project packages):
   *   true  -> --approve   (trust for this run; loads and EXECUTES repository TypeScript)
   *   false -> --no-approve (force-deny; the trust prompt never appears)
   *   absent -> pi's own defaultProjectTrust (ask).
   * Multi-user: MATERIALIZED to false for non-granted owners (§5.2).
   */
  approveProjectTrust?: boolean;
}

Flag mapping in buildPiCommand() (new, tmux-manager.ts, directly after buildAntigravityCommand at :718-736; every builder there regex-allowlists each user value and silently drops failures because the result lands in a bash -c "..." string):

Field Flag Validation
approveProjectTrust --approve / --no-approve / nothing tri-state boolean, clamped (§5.2)
model --model <v> /^[a-zA-Z0-9._\-/:]+$/ (: for sonnet:high, / for openai/gpt-4o)
provider --provider <v> /^[a-z0-9-]+$/
thinking --thinking <v> runtime allowlist of the 7 enum values (defense in depth beyond Zod)
resumeSessionId --session <v> /^[a-zA-Z0-9._-]+$/ (same shape as RESUME_ID_SAFE, :1021; excludes paths on purpose)
continueSession -c boolean; skipped when a valid resumeSessionId is present (the two conflict)

Not wired in v1, with reasons:

  • --api-key <key>: ⚠️ never wire this. It puts a provider secret on the spawn command line, which is exactly what the socket-scoped tmux setenv discipline exists to prevent (visible in ps, tmux server state, and logs). Listed here so nobody "helpfully" adds it later.
  • --tui-mode (released in 0.84.0): never passed by Codeman. The main-screen default is the friendly case for the browser terminal, and fullscreen remains the user's own runtime choice via /settings (§2.2 is designed for that). --use-theme (still unreleased) likewise.
  • --name <name> (-n): nice for /resume readability, but names contain spaces and would be the first user-controlled value needing real shell quoting in buildSpawnCommand. Defer.
  • --no-session: ephemeral mode fights respawn/resume. Defer.
  • -p/--print, --mode json, --mode rpc: non-interactive transports, a different product shape (§9). Note upstream already shipped a breaking change to JSON-mode message_update framing, so any future consumer must assemble deltas.
  • --tools / --exclude-tools / --no-tools / --no-builtin-tools (-t/-xt/-nt/-nbt): a genuinely useful "read-only session" affordance (0.84.0 also added a defaultTools setting), but it needs UI design. Follow-up.
  • -r/--resume (interactive picker), --fork, -e/--extension, --skill, --system-prompt, --append-system-prompt, --export, --models, --list-models: not session-manager concerns in v1. (-e matters later: §9's extension follow-up notes CLI extensions load before trust resolution.)

4. Implementation phases

Phase 1: Backend core

File Change
src/utils/pi-cli-resolver.ts New, mirror antigravity-cli-resolver.ts (65 lines: search-dir list, module-level cache with '' negative sentinel, which pi first). Search dirs: ~/.local/bin, /usr/local/bin, ~/.bun/bin, ~/.npm-global/bin, ~/bin. Add the pi --version sanity probe from §2.6 (execFileSync, cached, vitest-skipped). Export resolvePiDir(), isPiAvailable(), getPiCliVersion()
src/utils/index.ts Re-export the three (resolver block :30-36)
src/types/session.ts SessionMode union :46; both Extract lists: RemoteCommandMode :48-51, DockerCommandMode :157-161 (§2.8); new PiConfig after AntigravityConfig (:325-333); SessionState.piConfig after :486; @fileoverview mode list :11 + config list :17
src/mux-interface.ts piConfig?: PiConfig on CreateSessionOptions (config block ends :78) and RespawnPaneOptions (ends :109)
src/session.ts isExternalCliMode() :164-167 (+pi); getModeLabel() :168-183 (+'Pi'); _piConfig field decl :466-470; ctor option :556-563 + apply :652-654; toState() :1227-1230; _buildRespawnPaneOptions() :1466-1469 (single source of truth shared by startInteractive and reattachRemote); startInteractive() createSessionOptions :1680-1683; COLORTERM attach-env condition :1400-1402 (+pi); requires-tmux guard chain :1751-1768 (new block: "Pi sessions require tmux for env override injection via setenv")
src/tmux-manager.ts buildPiCommand() after :736 per §3; buildSpawnCommand() signature :770-779 + dispatch branch after :822-825; appendResumeFlag() :1030-1042 (case 'pi': return \${modeCommand} --session ${resumeId}`;); buildEnvExports()truecolor branches:1604-1609(+pi);buildPathExport() :1680-1707(+pi branch callingresolvePiDir()); missing-CLI error chain in createSession :1788-1806(+pi, install hintnpm install -g --ignore-scripts @earendil-works/pi-coding-agent; note respawnPanedeliberately has no such check);piConfigthreading at the four sites:1748, :1817, :2041, :2080. **No _configurePi`** (§2.3)
src/config/dependency-registry.ts New entry after antigravity's (:101-108; file unchanged since 2026-08-06): { id: 'pi', label: 'Pi CLI', category: 'core', required: false, usedBy: ['Pi sessions'], resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['pi'], versionArg: '--version' } }] }
src/docker-hosts.ts defaultDockerCommandForMode :138-149: pi: 'exec pi'. CRED_STORES :597-605: the .pi/agent seedFiles entry per §2.5 (nested rel already handled at :613-645). File unchanged since 2026-08-06
src/remote-hosts.ts defaultRemoteCommandForMode :92-118: pi: remoteLoginShellCommand('pi') (remoteLoginShellCommand at :88-90). Login-shell routing is mandatory (the #209/e803186 lesson: ssh remote-command exec sees only sshd's minimal PATH, and npm's global bin is usually only on PATH via rc files)

Phase 2: Web layer

File Change
src/web/schemas.ts 'PI_' in ALLOWED_ENV_PREFIXES :125 and the prose error message :163 (which now also names CLAUDE_CONFIG_DIR; the ALLOWED_ENV_KEYS exact-key set needs no change); new PiConfigSchema after AntigravityConfigSchema (:256-271), mirroring §3's regexes, .optional(), not .strict(); piConfig on CreateSessionSchema (:299 area) and QuickStartSchema (:712 area); 'pi' in all three mode enums (:285, :708, cron agentType :1214; they are byte-identical and there is no fourth); pi key in RemoteCommandOverridesSchema :426-436 (it is .strict(), so an unknown key is a hard error today; one edit covers both remote :501 and docker :577 reuse)
src/web/routes/session-routes.ts Thread piConfig through create (POST /api/sessions): disk-strip exclusion chain :705-712, availability gate :782-790 (+isPiAvailable with install-hint error), model resolution :825-838 (mode === 'pi' ? body.piConfig?.model : ...), clamp call :845, Session ctor :860 (piConfig: mode === 'pi' ? gatedPiConfig : undefined). Quick-start (POST /api/quick-start, handler :2559): remote-case config rejection :2614-2621 and docker-case :2645-2652 (+piConfig: per-CLI config does not cross ssh or the bind mount), hooks-scaffold exclusions :2801/:2809, availability gate :2744-2752 (local-case branch only), env-strip chains :2833/:2863, model resolution :2885, clamp :2897, ctor :2913. Extend clampExternalCliBypassForOwner() (:305-336, doc comment above): fifth param + return field; pi joins the materialize branch per §5.2. Alt-screen replay-strip at :2275 unchanged (pi not in it, §2.2)
src/web/routes/system-routes.ts GET /api/pi/status after the antigravity handler (:418-426; file unchanged since 2026-08-06), same shape plus version (§2.6); update the "CLI Integrations" prose comment :377
src/web/server.ts Restore path: piConfig: muxSession.mode === 'pi' ? savedState?.piConfig : undefined after :2636. renderIndexHtml CLI-availability injection :1375-1407: add isPiAvailable to the dynamic-import tuple (:1382) and a pi key to the injected object (:1399). Per §2.8 a missing key reads as available, so this is a correctness edit, not polish

Phase 3: Frontend

The antigravity touchpoints are the template. Since the first draft, the settings-surface overhaul moved most anchors and added one new touchpoint (the clone-repo Brain picker below). constants.js, api-client.js, ralph-wizard.js, cron-ui.js, webview-tabs.js and sw.js still need no changes (re-verified zero mode coupling at f39beb3; cron-ui reads the <select> generically and special-cases only shell).

File Change
index.html Welcome button welcomePiBtn after Gemini's (antigravity's is :347; there is deliberately no codex welcome button), display:none default, onclick="app.setRunMode('pi'); app.runPi()", text Run Pi; run-mode-option row with .run-mode-dot.pi after antigravity's (:526-528), before the .run-mode-sep :529; cron <option value="pi">Pi</option> after :803; NEW: the clone-repo "Brain" picker (cloneCaseBrain, :2476-2486): add <option value="pi" data-cli="pi">Pi</option> after the antigravity option :2483 (gating is automatic: session-ui.js :2107-2115 hides options whose data-cli fails isCliAvailable, and :2250 reads the value at clone time); docker image hint :2624 (claude/codex/gemini/opencode/agy + pi). No per-CLI remote-command override field needed (only codex has one, :2559)
session-ui.js @fileoverview mode list :2; run() dispatch branch after :400-402; _refreshRunModeAvailability list :468 (+'pi' as a quoted literal, the static test in §6 demands it); short-label ternary :565 (+'Run PI'); the runMode setter whitelist :2949-2960 (§2.8, the deceptive one); new runPi() modeled on runAntigravity() :1170-1219: same remote/docker skip, same _beginSessionLaunchStatus frame, probes /api/pi/status reading (await res.json()).data.available (envelope!), sends no piConfig at all (no bypass exists and trust defaults are pi's own; envOverrides still sent for local cases), install-hint error text matching Phase 1's; isAltMode :1233 and isExternalCli :1263 four-way comparisons (+pi)
settings-ui.js applyWelcomeCliVisibility() :1176-1191: add ['welcomePiBtn', 'pi']
app.js Response-viewer agent label :1998-2009 (+pi -> 'Pi'); tab badge ternary :3884 (<span class="tab-mode pi" aria-hidden="true">pi</span>; claude stays badge-less); kill-title ternary :5046-5057 (Kill Tmux & Pi)
panels-ui.js Command-palette labels map :430 (+pi: 'Pi'; the || mode fallback means this is cosmetic, not load-bearing)
mobile-overview.js MOBILE_OVERVIEW_RUN_MODES :55-62: { mode: 'pi', label: 'Pi', short: 'Pi' } after antigravity :60, before the shell entry. Nothing else: the Run-button badge (:499) and menu builder (:554-556) consume the list generically, and the buttons carry btn-toolbar btn-run mode-pi, which is exactly why they inherit the §2.9 cascade problem and its fix
terminal-ui.js Badge-row comment :1750 only (the badge itself is a raw s.mode passthrough, no list to extend). _sessionUsesServerMouseStrip unchanged (§2.2). _updateLocalEchoState unchanged for v1 (§2.10: pi lands on 'buffer' via the fallthrough; only touch it if E2E forces the 'off' fallback)
i18n.js 'Run Pi': '运行 Pi' in the zh-CN table (:102-107, matches the welcome-button text; short labels like Run PI are deliberately untranslated, as are the other modes')
styles.css Tab badge .session-tab .tab-mode.pi after :2157 (background: rgba(244,114,182,0.2); color: #f472b6;); add .session-tab .tab-mode.pi to the light-skin ink list :325-336 (gemini + antigravity are its precedent, :332); welcome .welcome-btn-pi + :hover after antigravity's :3366 block, rose family (e.g. base linear-gradient(135deg, #33121f 0%, #9d174d 55%, #be185d 100%), border rgba(244,114,182,0.4), text #fce7f3); toolbar gradient pair .btn-toolbar.btn-run.mode-pi, .btn-toolbar.btn-run-gear.mode-pi + :hover after :4420's antigravity block; .run-mode-dot.pi { background: #f472b6; } in the dot list :4506-4516; and the §2.9 rule inside the Daylight block next to codex's :13787 (e.g. background: linear-gradient(135deg, #be185d, #f472b6); border-color: #be185d; color: #fff1f7;). The dot needs no skin-block entry (the block overrides only claude/opencode/codex/shell dots; gemini/antigravity dots already fall through correctly)
mobile.css Phone toolbar block after :910 inside the @media (max-width: 430px) opened at :338: mode-pi base + :active, with !important on background/border-color/color (§2.9; antigravity's block :895-910 omits it and is dead); light-skin override entry after :2985 with the same four-skin html:is(...) prefix as its siblings

Phase 4: Docker image and installer

Both files are unchanged since the 2026-08-06 verification; all anchors stand.

  • docker/agent.Dockerfile: a separate RUN step after the antigravity block (:38-45), not a fifth line in the shared npm block (:31-36), because pi documents --ignore-scripts and that flag must not silently change how the other four install:

    # Pi (pi.dev). Upstream documents --ignore-scripts (pi needs no lifecycle scripts);
    # kept out of the shared npm block above so the flag cannot affect the other CLIs.
    RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent \
     && npm cache clean --force \
     && pi --version
    

    Implementation checklist item: the gid-0 pre-created dirs at :64-68 include .claude/projects and .codex/sessions; verify whether the cred-seed copy into ~/.pi/agent creates its target dir in a fresh container or whether .pi/agent must join that mkdir line. Rebuild with node scripts/build-agent-image.mjs --no-cache (the script itself needs no change; nothing in it is CLI-specific). The cached npm layer has silently frozen a CLI at a broken version before; see docs/docker-cases.md.

  • install.sh (six edit sites, all verified): PI_SEARCH_PATHS block after :125 (mirror the resolver's dirs); check_pi / get_pi_path pair inserted at :531 (antigravity's pair spans :504-530); the satisfying-AI-CLI chain :2032-2063 (has_pi local at :2037 area, detect block after :2059, widen the five-way test at :2061 and the warn text at :2063); the menu option-4 text :2070; the skip-path hints :2115-2116 (add npm install -g --ignore-scripts @earendil-works/pi-coding-agent (Pi)); the final no-CLI reminder :2416-2423 (add check_pi to the condition and a pi line to the echo block). Detection plus a hint only; do not add an auto-install path in this change.

Phase 5: Docs

  • docs/pi-integration.md (new, user-facing): install (both installers uninstall via npm), auth (/login OAuth for six providers vs API keys; pi auth check for preflight; Claude Pro/Max third-party harness usage bills as Anthropic "extra usage" per token, not plan limits; OpenRouter login supports pasting the redirect URL, which matters over remote SSH), what Codeman wires up and deliberately does not (§3, incl. never passing --tui-mode), the tmux extended-keys note from §2.7 with the manual ~/.tmux.conf fallback, Docker/remote behaviour (in-container sessions invisible host-side), the trust model in §1 words, known gaps.
  • CLAUDE.md: tech-stack line (six CLIs + SessionMode union), the env-prefix gotcha bullet, the multi-CLI prefix-discipline bullet, the "External CLI modes" key-pattern paragraph (note it now also carries the codex predictive-echo block; pi's echo-policy decision from §2.10 belongs in the same paragraph), the src/utils/ resolver list.
  • docs/architecture-invariants.md: the external-CLI-modes section. ⚠️ Its anchor was already renamed once to #external-cli-modes-opencode-codex-gemini-antigravity while CLAUDE.md's link text still shows the old name; when renaming again for pi, update every inbound link (CLAUDE.md and this file).
  • docs/docker-cases.md (cred-seeding table + supported modes + image contents), docs/remote-sessions.md (RemoteCommandMode), docs/cron-guide.md + docs/cron-discovery.md (agentType enum; note the readiness caveat from §6's cron paragraph), docs/security-architecture.md (env prefix allowlist row).
  • README.md + README.zh-CN.md: six CLIs.
  • package.json keywords: pi.
  • Update the issue #206 thread when it ships.

5. Security checklist

  1. Command injection. Every PiConfig value is regex-validated in buildPiCommand() before entering the bash -c "..." string; anything failing validation is dropped, not escaped (matching the four existing builders). No user string reaches the spawn line unvalidated. Pinned by a "rejects unsafe values" test per field.
  2. Multi-user clamp, materialize branch. approveProjectTrust is the privilege-shaped field: it makes pi execute repository-supplied TypeScript and install project packages. clampExternalCliBypassForOwner() (session-routes.ts:305-336) has two branches, and pi belongs in the gemini-style materialize branch, not the codex/antigravity only-if-sent branch: pi's absent-config default is an interactive trust prompt the session user can answer themselves in the terminal, so merely omitting --approve is not a clamp. For a non-granted owner, materialize { ...(piConfig ?? {}), approveProjectTrust: false } so buildPiCommand always emits --no-approve and the prompt never appears. Both call sites (:845, :2897) widen. This helper still has zero test coverage (re-confirmed at f39beb3); §6 adds the first tests.
  3. Secrets stay off the command line. PI_* overrides flow through applyEnvOverrides() / socket-scoped tmux setenv, never inlined into the spawn string. No -e at container create time. And --api-key is never wired (§3): it would put a provider secret into ps/tmux state.
  4. Env allowlist not widened. Only the PI_ prefix is added; the provider keys stay out (§2.4) and ALLOWED_ENV_KEYS is untouched. Pinned by a test that PI_OFFLINE passes and ANTHROPIC_API_KEY still fails validation.
  5. Docker seeding, not sharing. Per §2.5: RO mount then copy, so refreshed OAuth tokens never write back to the host; bind mounts stay excluded from docker commit so exports remain secret-free.
  6. Remote SSH. pi mode goes through defaultRemoteCommandForMode and therefore buildSshConnectionArgs(). No hand-built ssh line anywhere.
  7. No sandbox claims. Pi documents that it has no sandbox and no permission prompts, and that extensions run with the user's full permissions. Codeman docs must say plainly that a pi session can read, write and execute anything the Codeman user can, and point at Docker cases as the isolation story. Do not imply the trust prompt is a safety boundary (upstream itself says it is not). Worth one doc sentence: pi auth print-api-key / print-bearer-token (0.83.0) and pi auth check (0.84.1) mean a pi session can print its own provider credentials by design; isolation, again, is Docker.
  8. Loud-vs-silent audit. Before review, walk §2.8's silent list and confirm each site has its pi branch; the loud ones the compiler already caught.

6. Test plan

  • test/pi-mode.test.ts (new, modeled on test/antigravity-mode.test.ts, 125 lines, no port; file unchanged since 2026-08-06 so its structure remains the template): CreateSessionSchema/QuickStartSchema accept a pi config; unsafe model/provider/ resumeSessionId values are rejected ('pi; rm -rf /' shapes); buildSpawnCommand({ mode: 'pi', ... }) emits expected flags, drops invalid ones, emits --no-approve for approveProjectTrust: false and --approve for true, and skips -c when a resumeSessionId is present; defaultDockerCommandForMode('pi') === 'exec pi' and defaultRemoteCommandForMode('pi') === 'exec "${SHELL:-/bin/sh}" -i -l -c \'pi\''; isExternalCliMode('pi') === true, isAltScreenStripMode('pi') === false; the env pair (PI_OFFLINE accepted, ANTHROPIC_API_KEY rejected), mirroring antigravity-mode :49-63.
  • First-ever coverage for clampExternalCliBypassForOwner (still nothing in test/ touches it): cover pi's materialize branch (absent config still yields approveProjectTrust: false for a non-granted owner; a sent true is forced to false; granted owner passes through) and, while there, pin the three existing modes' behavior. Prefer exporting the helper for direct unit tests over a heavier multi-user route fixture; either way it lives under test/routes/.
  • test/run-mode-ui.test.ts: extend loadUi()'s stub lists (welcome-button ids, mode buttons, ALL_OFF) and add pi welcome/dropdown gating cases; note the static parser test 'gates every mode the run-mode menu actually offers' (:433-456) picks up the new data-mode="pi" from index.html automatically and fails until _refreshRunModeAvailability contains a quoted 'pi', which is exactly the regression it exists for. Add a describe('Pi quick start') modeled on the antigravity one (:840) driving runPi() against a stubbed /api/pi/status + /api/quick-start, asserting the posted body has mode: 'pi' and no piConfig, and that the envelope is unwrapped. (The short-label assertion pattern is at :82, 'Run AG'.)
  • test/render-index-html.test.ts :141: the injected window.__codemanCliAvailable is asserted with an exact toEqual and now carries seven keys (claude, opencode, codex, gemini, antigravity, cloudflared, and since 1.12+ git), so it must gain the pi key (and the resolver mock an isPiAvailable); its comment explains why: a dropped key silently un-gates (§2.8).
  • test/routes/system-routes.test.ts: GET /api/pi/status shape, modeled on the antigravity describe (:816-838) + resolver mock (:84-87); file unchanged since 2026-08-06.
  • test/mobile-overview.test.ts: :375 is an exact-array toEqual over the run-menu modes and will fail until updated to include 'pi' (the second exact-array at :366, ['claude', 'shell'], is a gating case and stays as-is); the sibling static parser then covers the new entry automatically. The no-hex-literals guard only scans .mobile-overview* rules, so pi's mode-pi colors in mobile.css do not trip it.
  • test/local-echo-codex-gating.test.ts (§2.10): once the buffer-policy decision is confirmed in E2E, add 'pi' to the it.each(['claude', 'gemini', 'opencode']) lists (:193, :376) so the chosen policy is pinned.
  • test/skin-themes.test.ts: will NOT trip (it enumerates skins, not modes); run it anyway since styles.css is touched. test/mobile-header-buttons-policy.test.ts: trips only if a header button is added; pi adds none (welcome button and run-menu rows are outside header-right).
  • Cron: schema-level acceptance of agentType: 'pi' (the service consumes SessionMode generically; src/cron/ is unchanged since the first draft). Known, documented degradation: the readiness poll (cron-service.ts:515) looks for ❯/tokens, which pi never prints, so cron pi jobs burn the ready-poll attempts and then send anyway. Acceptable for v1; note it in docs/cron-guide.md.
  • Sweep with npm run test:ci. Never bare npm test. No new ports needed (all new/extended suites are portless).

7. End-to-end verification (required before COM)

Unit tests passing is not evidence the mode works (pi is not currently installed on the dev box, so step 1 is a real step). Before shipping:

  1. Install pi (npm install -g --ignore-scripts @earendil-works/pi-coding-agent), authenticate once with /login.
  2. curl -sk https://localhost:3000/api/pi/status | jq reports available: true, the right path, and a sane version.
  3. Create a throwaway case, launch a pi session from the Run dropdown, send a prompt from the browser, confirm the reply renders and scrollback survives a tab switch. Do not touch w1/w2/w3.
  4. Local-echo policy gate (§2.10): on a phone profile, type into the pi editor through the buffer overlay (drive with page.keyboard.type(), never app.sendInput(), and force app._localEchoEnabled = true; headless Chromium reports touch as false) and confirm pi's composer renders the flushed text correctly on Enter. If it mis-renders, flip pi to the 'off' branch in _updateLocalEchoState and pin that instead.
  5. Visual pass on the default skin (the §2.9 finding makes this the load-bearing check, not a formality): run-button gradient actually renders rose (not generic claude blue), dot, tab badge, welcome button, kill-menu label; then a phone profile (toolbar !important colors and light-skin overrides are the usual regressions).
  6. Kill and respawn the session; confirm piConfig round-trips through state.json and the pane comes back with the same flags. Then /clear-style respawn via the Respawn tab.
  7. Extended keys (§2.7): in an attached terminal, verify whether Shift+Enter inserts a newline in pi's editor with and without the socket-scoped options; record the outcome in docs/pi-integration.md either way. While attached, also flip /settings to the fullscreen TUI and back to confirm the no-strip decision holds (§2.2).
  8. Trust model: point a throwaway case at a repo containing .pi/extensions, confirm the trust prompt appears interactively and that a multi-user non-granted session instead launches with --no-approve (prompt never shown, extensions not loaded).
  9. NOT RUN in this pass — an honest gap. Docker case with mode: 'pi': rebuild the agent image with --no-cache, confirm pi --version inside the container as the agent user, confirm seeded auth works and a session starts (this is exactly where the antigravity Docker path broke in 1.11.2: the CLI was never installed in the image).
  10. NOT RUN in this pass — the other gap. Remote SSH case with mode: 'pi': confirm the login-shell wrapper resolves the npm global bin.
  11. Only then: changeset, COM minor (new capability, additive to the API surface).

Verification actually performed (2026-08-13, pi 0.84.1, isolated CODEMAN_INSTANCE=pi-beta server on :5055 with its own tmux socket and data dir): steps 1-8 pass. Highlights: /api/pi/status resolved through the search-dir fallback (pi installed to ~/.npm-global/bin, deliberately not on PATH) and reported {available:true, path:'/home/arkon/.npm-global/bin', version:'0.84.1'}; the real spawn line came out as … COLORTERM=truecolor … && pi --approve --provider anthropic --thinking high; piConfig round-tripped through state.json across a full server restart; the trust prompt appeared for a case containing .pi/extensions + .pi/settings.json, and --no-approve suppressed it (This project is not trusted. Project .pi resources and packages are ignored.); on the default daylight-blue skin the toolbar Run button computed to linear-gradient(135deg, rgb(190,24,93), rgb(244,114,182)) — genuinely rose and distinct from claude's blue, so the §2.9 cascade trap is avoided; and flipping /settings to the fullscreen TUI put the pane into the alt screen (alternate_on=1), empirically confirming §2.2: had pi been in the strip list, Codeman would have stripped that switch and corrupted the session. Steps 9-10 need a Docker daemon and a remote host respectively.


8. Effort estimate

Calibrated against the real antigravity history, which is the honest baseline: the feature commit 26cbbe0 was 24 files, +638/-63, and it then took four follow-up commits (e803186 login-shell routing, 292ba2c ownership helpers, 5d28999 CLI gating incl. tests, 0d0b772 docs/installer/UI propagation) totaling roughly +600/-170 across ~43 file-touches to make the mode actually first-class. Budgeting only the feature-commit shape under-scopes by ~40%. This plan folds all four follow-up surfaces in from the start (login-shell routing in Phase 1, availability gating in Phases 2-3, installer/docs propagation in Phases 4-5), so expect the full footprint in one pass:

Phase Size
1. Backend core ~260 lines across 9 files, one new file (resolver incl. version probe)
2. Web layer ~110 lines across 4 files (incl. the clamp widening + availability inject)
3. Frontend ~175 lines across 10 files (enumerations + CSS in two sheets + skin block + the Brain picker option)
4. Docker + installer ~45 lines, plus one --no-cache image rebuild
5. Docs one new doc, ~10 files touched
6. Tests one new test file, 6 extended (2 of which fail loudly until updated), plus the first clamp coverage

9. Out of scope, tracked as follow-ups

  • A Codeman pi extension for real idle/completion events (highest value, now fully de-risked). Pi extensions are TypeScript modules with Node built-ins and npm deps available, so an HTTP POST to /api/hook-event is trivial. The agent_settled event shipped in 0.84.0 and is documented for exactly this use case (fires only when pi will not continue on its own: after auto-retries, auto-compaction and queued follow-ups; ctx.isIdle() is true inside the handler). That is a genuine idle signal replacing output-silence heuristics, i.e. the same class of upgrade hooks give Claude sessions. The bash tool exposes five env vars (PI_SESSION_ID, PI_SESSION_FILE, PI_PROVIDER, PI_MODEL, PI_REASONING_LEVEL), injected per command. Bonus: an extension can own the project_trust event (first yes/no wins, and CLI -e extensions load before trust resolution), so Codeman could answer the trust prompt programmatically, a cleaner mechanism than the --approve flag for both the single-user convenience case and the multi-user deny case.
  • Response viewer for pi. Sessions are JSONL v3 under ~/.pi/agent/sessions/--<cwd-dashed>--/<timestamp>_<uuid>.jsonl with an id/parentId tree and typed content blocks (text, image, thinking, toolCall); the cwd-derived dir name is trivially computable host-side. Feasible, and it would justify flipping the Docker cred policy to share sessions/ RW like Codex.
  • Mode-aware env allowlist. Would let pi sessions accept provider keys without widening the global list. Needs ALLOWED_ENV_PREFIXES to become a per-mode map plus mode context inside the Zod refine.
  • --tools / --exclude-tools / --no-tools / --no-builtin-tools read-only sessions (plus the 0.84.0 defaultTools setting). Real product value, needs UI.
  • Predictive echo for pi's composer if the §2.10 buffer decision does not hold up in practice: teach PredictiveEchoAddon pi's composer row the way isCodexComposerRow handles codex's.
  • --mode json / --mode rpc, and upstream's experimental remote-session client APIs (transport-neutral PiClient, CBOR protocol, Unix-socket transport, RemoteSession controller, still unreleased as of 0.84.1). A potential non-PTY integration path, a different architecture from the tmux+PTY model. Note the already-shipped breaking change to message_update framing (delta-only): any consumer must assemble deltas between message_start/message_end.
  • --name for session labels. Blocked on shell-quoting a user string in buildSpawnCommand.

10. Risks

Risk Mitigation
pi resolves to an unrelated binary pi --version + semver-shape check in the resolver (§2.6); path and version shown in /api/pi/status
Pi's TUI repaints in a way the browser terminal handles badly Test scrollback and repaint early (step 3 of §7); pi's default is main-screen with terminal-owned scrollback, which is the friendly case
Fullscreen TUI mode (shipped 0.84.0, runtime-switchable) Already designed for: pi stays OUT of the strip list, so a user flipping /settings to fullscreen gets opencode-like alt-screen behavior, not corruption. §7 step 7 tests the flip explicitly
The buffer local-echo overlay fights pi's live composer §2.10: explicit E2E gate (§7 step 4) with the one-line 'off' fallback; predictive echo for pi is a tracked follow-up, not a v1 blocker
Pi moves fast (pre-1.0; 9 releases in the 7 weeks before 0.84.1) Keep the flag surface small; every flag validated and droppable; nothing pinned in the Dockerfile beyond the --no-cache rebuild cadence. Live example of the hazard: --tui-mode went from main-only docs to released between the two drafts of this plan
Docker image grows Pi is an npm package; the layer is modest next to the ~190MB agy binary
Trust prompt blocks a session Narrower than feared: only fires when .pi/settings.json, .pi/extensions|skills|prompts|themes, .pi/SYSTEM.md/APPEND_SYSTEM.md or .agents/skills exists (bare .pi/ does not). Documented; approveProjectTrust is the opt-in escape hatch; multi-user forces --no-approve (§5.2); the project_trust extension follow-up removes the prompt entirely
Interactive /login OAuth can't complete headlessly Document: authenticate once interactively (or seed auth.json); pi auth check verifies credentials preflight; OpenRouter's paste-the-redirect-URL flow covers remote SSH
Provider auth is awkward without key prefixes in the allowlist /login writes ~/.pi/agent/auth.json once and Docker seeds it; the mode-aware allowlist follow-up removes the friction
Cron pi jobs mis-detect readiness Known degradation, documented in §6; readiness falls through after the poll budget and the prompt still sends