chat: remove Pi runner — route all chat/pipeline/channels through Claude

Stage 1 of removing Pi (Claude-only). Cuts the non-Claude branches in the chat
WS handler, pipeline executor, and channel send-and-await; deletes the Pi
sidecar, its ecosystem entry, pi-bridge, and the Pi model-listing spawn (now a
static Claude tier list). Adds a guard coercing any legacy non-claude-code model
preference to the Claude default so old settings don't break chat or jobs.
Removes the dead no-op session-save REST route and stale Pi docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 09:03:44 +00:00
co-authored by Claude Opus 4.8
parent fcdf5f0117
commit 63819fc25e
17 changed files with 55 additions and 3140 deletions
-88
View File
@@ -1,88 +0,0 @@
import type { SidecarCommand, SidecarEvent } from '../protocol';
import * as piManager from './pi-manager';
import { createSidecarConnector } from '../connect';
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
// ── Command handlers ──
type ReplyFn = (msg: SidecarEvent) => void;
async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
switch (cmd.type) {
case 'ping':
reply({ type: 'pong', id: cmd.id });
break;
case 'pi:spawn': {
try {
const onEvent = (event: import('../../api/pi/types').PiEvent) => {
connection.send({ type: 'pi:event', sessionId: cmd.params.sessionId, event });
};
await piManager.spawnPi({
sessionId: cmd.params.sessionId,
email: cmd.params.email,
userId: cmd.params.userId,
username: cmd.params.username,
role: cmd.params.role,
cwd: cmd.params.cwd,
model: cmd.params.model,
sessionFile: cmd.params.sessionFile,
onEvent,
});
reply({ type: 'pi:spawned', id: cmd.id, sessionId: cmd.params.sessionId });
} catch (err) {
reply({ type: 'pi:error', id: cmd.id, error: err instanceof Error ? err.message : String(err) });
}
break;
}
case 'pi:prompt':
piManager.sendPrompt(cmd.sessionId, cmd.prompt, cmd.requestId);
break;
case 'pi:abort':
piManager.abort(cmd.sessionId, cmd.requestId);
break;
case 'pi:kill':
piManager.killPiSession(cmd.sessionId);
reply({ type: 'pi:killed', id: cmd.id });
break;
case 'pi:set-thinking':
piManager.setThinkingLevel(cmd.sessionId, cmd.level);
break;
default:
reply({
type: 'error',
id: (cmd as SidecarCommand).id,
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
});
}
}
// ── Connect to API server ──
const connection = createSidecarConnector({
apiUrl: `${API_URL}/api/sidecar/register`,
name: 'pi',
capabilities: ['pi'],
onCommand(cmd, reply) {
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
},
});
// ── Graceful shutdown ──
function shutdown(signal: string) {
console.log(`[pi] ${signal} received, shutting down...`);
connection.destroy();
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
-459
View File
@@ -1,459 +0,0 @@
import { join, dirname } from 'node:path';
import { readdirSync, existsSync, mkdirSync } from 'node:fs';
import type { Subprocess } from 'bun';
import type { PiEvent, MessageCost } from '../../api/pi/types';
import type { PiSpawnParams, PiSessionInfo } from '../protocol';
import { sign } from '../../jwt';
import {
buildSandboxPrefix,
buildRunuserSuffix,
SANDBOX_DATA,
SANDBOX_GLOBAL_EXTENSIONS,
SANDBOX_GLOBAL_SKILLS,
SANDBOX_GLOBAL_TOOLS,
SANDBOX_HOME,
} from '../sandbox';
const OFFICER_API_URL = process.env.OFFICER_API_URL ?? `http://127.0.0.1:${process.env.PORT ?? '9010'}`;
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
const PI_CONFIG_DIR = join(require('node:os').homedir(), '.pi', 'agent');
const getHomeDir = (email: string) => join(DATA_PATH, email, 'home');
const getHomeDirForRole = (email: string, role: string | null): string =>
role === 'Super Admin' && process.env.HOME_DIR ? process.env.HOME_DIR : getHomeDir(email);
const OFFICER_ITEMS_DIR = process.env.OFFICER_ITEMS_DIR ?? join(process.cwd(), 'officer-items');
const itemsDir = (type: 'skills' | 'tools' | 'extensions') => join(OFFICER_ITEMS_DIR, type);
function isPidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
// Resolve pi as [node, cli.js] — the real .js path lives under ~/.local/lib/node_modules
// which is ro-mounted in the sandbox. Node is at /usr/bin/node (under /usr ro-bind).
const PI_CMD = (() => {
const whichResult = Bun.spawnSync({ cmd: ['which', 'pi'], stdout: 'pipe', stderr: 'ignore' });
const piBin = whichResult.stdout.toString().trim() || 'pi';
const readlinkResult = Bun.spawnSync({ cmd: ['readlink', '-f', piBin], stdout: 'pipe', stderr: 'ignore' });
const realPath = readlinkResult.stdout.toString().trim();
const nodeResult = Bun.spawnSync({ cmd: ['which', 'node'], stdout: 'pipe', stderr: 'ignore' });
const nodeBin = nodeResult.stdout.toString().trim() || 'node';
if (realPath && realPath.endsWith('.js')) {
return [nodeBin, realPath];
}
return [piBin];
})();
// Pi's nested node_modules — needed for NODE_PATH so extensions can resolve Pi's dependencies
// (e.g. @sinclair/typebox used by the tool-loader extension)
const PI_NODE_MODULES = (() => {
const whichResult = Bun.spawnSync({ cmd: ['which', 'pi'], stdout: 'pipe', stderr: 'ignore' });
const piBin = whichResult.stdout.toString().trim() || 'pi';
const readlinkResult = Bun.spawnSync({ cmd: ['readlink', '-f', piBin], stdout: 'pipe', stderr: 'ignore' });
const realPath = readlinkResult.stdout.toString().trim();
// cli.js is at .../pi-coding-agent/dist/cli.js — node_modules is at .../pi-coding-agent/node_modules
if (realPath) {
const pkgDir = join(dirname(realPath), '..');
const nm = join(pkgDir, 'node_modules');
if (existsSync(nm)) return nm;
}
return null;
})();
// Active Pi processes
type PiSession = {
sessionId: string;
email: string;
userId: number;
model: string;
cwd: string;
proc: Subprocess;
onEvent: (event: PiEvent) => void;
};
const sessions = new Map<string, PiSession>();
// ── Helpers ──
function collectSkillFlagsFromDir(scanDir: string, targetDir: string): string[] {
const flags: string[] = [];
if (!existsSync(scanDir)) return flags;
for (const entry of readdirSync(scanDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (existsSync(join(scanDir, entry.name, 'SKILL.md'))) {
flags.push('--skill', `${targetDir}/${entry.name}`);
}
}
return flags;
}
function collectSkillFlags(): string[] {
return collectSkillFlagsFromDir(itemsDir('skills'), itemsDir('skills'));
}
function collectExtensionFlagsFromDir(scanDir: string, targetDir: string): string[] {
const flags: string[] = [];
if (!existsSync(scanDir)) return flags;
for (const entry of readdirSync(scanDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (existsSync(join(scanDir, entry.name, 'index.ts'))) {
flags.push('--extension', `${targetDir}/${entry.name}/index.ts`);
}
}
return flags;
}
function collectExtensionFlags(): string[] {
return collectExtensionFlagsFromDir(itemsDir('extensions'), itemsDir('extensions'));
}
async function resolveApiKeyForModel(model: string): Promise<string | null> {
const provider = model.split('/')[0];
if (!provider) return null;
try {
const authFile = Bun.file(join(PI_CONFIG_DIR, 'auth.json'));
if (!(await authFile.exists())) return null;
const auth = (await authFile.json()) as Record<string, { key?: string }>;
return auth[provider]?.key?.trim() || null;
} catch {
return null;
}
}
// ── Event parsing (mirrors pi-bridge.ts) ──
function parseErrorMessage(raw: string): string {
try {
const parsed = JSON.parse(raw.replace(/^\d+\s*/, ''));
const inner = parsed?.error;
if (inner?.message) return inner.message;
} catch {
/* not JSON */
}
return raw;
}
function extractMessageError(msg: Record<string, unknown>): string | null {
if (msg.stopReason !== 'error') return null;
const raw = msg.errorMessage as string | undefined;
if (!raw) return null;
return parseErrorMessage(raw);
}
function parsePiEvent(event: Record<string, unknown>, currentStreamBuffer: string): PiEvent[] {
const type = event.type as string;
if (type === 'response') {
if (event.command === 'prompt' && !event.success) {
return [{ type: 'error', message: (event.error as string) ?? 'Prompt failed' }];
}
return [];
}
switch (type) {
case 'agent_start':
return [];
case 'message_update': {
const ame = event.assistantMessageEvent as Record<string, unknown> | undefined;
if (ame?.type === 'text_delta') {
return [{ type: 'delta', text: ame.delta as string }];
}
return [];
}
case 'message_end': {
const events: PiEvent[] = [];
if (currentStreamBuffer) {
events.push({ type: 'text', text: currentStreamBuffer });
}
const msg = event.message as Record<string, unknown> | undefined;
if (msg) {
const errorText = extractMessageError(msg);
if (errorText) events.push({ type: 'error', message: errorText });
}
return events;
}
case 'tool_execution_start':
return [
{
type: 'tool:start',
toolCallId: (event.toolCallId as string) ?? '',
toolName: (event.toolName as string) ?? 'unknown',
toolInput: (event.args as Record<string, unknown>) ?? {},
},
];
case 'tool_execution_end': {
const toolCallId = (event.toolCallId as string) ?? '';
const result = event.result;
let resultObj: Record<string, unknown> | null = null;
if (typeof result === 'object' && result !== null) {
resultObj = result as Record<string, unknown>;
} else if (typeof result === 'string') {
try {
resultObj = JSON.parse(result);
} catch {
/* not JSON */
}
}
const isError = (event.isError as boolean) ?? (resultObj?.isError as boolean) ?? false;
const output = result != null ? (typeof result === 'string' ? result : JSON.stringify(result)) : '';
return [{ type: 'tool:result', toolCallId, output, isError }];
}
case 'agent_end': {
const cost: MessageCost = { inputTokens: 0, outputTokens: 0, totalUSD: 0 };
const events: PiEvent[] = [];
const messages = event.messages as Array<Record<string, unknown>> | undefined;
if (messages) {
for (const msg of messages) {
const usage = msg.usage as Record<string, unknown> | undefined;
if (usage) {
cost.inputTokens += (usage.input as number) ?? 0;
cost.outputTokens += (usage.output as number) ?? 0;
const usageCost = usage.cost as Record<string, unknown> | undefined;
if (usageCost) cost.totalUSD += (usageCost.total as number) ?? 0;
}
const errorText = extractMessageError(msg);
if (errorText) events.push({ type: 'error', message: errorText });
}
}
events.push({ type: 'result', cost });
return events;
}
default:
return [];
}
}
function writeRpcCommand(proc: Subprocess, command: Record<string, unknown>): void {
const stdin = proc.stdin;
if (!stdin || typeof stdin === 'number') return;
try {
const writer = stdin as { write(data: string): void; flush(): void };
writer.write(JSON.stringify(command) + '\n');
writer.flush();
} catch (err) {
console.error('[pi] writeRpcCommand error:', err);
}
}
// ── Public API ──
export type PiSpawnOptions = {
sessionId: string;
email: string;
userId: number;
username: string;
role: string;
cwd: string;
model: string;
sessionFile?: string;
onEvent: (event: PiEvent) => void;
};
export async function spawnPi(options: PiSpawnOptions): Promise<void> {
const { sessionId, email, userId, username, role, cwd, model, sessionFile, onEvent } = options;
const isSuperAdmin = role === 'Super Admin';
const skillFlags = isSuperAdmin
? collectSkillFlags()
: collectSkillFlagsFromDir(itemsDir('skills'), SANDBOX_GLOBAL_SKILLS);
const extensionFlags = isSuperAdmin
? collectExtensionFlags()
: collectExtensionFlagsFromDir(itemsDir('extensions'), SANDBOX_GLOBAL_EXTENSIONS);
const piArgs = [
...PI_CMD,
'--mode',
'rpc',
'--no-skills',
'--no-prompt-templates',
'--no-themes',
...skillFlags,
...extensionFlags,
];
if (model) piArgs.push('--model', model);
if (sessionFile) piArgs.push('--session', sessionFile);
const apiKey = await resolveApiKeyForModel(model);
if (apiKey) piArgs.push('--api-key', apiKey);
if (!existsSync(cwd)) {
mkdirSync(cwd, { recursive: true });
}
const homeDir = getHomeDirForRole(email, role);
const toolsDirs = itemsDir('tools');
// Per-session JWT so tools (e.g. the gmail proxy) can call back to dev-platform
// as the owning user. Mirrors the signin payload shape so userMiddleware accepts it.
const officerAuthToken = await sign({ id: userId, email, username, role }, '24h');
let proc: Subprocess;
if (isSuperAdmin) {
// Super Admin: run directly with host env, no sandbox
const env: Record<string, string> = {
...(process.env as Record<string, string>),
HOME: process.env.HOME ?? homeDir,
OFFICER_USER_HOME: homeDir,
OFFICER_USER_ROOT: join(DATA_PATH, email),
PI_CODING_AGENT_DIR: PI_CONFIG_DIR,
PI_TOOLS_DIRS: toolsDirs,
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
OFFICER_API_URL,
OFFICER_AUTH_TOKEN: officerAuthToken,
TERM: 'xterm-256color',
};
if (PI_NODE_MODULES) env.NODE_PATH = PI_NODE_MODULES;
proc = Bun.spawn(piArgs, { cwd, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', env });
} else {
// Non-admin: run inside bwrap sandbox
const sandboxToolsDirs = SANDBOX_GLOBAL_TOOLS;
const prefix = buildSandboxPrefix(email);
// Pi-specific env vars
prefix.push('--setenv', 'OFFICER_USER_HOME', SANDBOX_HOME);
prefix.push('--setenv', 'OFFICER_USER_ROOT', SANDBOX_DATA);
prefix.push('--setenv', 'PI_CODING_AGENT_DIR', `${SANDBOX_HOME}/.pi/agent`);
prefix.push('--setenv', 'PI_TOOLS_DIRS', sandboxToolsDirs);
prefix.push('--setenv', 'OFFICER_EMAIL_DB', `${SANDBOX_DATA}/emails.db`);
prefix.push('--setenv', 'OFFICER_API_URL', OFFICER_API_URL);
prefix.push('--setenv', 'OFFICER_AUTH_TOKEN', officerAuthToken);
prefix.push('--setenv', 'TERM', 'xterm-256color');
if (PI_NODE_MODULES) prefix.push('--setenv', 'NODE_PATH', PI_NODE_MODULES);
const sandboxArgs = [...prefix, ...buildRunuserSuffix()];
proc = Bun.spawn([...sandboxArgs, ...piArgs], { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' });
}
const session: PiSession = { sessionId, email, userId, model, cwd, proc, onEvent };
sessions.set(sessionId, session);
console.log(`[pi] spawned Pi for session ${sessionId} (model=${model}, pid=${proc.pid})`);
// Read stdout JSON event stream
const stdout = proc.stdout as ReadableStream<Uint8Array>;
const reader = stdout.getReader();
const decoder = new TextDecoder();
let buffer = '';
let streamBuffer = '';
(async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line) as Record<string, unknown>;
const piEvents = parsePiEvent(event, streamBuffer);
for (const piEvent of piEvents) {
if (piEvent.type === 'delta') {
streamBuffer += piEvent.text;
} else if (piEvent.type === 'text' || piEvent.type === 'tool:start') {
streamBuffer = '';
}
onEvent(piEvent);
}
} catch {
/* skip */
}
}
}
} catch {
/* process ended */
}
})();
// Stderr → log
const stderr = proc.stderr as ReadableStream<Uint8Array>;
const stderrReader = stderr.getReader();
const stderrDecoder = new TextDecoder();
(async () => {
try {
while (true) {
const { done, value } = await stderrReader.read();
if (done) break;
const text = stderrDecoder.decode(value, { stream: true });
if (text.trim()) console.log(`[pi:stderr] ${text.trim()}`);
}
} catch {
/* process ended */
}
})();
// Handle exit
proc.exited.then((code) => {
sessions.delete(sessionId);
if (code !== 0) {
console.error(`[pi] Pi process ${sessionId} exited with code ${code}`);
}
});
}
export function sendPrompt(sessionId: string, prompt: string, requestId: string): boolean {
const session = sessions.get(sessionId);
if (!session) return false;
writeRpcCommand(session.proc, { type: 'prompt', id: requestId, message: prompt });
return true;
}
export function abort(sessionId: string, requestId: string): boolean {
const session = sessions.get(sessionId);
if (!session) return false;
writeRpcCommand(session.proc, { type: 'abort', id: requestId });
return true;
}
export function setThinkingLevel(sessionId: string, level: string): boolean {
const session = sessions.get(sessionId);
if (!session) return false;
writeRpcCommand(session.proc, { type: 'set_thinking_level', level });
return true;
}
export function killPiSession(sessionId: string): boolean {
const session = sessions.get(sessionId);
if (!session) return false;
try {
session.proc.kill();
} catch {
/* already dead */
}
sessions.delete(sessionId);
return true;
}
export function getSession(sessionId: string): PiSession | undefined {
return sessions.get(sessionId);
}
export function getAllSessions(): PiSessionInfo[] {
return Array.from(sessions.values()).map((s) => ({
sessionId: s.sessionId,
email: s.email,
userId: s.userId,
model: s.model,
cwd: s.cwd,
pid: s.proc.pid,
alive: isPidAlive(s.proc.pid),
}));
}
-32
View File
@@ -16,12 +16,6 @@ export type SidecarCommand =
| { type: 'claude:spawn-streaming'; id: string; params: ClaudeSpawnStreamingParams }
| { type: 'claude:kill'; id: string; sessionKey: string }
| { type: 'claude:clear-session'; id: string; sessionKey: string }
// Pi
| { type: 'pi:spawn'; id: string; params: PiSpawnParams }
| { type: 'pi:prompt'; id: string; sessionId: string; prompt: string; requestId: string }
| { type: 'pi:abort'; id: string; sessionId: string; requestId: string }
| { type: 'pi:kill'; id: string; sessionId: string }
| { type: 'pi:set-thinking'; id: string; sessionId: string; level: string }
// VNC
| { type: 'vnc:start'; id: string; params: VncStartParams }
| { type: 'vnc:stop'; id: string; email: string }
@@ -40,11 +34,6 @@ export type SidecarEvent =
| { type: 'claude:error'; id: string; error: string }
| { type: 'claude:killed'; id: string }
| { type: 'claude:session-cleared'; id: string }
// Pi
| { type: 'pi:spawned'; id: string; sessionId: string }
| { type: 'pi:event'; sessionId: string; event: PiEvent }
| { type: 'pi:error'; id: string; error: string }
| { type: 'pi:killed'; id: string }
// VNC
| { type: 'vnc:started'; id: string; port: number; display: number }
| { type: 'vnc:stopped'; id: string }
@@ -62,16 +51,6 @@ export type ClaudeState = {
claudeSessions: Record<string, string>; // sessionKey → Claude Code session_id
};
export type PiSessionInfo = {
sessionId: string;
email: string;
userId: number;
model: string;
cwd: string;
pid: number;
alive: boolean;
};
// ── Param types ──
export type ClaudeSpawnParams = {
@@ -102,17 +81,6 @@ export type ClaudeCodeResult = {
cost: MessageCost;
};
export type PiSpawnParams = {
sessionId: string;
email: string;
userId: number;
username: string;
role: string;
cwd: string;
model: string;
sessionFile?: string;
};
// ── VNC types ──
export type VncStartParams = {