/** * pivi companion extension. * * Loaded additively when pivi starts Pi, alongside the user's normal Pi configuration. * It makes loaded Neovim buffers authoritative for built-in file tools by injecting * operations, so result and detail shapes stay exactly those of the built-in tools. * It also exposes the live editor: core state and unrestricted Lua execution. */ import { createEditTool, createReadTool, createWriteTool, type EditOperations, type ExtensionAPI, type ReadOperations, type WriteOperations, } from "@earendil-works/pi-coding-agent"; import { spawn } from "child_process"; import { mkdtempSync, readFileSync, writeFileSync } from "fs"; import { readFile, writeFile } from "fs/promises"; import { tmpdir } from "os"; import { dirname, join, resolve } from "path"; import { fileURLToPath } from "url"; import { Type } from "typebox"; type BridgeResponse = { ok: boolean; error?: string; content?: string; source?: string; state?: unknown; view?: unknown; result?: string; count?: number; persisted?: boolean; joined?: boolean; found?: boolean; tag?: string; file?: string; line?: number; text?: string; candidates?: string[]; }; const here = dirname(fileURLToPath(import.meta.url)); const address = () => process.env.PIVI_NVIM_ADDRESS ?? ""; const nvimExecutable = () => process.env.PIVI_NVIM_EXE ?? "nvim"; const clientScript = () => process.env.PIVI_NVIM_CLIENT ?? join(here, "client.lua"); /** * Talks to the Neovim instance that started this Pi process. * A throwaway headless Neovim performs the msgpack RPC call, so no protocol * implementation is duplicated here. */ async function bridge(request: Record): Promise { const target = address(); if (!target) return { ok: false, error: "no neovim address" }; const directory = mkdtempSync(join(tmpdir(), "pivi-")); const requestPath = join(directory, "request.json"); const responsePath = join(directory, "response.json"); writeFileSync(requestPath, JSON.stringify(request), "utf8"); const code = await new Promise((done) => { const child = spawn( nvimExecutable(), ["--headless", "--clean", "-l", clientScript(), target, requestPath, responsePath], { stdio: "ignore" }, ); child.on("error", () => done(1)); child.on("close", (status) => done(status ?? 1)); }); if (code !== 0) return { ok: false, error: "neovim client failed" }; try { return JSON.parse(readFileSync(responsePath, "utf8")) as BridgeResponse; } catch (failure) { return { ok: false, error: String(failure) }; } } function bufferOperations(cwd: string): ReadOperations & WriteOperations & EditOperations { const absolute = (path: string) => resolve(cwd, path); return { readFile: async (path: string) => { const answer = await bridge({ action: "read", path: absolute(path) }); if (answer.ok && answer.source === "buffer" && typeof answer.content === "string") { return Buffer.from(answer.content, "utf8"); } return readFile(absolute(path)); }, access: async (path: string) => { const answer = await bridge({ action: "read", path: absolute(path) }); if (answer.ok) return; await readFile(absolute(path)); }, writeFile: async (path: string, content: string) => { const answer = await bridge({ action: "write", path: absolute(path), content }); if (answer.ok && answer.source === "buffer") return; await writeFile(absolute(path), content, "utf8"); }, }; } export default function (pi: ExtensionAPI) { const cwd = process.cwd(); const operations = bufferOperations(cwd); const read = createReadTool(cwd, { operations }); const write = createWriteTool(cwd, { operations }); const edit = createEditTool(cwd, { operations }); pi.registerTool({ ...read }); pi.registerTool({ ...write }); pi.registerTool({ ...edit }); pi.registerTool({ name: "nvim_state", label: "Neovim state", description: "Read live core Neovim state: working directory, mode, current buffer and window, loaded buffers with unsaved status, and window layout.", promptSnippet: "Inspect the live Neovim editor state", promptGuidelines: [ "Use nvim_state when the answer depends on what the user currently has open, selected, or unsaved.", ], parameters: Type.Object({}), async execute() { const answer = await bridge({ action: "state" }); // The visible view is attached to help questions already; this is the // wider ambient state a model asks for explicitly. if (!answer.ok) { return { content: [{ type: "text" as const, text: `Neovim unavailable: ${answer.error}` }], details: { error: true }, }; } return { content: [{ type: "text" as const, text: JSON.stringify(answer.state, null, 2) }], details: { state: answer.state }, }; }, }); pi.registerTool({ name: "nvim_lua", label: "Neovim Lua", description: "Execute arbitrary Lua inside the running Neovim instance and return the inspected result. Full editor access: buffers, selections, windows, tabs, marks, jumps, changes, lists, diagnostics, and commands.", promptSnippet: "Run Lua inside the running Neovim instance", promptGuidelines: [ "Use nvim_lua for editor operations that no other tool covers, and prefer returning a value over printing.", ], parameters: Type.Object({ code: Type.String({ description: "Lua chunk; return a value to receive it" }), }), async execute(_id, params: { code: string }) { const answer = await bridge({ action: "lua", code: params.code }); if (!answer.ok) { return { content: [{ type: "text" as const, text: `Lua failed: ${answer.error}` }], details: { error: true }, }; } return { content: [{ type: "text" as const, text: answer.result ?? "nil" }], details: { ok: true }, }; }, }); pi.registerTool({ name: "nvim_help", label: "Neovim help", description: "Read Neovim documentation for a help tag, resolved against this machine's runtime path, so the answer matches this Neovim version and the installed plugins. Equivalent to typing :help {tag}.", promptSnippet: "Read Neovim documentation for a help tag", promptGuidelines: [ "Use nvim_help instead of recalling Neovim or plugin documentation; it reflects the running version.", "When nvim_help reports no match, pick one of the returned candidate tags and call it again.", ], parameters: Type.Object({ tag: Type.String({ description: "Help tag, exactly as typed after :help" }), }), async execute(_id, params: { tag: string }) { const answer = await bridge({ action: "help", tag: params.tag }); if (!answer.ok) { return { content: [{ type: "text" as const, text: `Help unavailable: ${answer.error}` }], details: { error: true }, }; } if (!answer.found) { const candidates = answer.candidates ?? []; const hint = candidates.length > 0 ? `\nCandidates: ${candidates.join(", ")}` : ""; return { content: [{ type: "text" as const, text: `No help tag "${params.tag}".${hint}` }], details: { found: false, candidates }, }; } return { content: [ { type: "text" as const, text: `${answer.file}:${answer.line}\n\n${answer.text ?? ""}` }, ], details: { found: true, tag: answer.tag, file: answer.file, line: answer.line }, }; }, }); pi.registerTool({ name: "nvim_findings", label: "Neovim findings", description: "Publish cross-file findings to the Neovim quickfix list so the user can navigate them with normal motions.", promptSnippet: "Publish findings to the Neovim quickfix list", promptGuidelines: ["Use nvim_findings when reporting several file locations the user should visit."], parameters: Type.Object({ title: Type.Optional(Type.String()), items: Type.Array( Type.Object({ path: Type.String(), line: Type.Optional(Type.Number()), column: Type.Optional(Type.Number()), text: Type.Optional(Type.String()), severity: Type.Optional(Type.String()), }), ), }), async execute(_id, params: { title?: string; items: unknown[] }) { const answer = await bridge({ action: "findings", title: params.title, items: params.items }); if (!answer.ok) { return { content: [{ type: "text" as const, text: `Findings failed: ${answer.error}` }], details: { error: true }, }; } return { content: [{ type: "text" as const, text: `Published ${answer.count} findings to the quickfix list.` }], details: { count: answer.count }, }; }, }); }