Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ A Neovim Lua plugin that bridges Neovim and the `opencode` CLI (external binary)
## Dependencies

- **Required**: `opencode` CLI, `curl`
- **Auto-discovery**: reads OpenCode's background service registration (`service.json`) from its state directory (unless `server.url` is set)
- **Auto-discovery**: reads OpenCode's background service registration (`service.json`, or `service-<channel>.json` for other channels) from its state directory (unless `server.url` is set)
- **Optional**: `snacks.nvim` (enhances `ask()` with `snacks.input`, `select()` with `snacks.picker`), `blink.cmp` (completion plugin with LSP source)
- No hard Lua dependencies beyond Neovim itself

Expand Down Expand Up @@ -62,7 +62,7 @@ stylua .
## Architecture notes

- **Async**: custom Promise implementation in `lua/opencode/promise/init.lua` (fork of `promise.nvim`)
- **Server discovery flow** (`lua/opencode/server/discovery/init.lua`): connected server → configured URL → OpenCode background service registration (`service.json`, URL + password) → auto-start + poll (5s timeout)
- **Server discovery flow** (`lua/opencode/server/discovery/init.lua`): connected server → configured URL → OpenCode background service registrations (`service.json` or `service-<channel>.json`, URL + password; each tried newest first) → auto-start + poll (5s timeout)
- **OpenCode v2 API** (`lua/opencode/server/init.lua`): the HTTP API lives under `/api/*` and requires HTTP basic auth. The password comes from the service registration or `opts.server.password`. Requests carry `x-opencode-directory: <nvim cwd>`; the daemon is shared across projects, so `/api/session` is scoped with a `directory` query. Prompts and commands target the most recently updated root session for Neovim's directory (`Server:resolve_session()`), resolved per call and never cached. OpenCode v2 has no TUI-driving or prompt-append API.
- **Discovery vs connection**: server.connect (default true) controls whether the discovered server is automatically subscribed to via SSE. When false, the server is found but not connected.
- **Context system** (`lua/opencode/context/init.lua`): captures buffer/win/cursor/selection before UI opens, renders placeholders (`@this`, `@buffer`, etc.) in prompts
Expand Down
83 changes: 63 additions & 20 deletions lua/opencode/server/discovery/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -6,36 +6,71 @@ local M = {}
---@field pid? number
---@field version? string

---Read the registered OpenCode background service (URL + password).
---Read the registered OpenCode background services (URL + password), newest first.
---
---OpenCode v2 writes `service.json` to its state directory when the service
---starts. The plugin connects to that URL with the generated password.
---starts, or `service-<channel>.json` for channels other than `latest`, `dev`,
---`beta` and `next` (e.g. Nix builds use `prod`). A registration may be stale
---if its server has since exited, so callers should try each in order.
---
---@return opencode.server.discovery.Registration?
function M.registration()
---@return opencode.server.discovery.Registration[]
function M.registrations()
local state_home = vim.env.XDG_STATE_HOME
-- Mirror OpenCode's own resolution: XDG roots based on `os.homedir()`, which on
-- Windows is `%USERPROFILE%` (where `$HOME` is not reliably set).
local home = vim.uv.os_homedir() or vim.env.HOME or ""
local state_dir = (state_home and state_home ~= "") and vim.fs.joinpath(state_home, "opencode")
or vim.fs.joinpath(home, ".local", "state", "opencode")
local path = vim.fs.joinpath(state_dir, "service.json")
local ok, lines = pcall(vim.fn.readfile, path)
if not ok or not lines or #lines == 0 then
return nil

local candidates = {}
for name in vim.fs.dir(state_dir) do
if name == "service.json" or name:match("^service%-.+%.json$") then
local path = vim.fs.joinpath(state_dir, name)
-- fs_stat follows symlinks, so a linked registration file still counts
local stat = vim.uv.fs_stat(path)
if stat and stat.type == "file" then
table.insert(candidates, { name = name, path = path, mtime = stat.mtime })
end
end
end
table.sort(candidates, function(a, b)
if a.mtime.sec ~= b.mtime.sec then
return a.mtime.sec > b.mtime.sec
end
if (a.mtime.nsec or 0) ~= (b.mtime.nsec or 0) then
return (a.mtime.nsec or 0) > (b.mtime.nsec or 0)
end
-- same mtime: prefer the default service.json, then sort by name
if (a.name == "service.json") ~= (b.name == "service.json") then
return a.name == "service.json"
end
return a.name < b.name
end)

local decoded_ok, decoded = pcall(vim.fn.json_decode, table.concat(lines, "\n"))
if decoded_ok and type(decoded) == "table" and type(decoded.url) == "string" then
return {
url = decoded.url,
password = decoded.password,
pid = decoded.pid,
version = decoded.version,
}
local registrations = {}
for _, candidate in ipairs(candidates) do
local ok, lines = pcall(vim.fn.readfile, candidate.path)
if ok and lines and #lines > 0 then
local decoded_ok, decoded = pcall(vim.fn.json_decode, table.concat(lines, "\n"))
if decoded_ok and type(decoded) == "table" and type(decoded.url) == "string" then
table.insert(registrations, {
url = decoded.url,
password = decoded.password,
pid = decoded.pid,
version = decoded.version,
})
end
end
end

return nil
return registrations
end

---Read the most recently registered OpenCode background service.
---
---@return opencode.server.discovery.Registration?
function M.registration()
return M.registrations()[1]
end

local function find()
Expand All @@ -50,12 +85,20 @@ local function find()
return configured
end

local info = M.registration()
if info then
-- Try each registration in turn; a newer one may be left behind by an exited server
local registrations = M.registrations()
local function try(i)
local info = registrations[i]
return require("opencode.server").new(info.url, { password = info.password }):catch(function(err)
if registrations[i + 1] then
return try(i + 1)
end
return Promise.reject(err or ("Failed to connect to registered OpenCode server at " .. info.url))
end)
end
if #registrations > 0 then
return try(1)
end

return Promise.reject("No OpenCode server found")
end
Expand Down Expand Up @@ -100,7 +143,7 @@ end
---
---1. The currently connected server.
---2. The configured URL in `require("opencode.config").opts.server.url`.
---3. The background service registered in OpenCode's state directory (`service.json`).
---3. The background services registered in OpenCode's state directory (`service.json` or `service-<channel>.json`), newest first.
---4. Calling `vim.g.opencode_opts.server.start` and retrying the above over five seconds.
---
---@return Promise<opencode.server.Server>
Expand Down
Loading