/** * pi-procs — Background process manager for Pi * * Start dev servers, watch builds, tail logs — without blocking the agent. * Zero deps. Zero tmux. Just spawn/list/logs/kill. * * Dual backend: * - Built-in: Node.js child_process (always works, zero deps) * - mprocs: If `mprocs` is installed, /procs mprocs opens a TUI with all * managed processes. Best of both worlds: agent tools + visual TUI. * * Commands: * /procs start [--name ] — start a background process * /procs list — show all running processes * /procs logs [--lines N] — tail output from a process * /procs kill — stop a process * /procs killall — stop all processes * /procs mprocs — open mprocs TUI with all current processes * /procs export — export mprocs.yaml for current processes * * Tools: * procs_start — start a background process * procs_list — list all processes * procs_logs — get output from a process * procs_kill — kill a process */ import type { ExtensionAPI } from '@anthropic-ai/claude-code' import { spawn, execSync, ChildProcess } from 'child_process' import * as path from 'path' import * as os from 'os' import * as fs from 'fs' interface ManagedProcess { name: string cmd: string pid: number proc: ChildProcess output: string[] maxLines: number startedAt: number cwd: string } const processes = new Map() const MAX_OUTPUT_LINES = 500 // Check if mprocs is installed let hasMprocs: boolean | null = null function checkMprocs(): boolean { if (hasMprocs !== null) return hasMprocs try { execSync('mprocs --version', { stdio: 'ignore', timeout: 5000 }) hasMprocs = true } catch { hasMprocs = false } return hasMprocs } function generateName(cmd: string): string { const base = cmd.split(/\s+/)[0].replace(/[^a-zA-Z0-9]/g, '') let name = base || 'proc' let i = 1 while (processes.has(name)) { name = `${base}-${i++}` } return name } function startProcess(cmd: string, name: string | undefined, cwd: string): string { const procName = name || generateName(cmd) if (processes.has(procName)) { return `Process "${procName}" already running (PID ${processes.get(procName)!.pid}). Kill it first.` } const isWin = os.platform() === 'win32' const shell = isWin ? 'cmd.exe' : '/bin/bash' const shellArgs = isWin ? ['/c', cmd] : ['-c', cmd] const child = spawn(shell, shellArgs, { cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: !isWin, windowsHide: true, }) if (!child.pid) { return `Failed to start: ${cmd}` } const managed: ManagedProcess = { name: procName, cmd, pid: child.pid, proc: child, output: [], maxLines: MAX_OUTPUT_LINES, startedAt: Date.now(), cwd, } const appendLine = (line: string) => { managed.output.push(line) if (managed.output.length > managed.maxLines) { managed.output.shift() } } child.stdout?.on('data', (data: Buffer) => { const lines = data.toString().split('\n') lines.forEach(l => { if (l.trim()) appendLine(l) }) }) child.stderr?.on('data', (data: Buffer) => { const lines = data.toString().split('\n') lines.forEach(l => { if (l.trim()) appendLine(`[stderr] ${l}`) }) }) child.on('exit', (code) => { appendLine(`[exited with code ${code}]`) }) child.on('error', (err) => { appendLine(`[error: ${err.message}]`) }) processes.set(procName, managed) return `Started "${procName}" (PID ${child.pid}): ${cmd}` } function listProcesses(): string { if (processes.size === 0) return 'No running processes.' const rows: string[] = ['| Name | PID | Uptime | Command |', '|------|-----|--------|---------|'] for (const [name, p] of processes) { const alive = !p.proc.killed && p.proc.exitCode === null const uptime = Math.round((Date.now() - p.startedAt) / 1000) const uptimeStr = uptime < 60 ? `${uptime}s` : uptime < 3600 ? `${Math.floor(uptime / 60)}m` : `${Math.floor(uptime / 3600)}h` const status = alive ? '🟢' : '⚫' const cmdShort = p.cmd.length > 40 ? p.cmd.slice(0, 37) + '...' : p.cmd rows.push(`| ${status} ${name} | ${p.pid} | ${uptimeStr} | \`${cmdShort}\` |`) } const footer = checkMprocs() ? '\n\n💡 `mprocs` detected. Run `/procs mprocs` to open TUI view.' : '' return rows.join('\n') + footer } function getProcessLogs(name: string, lines: number = 30): string { const p = processes.get(name) if (!p) { const available = Array.from(processes.keys()).join(', ') || 'none' return `Process "${name}" not found. Available: ${available}` } const tail = p.output.slice(-lines) if (tail.length === 0) return `"${name}" has no output yet.` return `**${name}** (PID ${p.pid}, ${tail.length} lines):\n\`\`\`\n${tail.join('\n')}\n\`\`\`` } function killProcess(name: string): string { const p = processes.get(name) if (!p) { const available = Array.from(processes.keys()).join(', ') || 'none' return `Process "${name}" not found. Available: ${available}` } try { if (os.platform() === 'win32') { spawn('taskkill', ['/F', '/T', '/PID', String(p.pid)], { windowsHide: true }) } else { process.kill(-p.pid, 'SIGTERM') } } catch { try { p.proc.kill('SIGKILL') } catch {} } processes.delete(name) return `Killed "${name}" (PID ${p.pid})` } function killAll(): string { if (processes.size === 0) return 'No processes to kill.' const names = Array.from(processes.keys()) const results = names.map(n => killProcess(n)) return results.join('\n') } // --- mprocs integration --- function generateMprocsYaml(cwd: string): string { const procs: Record = {} for (const [name, p] of processes) { procs[name] = { shell: p.cmd, cwd: p.cwd, } } // Simple YAML serialization (no dep needed) const lines = ['procs:'] for (const [name, cfg] of Object.entries(procs)) { lines.push(` ${name}:`) lines.push(` shell: "${(cfg as any).shell}"`) if ((cfg as any).cwd !== cwd) { lines.push(` cwd: "${(cfg as any).cwd}"`) } } return lines.join('\n') } function exportMprocsYaml(cwd: string): string { if (processes.size === 0) return 'No processes to export.' const yaml = generateMprocsYaml(cwd) const outPath = path.join(cwd, 'mprocs.yaml') fs.writeFileSync(outPath, yaml, 'utf-8') return `Exported ${processes.size} processes to **${outPath}**:\n\`\`\`yaml\n${yaml}\n\`\`\`` } function launchMprocs(cwd: string): string { if (!checkMprocs()) { return 'mprocs not found. Install it: `npm i -g mprocs` or `brew install mprocs` or `cargo install mprocs`' } if (processes.size === 0) { return 'No processes running. Start some first with `/procs start `.' } // Write temp mprocs.yaml const yaml = generateMprocsYaml(cwd) const tmpDir = os.tmpdir() const tmpFile = path.join(tmpDir, `pi-procs-${Date.now()}.yaml`) fs.writeFileSync(tmpFile, yaml, 'utf-8') // Kill existing managed processes — mprocs will own them now const names = Array.from(processes.keys()) const cmds = names.map(n => ({ name: n, cmd: processes.get(n)!.cmd })) killAll() // Launch mprocs const isWin = os.platform() === 'win32' const child = spawn('mprocs', ['--config', tmpFile], { cwd, stdio: 'ignore', detached: !isWin, windowsHide: false, }) child.unref() return `Handed off ${cmds.length} processes to mprocs TUI (PID ${child.pid}).\nConfig: ${tmpFile}\n\nProcesses transferred:\n${cmds.map(c => ` - ${c.name}: \`${c.cmd}\``).join('\n')}\n\n⚠️ Processes are now owned by mprocs. Use mprocs to manage them.` } export default function init(pi: ExtensionAPI) { const cwd = pi.context?.cwd || process.cwd() // Commands pi.addCommand({ name: 'procs', description: 'Manage background processes. Use /procs mprocs for TUI view.', handler: async (args) => { const parts = args.trim().split(/\s+/) const sub = parts[0]?.toLowerCase() if (!sub || sub === 'list') { pi.sendMessage({ content: listProcesses(), display: true }, { triggerTurn: false }) return } if (sub === 'start') { const rest = args.replace(/^start\s+/, '') let name: string | undefined let cmd = rest const nameMatch = rest.match(/--name\s+(\S+)/) if (nameMatch) { name = nameMatch[1] cmd = rest.replace(/--name\s+\S+/, '').trim() } if (!cmd) { pi.sendMessage({ content: 'Usage: /procs start [--name ]', display: true }, { triggerTurn: false }) return } const result = startProcess(cmd, name, cwd) pi.sendMessage({ content: result, display: true }, { triggerTurn: false }) return } if (sub === 'logs') { const name = parts[1] const linesIdx = parts.indexOf('--lines') const lines = linesIdx >= 0 ? parseInt(parts[linesIdx + 1], 10) || 30 : 30 if (!name) { pi.sendMessage({ content: 'Usage: /procs logs [--lines N]', display: true }, { triggerTurn: false }) return } const result = getProcessLogs(name, lines) pi.sendMessage({ content: result, display: true }, { triggerTurn: false }) return } if (sub === 'kill') { const name = parts[1] if (!name) { pi.sendMessage({ content: 'Usage: /procs kill ', display: true }, { triggerTurn: false }) return } const result = killProcess(name) pi.sendMessage({ content: result, display: true }, { triggerTurn: false }) return } if (sub === 'killall') { const result = killAll() pi.sendMessage({ content: result, display: true }, { triggerTurn: false }) return } if (sub === 'mprocs') { const result = launchMprocs(cwd) pi.sendMessage({ content: result, display: true }, { triggerTurn: false }) return } if (sub === 'export') { const result = exportMprocsYaml(cwd) pi.sendMessage({ content: result, display: true }, { triggerTurn: false }) return } pi.sendMessage({ content: '**Usage:**\n- `/procs list` — show processes\n- `/procs start [--name ]` — start\n- `/procs logs [--lines N]` — tail output\n- `/procs kill ` — stop\n- `/procs killall` — stop all\n- `/procs mprocs` — open mprocs TUI (if installed)\n- `/procs export` — export mprocs.yaml', display: true, }, { triggerTurn: false }) }, }) // Tools pi.addTool({ name: 'procs_start', description: 'Start a background process (dev server, watcher, build). Returns immediately without blocking.', parameters: { type: 'object', properties: { command: { type: 'string', description: 'Shell command to run in background' }, name: { type: 'string', description: 'Optional name for the process' }, }, required: ['command'], }, handler: async (params: { command: string; name?: string }) => { return startProcess(params.command, params.name, cwd) }, }) pi.addTool({ name: 'procs_list', description: 'List all running background processes with PID, uptime, and command.', parameters: { type: 'object', properties: {} }, handler: async () => listProcesses(), }) pi.addTool({ name: 'procs_logs', description: 'Get recent output from a background process.', parameters: { type: 'object', properties: { name: { type: 'string', description: 'Process name' }, lines: { type: 'number', description: 'Number of lines to show (default 30)' }, }, required: ['name'], }, handler: async (params: { name: string; lines?: number }) => { return getProcessLogs(params.name, params.lines || 30) }, }) pi.addTool({ name: 'procs_kill', description: 'Kill a running background process by name.', parameters: { type: 'object', properties: { name: { type: 'string', description: 'Process name to kill' }, }, required: ['name'], }, handler: async (params: { name: string }) => killProcess(params.name), }) pi.addTool({ name: 'procs_export', description: 'Export current processes as mprocs.yaml config file.', parameters: { type: 'object', properties: {} }, handler: async () => exportMprocsYaml(cwd), }) pi.addTool({ name: 'procs_mprocs', description: 'Hand off all running processes to mprocs TUI. Opens a separate terminal with split panes per process. Requires mprocs to be installed.', parameters: { type: 'object', properties: {} }, handler: async () => launchMprocs(cwd), }) // Cleanup on exit process.on('exit', () => { for (const [name] of processes) { try { killProcess(name) } catch {} } }) }