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>
This commit is contained in:
Codeman maintainer
2026-08-13 13:54:47 +02:00
parent f39beb3326
commit c5b59633d8
45 changed files with 2143 additions and 101 deletions
+80 -2
View File
@@ -45,6 +45,7 @@ import {
type EffortLevel,
type GeminiConfig,
type AntigravityConfig,
type PiConfig,
type SessionRemote,
type SessionDocker,
type DockerCommandMode,
@@ -78,6 +79,7 @@ import {
resolveCodexDir,
resolveGeminiDir,
resolveAntigravityDir,
resolvePiDir,
resolveLocalShell,
loginShellArgs,
} from './utils/index.js';
@@ -735,6 +737,63 @@ function buildAntigravityCommand(config?: AntigravityConfig): string {
return parts.join(' ');
}
/** Pi's `--thinking` levels. Runtime allowlist — defense in depth beyond the Zod enum. */
const PI_THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
/**
* Build the Pi CLI (pi.dev) command with appropriate flags.
*
* Pi has NO permission prompts and no `--dangerously-skip-permissions` analog, so
* there is deliberately nothing bypass-shaped here. The privileged knob is the
* TRI-STATE `approveProjectTrust`: `true` -> `--approve` (trust repo-local `.pi/`
* config, which means loading and EXECUTING repository TypeScript and installing
* missing project packages), `false` -> `--no-approve` (force-deny, used by the
* multi-user clamp so the trust prompt never appears), absent -> pi's own
* `defaultProjectTrust`.
*
* `--api-key` is deliberately NEVER wired: it would put a provider secret on the
* spawn command line (visible in `ps` and tmux state), which is exactly what the
* socket-scoped `tmux setenv` discipline exists to prevent.
*
* Like the sibling builders, every user value is regex-allowlisted and silently
* DROPPED on failure — the result is interpolated into a `bash -c "..."` string.
*/
function buildPiCommand(config?: PiConfig): string {
const parts = ['pi'];
if (config?.approveProjectTrust === true) {
parts.push('--approve');
} else if (config?.approveProjectTrust === false) {
parts.push('--no-approve');
}
if (config?.model) {
// `:` for a thinking suffix (`sonnet:high`), `/` for `provider/id` (`openai/gpt-4o`).
const safeModel = /^[a-zA-Z0-9._\-/:]+$/.test(config.model) ? config.model : undefined;
if (safeModel) parts.push('--model', safeModel);
}
if (config?.provider) {
const safeProvider = /^[a-z0-9-]+$/.test(config.provider) ? config.provider : undefined;
if (safeProvider) parts.push('--provider', safeProvider);
}
if (config?.thinking && PI_THINKING_LEVELS.has(config.thinking)) {
parts.push('--thinking', config.thinking);
}
// --session and -c conflict; a valid explicit session id wins.
const safeSessionId =
config?.resumeSessionId && /^[a-zA-Z0-9._-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined;
if (safeSessionId) {
parts.push('--session', safeSessionId);
} else if (config?.continueSession) {
parts.push('-c');
}
return parts.join(' ');
}
/**
* Build the spawn command for any session mode.
* Shared by createSession() and respawnPane() to avoid duplication.
@@ -777,6 +836,7 @@ export function buildSpawnCommand(options: {
codexConfig?: CodexConfig;
geminiConfig?: GeminiConfig;
antigravityConfig?: AntigravityConfig;
piConfig?: PiConfig;
resumeSessionId?: string;
effort?: EffortLevel;
/** Codeman session name, passed to claude as `--name` (version-gated, sanitized; local spawns only). */
@@ -823,6 +883,9 @@ export function buildSpawnCommand(options: {
if (options.mode === 'antigravity') {
return buildAntigravityCommand(options.antigravityConfig);
}
if (options.mode === 'pi') {
return buildPiCommand(options.piConfig);
}
// #208: NOT the literal '$SHELL'. This string is embedded in the `bash -c "…"`
// argument of the respawn-pane line, which execSync runs through `/bin/sh -c`,
// so a `$SHELL` here is expanded by the SERVER process's shell against the
@@ -1036,6 +1099,8 @@ function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: stri
return `${modeCommand} resume ${resumeId}`;
case 'antigravity':
return `${modeCommand} --conversation ${resumeId}`;
case 'pi':
return `${modeCommand} --session ${resumeId}`;
default:
return modeCommand; // shell / opencode: no resume
}
@@ -1604,10 +1669,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const exports = [
'export LANG=en_US.UTF-8',
'export LC_ALL=en_US.UTF-8',
mode === 'codex' || mode === 'gemini' || mode === 'antigravity'
mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi'
? 'export COLORTERM=truecolor'
: 'unset COLORTERM',
...(mode === 'codex' || mode === 'gemini' || mode === 'antigravity' ? ['unset NO_COLOR'] : []),
...(mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' ? ['unset NO_COLOR'] : []),
// Stamp each Codex pane with a unique originator so the response-viewer
// can locate THIS pane's rollout exactly — codex writes the value into
// session_meta.originator of every rollout it creates. Without it,
@@ -1698,6 +1763,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const dir = resolveAntigravityDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
if (mode === 'pi') {
const dir = resolvePiDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
return { pathExport: '', dir: null };
}
@@ -1746,6 +1815,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
codexConfig,
geminiConfig,
antigravityConfig,
piConfig,
resumeSessionId,
envOverrides,
effort,
@@ -1802,6 +1872,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
);
}
if (mode === 'pi' && !cliDir) {
throw new Error(
'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent'
);
}
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
@@ -1815,6 +1890,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
codexConfig,
geminiConfig,
antigravityConfig,
piConfig,
resumeSessionId,
effort,
sessionName: name,
@@ -2039,6 +2115,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
codexConfig,
geminiConfig,
antigravityConfig,
piConfig,
resumeSessionId,
envOverrides,
effort,
@@ -2078,6 +2155,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
codexConfig,
geminiConfig,
antigravityConfig,
piConfig,
resumeSessionId,
effort,
sessionName: name,