diff --git a/AGENTS.md b/AGENTS.md index 882f07a0..4c3b4092 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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-.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 @@ -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-.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: `; 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 diff --git a/lua/opencode/server/discovery/init.lua b/lua/opencode/server/discovery/init.lua index 6fa74a13..d5dbd650 100644 --- a/lua/opencode/server/discovery/init.lua +++ b/lua/opencode/server/discovery/init.lua @@ -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-.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() @@ -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 @@ -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-.json`), newest first. ---4. Calling `vim.g.opencode_opts.server.start` and retrying the above over five seconds. --- ---@return Promise