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
+30 -6
View File
@@ -50,6 +50,7 @@ import {
type EffortLevel,
type GeminiConfig,
type AntigravityConfig,
type PiConfig,
type SessionRemote,
type SessionDocker,
} from './types.js';
@@ -162,7 +163,7 @@ const NEWLINE_SPLIT_PATTERN = /\r?\n/;
/** True for external-CLI run modes (non-Claude) that use their own TUI and output format. */
export function isExternalCliMode(mode: SessionMode): boolean {
return mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity';
return mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi';
}
function getModeLabel(mode: SessionMode): string {
@@ -175,6 +176,8 @@ function getModeLabel(mode: SessionMode): string {
return 'Gemini';
case 'antigravity':
return 'Antigravity';
case 'pi':
return 'Pi';
case 'shell':
return 'Shell';
case 'claude':
@@ -190,9 +193,12 @@ function getModeLabel(mode: SessionMode): string {
* Codex, Claude Code, and Gemini are known, controlled (Ink/React) TUIs that
* repaint via cursor positioning, so dropping the alt-screen switch is safe —
* content stays in the normal buffer. Excluded: `shell` (arbitrary programs like
* vim/less/htop legitimately need the alt screen) and `opencode` (renders its own
* TUI that may rely on it). Keep parity with the replay-side strip in
* session-routes.ts.
* vim/less/htop legitimately need the alt screen), `opencode` (renders its own
* TUI that may rely on it) and `pi` (its default TUI already renders into the
* MAIN screen with terminal-owned scrollback, so there is nothing to strip — and
* since pi 0.84.0 the user can switch to a fullscreen TUI at runtime via
* `/settings`, where the alt screen is load-bearing). Keep parity with the
* replay-side strip in session-routes.ts.
*/
export function isAltScreenStripMode(mode: SessionMode): boolean {
return mode === 'codex' || mode === 'claude' || mode === 'gemini';
@@ -468,6 +474,8 @@ export class Session extends EventEmitter {
private _geminiConfig: GeminiConfig | undefined;
// Antigravity configuration (only for mode === 'antigravity')
private _antigravityConfig: AntigravityConfig | undefined;
// Pi configuration (only for mode === 'pi')
private _piConfig: PiConfig | undefined;
private _resumeSessionId: string | undefined;
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
@@ -561,6 +569,8 @@ export class Session extends EventEmitter {
geminiConfig?: GeminiConfig;
/** Antigravity configuration (only for mode === 'antigravity') */
antigravityConfig?: AntigravityConfig;
/** Pi configuration (only for mode === 'pi') */
piConfig?: PiConfig;
/** Resume a previous Claude conversation (used after server reboot) */
resumeSessionId?: string;
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
@@ -654,6 +664,11 @@ export class Session extends EventEmitter {
this._antigravityConfig = config.antigravityConfig;
}
// Apply Pi configuration
if (config.piConfig) {
this._piConfig = config.piConfig;
}
// Apply env overrides (exported at spawn, not persisted to disk).
// Legacy migration: pre-0.7.2 carried effort as the CLAUDE_CODE_EFFORT_LEVEL env var,
// which hard-locks /effort switching. Extract it into _effort (--settings soft default)
@@ -1228,6 +1243,7 @@ export class Session extends EventEmitter {
codexConfig: this._codexConfig,
geminiConfig: this._geminiConfig,
antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig,
resumeSessionId: this._resumeSessionId,
effort: this._effort,
// COD-118: runtime-only — surfaced so the frontend can require explicit user
@@ -1397,9 +1413,11 @@ export class Session extends EventEmitter {
cols: ptyCols,
rows: ptyRows,
cwd: resolveMuxAttachCwd(this.workingDir, this._remote, this._docker),
// COD-75: codex/gemini/antigravity get COLORTERM=truecolor — mirrors buildEnvExports()
// COD-75: codex/gemini/antigravity/pi get COLORTERM=truecolor — mirrors buildEnvExports()
// in tmux-manager.ts so the attach client and the tmux session agree.
env: buildMuxAttachEnv(this.mode === 'codex' || this.mode === 'gemini' || this.mode === 'antigravity'),
env: buildMuxAttachEnv(
this.mode === 'codex' || this.mode === 'gemini' || this.mode === 'antigravity' || this.mode === 'pi'
),
})
);
} catch (spawnErr) {
@@ -1467,6 +1485,7 @@ export class Session extends EventEmitter {
codexConfig: this._codexConfig,
geminiConfig: this._geminiConfig,
antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
@@ -1681,6 +1700,7 @@ export class Session extends EventEmitter {
codexConfig: this._codexConfig,
geminiConfig: this._geminiConfig,
antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
@@ -1766,6 +1786,10 @@ export class Session extends EventEmitter {
if (this.mode === 'antigravity') {
throw new Error('Antigravity sessions require tmux. Direct PTY fallback is not supported.');
}
// Pi sessions require tmux for env override injection via setenv
if (this.mode === 'pi') {
throw new Error('Pi sessions require tmux. Direct PTY fallback is not supported.');
}
try {
// Pass --session-id to use the SAME ID as the Codeman session
// This ensures subagents can be directly matched to the correct tab