import { spawn } from "node:child_process"; import { mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import * as path from "node:path"; import { randomUUID } from "node:crypto"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import type { BrainHome, AutonomyTrustConfig } from "./types.ts"; import { requireBrain } from "./context.ts"; import { getTrustLevel, shouldProceed } from "./autonomy.ts"; import { isValidIdentifier } from "./utils.ts"; const ALLOWED_BG_OPERATIONS = new Set(["sync", "groom", "refine", "suggest", "agent"]); type TaskStatus = "pending" | "running" | "completed" | "failed"; export interface BrainTask { id: string; description: string; operation: keyof AutonomyTrustConfig; scope: string; createdAt: string; startedAt?: string; maxAttempts: number; attempts: number; error?: string; output?: string; } function taskDir(home: BrainHome, status: TaskStatus): string { return path.join(home.path, "wiki", "_state", "tasks", status); } async function ensureTaskDirs(home: BrainHome): Promise { for (const status of ["pending", "running", "completed", "failed"] as TaskStatus[]) { await mkdir(taskDir(home, status), { recursive: true }); } } async function loadTask(filePath: string): Promise { try { const text = await readFile(filePath, "utf-8"); return JSON.parse(text) as BrainTask; } catch { return null; } } async function saveTask(home: BrainHome, task: BrainTask, status: TaskStatus): Promise { await ensureTaskDirs(home); const filePath = path.join(taskDir(home, status), `${task.id}.json`); await writeFile(filePath, JSON.stringify(task, null, 2), "utf-8"); } async function moveTask(home: BrainHome, task: BrainTask, from: TaskStatus, to: TaskStatus): Promise { const fromPath = path.join(taskDir(home, from), `${task.id}.json`); if (existsSync(fromPath)) { await unlink(fromPath); } await saveTask(home, task, to); } function getPiInvocation(args: string[]): { command: string; args: string[] } { const currentScript = process.argv[1]; const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/"); const scriptBase = currentScript ? path.basename(currentScript).toLowerCase() : ""; const execName = path.basename(process.execPath).toLowerCase(); const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName); // If we're running inside the pi binary itself (not under node/bun with a // helper script), re-invoke pi with the same binary and arguments. if (!isGenericRuntime && currentScript && !isBunVirtualScript && existsSync(currentScript)) { return { command: process.execPath, args: [currentScript, ...args] }; } // If we're running under node/bun but the script is the pi binary, reuse it. if ( isGenericRuntime && (scriptBase === "pi" || scriptBase === "pi.exe") && currentScript && !isBunVirtualScript && existsSync(currentScript) ) { return { command: process.execPath, args: [currentScript, ...args] }; } if (!isGenericRuntime) { return { command: process.execPath, args }; } return { command: "pi", args }; } export async function executeTaskSubprocess(task: BrainTask, cwd: string): Promise<{ output: string; exitCode: number }> { const prompt = [ `You are running a background task for pi-brain.`, ``, `Task: ${task.description}`, `Operation: ${task.operation}`, `Scope: ${task.scope}`, ``, `Instructions:`, `- This is a background task. Do not ask the user questions.`, `- Perform the task using pi-brain tools and commands.`, `- Only low-risk operations are allowed.`, `- Do not edit approved shelves, commit, push, or make structural/repo changes.`, `- Report what you did and any findings concisely.`, ].join("\n"); const args: string[] = ["--mode", "json", "-p", "--no-session", prompt]; const invocation = getPiInvocation(args); return new Promise<{ output: string; exitCode: number }>((resolve) => { const proc = spawn(invocation.command, invocation.args, { cwd, shell: false, stdio: ["ignore", "pipe", "pipe"], }); let buffer = ""; let stderr = ""; const processLine = (line: string) => { if (!line.trim()) return; let event: any; try { event = JSON.parse(line); } catch { return; } if (event.type === "message_end" && event.message?.role === "assistant") { for (const part of event.message.content ?? []) { if (part.type === "text") buffer += part.text; } } }; proc.stdout.on("data", (data) => { buffer += data.toString(); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) processLine(line); }); proc.stderr.on("data", (data) => { stderr += data.toString(); }); proc.on("close", (code) => { if (buffer.trim()) processLine(buffer); resolve({ output: stderr || buffer || "(no output)", exitCode: code ?? 0 }); }); proc.on("error", (err) => { resolve({ output: err.message, exitCode: 1 }); }); }); } export async function enqueueTask( home: BrainHome, description: string, operation: keyof AutonomyTrustConfig, scope: string, ): Promise { if (!ALLOWED_BG_OPERATIONS.has(operation)) { throw new Error(`Operation ${operation} is not allowed for background tasks.`); } const task: BrainTask = { id: randomUUID(), description, operation, scope, createdAt: new Date().toISOString(), maxAttempts: 3, attempts: 0, }; await saveTask(home, task, "pending"); return task; } export async function listTasks(home: BrainHome): Promise> { await ensureTaskDirs(home); const result: Record = { pending: [], running: [], completed: [], failed: [] }; for (const status of Object.keys(result) as TaskStatus[]) { const dir = taskDir(home, status); const files = await readdir(dir).catch(() => [] as string[]); for (const file of files.filter((f) => f.endsWith(".json"))) { const task = await loadTask(path.join(dir, file)); if (task) result[status].push(task); } result[status].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); } return result; } export interface RunTasksOptions { only?: string; } async function claimNextPendingTask(home: BrainHome): Promise { await ensureTaskDirs(home); const dir = taskDir(home, "pending"); const files = await readdir(dir).catch(() => [] as string[]); for (const file of files.filter((f) => f.endsWith(".json"))) { const pendingPath = path.join(dir, file); const runningPath = path.join(taskDir(home, "running"), file); try { await rename(pendingPath, runningPath); } catch { // Another worker claimed this task; try the next one. continue; } const task = await loadTask(runningPath); if (task) { task.startedAt = new Date().toISOString(); await saveTask(home, task, "running"); return task; } } return null; } async function executeOneTask( home: BrainHome, task: BrainTask, cwd: string, executor: (task: BrainTask, cwd: string) => Promise<{ output: string; exitCode: number }>, ): Promise { const trust = await getTrustLevel(home, task.operation); if (!shouldProceed(trust)) { task.attempts++; task.error = `Operation ${task.operation} is blocked by autonomy trust.`; await moveTask(home, task, "running", "failed"); return; } const result = await executor(task, cwd); task.attempts++; if (result.exitCode === 0) { task.output = result.output.slice(0, 2000); await moveTask(home, task, "running", "completed"); } else { task.error = result.output.slice(0, 2000); if (task.attempts >= task.maxAttempts) { await moveTask(home, task, "running", "failed"); } else { await moveTask(home, task, "running", "pending"); } } } export async function runTasks( home: BrainHome, cwd: string, executor: (task: BrainTask, cwd: string) => Promise<{ output: string; exitCode: number }> = executeTaskSubprocess, options: RunTasksOptions = {}, ): Promise<{ completed: number; failed: number }> { await ensureTaskDirs(home); let completed = 0; let failed = 0; if (options.only) { const all = await listTasks(home); const task = all.pending.find((t) => t.id === options.only); if (task) { await moveTask(home, task, "pending", "running"); await executeOneTask(home, task, cwd, executor); return { completed: 1, failed: 0 }; } return { completed: 0, failed: 0 }; } // Clean up stale running tasks from a previous crashed worker. Only fail // tasks that have no recent claim (older than 5 minutes). const all = await listTasks(home); const now = Date.now(); for (const task of all.running) { const startTime = task.startedAt ? new Date(task.startedAt).getTime() : new Date(task.createdAt).getTime(); const runningAgeMs = now - startTime; if (runningAgeMs > 5 * 60 * 1000) { task.error = "Task was still marked running after a previous worker exited."; await moveTask(home, task, "running", "failed"); failed++; } } while (true) { const task = await claimNextPendingTask(home); if (!task) break; await executeOneTask(home, task, cwd, executor); const status = await listTasks(home); if (status.completed.some((t) => t.id === task.id)) completed++; else if (status.failed.some((t) => t.id === task.id)) failed++; } return { completed, failed }; } export interface RunTasksDetachedOptions { parallel?: boolean; } export async function runTasksDetached( home: BrainHome, cwd: string, options: RunTasksDetachedOptions = {}, ): Promise<{ started: boolean; pids: number[]; message: string }> { await ensureTaskDirs(home); const all = await listTasks(home); if (all.pending.length === 0) { return { started: false, pids: [], message: "No pending background tasks." }; } const runnerPath = path.join(home.path, "tools", "run-tasks.mjs"); const command = process.execPath; const baseArgs: string[] = []; // If the current process was launched via tsx/npx, preserve that so the // runner can import TypeScript source files. const currentScript = process.argv[1]; if (currentScript && /tsx|ts-node/.test(currentScript)) { baseArgs.push(currentScript); } baseArgs.push(runnerPath); const pids: number[] = []; if (options.parallel) { for (const task of all.pending) { const args = [...baseArgs, `--task-id=${task.id}`]; const proc = spawn(command, args, { cwd, detached: true, stdio: ["ignore", "ignore", "ignore"], }); proc.unref(); if (proc.pid) pids.push(proc.pid); } return { started: true, pids, message: `Started ${all.pending.length} background worker(s) (parallel) for ${all.pending.length} pending task(s). Check /brain:tasks for status.`, }; } const proc = spawn(command, baseArgs, { cwd, detached: true, stdio: ["ignore", "ignore", "ignore"], }); proc.unref(); if (proc.pid) pids.push(proc.pid); return { started: true, pids, message: `Started 1 background worker (sequential) for ${all.pending.length} pending task(s). Check /brain:tasks for status.`, }; } export function registerTasks(pi: ExtensionAPI) { pi.registerCommand("brain:enqueue", { description: "Enqueue a background task (usage: /brain:enqueue )", handler: async (args, ctx) => { const trimmed = args.trim(); if (!trimmed) { ctx.ui.notify("Usage: /brain:enqueue ", "warning"); return; } const parts = trimmed.split(/\s+/); if (parts.length < 3) { ctx.ui.notify("Usage: /brain:enqueue ", "warning"); return; } const [scope, operation, ...descriptionParts] = parts; if (!isValidIdentifier(scope)) { ctx.ui.notify("Scope must be a simple identifier (letters, numbers, -, _).", "warning"); return; } const description = descriptionParts.join(" "); const home = await requireBrain(ctx.cwd); if (!home) { ctx.ui.notify("No pi-brain home found.", "error"); return; } try { const task = await enqueueTask(home, description, operation as keyof AutonomyTrustConfig, scope); ctx.ui.notify(`Enqueued task ${task.id}`, "info"); } catch (err: any) { ctx.ui.notify(`Failed to enqueue: ${err.message}`, "error"); } }, }); pi.registerCommand("brain:run-tasks", { description: "Process all pending background tasks (usage: /brain:run-tasks [--detach] [--parallel])", handler: async (args, ctx) => { const home = await requireBrain(ctx.cwd); if (!home) { ctx.ui.notify("No pi-brain home found.", "error"); return; } const flags = new Set(args.trim().split(/\s+/).filter(Boolean)); const detach = flags.has("--detach"); const parallel = flags.has("--parallel"); if (detach || parallel) { const result = await runTasksDetached(home, ctx.cwd, { parallel }); ctx.ui.notify(result.message, result.started ? "info" : "warning"); return; } ctx.ui.notify("Running background tasks...", "info"); const result = await runTasks(home, ctx.cwd); ctx.ui.notify(`Background tasks done: ${result.completed} completed, ${result.failed} failed.`, "info"); }, }); pi.registerCommand("brain:bg-agent", { description: "Spin off a background agent for a task (usage: /brain:bg-agent )", handler: async (args, ctx) => { const trimmed = args.trim(); if (!trimmed) { ctx.ui.notify("Usage: /brain:bg-agent ", "warning"); return; } const parts = trimmed.split(/\s+/); if (parts.length < 2) { ctx.ui.notify("Usage: /brain:bg-agent ", "warning"); return; } const [scope, ...descriptionParts] = parts; if (!isValidIdentifier(scope)) { ctx.ui.notify("Scope must be a simple identifier (letters, numbers, -, _).", "warning"); return; } const description = descriptionParts.join(" "); const home = await requireBrain(ctx.cwd); if (!home) { ctx.ui.notify("No pi-brain home found.", "error"); return; } try { const task = await enqueueTask(home, description, "agent", scope); const result = await runTasksDetached(home, ctx.cwd, { parallel: true }); ctx.ui.notify(`Queued background agent ${task.id}. ${result.message}`, result.started ? "info" : "warning"); } catch (err: any) { ctx.ui.notify(`Failed to start background agent: ${err.message}`, "error"); } }, }); pi.registerCommand("brain:tasks", { description: "List background tasks", handler: async (_args, ctx) => { const home = await requireBrain(ctx.cwd); if (!home) { ctx.ui.notify("No pi-brain home found.", "error"); return; } const tasks = await listTasks(home); const lines = [ `Pending: ${tasks.pending.length}`, `Running: ${tasks.running.length}`, `Completed: ${tasks.completed.length}`, `Failed: ${tasks.failed.length}`, "", ]; for (const task of tasks.pending.slice(0, 5)) { lines.push(`• [pending] ${task.description}`); } for (const task of tasks.failed.slice(0, 5)) { lines.push(`• [failed] ${task.description}: ${task.error ?? ""}`.slice(0, 200)); } ctx.ui.notify(lines.join("\n"), "info"); }, }); }