OMP neovim server bridge

This commit is contained in:
Daniel Winkler 2026-08-07 15:00:50 +10:00
commit c7543c978a
3 changed files with 248 additions and 0 deletions

185
.omp/tools/nvim_buffers.mjs Normal file
View file

@ -0,0 +1,185 @@
// omp custom tool: read this machine's running Neovim buffers over its RPC
// socket. Read-only and on-demand — the model calls it when it needs buffer
// content; nothing is injected into prompts automatically.
//
// Install options
// 1. Project scope (this project): keep the file in .omp/tools/ — omp picks it
// up when a session's cwd is inside this repository.
// 2. All projects: mkdir -p ~/.omp/agent/tools
// ln -s "$PWD/.omp/tools/nvim_buffers.mjs" ~/.omp/agent/tools/
// then restart `omp` (custom tools are loaded at session bootstrap).
//
// Socket contract (must match lua/nvim_omp/init.lua):
// path = $NVIM_OMP_SOCKET or <XDG_STATE_HOME|~/.local/state>/nvim/omp.sock
// The nvim instance running this config starts the listener at startup.
// Only one instance can own the socket; a second instance never replaces a
// live owner. Remove a stale socket manually only after confirming no nvim
// process owns it.
import os from "node:os";
import path from "node:path";
// Safe default cap so a large buffer cannot flood the conversation.
const DEFAULT_MAX_LINES = 2000;
export default function (pi) {
// Resolve socket path with the same rule as lua/nvim_omp/init.lua.
const socketPath = () => {
const env = process.env.NVIM_OMP_SOCKET;
if (env) return env;
const state =
process.env.XDG_STATE_HOME ||
path.join(os.homedir(), ".local", "state");
return path.join(state, "nvim", "omp.sock");
};
// Evaluate expr in the remote nvim via `nvim --server`; returns parsed JSON.
const rpc = async (expr, signal) => {
const bin = process.env.NVIM_BIN || "nvim";
const { code, stdout, stderr, killed } = await pi.exec(
bin,
["--server", socketPath(), "--remote-expr", expr],
{ signal },
);
if (killed) throw new Error("cancelled");
if (code !== 0) {
const why = String(stderr || "").trim();
throw new Error(
why.includes("E247")
? `No Neovim RPC socket at ${socketPath()}. ` +
"Start nvim (or vv) first — the 31_nvim_omp.lua plugin binds the socket at startup."
: `nvim RPC failed (${code}): ${why}`,
);
}
const text = String(stdout ?? "").trim();
if (!text) throw new Error("empty response from nvim RPC");
return JSON.parse(text);
};
// Resolve a buffer argument (number, string, or partial path) to a buffer
// number. Returns null when nothing matches.
const resolveBuf = async (buffer, signal) => {
if (buffer === undefined || buffer === null || buffer === "") {
return rpc("json_encode(nvim_get_current_buf())", signal);
}
if (typeof buffer === "number") return buffer;
const bufnum = Number(buffer);
if (Number.isInteger(bufnum) && bufnum > 0) return bufnum;
const list = await rpc(
"json_encode(map(nvim_list_bufs(), {i, v -> " +
"{'bufnr': v, 'name': nvim_buf_get_name(v)}}))",
signal,
);
const q = String(buffer);
const hit =
list.find(
(b) => b.name === q || b.name.endsWith("/" + q) || b.name.endsWith(q),
) ||
list.find((b) => b.name.includes(q));
return hit ? hit.bufnr : null;
};
const tools = [];
// List open buffers -----------------------------------------------
tools.push({
name: "nvim_buffers",
label: "Neovim Buffers",
description:
"List the buffers currently open in the user's running Neovim " +
"(number, name, current/loaded state, modified). Use before " +
"nvim_buffer to pick a buffer.",
parameters: pi.arktype({}),
async execute(_id, _params, _onUpdate, _ctx, _signal) {
const list = await rpc(
"json_encode(map(nvim_list_bufs(), {i, v -> {'nr': v, " +
"'name': nvim_buf_get_name(v), " +
"'current': v == nvim_get_current_buf(), " +
"'loaded': nvim_buf_is_loaded(v), " +
"'modified': getbufvar(v, '&modified')}}))",
);
if (!list.length) {
return { content: [{ type: "text", text: "No buffers open." }] };
}
const lines = list.map(
(b) =>
`${b.nr}\t${b.name || "[no name]"}\t` +
`${b.current ? "current " : ""}` +
`${b.loaded ? "" : "unloaded "}` +
`${b.modified ? "modified" : ""}`.trim(),
);
return {
content: [
{
type: "text",
text: `Open buffers (${list.length}):\n` + lines.join("\n"),
},
],
};
},
});
// Read a single buffer, line-capped -------------------------------
tools.push({
name: "nvim_buffer",
label: "Neovim Buffer",
description:
"Read lines of a Neovim buffer. `buffer` accepts the current buffer " +
"(default), a buffer number from nvim_buffers, or a file name/path " +
"open in Neovim. `maxLines` caps the returned lines (default 2000); " +
"pass a larger value explicitly to read more of a long buffer.",
parameters: pi.arktype({
buffer: "string? | number?",
maxLines: "number?",
}),
async execute(_id, params, _onUpdate, _ctx, signal) {
const buf = await resolveBuf(params.buffer, signal);
if (buf === null) {
return {
content: [
{
type: "text",
text: `No buffer matches '${params.buffer}'. List open buffers with nvim_buffers.`,
},
],
};
}
const maxLines =
Number.isInteger(params.maxLines) && params.maxLines > 0
? Math.min(params.maxLines, 100000)
: DEFAULT_MAX_LINES;
const expr =
`json_encode({'name': nvim_buf_get_name(${buf}), ` +
`'total': nvim_buf_line_count(${buf}), ` +
`'lines': nvim_buf_get_lines(${buf}, 0, ${maxLines}, 0)})`;
const { name, total, lines } = await rpc(expr, signal);
if (!total) {
return {
content: [
{ type: "text", text: `Buffer ${buf} (${name}) is empty.` },
],
};
}
const truncated =
total > lines.length
? `\n... ${total - lines.length} more lines ` +
`(raise maxLines to read them)`
: "";
const numbered = lines.map((l, i) => `${i + 1}: ${l}`).join("\n");
return {
content: [
{
type: "text",
text:
`Buffer ${buf} (${name}), ${total} lines:\n` +
numbered +
truncated,
},
],
};
},
});
return tools;
}

