import { join, relative } from "path"; import { homedir } from "node:os"; import { readdirSync, existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; import type { Subprocess } from "bun"; import type { PiEvent, MessageCost } from "./types"; import { readApiKeys } from "../server-settings/pi-mono"; import { readSearxngConfig } from "../server-settings/searxng"; import { PI_CONFIG_DIR, DATA_PATH, getHomeDir, getGlobalSkillsDir, getUserSkillsDir, getGlobalExtensionsDir, getUserExtensionsDir, getGlobalToolsDir, getUserToolsDir, getNativeResourcesDir, getGlobalResourcesDir } from "../../data-path"; import { ensureDockerContainer } from "../terminal/websocket"; import { logger } from "./logger"; import { parseFrontmatter } from "../skills/skills"; export type PiEventHandler = (event: PiEvent) => void; type PathOverrides = { global: string; user: string }; function collectSkillFlags(email: string, containerPaths?: PathOverrides): string[] { const flags: string[] = []; const pairs: Array<[hostDir: string, outputDir: string]> = [ [getGlobalSkillsDir(), containerPaths?.global ?? getGlobalSkillsDir()], [getUserSkillsDir(email), containerPaths?.user ?? getUserSkillsDir(email)], ]; for (const [hostDir, outputDir] of pairs) { if (!existsSync(hostDir)) continue; for (const entry of readdirSync(hostDir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; if (existsSync(join(hostDir, entry.name, 'SKILL.md'))) { flags.push('--skill', `${outputDir}/${entry.name}`); } } } return flags; } function collectExtensionFlags(email: string, containerPaths?: PathOverrides): string[] { const flags: string[] = []; const pairs: Array<[hostDir: string, outputDir: string]> = [ [getGlobalExtensionsDir(), containerPaths?.global ?? getGlobalExtensionsDir()], [getUserExtensionsDir(email), containerPaths?.user ?? getUserExtensionsDir(email)], ]; for (const [hostDir, outputDir] of pairs) { if (!existsSync(hostDir)) continue; for (const entry of readdirSync(hostDir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; if (existsSync(join(hostDir, entry.name, 'index.ts'))) { flags.push('--extension', `${outputDir}/${entry.name}/index.ts`); } } } return flags; } export function generateResourceSkill(outputDir: string): string | null { const nativeDir = getNativeResourcesDir(); const globalDir = getGlobalResourcesDir(); // Collect all resource dirs (global overrides native) const resourceDirs = new Map(); for (const dir of [nativeDir, globalDir]) { if (!existsSync(dir)) continue; for (const entry of readdirSync(dir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; if (existsSync(join(dir, entry.name, 'RESOURCE.md'))) { resourceDirs.set(entry.name, dir); } } } if (resourceDirs.size === 0) return null; const sections: string[] = []; for (const [name, baseDir] of resourceDirs) { const resourceMd = join(baseDir, name, 'RESOURCE.md'); let mdContent = ''; try { mdContent = readFileSync(resourceMd, 'utf-8'); } catch { continue; } const { frontmatter } = parseFrontmatter(mdContent); // Merge native + global config let nativeConfig: Record = {}; let globalConfig: Record = {}; try { nativeConfig = JSON.parse(readFileSync(join(nativeDir, name, 'config.json'), 'utf-8')); } catch {} try { globalConfig = JSON.parse(readFileSync(join(globalDir, name, 'config.json'), 'utf-8')); } catch {} const config: Record = {}; for (const key of Object.keys(nativeConfig)) config[key] = globalConfig[key] ?? nativeConfig[key]!; for (const key of Object.keys(globalConfig)) if (!(key in config)) config[key] = globalConfig[key]!; const hasValues = Object.values(config).some((v) => v !== ''); const configLines = Object.entries(config) .filter(([, v]) => v) .map(([k, v]) => /key|secret|password|token/i.test(k) ? `- **${k}**: (configured)` : `- **${k}**: ${v}`); sections.push([ `### ${frontmatter.name || name}`, hasValues ? 'Status: **configured**' : 'Status: not configured', ...configLines, ].join('\n')); } const skillContent = [ '---', 'name: Available Resources', 'description: External services and APIs configured on this Officer instance', '---', '', 'These are external services available to you. Use their configured URLs directly via HTTP requests.', 'Do NOT try to install local alternatives (like tesseract, whisper, etc.) — use the configured HTTP APIs instead.', '', ...sections, ].join('\n'); const skillDir = join(outputDir, '.generated', 'available-resources'); try { mkdirSync(skillDir, { recursive: true }); writeFileSync(join(skillDir, 'SKILL.md'), skillContent, 'utf-8'); return skillDir; } catch { logger.error(`Failed to write resource skill to ${skillDir}`); return null; } } function buildResourcesEnv(): string { const nativeDir = getNativeResourcesDir(); const globalDir = getGlobalResourcesDir(); const resourceDirs = new Map(); for (const dir of [nativeDir, globalDir]) { if (!existsSync(dir)) continue; for (const entry of readdirSync(dir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; if (existsSync(join(dir, entry.name, 'RESOURCE.md')) || existsSync(join(dir, entry.name, 'config.json'))) { resourceDirs.set(entry.name, dir); } } } const result: Record> = {}; for (const [name] of resourceDirs) { let nativeConfig: Record = {}; let globalConfig: Record = {}; try { nativeConfig = JSON.parse(readFileSync(join(nativeDir, name, 'config.json'), 'utf-8')); } catch {} try { globalConfig = JSON.parse(readFileSync(join(globalDir, name, 'config.json'), 'utf-8')); } catch {} const config: Record = {}; for (const key of Object.keys(nativeConfig)) config[key] = globalConfig[key] ?? nativeConfig[key]!; for (const key of Object.keys(globalConfig)) if (!(key in config)) config[key] = globalConfig[key]!; // Only include resources that have at least one non-empty value if (Object.values(config).some((v) => v !== '')) { result[name] = config; } } return JSON.stringify(result); } function getGoogleConfigPath(): string { return join(homedir(), '.config', 'officer.dev', 'google-oauth.json'); } function getGoogleTokenPath(email: string): string { return join(DATA_PATH, email, 'integrations', 'google.json'); } type SandboxOptions = { userId: number; username: string; email: string; homeDir: string; }; export async function spawnPi( cwd: string, model: string, email: string, onEvent: PiEventHandler, sandbox?: SandboxOptions, ): Promise { let proc: Subprocess; if (sandbox) { const container = await ensureDockerContainer(sandbox.email, sandbox.userId, sandbox.homeDir, sandbox.username); const storedKeys = await readApiKeys(); const searxng = await readSearxngConfig(); const dockerPath = Bun.which('docker') ?? 'docker'; const containerId = container.dockerId; const containerHome = `/home/${sandbox.username}`; const containerPiConfig = `${containerHome}/.pi/agent`; // Collect skill/extension flags using container-side paths const skillFlags = collectSkillFlags(sandbox.email, { global: '/officer/skills', user: '/officer/user/skills', }); const extensionFlags = collectExtensionFlags(sandbox.email, { global: '/officer/extensions', user: '/officer/user/extensions', }); // Generate resource context skill (host-side, mounted into container) const resourceSkillHost = generateResourceSkill(DATA_PATH); const resourceSkillFlags = resourceSkillHost ? ['--skill', '/officer/generated/available-resources'] : []; const piArgs = [ 'pi', '--mode', 'rpc', '--no-skills', '--no-prompt-templates', '--no-themes', ...skillFlags, ...extensionFlags, ...resourceSkillFlags, ]; if (model) piArgs.push('--model', model); const resourcesEnv = buildResourcesEnv(); const googleConfigHost = getGoogleConfigPath(); const googleTokenHost = join(DATA_PATH, sandbox.email, 'integrations'); const envFlags = [ '-e', `PI_CODING_AGENT_DIR=${containerPiConfig}`, '-e', `HOME=${containerHome}`, '-e', `OFFICER_USER_HOME=${containerHome}`, '-e', `OFFICER_USER_ROOT=/officer/user`, '-e', `PI_TOOLS_DIRS=/officer/tools:/officer/user/tools`, '-e', `PI_SEARXNG_URL=${searxng.url}`, '-e', `OFFICER_RESOURCES=${resourcesEnv}`, '-e', `OFFICER_GOOGLE_CONFIG_PATH=/officer/google-oauth.json`, '-e', `OFFICER_GOOGLE_TOKEN_PATH=/officer/user/integrations/google.json`, '-e', `OFFICER_EMAIL_DB=/officer/emails.db`, ]; for (const [key, value] of Object.entries(storedKeys)) { if (value?.trim()) envFlags.push('-e', `${key}=${value.trim()}`); } const rel = relative(sandbox.homeDir, cwd); const workdir = rel && !rel.startsWith('..') ? join(containerHome, rel) : containerHome; proc = Bun.spawn([ dockerPath, 'exec', '-i', '-u', `${sandbox.username}`, '-w', workdir, ...envFlags, containerId, ...piArgs, ], { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }); logger.info('Spawned Pi in container', { containerId, model, skills: skillFlags.filter((f) => f !== '--skill').length, extensions: extensionFlags.filter((f) => f !== '--extension').length, }); } else { const storedKeys = await readApiKeys(); const searxng = await readSearxngConfig(); const skillFlags = collectSkillFlags(email); const extensionFlags = collectExtensionFlags(email); // Generate resource context skill const resourceSkillDir = generateResourceSkill(DATA_PATH); const resourceSkillFlags = resourceSkillDir ? ['--skill', resourceSkillDir] : []; const args = ['pi', '--mode', 'rpc', '--no-skills', '--no-prompt-templates', '--no-themes', ...skillFlags, ...extensionFlags, ...resourceSkillFlags]; if (model) args.push('--model', model); if (!existsSync(cwd)) { mkdirSync(cwd, { recursive: true }); } const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].join(':'); proc = Bun.spawn(args, { cwd, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', env: { ...process.env, ...storedKeys, HOME: getHomeDir(email), OFFICER_USER_HOME: getHomeDir(email), OFFICER_USER_ROOT: join(DATA_PATH, email), PI_CODING_AGENT_DIR: PI_CONFIG_DIR, PI_TOOLS_DIRS: toolsDirs, PI_SEARXNG_URL: searxng.url, OFFICER_RESOURCES: buildResourcesEnv(), OFFICER_GOOGLE_CONFIG_PATH: getGoogleConfigPath(), OFFICER_GOOGLE_TOKEN_PATH: getGoogleTokenPath(email), OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db') }, }); logger.info('Spawned Pi locally', { model, skills: skillFlags.filter((f) => f !== '--skill').length, extensions: extensionFlags.filter((f) => f !== '--extension').length, }); } // Read stdout JSON event stream (runs in background) const stdout = proc.stdout as ReadableStream; 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; const piEvent = parsePiEvent(event, streamBuffer); if (piEvent) { if (piEvent.type === 'delta') { streamBuffer += piEvent.text; } else if (piEvent.type === 'text' || piEvent.type === 'tool:start') { streamBuffer = ''; } onEvent(piEvent); } } catch { // Skip unparseable lines } } } } catch { // Process ended } })(); // Stderr → debug log const stderr = proc.stderr as ReadableStream; 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()) logger.info('Pi stderr', { text: text.trim() }); } } catch { // Process ended } })(); // Handle process exit proc.exited.then((code) => { logger.info('Pi process exited', { code }); }); return proc; } function parsePiEvent(event: Record, currentStreamBuffer: string): PiEvent | null { const type = event.type as string; // Handle response (success/failure for commands) if (type === 'response') { if (event.command === 'prompt' && !event.success) { const errorMsg = (event.error as string) ?? 'Prompt failed'; return { type: 'error', message: errorMsg }; } return null; } switch (type) { case 'agent_start': // No event to emit, just resets state return null; case 'message_update': { const ame = event.assistantMessageEvent as Record | undefined; if (ame?.type === 'text_delta') { const delta = ame.delta as string; return { type: 'delta', text: delta }; } return null; } case 'message_end': { if (currentStreamBuffer) { return { type: 'text', text: currentStreamBuffer }; } return null; } case 'tool_execution_start': { const toolCallId = (event.toolCallId as string) ?? ''; const toolName = (event.toolName as string) ?? 'unknown'; const args = (event.args as Record) ?? {}; return { type: 'tool:start', toolCallId, toolName, toolInput: args, }; } case 'tool_execution_end': { const toolCallId = (event.toolCallId as string) ?? ''; const result = event.result; let resultObj: Record | null = null; if (typeof result === 'object' && result !== null) { resultObj = result as Record; } 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 messages = event.messages as Array> | undefined; if (messages) { for (const msg of messages) { const usage = msg.usage as Record | undefined; if (!usage) continue; cost.inputTokens += (usage.input as number) ?? 0; cost.outputTokens += (usage.output as number) ?? 0; const usageCost = usage.cost as Record | undefined; if (usageCost) cost.totalUSD += (usageCost.total as number) ?? 0; } } return { type: 'result', cost }; } case 'extension_ui_request': { // Will be handled separately return null; } default: return null; } } function writeRpcCommand(proc: Subprocess, command: Record): 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) { logger.error('writeRpcCommand error', { error: String(err) }); } } export function setThinkingLevel( process: Subprocess, level: string, ): void { writeRpcCommand(process, { type: 'set_thinking_level', level, }); } export function sendPrompt( process: Subprocess, prompt: string, requestId: string ): void { writeRpcCommand(process, { type: 'prompt', id: requestId, message: prompt, }); } export function abort( process: Subprocess, requestId: string ): void { writeRpcCommand(process, { type: 'abort', id: requestId, }); } export function cancelExtensionUi( process: Subprocess, id: unknown ): void { writeRpcCommand(process, { type: 'extension_ui_response', id, cancelled: true, }); } export function killPi(process: Subprocess): void { try { process.kill(); } catch { // Already dead } }