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
+9 -5
View File
@@ -2003,9 +2003,11 @@ class CodemanApp {
? 'Gemini'
: mode === 'antigravity'
? 'Antigravity'
: mode === 'opencode'
? 'OpenCode'
: 'Claude';
: mode === 'pi'
? 'Pi'
: mode === 'opencode'
? 'OpenCode'
: 'Claude';
}
async toggleResponseViewer() {
@@ -3881,7 +3883,7 @@ class CodemanApp {
<span class="tab-status ${status}" aria-hidden="true"></span>
<span class="tab-info">
<span class="tab-name-row">
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : ''}
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : ''}
<span class="tab-name" data-session-id="${id}">${escapeHtml(tabLabel)}</span>
<span class="tab-detached-badge" aria-hidden="true">detached</span>
</span>
@@ -5053,7 +5055,9 @@ class CodemanApp {
? 'Kill Tmux & Gemini'
: session.mode === 'antigravity'
? 'Kill Tmux & Antigravity'
: 'Kill Tmux & Claude Code';
: session.mode === 'pi'
? 'Kill Tmux & Pi'
: 'Kill Tmux & Claude Code';
}
document.getElementById('closeConfirmModal').classList.add('active');
+1
View File
@@ -104,6 +104,7 @@
'Run OpenCode': '运行 OpenCode',
'Run Gemini': '运行 Gemini',
'Run Antigravity': '运行 Antigravity',
'Run Pi': '运行 Pi',
'Run Shell': '运行 Shell',
'Select AI backend': '选择 AI 后端',
'Create New Case': '新建案例',
+10 -1
View File
@@ -352,6 +352,10 @@
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Run Gemini
</button>
<button class="welcome-btn welcome-btn-pi" id="welcomePiBtn" style="display: none;" onclick="app.setRunMode('pi'); app.runPi()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Run Pi
</button>
</div>
<div class="welcome-qr" id="welcomeQr" onclick="app.toggleWelcomeQrSize()">
<div class="welcome-qr-inner" id="welcomeQrInner"></div>
@@ -526,6 +530,9 @@
<button class="run-mode-option" data-mode="antigravity" onclick="app.setRunMode('antigravity')">
<span class="run-mode-dot antigravity"></span>Antigravity
</button>
<button class="run-mode-option" data-mode="pi" onclick="app.setRunMode('pi')">
<span class="run-mode-dot pi"></span>Pi
</button>
<div class="run-mode-sep"></div>
<button class="run-mode-option" data-mode="shell" onclick="app.setRunMode('shell')">
<span class="run-mode-dot shell"></span>Terminal / Shell
@@ -801,6 +808,7 @@
<option value="codex">Codex</option>
<option value="gemini">Gemini</option>
<option value="antigravity">Antigravity</option>
<option value="pi">Pi</option>
</select>
</div>
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
@@ -2481,6 +2489,7 @@
<option value="gemini" data-cli="gemini">Gemini</option>
<option value="opencode" data-cli="opencode">OpenCode</option>
<option value="antigravity" data-cli="antigravity">Antigravity</option>
<option value="pi" data-cli="pi">Pi</option>
<option value="shell">Shell (no agent)</option>
</select>
<span class="form-hint">Which CLI to point the Run button at once the clone finishes. Changeable any time from the Run dropdown.</span>
@@ -2621,7 +2630,7 @@
<div class="form-row">
<label>Image</label>
<input type="text" id="dockerImage" placeholder="codeman/agent:base" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/codex/gemini/opencode/agy + tmux.</span>
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/codex/gemini/opencode/agy/pi + tmux.</span>
</div>
<div class="form-row">
<label>Network</label>
+1
View File
@@ -58,6 +58,7 @@ const MOBILE_OVERVIEW_RUN_MODES = [
{ mode: 'codex', label: 'Codex', short: 'Codex' },
{ mode: 'gemini', label: 'Gemini', short: 'Gemini' },
{ mode: 'antigravity', label: 'Antigravity', short: 'Antigravity' },
{ mode: 'pi', label: 'Pi', short: 'Pi' },
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
];
+25
View File
@@ -911,6 +911,25 @@ html.mobile-init .file-browser-panel {
border-color: rgba(34, 211, 238, 0.5);
}
/* Pi mode colors on mobile.
`!important` is load-bearing here, not noise: styles.css nests its skin rules
inside `html:not([data-skin="og"])`, so a bare `.btn-toolbar.btn-run` in there
resolves to (0,2,1) and outranks this (0,2,0) `.mode-pi` pair regardless of
load order. The antigravity block right above omits it and is consequently
dead on every non-og skin (i.e. on the default) — do not copy that. */
.btn-toolbar.btn-run.mode-pi,
.btn-toolbar.btn-run-gear.mode-pi {
background: #33121f !important;
border-color: rgba(244, 114, 182, 0.3) !important;
color: #fce7f3 !important;
}
.btn-toolbar.btn-run.mode-pi:active,
.btn-toolbar.btn-run-gear.mode-pi:active {
background: #9d174d !important;
border-color: rgba(244, 114, 182, 0.5) !important;
}
/* Run mode dropdown menu — positioned above toolbar on mobile */
.run-mode-menu {
bottom: 100%;
@@ -2988,6 +3007,12 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
color: #ffffff;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-pi, .btn-toolbar.btn-run-gear.mode-pi) {
background: linear-gradient(135deg, #be185d, #db2777);
border-color: #9d174d;
color: #ffffff;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-toolbar.btn-run-gear {
border-left-color: var(--control-border-hover) !important;
}
+1 -1
View File
@@ -427,7 +427,7 @@ Object.assign(CodemanApp.prototype, {
_buildCommandPaletteNewSessionItem(query = '') {
const mode = this.runMode || this._runMode || 'claude';
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity' };
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi' };
const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
return {
id: 'new-session',
+68 -8
View File
@@ -1,5 +1,5 @@
/**
* @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode/Codex/Gemini/Antigravity),
* @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode/Codex/Gemini/Antigravity/Pi),
* session options modal (per-session settings, color picker, rename),
* session options tabs (Ralph config tab), case settings (CRUD, links),
* create case modal, and mobile case picker.
@@ -400,6 +400,9 @@ Object.assign(CodemanApp.prototype, {
if (mode === 'antigravity') {
return await this.runAntigravity();
}
if (mode === 'pi') {
return await this.runPi();
}
if (mode === 'shell') {
return await this.runShell();
}
@@ -461,11 +464,11 @@ Object.assign(CodemanApp.prototype, {
* `.run-mode-option` is also the class the saved-dashboard rows and the history
* rows use, and a bare querySelector would find whichever came first in the DOM.
*
* Antigravity is in this list even though #201 predates it — it is a run mode
* like the rest, and `agy` is the LEAST likely of the five to be installed.
* Antigravity and Pi are in this list even though #201 predates them — they are
* run modes like the rest, and neither `agy` nor `pi` is likely to be installed.
*/
_refreshRunModeAvailability(menu) {
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity']) {
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi']) {
const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none';
}
@@ -562,7 +565,7 @@ Object.assign(CodemanApp.prototype, {
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
}
if (label) {
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'shell' ? 'Run SH' : 'Run';
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'shell' ? 'Run SH' : 'Run';
}
},
@@ -1218,6 +1221,63 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* Launch a Pi (pi.dev) session.
*
* Deliberately sends NO piConfig: pi has no permission prompts, so there is no
* bypass to opt into, and project trust is pi's own `defaultProjectTrust`
* decision (an interactive prompt the user answers in the terminal). Sending
* `approveProjectTrust: true` here would silently opt every browser-launched pi
* session into executing repo-supplied TypeScript.
*/
async runPi() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Remote/docker cases run pi on the OTHER side — skip the local status probe and the
// local-only config/env below (quick-start rejects them for remote cases).
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Pi session in ${caseName}...`);
this.terminal.focus();
try {
if (!isRemote) {
const statusRes = await fetch('/api/pi/status');
const status = (await statusRes.json()).data;
if (!status.available) {
this._reportSessionLaunchError(
ownsLaunchTerminal,
'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent'
);
return;
}
}
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
caseName,
mode: 'pi',
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
...(isRemote || Object.keys(envOverrides).length === 0 ? {} : { envOverrides }),
})
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start Pi');
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
if (data.data.sessionId) {
await this.selectSession(data.data.sessionId);
}
this.terminal.focus();
} catch (err) {
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
}
},
// ═══════════════════════════════════════════════════════════════
// Session Options Modal
@@ -1230,7 +1290,7 @@ Object.assign(CodemanApp.prototype, {
this.editingSessionId = sessionId;
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity';
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi';
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
// Update respawn status display and buttons
@@ -1260,7 +1320,7 @@ Object.assign(CodemanApp.prototype, {
}
// Hide Claude-specific options for external CLI sessions
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity';
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi';
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; });
@@ -2952,7 +3012,7 @@ Object.defineProperty(CodemanApp.prototype, 'runMode', {
},
set(mode) {
this._runMode =
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'claude'
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'claude'
? mode
: 'claude';
},
+1
View File
@@ -1179,6 +1179,7 @@ Object.assign(CodemanApp.prototype, {
['welcomeOpencodeBtn', 'opencode'],
['welcomeAntigravityBtn', 'antigravity'],
['welcomeGeminiBtn', 'gemini'],
['welcomePiBtn', 'pi'],
// Not a run mode, same reasoning: offering a Cloudflare Tunnel on a box
// without cloudflared can only ever produce "cloudflared not found".
['welcomeTunnelBtn', 'cloudflared'],
+56 -1
View File
@@ -329,7 +329,8 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
.search-badge-session,
.history-view-all-btn,
.session-tab .tab-mode.gemini,
.session-tab .tab-mode.antigravity
.session-tab .tab-mode.antigravity,
.session-tab .tab-mode.pi
) {
color: var(--accent-d);
}
@@ -2159,6 +2160,11 @@ body.solo-mode .btn-lifecycle-log {
color: #22d3ee;
}
.session-tab .tab-mode.pi {
background: rgba(244, 114, 182, 0.2);
color: #f472b6;
}
/* Timer Banner - Compact */
.timer-banner {
display: flex;
@@ -3378,6 +3384,23 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
transform: translateY(-1px);
}
/* Pi (pi.dev): rose identity, matching .btn-toolbar.btn-run.mode-pi and
.run-mode-dot.pi so the welcome action reads as the same backend. */
.welcome-btn-pi {
background: linear-gradient(135deg, #33121f 0%, #9d174d 55%, #be185d 100%);
border-color: rgba(244, 114, 182, 0.4);
color: #fce7f3;
box-shadow: 0 2px 8px rgba(244, 114, 182, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.welcome-btn-pi:hover {
background: linear-gradient(135deg, #4a1a2c 0%, #be185d 55%, #db2777 100%);
box-shadow: 0 4px 20px rgba(244, 114, 182, 0.3), 0 0 40px rgba(190, 24, 93, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.08);
border-color: rgba(249, 168, 212, 0.5);
color: #fff1f7;
transform: translateY(-1px);
}
.welcome-btn-gemini {
background: linear-gradient(135deg, #10243f 0%, #174ea6 55%, #4f46e5 100%);
border-color: rgba(96, 165, 250, 0.4);
@@ -4432,6 +4455,26 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
color: #ecfeff;
}
/* Pi mode colors. NOTE: this base-sheet pair only renders on the `og` skin — the
nested `html:not([data-skin="og"])` block further down re-declares
`.btn-toolbar.btn-run` at a HIGHER specificity, which is why gemini's and
antigravity's gradients are dead on the default skin. Pi therefore also carries
a rule inside that block (search `.btn-toolbar.btn-run.mode-pi`). */
.btn-toolbar.btn-run.mode-pi,
.btn-toolbar.btn-run-gear.mode-pi {
background: linear-gradient(135deg, #33121f 0%, #9d174d 55%, #be185d 100%);
border-color: rgba(244, 114, 182, 0.5);
color: #fce7f3;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.btn-toolbar.btn-run.mode-pi:hover,
.btn-toolbar.btn-run-gear.mode-pi:hover {
background: linear-gradient(135deg, #4a1a2c 0%, #be185d 55%, #db2777 100%);
box-shadow: 0 0 12px rgba(244, 114, 182, 0.35), 0 2px 8px rgba(190, 24, 93, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08);
border-color: rgba(249, 168, 212, 0.6);
color: #fff1f7;
}
/* Dropdown menu */
.run-mode-menu {
display: none;
@@ -4514,6 +4557,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
.run-mode-dot.codex { background: #a855f7; }
.run-mode-dot.gemini { background: #8ab4f8; }
.run-mode-dot.antigravity { background: #22d3ee; }
.run-mode-dot.pi { background: #f472b6; }
.run-mode-dot.shell { background: #94a3b8; }
/* Phone-only Enter button (see index.html). Hidden by default at every width;
@@ -13790,6 +13834,17 @@ html:not([data-skin="og"]) {
color: #061c20;
}
.btn-toolbar.btn-run.mode-codex:hover { box-shadow: 0 0 14px -2px rgba(43, 203, 187, 0.45); }
/* Pi keeps its rose identity on the non-og skins. This rule has to live INSIDE
this nested block: the generic `.btn-toolbar.btn-run` above resolves to (0,3,1)
here and would otherwise beat the base sheet's (0,3,0) `.mode-pi` pair, which
is exactly why gemini's and antigravity's gradients render as generic claude
blue on the default skin. */
.btn-toolbar.btn-run.mode-pi {
background: linear-gradient(135deg, #be185d, #f472b6);
border-color: #be185d;
color: #fff1f7;
}
.btn-toolbar.btn-run.mode-pi:hover { box-shadow: 0 0 14px -2px rgba(244, 114, 182, 0.45); }
.btn-toolbar.btn-run-gear {
background: var(--accent-d);
border-color: var(--accent);
+1 -1
View File
@@ -1747,7 +1747,7 @@ Object.assign(CodemanApp.prototype, {
}
titleSpan.appendChild(document.createTextNode(this._historyRowLabel(s, shortDir)));
// Badge row: mode (claude/codex/opencode/gemini/antigravity/shell) + a LIVE pill.
// Badge row: mode (claude/codex/opencode/gemini/antigravity/pi/shell) + a LIVE pill.
const badgeRow = document.createElement('div');
badgeRow.className = 'history-item-badges';
if (s.mode) {
+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)
// ═══════════════════════════════════════════════════════════════
+39 -5
View File
@@ -122,7 +122,7 @@ export const FileWriteSchema = z
// ========== Env Var Allowlist ==========
/** Allowlisted env var key prefixes */
const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_', 'CODEX_', 'GEMINI_', 'GOOGLE_', 'ANTIGRAVITY_'];
const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_', 'CODEX_', 'GEMINI_', 'GOOGLE_', 'ANTIGRAVITY_', 'PI_'];
/**
* Allowlisted exact env var keys (checked alongside the prefixes).
@@ -161,7 +161,7 @@ const safeEnvOverridesSchema = z
},
{
message:
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_* keys and CLAUDE_CONFIG_DIR are allowed.',
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_* keys and CLAUDE_CONFIG_DIR are allowed.',
}
);
@@ -269,6 +269,37 @@ const AntigravityConfigSchema = z
})
.optional();
/**
* Schema for Pi CLI (pi.dev)-specific configuration.
*
* No bypass field exists on purpose: pi has no permission prompts. The one
* privilege-shaped knob is the TRI-STATE `approveProjectTrust` (see PiConfig),
* which the multi-user clamp MATERIALIZES to `false` for non-granted owners.
*/
const PiConfigSchema = z
.object({
// `:` for a thinking suffix (`sonnet:high`), `/` for `provider/id`.
model: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9._\-/:]+$/)
.optional(),
provider: z
.string()
.max(50)
.regex(/^[a-z0-9-]+$/)
.optional(),
thinking: z.enum(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']).optional(),
continueSession: z.boolean().optional(),
resumeSessionId: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9._-]+$/)
.optional(),
approveProjectTrust: z.boolean().optional(),
})
.optional();
/**
* The session that spawned the one being created — pure UI decoration, drawn as a
* lineage line between the two tabs. Accepted here and, equivalently, as the
@@ -282,7 +313,7 @@ const parentSessionIdSchema = z.string().max(100).optional();
export const CreateSessionSchema = z.object({
workingDir: safePathSchema.optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity']).optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi']).optional(),
name: z.string().max(100).optional(),
/** Session that spawned this one — see parentSessionIdSchema. */
parentSessionId: parentSessionIdSchema,
@@ -297,6 +328,7 @@ export const CreateSessionSchema = z.object({
codexConfig: CodexConfigSchema,
geminiConfig: GeminiConfigSchema,
antigravityConfig: AntigravityConfigSchema,
piConfig: PiConfigSchema,
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
resumeSessionId: z
.string()
@@ -431,6 +463,7 @@ const RemoteCommandOverridesSchema = z
codex: z.string().min(1).max(300).optional(),
gemini: z.string().min(1).max(300).optional(),
antigravity: z.string().min(1).max(300).optional(),
pi: z.string().min(1).max(300).optional(),
})
.strict()
.optional();
@@ -705,11 +738,12 @@ export const QuickStartSchema = z.object({
* a real host dir, so the settings file crosses the bind mount); rejected for
* remote cases (the file would be written on the WRONG machine). */
modelOverride: z.string().max(50).optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity']).optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi']).optional(),
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
geminiConfig: GeminiConfigSchema,
antigravityConfig: AntigravityConfigSchema,
piConfig: PiConfigSchema,
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
@@ -1211,7 +1245,7 @@ const noNewlines = (v: string) => !/[\r\n]/.test(v);
/** Shared field shape for creating/updating a scheduled job. */
const CronJobBaseSchema = z.object({
name: z.string().min(1).max(200),
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity']),
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi']),
workingDir: safePathSchema,
launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(),
promptMode: z.enum(['inline_text', 'prompt_file_path']),
+4
View File
@@ -1380,6 +1380,7 @@ export class WebServer extends EventEmitter {
{ isCodexAvailable },
{ isGeminiAvailable },
{ isAntigravityAvailable },
{ isPiAvailable },
{ isCloudflaredAvailable },
{ isGitAvailable },
] = await Promise.all([
@@ -1388,6 +1389,7 @@ export class WebServer extends EventEmitter {
import('../utils/codex-cli-resolver.js'),
import('../utils/gemini-cli-resolver.js'),
import('../utils/antigravity-cli-resolver.js'),
import('../utils/pi-cli-resolver.js'),
import('../utils/cloudflared-resolver.js'),
import('../git-clone.js'),
]);
@@ -1397,6 +1399,7 @@ export class WebServer extends EventEmitter {
codex: isCodexAvailable(),
gemini: isGeminiAvailable(),
antigravity: isAntigravityAvailable(),
pi: isPiAvailable(),
cloudflared: isCloudflaredAvailable(),
// Not a run mode: the Add Case → Clone tab is an offer this box cannot
// keep without git (issue #236), same reasoning as cloudflared above.
@@ -2634,6 +2637,7 @@ export class WebServer extends EventEmitter {
codexConfig: muxSession.mode === 'codex' ? savedState?.codexConfig : undefined,
geminiConfig: muxSession.mode === 'gemini' ? savedState?.geminiConfig : undefined,
antigravityConfig: muxSession.mode === 'antigravity' ? savedState?.antigravityConfig : undefined,
piConfig: muxSession.mode === 'pi' ? savedState?.piConfig : undefined,
envOverrides: savedEnvOverrides,
effort: savedState?.effort,
attachmentHistory: savedAttachmentHistory,