55
lua/nvim_omp/init.lua Normal file
View file

@ -0,0 +1,55 @@
-- omp bridge: expose an RPC socket so the omp harness can read this Neovim
-- instance's buffers on demand. Read-only on the agent side; this module only
-- owns the socket lifecycle.
--
-- Contract
-- * Socket path: $NVIM_OMP_SOCKET if set, else <stdpath('state')>/omp.sock
-- (~/.local/state/nvim/omp.sock on macOS, ~/.local/state/nvim/omp.sock
-- elsewhere). The omp-side tool in .omp/tools/nvim_buffers.mjs resolves the
-- *same* path, so the two sides agree without configuration.
-- * The agent reads buffers by evaluating read-only nvim API expressions over
-- the socket with `nvim --server <path> --remote-expr 'json_encode(...)'`.
-- Nothing here writes buffers or executes model-supplied commands.
local M = {}
local SOCKET_NAME = "omp.sock"
-- Resolve the deterministic socket path. Copies the rule on the omp side; keep
-- the two files in sync when changing the fallback or env override.
function M.socket_path()
local env = vim.env.NVIM_OMP_SOCKET
if env and env ~= "" then
return env
end
return vim.fn.stdpath("state") .. "/" .. SOCKET_NAME
end
-- Start the RPC listener. Returns the live socket path, or nil.
-- A second instance cannot bind the same address. This function deliberately
-- never removes an existing socket file: a failed liveness probe must not
-- disconnect another live Neovim instance.
-- If a crash leaves a stale socket, remove it manually only after confirming
-- that no Neovim process owns the path, then restart vv.
function M.start()
if vim.v.headless == 1 then
-- Headless runs (tests, CI) get no socket; nothing should depend on one.
return nil
end
local path = M.socket_path()
local ok, res = pcall(vim.fn.serverstart, path)
if ok and type(res) == "string" and res ~= "" then
-- serverstart returns the bound address string (e.g. "/tmp/omp.sock").
vim.notify("nvim_omp: RPC socket ready at " .. path, vim.log.levels.INFO)
return path
end
vim.notify(
"nvim_omp: could not bind RPC socket at " .. path
.. "; another instance may own it or a stale socket needs manual cleanup.",
vim.log.levels.WARN
)
return nil
end
return M

8
plugin/31_nvim_omp.lua Normal file
View file

@ -0,0 +1,8 @@
-- omp bridge entrypoint: start the RPC socket as early as possible so the omp
-- harness can read this instance's buffers on demand (see lua/nvim_omp/init.lua
-- and .omp/tools/nvim_buffers.mjs). Safe to fail silently — the socket is a
-- convenience, not a dependency of the editor.
local ok, nvim_omp = pcall(require, "nvim_omp")
if ok then
nvim_omp.start()
end