/** * Gondolin Tool Routing Example * * Runs pi's built-in tools inside a local Gondolin micro-VM. The host working * directory is mounted at /workspace in the guest. File changes under * /workspace write through to the host; other guest filesystem changes are * isolated to the VM. * * Setup: * cd packages/coding-agent/examples/extensions/gondolin * npm install --ignore-scripts * * Usage: * cd /path/to/project * pi -e /path/to/pi/packages/coding-agent/examples/extensions/gondolin * * Requirements: * - Node.js >= 23.6.0 for @earendil-works/gondolin * - QEMU installed (for example, `brew install qemu` on macOS) */ import path from "node:path"; import { RealFSProvider, VM } from "@earendil-works/gondolin"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { type BashOperations, createBashTool, createEditTool, createFindTool, createGrepTool, createLsTool, createReadTool, createWriteTool, DEFAULT_MAX_BYTES, type EditOperations, type FindOperations, formatSize, type GrepToolDetails, type GrepToolInput, type LsOperations, type ReadOperations, truncateHead, truncateLine, type WriteOperations, } from "@earendil-works/pi-coding-agent"; const GUEST_WORKSPACE = "/workspace"; const DEFAULT_GREP_LIMIT = 100; type TextToolResult = { content: Array<{ type: "text"; text: string }>; details: TDetails | undefined; }; function stripAtPrefix(value: string): string { return value.startsWith("@") ? value.slice(1) : value; } function toPosix(value: string): string { return value.split(path.sep).join(path.posix.sep); } function isInsideHostPath(root: string, value: string): boolean { const relativePath = path.relative(root, value); return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); } function hostPathToGuest(localCwd: string, hostPath: string): string { const relativePath = path.relative(localCwd, hostPath); if (!isInsideHostPath(localCwd, hostPath)) return toPosix(hostPath); return relativePath ? path.posix.join(GUEST_WORKSPACE, toPosix(relativePath)) : GUEST_WORKSPACE; } function toGuestPath(localCwd: string, inputPath: string): string { const trimmed = stripAtPrefix(inputPath.trim()); if (!trimmed) return GUEST_WORKSPACE; if (path.isAbsolute(trimmed)) { if (isInsideHostPath(localCwd, trimmed)) return hostPathToGuest(localCwd, trimmed); return path.posix.resolve("/", toPosix(trimmed)); } return path.posix.resolve(GUEST_WORKSPACE, toPosix(trimmed)); } function createGondolinReadOps(vm: VM, localCwd: string): ReadOperations { return { readFile: async (filePath) => vm.fs.readFile(toGuestPath(localCwd, filePath)), access: async (filePath) => { await vm.fs.access(toGuestPath(localCwd, filePath)); }, detectImageMimeType: async (filePath) => { const ext = path.posix.extname(toGuestPath(localCwd, filePath)).toLowerCase(); if (ext === ".png") return "image/png"; if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg"; if (ext === ".gif") return "image/gif"; if (ext === ".webp") return "image/webp"; return null; }, }; } function createGondolinWriteOps(vm: VM, localCwd: string): WriteOperations { return { writeFile: async (filePath, content) => { await vm.fs.writeFile(toGuestPath(localCwd, filePath), content, { encoding: "utf8" }); }, mkdir: async (dirPath) => { await vm.fs.mkdir(toGuestPath(localCwd, dirPath), { recursive: true }); }, }; } function createGondolinEditOps(vm: VM, localCwd: string): EditOperations { const readOps = createGondolinReadOps(vm, localCwd); const writeOps = createGondolinWriteOps(vm, localCwd); return { readFile: readOps.readFile, writeFile: writeOps.writeFile, access: readOps.access, }; } function createGondolinLsOps(vm: VM, localCwd: string): LsOperations { return { exists: async (filePath) => { try { await vm.fs.access(toGuestPath(localCwd, filePath)); return true; } catch { return false; } }, stat: async (filePath) => vm.fs.stat(toGuestPath(localCwd, filePath)), readdir: async (dirPath) => vm.fs.listDir(toGuestPath(localCwd, dirPath)), }; } async function walkGuestFiles( vm: VM, root: string, visit: (guestPath: string, relativePath: string) => Promise, signal?: AbortSignal, ): Promise { if (signal?.aborted) throw new Error("Operation aborted"); const stat = await vm.fs.stat(root, { signal }); if (!stat.isDirectory()) return visit(root, path.posix.basename(root)); const walkDirectory = async (dir: string, relativeDir: string): Promise => { if (signal?.aborted) throw new Error("Operation aborted"); const entries = await vm.fs.listDir(dir, { signal }); for (const entry of entries) { if (entry === ".git" || entry === "node_modules") continue; const guestPath = path.posix.join(dir, entry); const relativePath = relativeDir ? path.posix.join(relativeDir, entry) : entry; let entryStat: Awaited>; try { entryStat = await vm.fs.stat(guestPath, { signal }); } catch { continue; } if (entryStat.isDirectory()) { if (!(await walkDirectory(guestPath, relativePath))) return false; } else if (!(await visit(guestPath, relativePath))) { return false; } } return true; }; return walkDirectory(root, ""); } function matchesToolGlob(relativePath: string, pattern: string): boolean { const normalizedPattern = toPosix(pattern); if (normalizedPattern.includes("/")) { return ( path.posix.matchesGlob(relativePath, normalizedPattern) || path.posix.matchesGlob(relativePath, `**/${normalizedPattern}`) ); } return path.posix.matchesGlob(path.posix.basename(relativePath), normalizedPattern); } function createGondolinFindOps(vm: VM, localCwd: string): FindOperations { return { exists: async (filePath) => { try { await vm.fs.access(toGuestPath(localCwd, filePath)); return true; } catch { return false; } }, glob: async (pattern, cwd, options) => { const root = toGuestPath(localCwd, cwd); const results: string[] = []; await walkGuestFiles(vm, root, async (guestPath, relativePath) => { if (results.length >= options.limit) return false; if (matchesToolGlob(relativePath, pattern)) results.push(guestPath); return results.length < options.limit; }); return results; }, }; } function createLineMatcher(pattern: string, literal: boolean | undefined, ignoreCase: boolean | undefined) { if (literal) { const needle = ignoreCase ? pattern.toLowerCase() : pattern; return (line: string) => (ignoreCase ? line.toLowerCase() : line).includes(needle); } const regex = new RegExp(pattern, ignoreCase ? "i" : undefined); return (line: string) => regex.test(line); } function appendGrepBlock(params: { outputLines: string[]; lines: string[]; relativePath: string; lineIndex: number; contextLines: number; }): boolean { let linesTruncated = false; const start = params.contextLines > 0 ? Math.max(0, params.lineIndex - params.contextLines) : params.lineIndex; const end = params.contextLines > 0 ? Math.min(params.lines.length - 1, params.lineIndex + params.contextLines) : params.lineIndex; for (let index = start; index <= end; index++) { const rawLine = params.lines[index] ?? ""; const { text, wasTruncated } = truncateLine(rawLine.replace(/\r/g, "")); if (wasTruncated) linesTruncated = true; const separator = index === params.lineIndex ? ":" : "-"; params.outputLines.push(`${params.relativePath}${separator}${index + 1}${separator} ${text}`); } return linesTruncated; } async function executeGondolinGrep( vm: VM, localCwd: string, params: GrepToolInput, signal?: AbortSignal, ): Promise> { const root = toGuestPath(localCwd, params.path ?? "."); const rootStat = await vm.fs.stat(root, { signal }); const rootIsDirectory = rootStat.isDirectory(); const matcher = createLineMatcher(params.pattern, params.literal, params.ignoreCase); const contextLines = params.context && params.context > 0 ? params.context : 0; const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT); const outputLines: string[] = []; const details: GrepToolDetails = {}; let matchCount = 0; let matchLimitReached = false; let linesTruncated = false; await walkGuestFiles( vm, root, async (guestPath, relativePath) => { if (matchCount >= effectiveLimit) return false; if (params.glob && !matchesToolGlob(relativePath, params.glob)) return true; let content: string; try { content = await vm.fs.readFile(guestPath, { encoding: "utf8", signal }); } catch { return true; } const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); const displayPath = rootIsDirectory ? relativePath : path.posix.basename(guestPath); for (let index = 0; index < lines.length; index++) { if (signal?.aborted) throw new Error("Operation aborted"); if (!matcher(lines[index] ?? "")) continue; matchCount++; if (appendGrepBlock({ outputLines, lines, relativePath: displayPath, lineIndex: index, contextLines })) { linesTruncated = true; } if (matchCount >= effectiveLimit) { matchLimitReached = true; return false; } } return true; }, signal, ); if (matchCount === 0) return { content: [{ type: "text", text: "No matches found" }], details: undefined }; const rawOutput = outputLines.join("\n"); const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); const notices: string[] = []; let output = truncation.content; if (matchLimitReached) { details.matchLimitReached = effectiveLimit; notices.push(`${effectiveLimit} matches limit reached`); } if (linesTruncated) { details.linesTruncated = true; notices.push("long lines truncated"); } if (truncation.truncated) { details.truncation = truncation; notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); } if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`; return { content: [{ type: "text", text: output }], details: Object.keys(details).length > 0 ? details : undefined, }; } function sanitizeEnv(env: NodeJS.ProcessEnv | undefined): Record | undefined { if (!env) return undefined; const result: Record = {}; for (const [key, value] of Object.entries(env)) { if (typeof value === "string") result[key] = value; } return result; } function createGondolinBashOps(vm: VM, localCwd: string, shellPath: string): BashOperations { return { exec: async (command, cwd, { onData, signal, timeout, env }) => { if (signal?.aborted) throw new Error("aborted"); const guestCwd = toGuestPath(localCwd, cwd); const controller = new AbortController(); const onAbort = () => controller.abort(); signal?.addEventListener("abort", onAbort, { once: true }); let timedOut = false; const timer = timeout && timeout > 0 ? setTimeout(() => { timedOut = true; controller.abort(); }, timeout * 1000) : undefined; try { const proc = vm.exec([shellPath, "-lc", command], { cwd: guestCwd, env: sanitizeEnv(env), signal: controller.signal, stdout: "pipe", stderr: "pipe", }); for await (const chunk of proc.output()) onData(chunk.data); const result = await proc; return { exitCode: result.exitCode }; } catch (error) { if (signal?.aborted) throw new Error("aborted"); if (timedOut) throw new Error(`timeout:${timeout}`); throw error; } finally { if (timer) clearTimeout(timer); signal?.removeEventListener("abort", onAbort); } }, }; } export default function (pi: ExtensionAPI) { const localCwd = process.cwd(); const localRead = createReadTool(localCwd); const localWrite = createWriteTool(localCwd); const localEdit = createEditTool(localCwd); const localBash = createBashTool(localCwd); const localGrep = createGrepTool(localCwd); const localFind = createFindTool(localCwd); const localLs = createLsTool(localCwd); let vm: VM | undefined; let vmStarting: Promise | undefined; let shellPath = "/bin/sh"; async function startVm(ctx?: ExtensionContext): Promise { ctx?.ui.setStatus("gondolin", ctx.ui.theme.fg("accent", `Gondolin: starting ${GUEST_WORKSPACE}`)); const created = await VM.create({ sessionLabel: `pi ${path.basename(localCwd)}`, vfs: { mounts: { [GUEST_WORKSPACE]: new RealFSProvider(localCwd), }, }, }); const bashProbe = await created.exec(["/bin/sh", "-lc", "command -v bash || true"]); shellPath = bashProbe.stdout.trim() || "/bin/sh"; vm = created; ctx?.ui.setStatus( "gondolin", ctx.ui.theme.fg("accent", `Gondolin: ${created.id.slice(0, 8)} (${GUEST_WORKSPACE})`), ); ctx?.ui.notify(`Gondolin VM ready. ${localCwd} is mounted at ${GUEST_WORKSPACE}.`, "info"); return created; } async function ensureVm(ctx?: ExtensionContext): Promise { if (vm) return vm; if (!vmStarting) { vmStarting = startVm(ctx).finally(() => { vmStarting = undefined; }); } return vmStarting; } pi.on("session_start", async (_event, ctx) => { await ensureVm(ctx); }); pi.on("session_shutdown", async (_event, ctx) => { const activeVm = vm; vm = undefined; vmStarting = undefined; if (!activeVm) return; ctx.ui.setStatus("gondolin", ctx.ui.theme.fg("muted", "Gondolin: stopping")); try { await activeVm.close(); } finally { ctx.ui.setStatus("gondolin", undefined); } }); pi.registerCommand("gondolin", { description: "Show Gondolin VM status", handler: async (_args, ctx) => { const activeVm = await ensureVm(ctx); ctx.ui.notify( [ `Gondolin VM: ${activeVm.id}`, `Host workspace: ${localCwd}`, `Guest workspace: ${GUEST_WORKSPACE}`, `Shell: ${shellPath}`, ].join("\n"), "info", ); }, }); pi.registerTool({ ...localRead, async execute(id, params, signal, onUpdate, ctx) { const activeVm = await ensureVm(ctx); const tool = createReadTool(GUEST_WORKSPACE, { operations: createGondolinReadOps(activeVm, localCwd), }); return tool.execute(id, params, signal, onUpdate); }, }); pi.registerTool({ ...localWrite, async execute(id, params, signal, onUpdate, ctx) { const activeVm = await ensureVm(ctx); const tool = createWriteTool(GUEST_WORKSPACE, { operations: createGondolinWriteOps(activeVm, localCwd), }); return tool.execute(id, params, signal, onUpdate); }, }); pi.registerTool({ ...localEdit, async execute(id, params, signal, onUpdate, ctx) { const activeVm = await ensureVm(ctx); const tool = createEditTool(GUEST_WORKSPACE, { operations: createGondolinEditOps(activeVm, localCwd), }); return tool.execute(id, params, signal, onUpdate); }, }); pi.registerTool({ ...localBash, async execute(id, params, signal, onUpdate, ctx) { const activeVm = await ensureVm(ctx); const tool = createBashTool(GUEST_WORKSPACE, { operations: createGondolinBashOps(activeVm, localCwd, shellPath), }); return tool.execute(id, params, signal, onUpdate); }, }); pi.registerTool({ ...localLs, async execute(id, params, signal, onUpdate, ctx) { const activeVm = await ensureVm(ctx); const tool = createLsTool(GUEST_WORKSPACE, { operations: createGondolinLsOps(activeVm, localCwd), }); return tool.execute(id, params, signal, onUpdate); }, }); pi.registerTool({ ...localFind, async execute(id, params, signal, onUpdate, ctx) { const activeVm = await ensureVm(ctx); const tool = createFindTool(GUEST_WORKSPACE, { operations: createGondolinFindOps(activeVm, localCwd), }); return tool.execute(id, params, signal, onUpdate); }, }); pi.registerTool({ ...localGrep, async execute(_id, params, signal, _onUpdate, ctx) { const activeVm = await ensureVm(ctx); return executeGondolinGrep(activeVm, localCwd, params, signal); }, }); pi.on("user_bash", async (_event, ctx) => { const activeVm = await ensureVm(ctx); return { operations: createGondolinBashOps(activeVm, localCwd, shellPath) }; }); pi.on("before_agent_start", async (event, ctx) => { await ensureVm(ctx); const localLine = `Current working directory: ${localCwd}`; const guestLine = `Current working directory: ${GUEST_WORKSPACE} (Gondolin VM; host workspace mounted from ${localCwd})`; const systemPrompt = event.systemPrompt.includes(localLine) ? event.systemPrompt.replace(localLine, guestLine) : `${event.systemPrompt}\n\n${guestLine}`; return { systemPrompt }; }); }