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
+76 -14
View File
@@ -22,6 +22,7 @@ import {
type CodexConfig,
type GeminiConfig,
type AntigravityConfig,
type PiConfig,
} from '../../types.js';
import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
import { SseEvent } from '../sse-events.js';
@@ -312,29 +313,50 @@ export function _resetPasteRateBuckets(): void {
* Antigravity is like Codex: an ABSENT config already defaults safe (no bypass flag), so
* only a sent config needs the flag forced off. No-op in single-user mode / for a granted
* owner (canUsernameRunPrivilegedCommands returns true when !isMultiUserMode()).
*
* Pi has no permission prompts at all, so there is no bypass switch to clamp; its
* privilege-shaped knob is `approveProjectTrust`, which makes pi LOAD AND EXECUTE
* repo-local `.pi/extensions` TypeScript and npm-install missing project packages.
* Pi joins the gemini-style MATERIALIZE branch, not the codex/antigravity
* only-if-sent one: pi's absent-config default is an interactive trust prompt the
* session user could simply answer "yes" to in the terminal, so merely omitting
* `--approve` is not a clamp. Forcing `approveProjectTrust: false` makes
* buildPiCommand emit `--no-approve`, and the prompt never appears.
*/
async function clampExternalCliBypassForOwner(
owner: string | undefined,
codexConfig: CodexConfig | undefined,
geminiConfig: GeminiConfig | undefined,
antigravityConfig: AntigravityConfig | undefined
antigravityConfig: AntigravityConfig | undefined,
piConfig: PiConfig | undefined
): Promise<{
codexConfig: CodexConfig | undefined;
geminiConfig: GeminiConfig | undefined;
antigravityConfig: AntigravityConfig | undefined;
piConfig: PiConfig | undefined;
}> {
const granted = await canUsernameRunPrivilegedCommands(owner);
if (granted) return { codexConfig, geminiConfig, antigravityConfig };
if (granted) return { codexConfig, geminiConfig, antigravityConfig, piConfig };
// Non-granted: force codex/antigravity bypass off (only meaningful when a config was
// sent) and materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default).
// sent) and materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default)
// and pi to --no-approve (clamps an explicit true AND pi's own "ask" default).
const clampedCodex = codexConfig ? { ...codexConfig, dangerouslyBypassApprovals: false } : codexConfig;
const clampedGemini: GeminiConfig = { ...(geminiConfig ?? {}), approvalMode: 'auto_edit' };
const clampedAntigravity = antigravityConfig
? { ...antigravityConfig, dangerouslySkipPermissions: false }
: antigravityConfig;
return { codexConfig: clampedCodex, geminiConfig: clampedGemini, antigravityConfig: clampedAntigravity };
const clampedPi: PiConfig = { ...(piConfig ?? {}), approveProjectTrust: false };
return {
codexConfig: clampedCodex,
geminiConfig: clampedGemini,
antigravityConfig: clampedAntigravity,
piConfig: clampedPi,
};
}
/** Test hook: the clamp is the multi-user safety gate for the external CLIs' privileged flags. */
export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner;
// ═══════════════════════════════════════════════════════════════
// Agent wait helpers (shared by GET /wait, GET /wait-output, POST /input)
// ═══════════════════════════════════════════════════════════════
@@ -706,6 +728,7 @@ export function registerSessionRoutes(
body.mode !== 'codex' &&
body.mode !== 'gemini' &&
body.mode !== 'antigravity' &&
body.mode !== 'pi' &&
body.envOverrides &&
Object.keys(body.envOverrides).length > 0 &&
(workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/'));
@@ -788,6 +811,15 @@ export function registerSessionRoutes(
);
}
}
if (body.mode === 'pi') {
const { isPiAvailable } = await import('../../utils/pi-cli-resolver.js');
if (!isPiAvailable()) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent'
);
}
}
// Pre-validate resumeSessionId: check that the conversation file actually exists
// in Claude's projects directory. If not, skip resume to avoid confusing
@@ -831,9 +863,11 @@ export function registerSessionRoutes(
? body.geminiConfig?.model
: mode === 'antigravity'
? body.antigravityConfig?.model
: mode !== 'shell'
? modelConfig?.defaultModel || undefined
: undefined;
: mode === 'pi'
? body.piConfig?.model
: mode !== 'shell'
? modelConfig?.defaultModel || undefined
: undefined;
const claudeModeConfig = await ctx.getClaudeModeConfig();
// Section 6.3: force non-granted users to a classifier-guarded mode.
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, owner);
@@ -842,7 +876,14 @@ export function registerSessionRoutes(
codexConfig: gatedCodexConfig,
geminiConfig: gatedGeminiConfig,
antigravityConfig: gatedAntigravityConfig,
} = await clampExternalCliBypassForOwner(owner, body.codexConfig, body.geminiConfig, body.antigravityConfig);
piConfig: gatedPiConfig,
} = await clampExternalCliBypassForOwner(
owner,
body.codexConfig,
body.geminiConfig,
body.antigravityConfig,
body.piConfig
);
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const session = new Session({
workingDir,
@@ -858,6 +899,7 @@ export function registerSessionRoutes(
codexConfig: mode === 'codex' ? gatedCodexConfig : undefined,
geminiConfig: mode === 'gemini' ? gatedGeminiConfig : undefined,
antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined,
piConfig: mode === 'pi' ? gatedPiConfig : undefined,
resumeSessionId: validatedResumeId,
envOverrides: body.envOverrides,
effort: body.effort,
@@ -2570,6 +2612,7 @@ export function registerSessionRoutes(
codexConfig,
geminiConfig,
antigravityConfig,
piConfig,
envOverrides,
effort,
parentSessionId,
@@ -2617,6 +2660,7 @@ export function registerSessionRoutes(
codexConfig ||
geminiConfig ||
antigravityConfig ||
piConfig ||
openCodeConfig
) {
return createErrorResponse(
@@ -2648,6 +2692,7 @@ export function registerSessionRoutes(
codexConfig ||
geminiConfig ||
antigravityConfig ||
piConfig ||
openCodeConfig
) {
return createErrorResponse(
@@ -2751,6 +2796,17 @@ export function registerSessionRoutes(
}
}
// Check Pi availability if requested
if (mode === 'pi') {
const { isPiAvailable } = await import('../../utils/pi-cli-resolver.js');
if (!isPiAvailable()) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent'
);
}
}
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
// external project directories are honoured by quick-start just like regular case routes.
@@ -2798,7 +2854,7 @@ export function registerSessionRoutes(
// Write .claude/settings.local.json with hooks for desktop notifications
// (Claude-specific — OpenCode, Codex, Gemini, and Antigravity use their own systems)
if (mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini' && mode !== 'antigravity') {
if (mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini' && mode !== 'antigravity' && mode !== 'pi') {
await writeHooksConfig(resolvedCasePath);
}
@@ -2833,7 +2889,8 @@ export function registerSessionRoutes(
mode !== 'opencode' &&
mode !== 'codex' &&
mode !== 'gemini' &&
mode !== 'antigravity'
mode !== 'antigravity' &&
mode !== 'pi'
) {
try {
if (!existsSync(join(resolvedCasePath, 'CLAUDE.md'))) {
@@ -2864,6 +2921,7 @@ export function registerSessionRoutes(
mode !== 'codex' &&
mode !== 'gemini' &&
mode !== 'antigravity' &&
mode !== 'pi' &&
!remote &&
envOverrides &&
Object.keys(envOverrides).length > 0
@@ -2884,9 +2942,11 @@ export function registerSessionRoutes(
? geminiConfig?.model
: mode === 'antigravity'
? antigravityConfig?.model
: mode !== 'shell'
? qsModelConfig?.defaultModel || undefined
: undefined;
: mode === 'pi'
? piConfig?.model
: mode !== 'shell'
? qsModelConfig?.defaultModel || undefined
: undefined;
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
const qsEffectiveClaudeMode = await resolveClaudeModeForUsername(qsClaudeModeConfig.claudeMode, owner);
// Section 6.3: clamp Codex/Gemini/Antigravity bypass switches for a non-granted owner (no-op single-user/granted).
@@ -2894,7 +2954,8 @@ export function registerSessionRoutes(
codexConfig: qsGatedCodexConfig,
geminiConfig: qsGatedGeminiConfig,
antigravityConfig: qsGatedAntigravityConfig,
} = await clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig, antigravityConfig);
piConfig: qsGatedPiConfig,
} = await clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig, antigravityConfig, piConfig);
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const session = new Session({
workingDir: resolvedCasePath,
@@ -2911,6 +2972,7 @@ export function registerSessionRoutes(
codexConfig: mode === 'codex' ? qsGatedCodexConfig : undefined,
geminiConfig: mode === 'gemini' ? qsGatedGeminiConfig : undefined,
antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined,
piConfig: mode === 'pi' ? qsGatedPiConfig : undefined,
envOverrides,
effort,
remote,
+16 -1
View File
@@ -374,7 +374,7 @@ export function registerSystemRoutes(
});
// ═══════════════════════════════════════════════════════════════
// CLI Integrations (Claude, OpenCode, Codex, Gemini, Antigravity)
// CLI Integrations (Claude, OpenCode, Codex, Gemini, Antigravity, Pi)
// ═══════════════════════════════════════════════════════════════
// ========== Claude ==========
@@ -425,6 +425,21 @@ export function registerSystemRoutes(
};
});
// ========== Pi ==========
// Carries `version` on top of the sibling shape: `pi` is a short, generic binary
// name, so the resolver sanity-probes `pi --version` and rejects anything that
// is not the coding agent. Surfacing path + version makes a misresolution
// diagnosable from the UI instead of presenting as "the mode just doesn't work".
app.get('/api/pi/status', async () => {
const { isPiAvailable, resolvePiDir, getPiCliVersion } = await import('../../utils/pi-cli-resolver.js');
return {
available: isPiAvailable(),
path: resolvePiDir(),
version: getPiCliVersion(),
};
});
// ═══════════════════════════════════════════════════════════════
// State & Lifecycle (cleanup, lifecycle log, stats)
// ═══════════════════════════════════════════════════════════════