From e9eaa4b8e2a6bde5238ac71ae28cc25b4bf7e205 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Fri, 18 Sep 2026 11:35:20 +0200 Subject: [PATCH 1/8] feat: support OpenCode v2 OpenCode v2 replaced its server model: the HTTP API moved under `/api/*`, servers always require HTTP basic auth, the background service is discovered via its registration file, and the `/tui/*` control endpoints were removed. - Discover the background service from `service.json` (URL + password) - Authenticate all requests with basic auth - Target the `/api/*` surface - Send prompts and commands through session endpoints (fire-and-forget) - Update event handling to v2's `{ id, type, data }` shape - Detect legacy `/tui/*` endpoints and use them when available - Drop v1 process discovery and the server/session pickers --- AGENTS.md | 7 +- README.md | 74 ++-- lua/opencode.lua | 151 +++++++ lua/opencode/api/command.lua | 107 ++++- lua/opencode/api/prompt.lua | 19 +- lua/opencode/config.lua | 6 +- lua/opencode/events/permissions/edits.lua | 37 +- lua/opencode/events/permissions/init.lua | 8 +- lua/opencode/events/status.lua | 17 +- lua/opencode/health.lua | 34 +- lua/opencode/server/discovery/init.lua | 124 +++--- .../server/discovery/process/init.lua | 18 - .../server/discovery/process/unix.lua | 66 --- .../server/discovery/process/windows.lua | 40 -- lua/opencode/server/init.lua | 395 ++++++++++++++---- lua/opencode/ui/select.lua | 34 +- lua/opencode/ui/select_server.lua | 62 --- lua/opencode/ui/select_session.lua | 33 -- plugin/events/permissions/edits.lua | 2 +- plugin/events/permissions/init.lua | 8 +- plugin/events/reload.lua | 2 +- plugin/events/status.lua | 4 +- 22 files changed, 761 insertions(+), 487 deletions(-) delete mode 100644 lua/opencode/server/discovery/process/init.lua delete mode 100644 lua/opencode/server/discovery/process/unix.lua delete mode 100644 lua/opencode/server/discovery/process/windows.lua delete mode 100644 lua/opencode/ui/select_server.lua delete mode 100644 lua/opencode/ui/select_session.lua diff --git a/AGENTS.md b/AGENTS.md index ccebe8a0..aeef5c3f 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**: `pgrep` + `lsof` (Unix, unless `server.url` is set) +- **Auto-discovery**: reads OpenCode's background service registration (`service.json`) 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,10 +62,11 @@ 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 → local process scan (filtered by CWD overlap) → auto-start + poll (5s timeout) +- **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) +- **OpenCode v2 API** (`lua/opencode/server/init.lua`): the HTTP API lives under `/api/*` and always requires HTTP basic auth. The password comes from the service registration or `opts.server.password`. TUI-driving commands (scroll, navigation, prompt box) require the legacy `/tui/*` endpoints, detected at connect time into `server.tui` and absent in OpenCode v2.0.x. - **Discovery vs connection**: server.connect (default true) controls whether auto-discovered servers are automatically subscribed to via SSE. When false, the server is found but not connected — use the select menu's "Connect to a server" / "Disconnect from connected server" items to manage connections manually. - **Context system** (`lua/opencode/context/init.lua`): captures buffer/win/cursor/selection before UI opens, renders placeholders (`@this`, `@buffer`, etc.) in prompts -- **Events**: SSE subscribed on `connect()`, dispatched as `OpencodeEvent:` User autocmds +- **Events**: SSE subscribed on `connect()` (`/api/event`), dispatched as `OpencodeEvent:` User autocmds. OpenCode v2 events are shaped `{ id, type, data }`. - **Edit review**: opens diff in new tab via `:diffpatch`, keymaps `da`/`dr` to accept/reject, `dp`/`do` for per-hunk - **Ask completion**: in-process LSP server (`lua/opencode/ui/ask/cmp.lua`) providing context placeholder + agent completions - **Integration policy**: code that bridges another tool _to_ opencode.nvim (e.g. picker send, terminal toggle) belongs in README examples. Code that enhances opencode.nvim's own UI (ask/select with snacks input/picker) stays in the plugin. diff --git a/README.md b/README.md index f3f3dd75..7deeef75 100644 --- a/README.md +++ b/README.md @@ -207,18 +207,20 @@ Select prompts to review, explain, and improve your code: ### Server -Run `opencode` locally however you like and opencode.nvim will find them! Or point `vim.g.opencode_opts.server.url` to a specific server, including remotes. +OpenCode v2 runs a **background service** that the TUI and other clients attach to. opencode.nvim discovers it automatically by reading OpenCode's registration file — its URL and generated password — from OpenCode's state directory (`$XDG_STATE_HOME/opencode/service.json`, or `~/.local/state/opencode/service.json`). + +Run `opencode service start` yourself, or point `vim.g.opencode_opts.server.url` to a specific server, including remotes. > [!IMPORTANT] -> You _must_ run `opencode` with the `--port` flag to expose its server. +> OpenCode v2 always secures its server with HTTP basic auth. opencode.nvim reads the generated password from the registration file, or falls back to `vim.g.opencode_opts.server.password` (and `username`, defaulting to the same `$OPENCODE_SERVER_PASSWORD` / `$OPENCODE_SERVER_USERNAME` environment variables as OpenCode). -If opencode.nvim can't find a running `opencode`, it starts one via `vim.g.opencode_opts.server.start`, defaulting to `term://opencode --port`. +If opencode.nvim can't find a running service, it starts one via `vim.g.opencode_opts.server.start`, which defaults to running `opencode service start` and opening a TUI connected to it.
Start via snacks.terminal ```lua -local opencode_cmd = 'opencode --port' +local opencode_cmd = 'opencode' ---@type snacks.terminal.Opts local snacks_terminal_opts = { win = { @@ -242,17 +244,13 @@ vim.keymap.set({ 'n', 't' }, '', function() require('snacks.terminal').toggle(opencode_cmd, snacks_terminal_opts) end, { desc = 'Toggle OpenCode' }) --- Optionally show upon submitting prompt +-- Optionally show the terminal when a prompt is admitted to the session vim.api.nvim_create_autocmd('User', { - pattern = { 'OpencodeEvent:tui.command.execute' }, - callback = function(args) - ---@type opencode.server.Event - local event = args.data.event - if event.properties.command == 'prompt.submit' then - local win = require('snacks.terminal').get(opencode_cmd, { create = false }) - if win then - win:show() - end + pattern = { 'OpencodeEvent:session.inbox.delivered' }, + callback = function() + local win = require('snacks.terminal').get(opencode_cmd, { create = false }) + if win then + win:show() end end, }) @@ -289,9 +287,12 @@ Highlights and previews items when using [snacks.picker](https://github.com/folk Prompt OpenCode. - Injects configured contexts. -- Trailing space appends; trailing "..." opens in Ask. +- Trailing "..." opens in Ask. - OpenCode will interpret references to files or subagents. +> [!NOTE] +> On servers that expose the legacy `/tui/*` endpoints, a trailing space appends to the TUI prompt without submitting. Without them (OpenCode v2.0.x), the prompt is admitted straight to the active session. + ### Operator — `require("opencode").operator()` Wraps Prompt as an operator, supporting ranges and dot-repeat. @@ -300,24 +301,27 @@ Wraps Prompt as an operator, supporting ranges and dot-repeat. Command OpenCode: -| Command | Description | -| ------------------------ | ------------------------------------------ | -| `agent.cycle` | Cycle selected agent | -| `prompt.clear` | Clear current prompt | -| `prompt.submit` | Submit current prompt | -| `session.compact` | Compact current session | -| `session.first` | Jump to first message in session | -| `session.half.page.up` | Scroll messages up half a page | -| `session.half.page.down` | Scroll messages down half a page | -| `session.interrupt` | Interrupt current session | -| `session.last` | Jump to last message in current session | -| `session.new` | Start new session | -| `session.page.up` | Scroll messages up one page | -| `session.page.down` | Scroll messages down one page | -| `session.select` | Select session | -| `session.share` | Share current session | -| `session.redo` | Redo last undone action in current session | -| `session.undo` | Undo last action in current session | +| Command | Description | +| ------------------------- | ------------------------------------------ | +| `agent.cycle` | Cycle selected agent | +| `prompt.clear` † | Clear current prompt | +| `prompt.submit` † | Submit current prompt | +| `session.compact` | Compact current session | +| `session.first` † | Jump to first message in session | +| `session.half.page.up` † | Scroll messages up half a page | +| `session.half.page.down` †| Scroll messages down half a page | +| `session.interrupt` | Interrupt current session | +| `session.last` † | Jump to last message in current session | +| `session.new` | Start new session | +| `session.page.up` † | Scroll messages up one page | +| `session.page.down` † | Scroll messages down one page | +| `session.select` † | Select session | +| `session.share` † | Share current session | +| `session.redo` † | Redo last undone action in current session | +| `session.undo` † | Undo last action in current session | + +> [!NOTE] +> Commands marked † rely on OpenCode's legacy TUI control endpoints, which were removed in OpenCode v2.0.x and re-added in newer builds. They work when the connected server exposes `/tui/*`, and are no-ops otherwise. The unmarked commands map directly to OpenCode v2's HTTP API. ## 👀 Events @@ -333,11 +337,11 @@ vim.api.nvim_create_autocmd("User", { ---@type string local url = args.data.url - -- See the available event types and their properties + -- See the available event types and their data vim.notify(vim.inspect(event)) -- Do something useful if event.type == "session.status" then - vim.notify("OpenCode status updated: " .. event.properties.status.type) + vim.notify("OpenCode status updated: " .. event.data.status.type) end end, }) diff --git a/lua/opencode.lua b/lua/opencode.lua index 8cef9bb0..1e318a52 100644 --- a/lua/opencode.lua +++ b/lua/opencode.lua @@ -18,6 +18,7 @@ end --- ---@param default? string Text to pre-fill the input with. function M.ask(default) + M.open() require("opencode.server.discovery") .get() :next(function(server) @@ -39,6 +40,7 @@ end --- ---@param opts? opencode.select.Opts Override configured options for this call. function M.select(opts) + M.open() require("opencode.server.discovery") .get() :next(function(server) @@ -58,6 +60,7 @@ M.statusline = require("opencode.events.status").statusline --- ---@param prompt string function M.prompt(prompt) + M.open() require("opencode.server.discovery") .get() :next(function(server) @@ -71,6 +74,7 @@ end --- ---@param command opencode.server.Command | string function M.command(command) + M.open() require("opencode.server.discovery") .get() :next(function(server) @@ -108,6 +112,153 @@ function M.operator(prompt) return "g@" end +---The buffer backing an OpenCode TUI opened by `toggle()`, if any. +---@type integer? +local tui_buf = nil +---The window currently showing that TUI, if visible. +---@type integer? +local tui_win = nil + +---Whether the tracked TUI terminal still has a live job. +--- +---@return boolean +local function tui_alive() + return tui_buf ~= nil and vim.api.nvim_buf_is_valid(tui_buf) and (vim.bo[tui_buf].channel or 0) > 0 +end + +---Path to the file caching one panel session ID per working directory. +---@return string +local function panel_sessions_file() + return vim.fs.joinpath(vim.fn.stdpath("state"), "opencode.nvim-panel-sessions.json") +end + +---@return table +local function read_panel_sessions() + local path = panel_sessions_file() + if vim.fn.filereadable(path) ~= 1 then + return {} + end + local ok, decoded = pcall(vim.fn.json_decode, table.concat(vim.fn.readfile(path), "\n")) + return ok and type(decoded) == "table" and decoded or {} +end + +---@param sessions table +local function write_panel_sessions(sessions) + pcall(vim.fn.writefile, { vim.fn.json_encode(sessions) }, panel_sessions_file()) +end + +---Perform a request against the registered OpenCode service, synchronously. +--- +---@param method string +---@param path string +---@param body? string +---@return table? +local function service_request(method, path, body) + local info = require("opencode.server.discovery").registration() + if not info then + return nil + end + + local cmd = { + "curl", + "-s", + "-S", + "--fail-with-body", + "-X", + method, + "-H", + "Content-Type: application/json", + "-H", + "Accept: application/json", + } + if info.password and info.password ~= "" then + local token = vim.base64 and vim.base64.encode("opencode:" .. info.password) + or vim.trim(vim.fn.system({ "base64" }, "opencode:" .. info.password):gsub("%s+", "")) + table.insert(cmd, "-H") + table.insert(cmd, "Authorization: Basic " .. token) + end + if body then + table.insert(cmd, "-d") + table.insert(cmd, body) + end + table.insert(cmd, info.url .. path) + + local out = vim.fn.system(cmd) + if vim.v.shell_error ~= 0 or out == "" then + return nil + end + local ok, decoded = pcall(vim.fn.json_decode, out) + return ok and type(decoded) == "table" and decoded or nil +end + +---Return the panel's session, reusing this directory's cached one while it still +---exists. Keyed per working directory so concurrent Neovim instances (and +---projects) each get their own panel session. +--- +---@return string? +local function panel_session() + local cwd = vim.fn.getcwd() + local sessions = read_panel_sessions() + local cached = sessions[cwd] + + if cached and cached ~= "" then + local existing = service_request("GET", "/api/session/" .. cached) + if existing and existing.data then + return cached + end + end + + local created = service_request("POST", "/api/session", "{}") + local id = created and created.data and created.data.id + if id then + sessions[cwd] = id + write_panel_sessions(sessions) + end + return id +end + +---Show the OpenCode TUI panel on the right. +--- +---Opens `opencode` in a right-hand split, reusing the existing terminal when one +---is already running. A dedicated session is created so prompts from Neovim reach +---this panel instead of whatever session the TUI would otherwise resume. +function M.open() + if tui_win and vim.api.nvim_win_is_valid(tui_win) then + return + end + + local origin = vim.api.nvim_get_current_win() + vim.cmd("botright vsplit") + if tui_alive() and tui_buf then + vim.api.nvim_win_set_buf(0, tui_buf) + else + local session_id = panel_session() + if session_id then + require("opencode.server").current_session_id = session_id + require("opencode.server")._current_session_seen = false + vim.cmd("terminal opencode --session " .. session_id) + else + vim.cmd("terminal opencode") + end + tui_buf = vim.api.nvim_get_current_buf() + end + tui_win = vim.api.nvim_get_current_win() + vim.api.nvim_set_current_win(origin) +end + +---Toggle the OpenCode TUI panel on the right. +--- +---Opens `opencode` in a right-hand split if hidden, or hides it if visible. +---The terminal keeps running across toggles. +function M.toggle() + if tui_win and vim.api.nvim_win_is_valid(tui_win) then + vim.api.nvim_win_hide(tui_win) + tui_win = nil + return + end + M.open() +end + M.format = require("opencode.context").format return M diff --git a/lua/opencode/api/command.lua b/lua/opencode/api/command.lua index 05552619..e9064244 100644 --- a/lua/opencode/api/command.lua +++ b/lua/opencode/api/command.lua @@ -1,16 +1,111 @@ local M = {} +local Promise = require("opencode.promise") + +---Commands that only the legacy `/tui/*` control endpoints can perform. +---These have no OpenCode v2 HTTP API equivalent (scroll, navigation, prompt box). +---@type table +local TUI_ONLY = { + ["prompt.clear"] = true, + ["prompt.submit"] = true, + ["session.first"] = true, + ["session.last"] = true, + ["session.half.page.up"] = true, + ["session.half.page.down"] = true, + ["session.page.up"] = true, + ["session.page.down"] = true, + ["session.select"] = true, + ["session.share"] = true, + ["session.redo"] = true, + ["session.undo"] = true, +} + +---Cycle the active session's agent through the visible primary agents. +--- +---@param server opencode.server.Server +---@return Promise +local function cycle_agent(server) + return server:resolve_session_id():next(function(session_id) + return Promise.all({ + server:get_sessions(), + server:get_agents(), + }):next(function(results) + local sessions, agents = results[1], results[2] + + -- The session's current agent is only exposed on list items. + local current + for _, session in ipairs(sessions) do + if session.id == session_id then + current = session.agent + break + end + end + + local primary = vim.tbl_filter(function(agent) ---@param agent { id: string, mode: string, hidden?: boolean } + return agent.mode == "primary" and not agent.hidden + end, agents or {}) + + if #primary == 0 then + return Promise.resolve(nil) + end + + local next_agent = primary[1] + for index, agent in ipairs(primary) do + if agent.id == current then + next_agent = primary[index % #primary + 1] + break + end + end + + return server:switch_agent(session_id, next_agent.id) + end) + end) +end + +---Execute a built-in OpenCode command. +--- +---Commands map onto v2 HTTP API calls where possible; TUI-only commands +---(scroll, navigation, prompt box) require the legacy `/tui/*` endpoints and +---reject with a clear message when the connected server does not expose them. +--- ---@param command opencode.server.Command | string ---@param server opencode.server.Server ---@return Promise function M.command(command, server) - return server:tui_execute_command(command):next(function() - if command == "session.interrupt" then - -- Evidently OpenCode only uses this command for their "double-tap Esc to interrupt" user keybind. - -- So we have to double-send it to actually interrupt. - return server:tui_execute_command(command) + if command == "session.new" then + return server:create_session() + elseif command == "session.interrupt" then + return server:resolve_session_id():next(function(session_id) + return server:interrupt(session_id) + end) + elseif command == "session.compact" then + return server:resolve_session_id():next(function(session_id) + return server:compact(session_id) + end) + elseif command == "agent.cycle" then + return cycle_agent(server) + end + + if TUI_ONLY[command] then + if not server.tui then + return Promise.reject( + "`" .. command .. "` requires the OpenCode TUI control endpoints, which this server does not expose." + ) end - end) + return server:tui_execute_command(command):next(function() + if command == "session.interrupt" then + -- Evidently OpenCode only uses this command for their "double-tap Esc to interrupt" user keybind. + -- So we have to double-send it to actually interrupt. + return server:tui_execute_command(command) + end + end) + end + + -- Unknown command: forward to the TUI when available, otherwise surface it. + if server.tui then + return server:tui_execute_command(command) + end + return Promise.reject("Unknown OpenCode command: `" .. command .. "`") end return M diff --git a/lua/opencode/api/prompt.lua b/lua/opencode/api/prompt.lua index 0ba49391..20e30056 100644 --- a/lua/opencode/api/prompt.lua +++ b/lua/opencode/api/prompt.lua @@ -11,11 +11,22 @@ function M.prompt(prompt, context) ) :next(function(_prompt) local plaintext = context:render(_prompt).output:plaintext() + local server = context.server - return context.server:tui_append_prompt(plaintext):next(function() - if not _prompt:match(" $") then - return context.server:tui_execute_command("prompt.submit") - end + -- Servers exposing the legacy TUI control endpoints keep the v1 behavior: + -- append to the TUI prompt, then submit unless the prompt ends with a space. + if server.tui then + return server:tui_append_prompt(plaintext):next(function() + if not _prompt:match(" $") then + return server:tui_execute_command("prompt.submit") + end + end) + end + + -- Otherwise send the prompt straight to the active session. OpenCode v2 + -- has no "append without submitting" concept outside the TUI. + return server:resolve_session_id():next(function(session_id) + return server:send_prompt(session_id, plaintext) end) end) :next(function() diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index ab6f80a8..410b9d1d 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -24,7 +24,11 @@ local defaults = { username = vim.env.OPENCODE_SERVER_USERNAME or "opencode", -- Same env vars and defaults as OpenCode password = vim.env.OPENCODE_SERVER_PASSWORD, start = function() - vim.cmd("vsplit term://opencode --port | wincmd p") + -- OpenCode v2 runs a background service that the TUI attaches to. Start it + -- first so the plugin can discover it via the service registration file, + -- then open a TUI connected to the same service. + vim.fn.system({ "opencode", "service", "start" }) + vim.cmd("vsplit term://opencode | wincmd p") end, }, contexts = { diff --git a/lua/opencode/events/permissions/edits.lua b/lua/opencode/events/permissions/edits.lua index 2ea5a181..5cd35b32 100644 --- a/lua/opencode/events/permissions/edits.lua +++ b/lua/opencode/events/permissions/edits.lua @@ -3,20 +3,40 @@ local M = {} ----@type integer? +---@type string? local current_edit_request_id = nil ---@type integer? local diff_tabpage = nil ----@param event opencode.server.Event | { type: "permission.asked" } | { type: "permission.replied" } +---Extract the diff and target file from an OpenCode v2 edit permission request. +--- +---v2 shape: `{ action = "edit", resources = { file }, metadata = { files = { { file, patch, … } } } }` +--- +---@param event opencode.server.Event +---@return { diff: string, filepath: string }? +local function edit_preview(event) + if event.type ~= "permission.asked" or event.data.action ~= "edit" then + return nil + end + + local files = event.data.metadata and event.data.metadata.files + local file = files and files[1] + if not file or not file.patch then + return nil + end + + return { diff = file.patch, filepath = file.file or event.data.resources[1] } +end + +---@param event opencode.server.Event ---@return Promise function M.diff(event) local Promise = require("opencode.promise") - if event.type == "permission.asked" and event.properties.permission == "edit" then - local diff = event.properties.metadata.diff - - local filepath = event.properties.metadata.filepath + local preview = edit_preview(event) + if preview then + local diff = preview.diff + local filepath = preview.filepath local absolute_filepath = vim.fn.fnamemodify(filepath, ":p") -- Opencode sends the absolute path sometimes with the HOME and sometimes without @@ -44,7 +64,6 @@ function M.diff(event) -- Diffing changes some of the buffer's display options (namely folding) to make it easier to compare side-by-side, -- so open the target file in a new tab first. vim.cmd("tabnew " .. filepath) - -- FIX: Errors in diff occur due to opencode's trimDiff function vim.cmd("silent vert diffpatch " .. patch_filepath) local diff_buff = vim.api.nvim_get_current_buf() @@ -52,7 +71,7 @@ function M.diff(event) -- Also prevents it from lingering in e.g. pickers and `:ls`. vim.bo[diff_buff].bufhidden = "wipe" diff_tabpage = vim.api.nvim_get_current_tabpage() - current_edit_request_id = event.properties.id + current_edit_request_id = event.data.id return Promise.new(function(resolve, reject) -- Override native hunk-specific keymaps to reject the edit as a whole first @@ -90,7 +109,7 @@ function M.diff(event) reject() end, { buffer = true, desc = "Close OpenCode edit diff" }) end) - elseif event.type == "permission.replied" and current_edit_request_id == event.properties.requestID then + elseif event.type == "permission.replied" and current_edit_request_id == event.data.requestID then -- Entire edit was accepted or rejected, either in the plugin or TUI; close the diff current_edit_request_id = nil if diff_tabpage and vim.api.nvim_tabpage_is_valid(diff_tabpage) then diff --git a/lua/opencode/events/permissions/init.lua b/lua/opencode/events/permissions/init.lua index 96ec9112..01342467 100644 --- a/lua/opencode/events/permissions/init.lua +++ b/lua/opencode/events/permissions/init.lua @@ -4,15 +4,13 @@ local M = {} ----@param event opencode.server.Event | { type: "permission.asked" } +---@param event opencode.server.Event | { type: "permission.asked", data: opencode.server.PermissionRequest } ---@return Promise function M.request(event) + local data = event.data return require("opencode.promise.ui") .select({ "Once", "Always", "Reject" }, { - prompt = "Permit opencode to: " .. event.properties.permission .. " " .. table.concat( - event.properties.patterns, - ", " - ) .. "?: ", + prompt = "Permit opencode to: " .. data.action .. " " .. table.concat(data.resources or {}, ", ") .. "?: ", format_item = function(item) return item end, diff --git a/lua/opencode/events/status.lua b/lua/opencode/events/status.lua index f1548e88..f25872d2 100644 --- a/lua/opencode/events/status.lua +++ b/lua/opencode/events/status.lua @@ -29,15 +29,18 @@ end function M.update(event, url) M.url = url - if - event.type == "server.connected" or (event.type == "session.status" and event.properties.status.type == "idle") - then + if event.type == "server.connected" then status = "idle" - elseif event.type == "session.status" and event.properties.status.type == "busy" then - status = "busy" - elseif event.type == "session.status" and event.properties.status.type == "error" then + elseif event.type == "session.status" then + local kind = event.data.status.type + if kind == "idle" then + status = "idle" + elseif kind == "busy" or kind == "retry" then + status = "busy" + end + elseif event.type == "session.execution.failed" then status = "error" - elseif event.type == "server.instance.disposed" then + elseif event.type == "global.disposed" or event.type == "location.shutdown" then M.reset() end end diff --git a/lua/opencode/health.lua b/lua/opencode/health.lua index a7943cb8..fa23a47d 100644 --- a/lua/opencode/health.lua +++ b/lua/opencode/health.lua @@ -35,7 +35,7 @@ function M.check() vim.health.ok("`opencode` available with version `" .. found_version .. "`.") local found_version_parsed = vim.version.parse(found_version) - local minimum_version = "1.17" + local minimum_version = "2.0" local minimum_version_parsed = vim.version.parse(minimum_version) if found_version_parsed @@ -65,22 +65,26 @@ function M.check() }) end - -- Binaries for auto-finding `opencode` process (Unix only) - if vim.fn.has("win32") == 0 and not (opts and opts.server and opts.server.url) then - if vim.fn.executable("pgrep") == 1 then - vim.health.ok("`pgrep` available.") - else - vim.health.error( - "`pgrep` executable not found in `$PATH`.", - { "Install `pgrep` and ensure it's in your `$PATH`", "Or set `vim.g.opencode_opts.server.url`." } - ) + -- OpenCode v2 registers its background service in its state directory. + -- The plugin reads that registration to discover the server URL and password. + if not (opts and opts.server and opts.server.url) then + local state_home = vim.env.XDG_STATE_HOME + local dir = state_home and state_home ~= "" and vim.fs.joinpath(state_home, "opencode") + or vim.fs.joinpath(vim.env.HOME or "", ".local", "state", "opencode") + local found = nil + for _, name in ipairs({ "service.json", "server.json" }) do + local path = vim.fs.joinpath(dir, name) + if vim.fn.filereadable(path) == 1 then + found = path + break + end end - if vim.fn.executable("lsof") == 1 then - vim.health.ok("`lsof` available.") + if found then + vim.health.ok("OpenCode background service registered at `" .. found .. "`.") else - vim.health.error( - "`lsof` executable not found in `$PATH`.", - { "Install `lsof` and ensure it's in your `$PATH`", "Or set `vim.g.opencode_opts.server.url`." } + vim.health.info( + "No OpenCode background service registered yet. " + .. "Run `opencode service start` or set `vim.g.opencode_opts.server.url`." ) end end diff --git a/lua/opencode/server/discovery/init.lua b/lua/opencode/server/discovery/init.lua index d1642516..a82b4acc 100644 --- a/lua/opencode/server/discovery/init.lua +++ b/lua/opencode/server/discovery/init.lua @@ -1,28 +1,54 @@ local M = {} +---@class opencode.server.discovery.Registration +---@field url string +---@field password? string +---@field pid? number +---@field version? string + +---Resolve OpenCode's state directory, honoring `$XDG_STATE_HOME`. +--- +---@return string +local function state_dir() + local state_home = vim.env.XDG_STATE_HOME + if state_home and state_home ~= "" then + return vim.fs.joinpath(state_home, "opencode") + end + return vim.fs.joinpath(vim.env.HOME or "", ".local", "state", "opencode") +end + +---Read the registered OpenCode server (URL + password) that OpenCode writes when +---its background service starts (`opencode service start`, or `opencode serve --register`). +--- +---The registration file was named `server.json` historically and is `service.json` +---in v2.0.x; check both for forward compatibility. +--- +---@return opencode.server.discovery.Registration? +local function registered() + local dir = state_dir() + for _, name in ipairs({ "service.json", "server.json" }) do + local path = vim.fs.joinpath(dir, name) + local ok, lines = pcall(vim.fn.readfile, 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 + return { + url = decoded.url, + password = decoded.password, + pid = decoded.pid, + version = decoded.version, + } + end + end + end + return nil +end + local function find() local Promise = require("opencode.promise") local connected_server = require("opencode.server").connected - return connected_server and Promise.resolve(connected_server) - or M.configured() - or M.locally():next(function(servers) - local nvim_cwd = vim.fn.getcwd() - local servers_sharing_cwd = vim.tbl_filter(function(server) ---@param server opencode.server.Server - -- Overlaps in either direction, with no non-empty mismatch - return server.cwd:find(nvim_cwd, 0, true) == 1 or nvim_cwd:find(server.cwd, 0, true) == 1 - end, servers) - - if #servers_sharing_cwd == 0 then - -- We prefer falling back to `opts.server.start` over selecting from servers that don't match the CWD. - -- Manual selection is still available for that rare need. - return Promise.reject("No OpenCode servers found with overlapping CWD") - elseif #servers_sharing_cwd == 1 then - return Promise.resolve(servers_sharing_cwd[1]) - else - return require("opencode.ui.select_server").select_server(servers_sharing_cwd) - end - end) + return connected_server and Promise.resolve(connected_server) or M.configured() or M.registered() end ---Look for an OpenCode server every second, rejecting if not found after five seconds. @@ -65,7 +91,7 @@ end --- ---1. The currently connected server. ---2. The configured URL in `require("opencode.config").opts.server.url`. ----3. All local servers that overlap with Neovim's CWD. Automatically selects if just one, otherwise prompts to select from them. +---3. The background service registered in OpenCode's state directory. ---4. Calling `vim.g.opencode_opts.server.start` and retrying the above over five seconds. --- ---@return Promise @@ -102,49 +128,27 @@ function M.get() end) end ----Search for `opencode` processes on this machine and attempt to resolve them to servers. +---The registered OpenCode background service (URL + password), if any. +---Useful for callers that need the raw registration before a full server connection. --- ----@return Promise -function M.locally() - local Promise = require("opencode.promise") - return require("opencode.server.discovery.process") - .get() - :next(function(processes) - if #processes == 0 then - return Promise.reject("No `opencode ... --port` processes found") - else - return Promise.resolve(processes) - end - end) - :next(function(processes) - -- `all_settled` because we expect non-servers (falsely discovered processes) to reject - return Promise.all_settled( - vim.tbl_map(function(process) ---@param process opencode.server.discovery.process.Process - return require("opencode.server").new("http://localhost:" .. process.port) - end, processes) - ) - end) - :next(function(results) - local servers = {} - for _, result in ipairs(results) do - if result.status == "fulfilled" then - table.insert(servers, result.value) - end - end - - if #servers == 0 then - for _, result in ipairs(results) do - if result.status == "rejected" and result.reason then - -- Prefer to surface a specific rejection - it's likely from a valid server (e.g. unauthenticated) - return Promise.reject(result.reason) - end - end +---@return opencode.server.discovery.Registration? +function M.registration() + return registered() +end - return Promise.reject("No OpenCode servers found") - end +---Attempt to connect to the OpenCode background service registered on this machine. +--- +---@return Promise? +function M.registered() + local Promise = require("opencode.promise") + local info = registered() + if info == nil then + return nil + end - return Promise.resolve(servers) - end) + return require("opencode.server").new(info.url, { password = info.password }):catch(function(err) + return Promise.reject(err or ("Failed to connect to registered OpenCode server at " .. info.url)) + end) end ---Attempt to connect to the OpenCode server at `vim.g.opencode_opts.server.url`. diff --git a/lua/opencode/server/discovery/process/init.lua b/lua/opencode/server/discovery/process/init.lua deleted file mode 100644 index 0a8562f2..00000000 --- a/lua/opencode/server/discovery/process/init.lua +++ /dev/null @@ -1,18 +0,0 @@ ----An `opencode` process. ----Retrieval is platform-dependent. ----@class opencode.server.discovery.process.Process ----@field pid number ----@field port number - -local M = {} - ----@return Promise -function M.get() - if vim.fn.has("win32") == 1 then - return require("opencode.server.discovery.process.windows").get() - else - return require("opencode.server.discovery.process.unix").get() - end -end - -return M diff --git a/lua/opencode/server/discovery/process/unix.lua b/lua/opencode/server/discovery/process/unix.lua deleted file mode 100644 index 30b44714..00000000 --- a/lua/opencode/server/discovery/process/unix.lua +++ /dev/null @@ -1,66 +0,0 @@ -local M = {} - ----@return Promise -local function find_pids() - return require("opencode.promise.system").system({ "pgrep", "-f", "opencode.*--port" }):next(function(pgrep_stdout) - return require("opencode.promise").resolve( - vim.tbl_map(tonumber, vim.split(pgrep_stdout, "\n", { trimempty = true })) - ) - end) -end - ----@param pids number[] ----@return Promise -local function find_ports(pids) - return require("opencode.promise.system") - .system({ - "lsof", - "-Fpn", -- Output PID and network interface in a reliable (portable) format - "-w", -- Suppress warning messages about files that can't be accessed (common with e.g. Docker FUSE mounts) - -- Only network files with TCP state LISTEN - "-iTCP", - "-sTCP:LISTEN", - -- Only these PIDS - "-p", - table.concat(pids, ","), - "-a", -- AND the above conditions together - "-P", -- Don't resolve port numbers to port names - we can't use the latter to send requests, and it's slower anyway - "-n", -- Don't resolve port numbers to hostnames - same as above - }) - :next(function(lsof_stdout) - ---@type opencode.server.discovery.process.Process[] - local processes = {} - local pid - for line in lsof_stdout:gmatch("[^\n]+") do - local prefix = line:sub(1, 1) - local value = line:sub(2) - - if prefix == "p" then - pid = tonumber(value) - elseif prefix == "n" then - local port = tonumber(value:match(":(%d+)$")) - if port then - table.insert( - processes, - ---@type opencode.server.discovery.process.Process - { pid = pid, port = port } - ) - end - end - end - - return require("opencode.promise").resolve(processes) - end) -end - ----@return Promise -function M.get() - return find_pids():next(function(pids) - if #pids == 0 then - return require("opencode.promise").resolve({}) - end - return find_ports(pids) - end) -end - -return M diff --git a/lua/opencode/server/discovery/process/windows.lua b/lua/opencode/server/discovery/process/windows.lua deleted file mode 100644 index 1215c512..00000000 --- a/lua/opencode/server/discovery/process/windows.lua +++ /dev/null @@ -1,40 +0,0 @@ -local M = {} - -local ps_script = [[ -Get-Process -Name '*opencode*' -ErrorAction SilentlyContinue | -ForEach-Object { - $ports = Get-NetTCPConnection -State Listen -OwningProcess $_.Id -ErrorAction SilentlyContinue - if ($ports) { - foreach ($port in $ports) { - [PSCustomObject]@{pid=$_.Id; port=$port.LocalPort} - } - } -} | ConvertTo-Json -Compress -]] - ----@return Promise -function M.get() - local Promise = require("opencode.promise") - - return require("opencode.promise.system") - .system({ "powershell", "-NoProfile", "-Command", ps_script }) - :next(function(ps_stdout) - if ps_stdout == "" then - return Promise.resolve({}) - end - - local ok, processes = pcall(vim.json.decode, ps_stdout) - if not ok then - return Promise.reject("Failed to parse `powershell` output: " .. tostring(processes)) - end - - -- A single process was found - if processes.pid then - processes = { processes } - end - - return Promise.resolve(processes) - end) -end - -return M diff --git a/lua/opencode/server/init.lua b/lua/opencode/server/init.lua index bd436d47..5c663f60 100644 --- a/lua/opencode/server/init.lua +++ b/lua/opencode/server/init.lua @@ -1,5 +1,5 @@ ---@class opencode.server.Opts ----Full URL of an OpenCode server, e.g. `"http://localhost:4096"`. +---Full URL of an OpenCode server, e.g. `"http://127.0.0.1:4096"`. ---Bypasses local process discovery and connects directly. ---You _must_ run `opencode` with the `--port` flag to expose its server. ---If pointing to a headless server, you _must_ attach a TUI via `opencode attach `. @@ -9,18 +9,29 @@ ---@field password? string Basic auth password. ---@field start? fun() | false Start an OpenCode server. Called when none are found; will retry after. +---@class opencode.server.Credentials +---@field username? string +---@field password? string + ---An OpenCode server. ---@class opencode.server.Server ---@field url string +---@field username string +---@field password? string ---@field cwd string ---@field title string ---@field subagents opencode.server.Agent[] +---@field session_id? string The currently active session ID, tracked for session-scoped endpoints. +---@field version? string +---@field tui boolean Whether the server exposes the legacy `/tui/*` control endpoints (OpenCode >= dev, not v2.0.x). ---@field subscription_job_id? number ---@field heartbeat_timer? uv.uv_timer_t local Server = {} Server.__index = Server ----Built-in OpenCode commands. +---Built-in OpenCode commands exposed by `command()`. +---Aliases retained from v1; mapped onto v2 API calls where possible and the +---legacy `/tui/*` control endpoints when the server exposes them. ---@alias opencode.server.Command ---| 'agent.cycle' ---| 'prompt.clear' @@ -48,61 +59,119 @@ Server.__index = Server ---@field description string ---@field mode "primary" | "subagent" +---@class opencode.server.PermissionRequest +---@field id string +---@field sessionID string +---@field action string +---@field resources string[] +---@field save? string[] +---@field metadata? table +---@field source? { type: "tool", messageID: string, id: string } +---@field message? string + ---@alias opencode.server.PermissionReply ---| "once" ---| "always" ---| "reject" ----Events emitted by OpenCode. +---Events emitted by OpenCode v2. +---Note the v2 shape: `{ id, type, data }` (v1 used `{ type, properties }`). ---Not exhaustive. ---@alias opencode.server.Event ----| { type: "file.edited" } ----| { type: "permission.asked", properties: { id: number, permission: string, patterns: string[], metadata?: { diff: string, filepath: string } } } ----| { type: "permission.replied", properties: { requestID: number } } ----| { type: "server.connected" } ----| { type: "server.instance.disposed" } ----| { type: "session.status", properties: { status: { type: "idle" | "busy" | "error" } } } ----| { type: "tui.command.execute", properties: { command: string } } ----| { type: string, properties: table } +---| { id: string, type: "filesystem.changed", data: { file: string, event: "add" | "change" | "unlink" } } +---| { id: string, type: "permission.asked", data: opencode.server.PermissionRequest } +---| { id: string, type: "permission.replied", data: { sessionID: string, requestID: string, reply: opencode.server.PermissionReply } } +---| { id: string, type: "server.connected", data: {} } +---| { id: string, type: "global.disposed", data: table } +---| { id: string, type: "location.shutdown", data: table } +---| { id: string, type: "session.status", data: { sessionID: string, status: { type: "idle" | "busy" | "retry" } } } +---| { id: string, type: "session.created", data: table } +---| { id: string, type: string, data: table } + +---Credentials for the most recently discovered server. +---OpenCode v2 always requires basic auth, and event listeners reconstruct server +---objects from a bare URL, so cache the discovered credentials for reuse. +---@type opencode.server.Credentials? +Server.credentials = nil + +---The session the plugin is currently targeting — set when opening the TUI panel, +---so prompts reach it without racing the TUI's `tabs.json` write. +---@type string? +Server.current_session_id = nil +---Whether `tabs.json` has caught up with `current_session_id`. Until it has, the +---explicit target wins; afterwards the TUI is authoritative again (so session +---switches inside the TUI are honored). +---@type boolean +Server._current_session_seen = false ---Attempt to connect to an OpenCode server and fetch its health and details. ---Rejects if the health fails — the last line of defense against false-positive server discovery. ---Rejection message is non-empty if from a valid OpenCode server. --- ---@param url string +---@param credentials? opencode.server.Credentials ---@return Promise -function Server.new(url) +function Server.new(url, credentials) local self = setmetatable({}, Server) + if credentials then + Server.credentials = credentials + end + local creds = credentials or Server.credentials or {} self.url = url:gsub("/$", "") + self.username = creds.username or require("opencode.config").opts.server.username or "opencode" + self.password = creds.password or require("opencode.config").opts.server.password self.heartbeat_timer = vim.uv.new_timer() local Promise = require("opencode.promise") -- Serially check health first to confirm that this is a valid and authenticated OpenCode server. - -- Would like to differentiate headless servers, but not possible afaict unfortunately. - -- No endpoint exposes such information, and TUI commands sent to a headless server with none attached just no-op, with no tell in the respone. - -- So user must manually `opencode attach` in that case. return self - :get_health() - :next(function() - return require("opencode.promise").all({ - self:get_path(), + :get_info() + :next(function(info) + self.version = info.version + return Promise.all({ + self:get_location(), self:get_sessions(), self:get_agents(), }) end) :next( - function(results) ---@param results { [1]: { directory: string, worktree: string }, [2]: opencode.server.Session[], [3]: opencode.server.Agent } - self.cwd = results[1].directory or results[1].worktree + function(results) ---@param results { [1]: { directory: string }, [2]: opencode.server.Session[], [3]: opencode.server.Agent[] } + self.cwd = results[1].directory or vim.fn.getcwd() self.title = results[2][1] and results[2][1].title or "" self.subagents = vim.tbl_filter(function(agent) ---@param agent opencode.server.Agent return agent.mode == "subagent" end, results[3]) - return Promise.resolve(self) + return self:probe_tui():next(function() + return Promise.resolve(self) + end) end ) end +---Detect whether the server exposes the legacy `/tui/*` control endpoints. +---These were removed in OpenCode v2.0.x and re-added later; when absent, all +---TUI-driving commands degrade gracefully to no-ops. +--- +---Checked via the published OpenAPI spec: OpenCode v2.0.x exposes only the +---`/api/*` surface there, while newer builds that restore the TUI routes include +---`/tui/*` as well. +--- +---@return Promise +function Server:probe_tui() + local Promise = require("opencode.promise") + return self + :request("/openapi.json", "GET") + :next(function(spec) + self.tui = spec ~= nil and spec.paths ~= nil and spec.paths["/tui/execute-command"] ~= nil + return Promise.resolve(self.tui) + end) + :catch(function() + self.tui = false + return Promise.resolve(false) + end) +end + ---Human-readable name, stripping the protocol prefix. --- ---@return string @@ -111,18 +180,33 @@ function Server:display_name() return name end +---Build the `Authorization` header value, if credentials are configured. +--- +---@return string? authorization +function Server:authorization() + if not self.password or self.password == "" then + return nil + end + local credentials = self.username .. ":" .. self.password + local token + if vim.base64 and vim.base64.encode then + token = vim.base64.encode(credentials) + else + token = vim.trim(vim.fn.system({ "base64" }, credentials):gsub("%s+", "")) + end + return "Basic " .. token +end + ---@param path string ----@param method "GET" | "POST" +---@param method "GET" | "POST" | "PATCH" | "DELETE" ---@param body table? ----@param on_success fun(response: table) +---@param on_success fun(response: table?) ---@param on_error fun(msg: string, code: number, status: number?) ----@param opts? { persistent?: boolean } +---@param opts? { persistent?: boolean, max_time?: number } ---@return number job_id function Server:curl(path, method, body, on_success, on_error, opts) local url = self.url .. path - opts = opts or { - persistent = false, - } + opts = opts or {} local cmd = { "curl", @@ -140,22 +224,24 @@ function Server:curl(path, method, body, on_success, on_error, opts) "-N", } - local username = require("opencode.config").opts.server.username - local password = require("opencode.config").opts.server.password - if username and password then + local authorization = self:authorization() + if authorization then -- We can always send credentials; servers with no auth set just ignore them - table.insert(cmd, "--user") - table.insert(cmd, username .. ":" .. password) + table.insert(cmd, "-H") + table.insert(cmd, "Authorization: " .. authorization) end if not opts.persistent then table.insert(cmd, "--max-time") - table.insert(cmd, 2) + table.insert(cmd, opts.max_time or 2) end if body then + -- `vim.fn.json_encode({})` encodes an empty table as `[]`, but the OpenCode + -- API expects `{}` for empty object bodies (e.g. session create). + local encoded = next(body) == nil and "{}" or vim.fn.json_encode(body) table.insert(cmd, "-d") - table.insert(cmd, vim.fn.json_encode(body)) + table.insert(cmd, encoded) end table.insert(cmd, url) @@ -191,9 +277,13 @@ function Server:curl(path, method, body, on_success, on_error, opts) return end for _, line in ipairs(data) do - if line == "" and opts.persistent then - process_response_buffer() - else + if line == "" then + -- Blank line: SSE event terminator when persistent, otherwise a + -- line-split artifact (e.g. an empty 204 body) to ignore. + if opts.persistent then + process_response_buffer() + end + elseif not line:match("^:") then local clean_line = (line:gsub("^data: ?", "")) table.insert(response_buffer, clean_line) end @@ -210,7 +300,14 @@ function Server:curl(path, method, body, on_success, on_error, opts) end, on_exit = function(_, code) if code == 0 then - process_response_buffer() + if #response_buffer > 0 then + process_response_buffer() + elseif on_success then + -- Empty success body (e.g. 204 No Content from DELETE): resolve anyway. + vim.schedule(function() + on_success(nil) + end) + end else local response_message = #response_buffer > 0 and table.concat(response_buffer, "\n") or nil local stderr_message = #stderr_lines > 0 and table.concat(stderr_lines, "") or nil @@ -234,10 +331,15 @@ function Server:curl(path, method, body, on_success, on_error, opts) }) end +---Wrap a single JSON request in a Promise. +--- +---@param path string +---@param method "GET" | "POST" | "PATCH" | "DELETE" +---@param body table? ---@return Promise -function Server:get_health() +function Server:request(path, method, body) return require("opencode.promise").new(function(resolve, reject) - self:curl("/global/health", "GET", nil, resolve, function(msg, _, status) + self:curl(path, method, body, resolve, function(msg, code, status) if status == 401 then reject("Unauthorized response from OpenCode at " .. self:display_name()) else @@ -247,71 +349,200 @@ function Server:get_health() end) end ----@param text string ----@return Promise -function Server:tui_append_prompt(text) - return require("opencode.promise").new(function(resolve, reject) - self:curl("/tui/publish", "POST", { type = "tui.prompt.append", properties = { text = text } }, resolve, reject) - end) +---@return Promise<{ version: string, pid: number, urls: string[], paths: table }> +function Server:get_info() + return self:request("/api/info", "GET") end ----@param command opencode.server.Command | string ----@return Promise -function Server:tui_execute_command(command) - return require("opencode.promise").new(function(resolve, reject) - self:curl( - "/tui/publish", - "POST", - { type = "tui.command.execute", properties = { command = command } }, - resolve, - reject - ) - end) +---@return Promise<{ directory: string, project: table }> +function Server:get_location() + return self:request("/api/location", "GET") end ----@param permission number ----@param reply opencode.server.PermissionReply ----@return Promise -function Server:permit(permission, reply) - return require("opencode.promise").new(function(resolve, reject) - self:curl("/permission/" .. permission .. "/reply", "POST", { reply = reply }, resolve, reject) +---@return Promise +function Server:get_sessions() + local Promise = require("opencode.promise") + return self:request("/api/session", "GET"):next(function(response) + return Promise.resolve(response.data or {}) end) end ---@return Promise function Server:get_agents() - return require("opencode.promise").new(function(resolve, reject) - self:curl("/agent", "GET", nil, resolve, reject) + local Promise = require("opencode.promise") + return self:request("/api/agent", "GET"):next(function(response) + return Promise.resolve(response.data or {}) end) end ----@return Promise -function Server:get_sessions() - return require("opencode.promise").new(function(resolve, reject) - self:curl("/session", "GET", nil, resolve, reject) +---The ID of the currently active session, if any. +--- +---@return Promise +function Server:get_active_session() + local Promise = require("opencode.promise") + return self:request("/api/session/active", "GET"):next(function(response) + local active = response.data or {} + local id = next(active) + self.session_id = id + return Promise.resolve(id) + end) +end + +---Update and return the session the TUI is currently viewing, if discoverable. +---The TUI records its open tabs (per directory) in OpenCode's state directory. +--- +---@return string? +function Server:tui_current_session() + local state_home = vim.env.XDG_STATE_HOME + local dir = state_home and state_home ~= "" and vim.fs.joinpath(state_home, "opencode") + or vim.fs.joinpath(vim.env.HOME or "", ".local", "state", "opencode") + local path = vim.fs.joinpath(dir, "latest", "tui", "tabs.json") + local ok, lines = pcall(vim.fn.readfile, path) + if not ok or not lines or #lines == 0 then + return nil + end + local decoded_ok, decoded = pcall(vim.fn.json_decode, table.concat(lines, "\n")) + if not decoded_ok or type(decoded) ~= "table" or type(decoded.cwd) ~= "table" then + return nil + end + for _, directory in ipairs({ self.cwd, vim.fn.getcwd() }) do + local entry = directory and decoded.cwd[directory] + local tabs = entry and entry.tabs + local last = tabs and tabs[#tabs] + if last and last.sessionID then + return last.sessionID + end + end + return nil +end + +---Resolve the session to act on. +---Prefers the plugin's explicit panel session until the TUI catches up, then +---the session the TUI is currently viewing, then the most recently viewed +---(else updated) session. +--- +---@return Promise +function Server:resolve_session_id() + local Promise = require("opencode.promise") + if self.session_id then + return Promise.resolve(self.session_id) + end + + local from_tui = self:tui_current_session() + if from_tui and from_tui == Server.current_session_id then + -- The TUI now shows the panel session; trust it from here on. + Server._current_session_seen = true + end + + if Server.current_session_id and not Server._current_session_seen then + return Promise.resolve(Server.current_session_id) + end + + if from_tui then + return Promise.resolve(from_tui) + end + + if Server.current_session_id then + return Promise.resolve(Server.current_session_id) + end + + return self:get_sessions():next(function(sessions) + if #sessions == 0 then + return Promise.reject("No OpenCode sessions found") + end + table.sort(sessions, function(a, b) + local av = (a.time and (a.time.viewed or a.time.updated)) or 0 + local bv = (b.time and (b.time.viewed or b.time.updated)) or 0 + return av > bv + end) + return Promise.resolve(sessions[1].id) end) end ---@param session_id string +---@param text string ---@return Promise -function Server:select_session(session_id) - return require("opencode.promise").new(function(resolve, reject) - self:curl("/tui/select-session", "POST", { sessionID = session_id }, resolve, reject) - end) +function Server:send_prompt(session_id, text) + return self:request("/api/session/" .. session_id .. "/prompt", "POST", { text = text }) end ----@return Promise<{ directory: string, worktree: string }> -function Server:get_path() - return require("opencode.promise").new(function(resolve, reject) - self:curl("/path", "GET", nil, resolve, reject) - end) +---@param session_id string +---@param name string +---@param text string +---@return Promise +function Server:run_command(session_id, name, text) + return self:request("/api/session/" .. session_id .. "/command", "POST", { name = name, text = text }) +end + +---@param session_id string +---@return Promise +function Server:interrupt(session_id) + return self:request("/api/session/" .. session_id .. "/interrupt", "POST") +end + +---@param session_id string +---@return Promise +function Server:compact(session_id) + return self:request("/api/session/" .. session_id .. "/compact", "POST", {}) +end + +---@param session_id string +---@param agent string +---@return Promise +function Server:switch_agent(session_id, agent) + return self:request("/api/session/" .. session_id .. "/agent", "POST", { agent = agent }) +end + +---@return Promise +function Server:create_session() + return self:request("/api/session", "POST", {}) +end + +---@param session_id string +---@return Promise +function Server:revert_commit(session_id) + return self:request("/api/session/" .. session_id .. "/revert/commit", "POST") +end + +---@param session_id string +---@return Promise +function Server:revert_clear(session_id) + return self:request("/api/session/" .. session_id .. "/revert", "DELETE") +end + +---@param session_id string +---@param request_id string +---@param reply opencode.server.PermissionReply +---@return Promise +function Server:permit(session_id, request_id, reply) + return self:request("/api/session/" .. session_id .. "/permission/" .. request_id .. "/reply", "POST", { + decision = reply, + }) +end + +---Legacy TUI control: execute a built-in command (scroll, navigation, …). +---Only available when the server exposes `/tui/*` endpoints. +--- +---@param command string +---@return Promise +function Server:tui_execute_command(command) + return self:request("/tui/execute-command", "POST", { command = command }) +end + +---Legacy TUI control: append text to the TUI's prompt input. +---Only available when the server exposes `/tui/*` endpoints. +--- +---@param text string +---@return Promise +function Server:tui_append_prompt(text) + return self:request("/tui/append-prompt", "POST", { text = text }) end ---@param on_success fun(response: opencode.server.Event) Invoked with each received event. ---@param on_error fun(msg: string?, code: number) ---@return number job_id function Server:sse_subscribe(on_success, on_error) - return self:curl("/event", "GET", nil, on_success, on_error, { persistent = true }) + return self:curl("/api/event", "GET", nil, on_success, on_error, { persistent = true }) end ---How often OpenCode sends heartbeat events. @@ -352,7 +583,7 @@ function Server:connect() if response.type == "server.connected" then Server.connected = self resolve(self) - elseif response.type == "server.instance.disposed" then + elseif response.type == "global.disposed" or response.type == "location.shutdown" then self:disconnect() end diff --git a/lua/opencode/ui/select.lua b/lua/opencode/ui/select.lua index b96510af..704c8bc0 100644 --- a/lua/opencode/ui/select.lua +++ b/lua/opencode/ui/select.lua @@ -149,40 +149,10 @@ function M.select(context, opts) if choice.__type == "prompt" then return require("opencode.api.prompt").prompt(choice.text, context) elseif choice.__type == "command" then - if choice.name == "session.select" then - return require("opencode.ui.select_session").select_session(context.server):next(function(session) - return context.server:select_session(session.id) - end) - else - return require("opencode.api.command").command(choice.name, context.server) - end + return require("opencode.api.command").command(choice.name, context.server) elseif choice.__type == "server" then if choice.name == "server.connect" then - return require("opencode.server.discovery") - .locally() - :next(function(servers) - local configured = require("opencode.server.discovery").configured() - if configured then - return configured:next(function(configured_server) - if - not vim.tbl_contains(servers, function(local_server) - return local_server.url == configured_server.url - end, { predicate = true }) - then - table.insert(servers, 1, configured_server) - end - return Promise.resolve(servers) - end) - else - return Promise.resolve(servers) - end - end) - :next(function(servers) - return require("opencode.ui.select_server").select_server(servers) - end) - :next(function(selected_server) - return selected_server:connect() - end) + return require("opencode.server.discovery").get() elseif choice.name == "server.start" then return config.opts.server.start() elseif choice.name == "server.disconnect" then diff --git a/lua/opencode/ui/select_server.lua b/lua/opencode/ui/select_server.lua deleted file mode 100644 index f9a22bd4..00000000 --- a/lua/opencode/ui/select_server.lua +++ /dev/null @@ -1,62 +0,0 @@ -local M = {} - ----@param path1 string ----@param path2 string ----@return integer -local function common_prefix_score(path1, path2) - -- Normalize and split paths - local function split(path) - local out = {} - for seg in string.gmatch(path, "[^/]+") do - table.insert(out, seg) - end - return out - end - - local segments1 = split(path1) - local segments2 = split(path2) - local score = 0 - for i = 1, math.min(#segments1, #segments2) do - if segments1[i] == segments2[i] then - score = score + 1 - else - break - end - end - return score -end - ----Select an OpenCode server from the given list. ---- ----@param servers opencode.server.Server[] ----@return Promise -function M.select_server(servers) - local nvim_cwd = vim.fn.getcwd() - - -- Sort servers by common prefix overlap with Neovim's CWD - table.sort(servers, function(a, b) - local score_a = common_prefix_score(nvim_cwd, a.cwd) - local score_b = common_prefix_score(nvim_cwd, b.cwd) - if score_a == score_b then - return a.cwd < b.cwd -- fallback: alphabetical - end - return score_a > score_b - end) - - local picker_opts = { - prompt = "Select an OpenCode server:", - format_item = function(server) ---@param server opencode.server.Server - return string.format("%s | %s | %s", server:display_name(), server.cwd, server.title or "") - end, - snacks = { - layout = { - hidden = { "preview" }, - }, - }, - } - picker_opts = vim.tbl_deep_extend("keep", picker_opts, require("opencode.config").opts.select or {}) - - return require("opencode.promise.ui").select(servers, picker_opts) -end - -return M diff --git a/lua/opencode/ui/select_session.lua b/lua/opencode/ui/select_session.lua deleted file mode 100644 index 0bcfd4f4..00000000 --- a/lua/opencode/ui/select_session.lua +++ /dev/null @@ -1,33 +0,0 @@ -local M = {} - -local function ellipsize(s, max_len) - if vim.fn.strdisplaywidth(s) <= max_len then - return s - end - local truncated = vim.fn.strcharpart(s, 0, max_len - 3) - truncated = truncated:gsub("%s+%S*$", "") - - return truncated .. "..." -end - ----@param server opencode.server.Server ----@return Promise -function M.select_session(server) - return server:get_sessions():next(function(sessions) - table.sort(sessions, function(a, b) - return a.time.updated > b.time.updated - end) - - return require("opencode.promise.ui").select(sessions, { - prompt = "Select session: ", - format_item = function(item) - local title_length = 60 - local updated = os.date("%b %d, %Y %H:%M:%S", item.time.updated / 1000) - local title = ellipsize(item.title, title_length) - return ("%s%s%s"):format(title, string.rep(" ", title_length - #title), updated) - end, - }) - end) -end - -return M diff --git a/plugin/events/permissions/edits.lua b/plugin/events/permissions/edits.lua index 6abc25c8..59539dc4 100644 --- a/plugin/events/permissions/edits.lua +++ b/plugin/events/permissions/edits.lua @@ -17,7 +17,7 @@ vim.api.nvim_create_autocmd("User", { :next(function(server) return require("opencode.events.permissions.edits").diff(event):next(function(reply) if reply then - return server:permit(event.properties.id, reply) + return server:permit(event.data.sessionID, event.data.id, reply) end end) end) diff --git a/plugin/events/permissions/init.lua b/plugin/events/permissions/init.lua index 213b1fea..4c67dc7d 100644 --- a/plugin/events/permissions/init.lua +++ b/plugin/events/permissions/init.lua @@ -8,11 +8,7 @@ vim.api.nvim_create_autocmd("User", { local url = args.data.url local opts = require("opencode.config").opts.events.permissions or {} - if - not opts.enabled - or event.type ~= "permission.asked" - or (opts.edits.enabled and event.properties.permission == "edit") - then + if not opts.enabled or event.type ~= "permission.asked" or (opts.edits.enabled and event.data.action == "edit") then return end @@ -20,7 +16,7 @@ vim.api.nvim_create_autocmd("User", { .new(url) :next(function(server) return require("opencode.events.permissions").request(event):next(function(choice) - return server:permit(event.properties.id, choice) + return server:permit(event.data.sessionID, event.data.id, choice) end) end) :catch(function(err) diff --git a/plugin/events/reload.lua b/plugin/events/reload.lua index 1d56b195..9e679a94 100644 --- a/plugin/events/reload.lua +++ b/plugin/events/reload.lua @@ -1,6 +1,6 @@ vim.api.nvim_create_autocmd("User", { group = vim.api.nvim_create_augroup("OpencodeReload", { clear = true }), - pattern = "OpencodeEvent:file.edited", + pattern = "OpencodeEvent:filesystem.changed", callback = function() if require("opencode.config").opts.events.reload.enabled then -- `schedule` because blocking the event loop during rapid SSE influx can drop events diff --git a/plugin/events/status.lua b/plugin/events/status.lua index f4b81977..ee5a00ff 100644 --- a/plugin/events/status.lua +++ b/plugin/events/status.lua @@ -3,7 +3,9 @@ vim.api.nvim_create_autocmd("User", { pattern = { "OpencodeEvent:server.connected", "OpencodeEvent:session.status", - "OpencodeEvent:server.instance.disposed", + "OpencodeEvent:session.execution.failed", + "OpencodeEvent:global.disposed", + "OpencodeEvent:location.shutdown", }, callback = function(args) ---@type opencode.server.Event From 7ae4a0af1f936676ad6d969e6f3032e8791fbbd7 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Fri, 18 Sep 2026 13:10:53 +0200 Subject: [PATCH 2/8] fix: address PR review (v2 routing, discovery, panel) - Send `x-opencode-directory` on every request: v2 routes instance calls by directory, so a shared service would otherwise answer for its own cwd. - Return a rejected Promise from discovery when nothing is found, so the auto-start/poll path is reached instead of `nil:catch`. - Key panel session targets by server URL, avoiding a stale target across services; make `session.new` update the target. - Degrade TUI-only commands to a silent no-op on servers without `/tui/*`. - Fall back to the generic permission prompt when an edit has no diff preview. - Panel: honor configured credentials, bound the sync request with timeouts, and skip opening a local TUI when `server.url` is configured. --- lua/opencode.lua | 30 ++++++++++++++++---- lua/opencode/api/command.lua | 16 +++++++---- lua/opencode/events/permissions/edits.lua | 9 ++++++ lua/opencode/server/discovery/init.lua | 5 +++- lua/opencode/server/init.lua | 34 +++++++++++++---------- plugin/events/permissions/init.lua | 8 +++++- 6 files changed, 74 insertions(+), 28 deletions(-) diff --git a/lua/opencode.lua b/lua/opencode.lua index 1e318a52..463362ec 100644 --- a/lua/opencode.lua +++ b/lua/opencode.lua @@ -147,7 +147,7 @@ local function write_panel_sessions(sessions) pcall(vim.fn.writefile, { vim.fn.json_encode(sessions) }, panel_sessions_file()) end ----Perform a request against the registered OpenCode service, synchronously. +---Perform a bounded request against the registered OpenCode service, synchronously. --- ---@param method string ---@param path string @@ -159,21 +159,31 @@ local function service_request(method, path, body) return nil end + local server = require("opencode.config").opts.server or {} + local username = server.username or "opencode" + local password = info.password or server.password + local cmd = { "curl", "-s", "-S", "--fail-with-body", + "--connect-timeout", + "1", + "--max-time", + "3", "-X", method, "-H", "Content-Type: application/json", "-H", "Accept: application/json", + "-H", + "x-opencode-directory: " .. vim.uri_encode(vim.fn.getcwd()), } - if info.password and info.password ~= "" then - local token = vim.base64 and vim.base64.encode("opencode:" .. info.password) - or vim.trim(vim.fn.system({ "base64" }, "opencode:" .. info.password):gsub("%s+", "")) + if password and password ~= "" then + local token = vim.base64 and vim.base64.encode(username .. ":" .. password) + or vim.trim(vim.fn.system({ "base64" }, username .. ":" .. password):gsub("%s+", "")) table.insert(cmd, "-H") table.insert(cmd, "Authorization: Basic " .. token) end @@ -227,6 +237,12 @@ function M.open() return end + -- The panel is a local TUI attached to the local background service. When a + -- URL is configured (e.g. a remote server), leave it alone (PR #330 review). + if require("opencode.config").opts.server.url ~= nil then + return + end + local origin = vim.api.nvim_get_current_win() vim.cmd("botright vsplit") if tui_alive() and tui_buf then @@ -234,8 +250,10 @@ function M.open() else local session_id = panel_session() if session_id then - require("opencode.server").current_session_id = session_id - require("opencode.server")._current_session_seen = false + local info = require("opencode.server.discovery").registration() + if info then + require("opencode.server").panel_targets[info.url] = { id = session_id, seen = false } + end vim.cmd("terminal opencode --session " .. session_id) else vim.cmd("terminal opencode") diff --git a/lua/opencode/api/command.lua b/lua/opencode/api/command.lua index e9064244..7735c935 100644 --- a/lua/opencode/api/command.lua +++ b/lua/opencode/api/command.lua @@ -66,14 +66,21 @@ end --- ---Commands map onto v2 HTTP API calls where possible; TUI-only commands ---(scroll, navigation, prompt box) require the legacy `/tui/*` endpoints and ----reject with a clear message when the connected server does not expose them. +---degrade to a silent no-op when the connected server does not expose them. --- ---@param command opencode.server.Command | string ---@param server opencode.server.Server ---@return Promise function M.command(command, server) if command == "session.new" then - return server:create_session() + return server:create_session():next(function(created) + local id = created and created.data and created.data.id + if id then + -- Make the new session the target for subsequent prompts. + require("opencode.server").panel_targets[server.url] = { id = id, seen = false } + end + return Promise.resolve(created) + end) elseif command == "session.interrupt" then return server:resolve_session_id():next(function(session_id) return server:interrupt(session_id) @@ -88,9 +95,8 @@ function M.command(command, server) if TUI_ONLY[command] then if not server.tui then - return Promise.reject( - "`" .. command .. "` requires the OpenCode TUI control endpoints, which this server does not expose." - ) + -- No `/tui/*` on OpenCode v2.0.x: these have no API equivalent. + return Promise.resolve(nil) end return server:tui_execute_command(command):next(function() if command == "session.interrupt" then diff --git a/lua/opencode/events/permissions/edits.lua b/lua/opencode/events/permissions/edits.lua index 5cd35b32..c7aab702 100644 --- a/lua/opencode/events/permissions/edits.lua +++ b/lua/opencode/events/permissions/edits.lua @@ -28,6 +28,15 @@ local function edit_preview(event) return { diff = file.patch, filepath = file.file or event.data.resources[1] } end +---Whether an edit permission carries a diff preview we can display. +---Handlers use this to fall back to the generic permission prompt when it does not. +--- +---@param event opencode.server.Event +---@return boolean +function M.has_preview(event) + return edit_preview(event) ~= nil +end + ---@param event opencode.server.Event ---@return Promise function M.diff(event) diff --git a/lua/opencode/server/discovery/init.lua b/lua/opencode/server/discovery/init.lua index a82b4acc..917fa8f6 100644 --- a/lua/opencode/server/discovery/init.lua +++ b/lua/opencode/server/discovery/init.lua @@ -48,7 +48,10 @@ local function find() local Promise = require("opencode.promise") local connected_server = require("opencode.server").connected - return connected_server and Promise.resolve(connected_server) or M.configured() or M.registered() + return connected_server and Promise.resolve(connected_server) + or M.configured() + or M.registered() + or Promise.reject("No OpenCode server found") end ---Look for an OpenCode server every second, rejecting if not found after five seconds. diff --git a/lua/opencode/server/init.lua b/lua/opencode/server/init.lua index 5c663f60..90c4175f 100644 --- a/lua/opencode/server/init.lua +++ b/lua/opencode/server/init.lua @@ -94,15 +94,10 @@ Server.__index = Server ---@type opencode.server.Credentials? Server.credentials = nil ----The session the plugin is currently targeting — set when opening the TUI panel, ----so prompts reach it without racing the TUI's `tabs.json` write. ----@type string? -Server.current_session_id = nil ----Whether `tabs.json` has caught up with `current_session_id`. Until it has, the ----explicit target wins; afterwards the TUI is authoritative again (so session ----switches inside the TUI are honored). ----@type boolean -Server._current_session_seen = false +---Panel session targets, keyed by server URL so a target is never reused against +---a different service. `seen` flips once the TUI's `tabs.json` catches up. +---@type table +Server.panel_targets = {} ---Attempt to connect to an OpenCode server and fetch its health and details. ---Rejects if the health fails — the last line of defense against false-positive server discovery. @@ -231,6 +226,14 @@ function Server:curl(path, method, body, on_success, on_error, opts) table.insert(cmd, "Authorization: " .. authorization) end + -- OpenCode v2 routes instance requests to a project by directory. Without it, a + -- shared service answers for its own ambient cwd (PR #330 review). + local directory = self.cwd or vim.fn.getcwd() + if directory and directory ~= "" then + table.insert(cmd, "-H") + table.insert(cmd, "x-opencode-directory: " .. vim.uri_encode(directory)) + end + if not opts.persistent then table.insert(cmd, "--max-time") table.insert(cmd, opts.max_time or 2) @@ -428,22 +431,23 @@ function Server:resolve_session_id() return Promise.resolve(self.session_id) end + local target = Server.panel_targets[self.url] local from_tui = self:tui_current_session() - if from_tui and from_tui == Server.current_session_id then + if target and from_tui and from_tui == target.id then -- The TUI now shows the panel session; trust it from here on. - Server._current_session_seen = true + target.seen = true end - if Server.current_session_id and not Server._current_session_seen then - return Promise.resolve(Server.current_session_id) + if target and not target.seen then + return Promise.resolve(target.id) end if from_tui then return Promise.resolve(from_tui) end - if Server.current_session_id then - return Promise.resolve(Server.current_session_id) + if target then + return Promise.resolve(target.id) end return self:get_sessions():next(function(sessions) diff --git a/plugin/events/permissions/init.lua b/plugin/events/permissions/init.lua index 4c67dc7d..ed688970 100644 --- a/plugin/events/permissions/init.lua +++ b/plugin/events/permissions/init.lua @@ -8,7 +8,13 @@ vim.api.nvim_create_autocmd("User", { local url = args.data.url local opts = require("opencode.config").opts.events.permissions or {} - if not opts.enabled or event.type ~= "permission.asked" or (opts.edits.enabled and event.data.action == "edit") then + -- Only defer to the edit-preview handler when it can actually render a diff; + -- otherwise fall back to the generic prompt so the request is not left pending. + local handled_as_edit = opts.edits + and opts.edits.enabled + and event.data.action == "edit" + and require("opencode.events.permissions.edits").has_preview(event) + if not opts.enabled or event.type ~= "permission.asked" or handled_as_edit then return end From 2875ae6e3e74b77c6c4127469ad55672253eee94 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Fri, 18 Sep 2026 13:32:16 +0200 Subject: [PATCH 3/8] fix: address second review pass - Defer multi-file edit permissions to the generic prompt: only files[1] was previewed while the reply covered the whole request. - Key the discovered-credentials cache by normalized URL so one service's password is never sent to another host. --- lua/opencode/events/permissions/edits.lua | 8 +++++++- lua/opencode/server/init.lua | 21 ++++++++++++--------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/lua/opencode/events/permissions/edits.lua b/lua/opencode/events/permissions/edits.lua index c7aab702..2056be51 100644 --- a/lua/opencode/events/permissions/edits.lua +++ b/lua/opencode/events/permissions/edits.lua @@ -20,7 +20,13 @@ local function edit_preview(event) end local files = event.data.metadata and event.data.metadata.files - local file = files and files[1] + -- A multi-file edit only shows files[1] while the reply covers the whole + -- request, so approve/reject blindly on the rest. Defer those to the generic + -- permission prompt instead. + if not files or #files ~= 1 then + return nil + end + local file = files[1] if not file or not file.patch then return nil end diff --git a/lua/opencode/server/init.lua b/lua/opencode/server/init.lua index 90c4175f..4e53f856 100644 --- a/lua/opencode/server/init.lua +++ b/lua/opencode/server/init.lua @@ -88,11 +88,12 @@ Server.__index = Server ---| { id: string, type: "session.created", data: table } ---| { id: string, type: string, data: table } ----Credentials for the most recently discovered server. +---Credentials for discovered servers, keyed by normalized URL. ---OpenCode v2 always requires basic auth, and event listeners reconstruct server ----objects from a bare URL, so cache the discovered credentials for reuse. ----@type opencode.server.Credentials? -Server.credentials = nil +---objects from a bare URL, so cache the discovered credentials for reuse — +---keyed so one service's password is never sent to another host. +---@type table +Server.credentials = {} ---Panel session targets, keyed by server URL so a target is never reused against ---a different service. `seen` flips once the TUI's `tabs.json` catches up. @@ -108,13 +109,15 @@ Server.panel_targets = {} ---@return Promise function Server.new(url, credentials) local self = setmetatable({}, Server) + url = url:gsub("/$", "") if credentials then - Server.credentials = credentials + Server.credentials[url] = credentials end - local creds = credentials or Server.credentials or {} - self.url = url:gsub("/$", "") - self.username = creds.username or require("opencode.config").opts.server.username or "opencode" - self.password = creds.password or require("opencode.config").opts.server.password + local config = require("opencode.config").opts.server or {} + local creds = credentials or Server.credentials[url] or {} + self.url = url + self.username = creds.username or config.username or "opencode" + self.password = creds.password or config.password self.heartbeat_timer = vim.uv.new_timer() local Promise = require("opencode.promise") From 22fdb1699e8b7815ad205c89c4c93201bebf8b1f Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Fri, 18 Sep 2026 22:36:03 +0200 Subject: [PATCH 4/8] fix(server): prefer Neovim cwd over service directory for routing The x-opencode-directory header was using the server's own directory (from /api/location) instead of Neovim's working directory, causing requests to be routed to the service's ambient cwd (often HOME) instead of the project the user is working in. --- lua/opencode/server/init.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/opencode/server/init.lua b/lua/opencode/server/init.lua index 4e53f856..70b3728f 100644 --- a/lua/opencode/server/init.lua +++ b/lua/opencode/server/init.lua @@ -134,7 +134,7 @@ function Server.new(url, credentials) end) :next( function(results) ---@param results { [1]: { directory: string }, [2]: opencode.server.Session[], [3]: opencode.server.Agent[] } - self.cwd = results[1].directory or vim.fn.getcwd() + self.cwd = vim.fn.getcwd() or results[1].directory self.title = results[2][1] and results[2][1].title or "" self.subagents = vim.tbl_filter(function(agent) ---@param agent opencode.server.Agent return agent.mode == "subagent" @@ -231,7 +231,7 @@ function Server:curl(path, method, body, on_success, on_error, opts) -- OpenCode v2 routes instance requests to a project by directory. Without it, a -- shared service answers for its own ambient cwd (PR #330 review). - local directory = self.cwd or vim.fn.getcwd() + local directory = vim.fn.getcwd() or self.cwd if directory and directory ~= "" then table.insert(cmd, "-H") table.insert(cmd, "x-opencode-directory: " .. vim.uri_encode(directory)) From e867d4b19a28b1f9bfb346b9e1b204792c793bb2 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Sat, 19 Sep 2026 17:47:05 +0200 Subject: [PATCH 5/8] fix(panel): keep sessions in the project directory OpenCode v2 (2.0.x) ignores the directory header/query on /api/session and always creates a new session in the shared service's ambient cwd (often the home directory). Pre-creating a panel session through that API was therefore the cause of prompts running in ~ instead of the project. - open(): run the TUI in the Neovim working directory instead of attaching to an API-created session, so opencode manages the project's own sessions. - resolve_session_id(): scope the resolved session to the current directory, preferring the TUI's per-directory tab, then the most recent session whose location matches the cwd. - Remove the now-unused panel-session cache and panel_targets tracking. - config(): server.start no longer opens a second TUI panel on startup. --- lua/opencode.lua | 121 +++-------------------------------- lua/opencode/api/command.lua | 5 -- lua/opencode/config.lua | 6 +- lua/opencode/server/init.lua | 49 +++++++------- 4 files changed, 34 insertions(+), 147 deletions(-) diff --git a/lua/opencode.lua b/lua/opencode.lua index 463362ec..5a5b6320 100644 --- a/lua/opencode.lua +++ b/lua/opencode.lua @@ -126,112 +126,13 @@ local function tui_alive() return tui_buf ~= nil and vim.api.nvim_buf_is_valid(tui_buf) and (vim.bo[tui_buf].channel or 0) > 0 end ----Path to the file caching one panel session ID per working directory. ----@return string -local function panel_sessions_file() - return vim.fs.joinpath(vim.fn.stdpath("state"), "opencode.nvim-panel-sessions.json") -end - ----@return table -local function read_panel_sessions() - local path = panel_sessions_file() - if vim.fn.filereadable(path) ~= 1 then - return {} - end - local ok, decoded = pcall(vim.fn.json_decode, table.concat(vim.fn.readfile(path), "\n")) - return ok and type(decoded) == "table" and decoded or {} -end - ----@param sessions table -local function write_panel_sessions(sessions) - pcall(vim.fn.writefile, { vim.fn.json_encode(sessions) }, panel_sessions_file()) -end - ----Perform a bounded request against the registered OpenCode service, synchronously. ---- ----@param method string ----@param path string ----@param body? string ----@return table? -local function service_request(method, path, body) - local info = require("opencode.server.discovery").registration() - if not info then - return nil - end - - local server = require("opencode.config").opts.server or {} - local username = server.username or "opencode" - local password = info.password or server.password - - local cmd = { - "curl", - "-s", - "-S", - "--fail-with-body", - "--connect-timeout", - "1", - "--max-time", - "3", - "-X", - method, - "-H", - "Content-Type: application/json", - "-H", - "Accept: application/json", - "-H", - "x-opencode-directory: " .. vim.uri_encode(vim.fn.getcwd()), - } - if password and password ~= "" then - local token = vim.base64 and vim.base64.encode(username .. ":" .. password) - or vim.trim(vim.fn.system({ "base64" }, username .. ":" .. password):gsub("%s+", "")) - table.insert(cmd, "-H") - table.insert(cmd, "Authorization: Basic " .. token) - end - if body then - table.insert(cmd, "-d") - table.insert(cmd, body) - end - table.insert(cmd, info.url .. path) - - local out = vim.fn.system(cmd) - if vim.v.shell_error ~= 0 or out == "" then - return nil - end - local ok, decoded = pcall(vim.fn.json_decode, out) - return ok and type(decoded) == "table" and decoded or nil -end - ----Return the panel's session, reusing this directory's cached one while it still ----exists. Keyed per working directory so concurrent Neovim instances (and ----projects) each get their own panel session. ---- ----@return string? -local function panel_session() - local cwd = vim.fn.getcwd() - local sessions = read_panel_sessions() - local cached = sessions[cwd] - - if cached and cached ~= "" then - local existing = service_request("GET", "/api/session/" .. cached) - if existing and existing.data then - return cached - end - end - - local created = service_request("POST", "/api/session", "{}") - local id = created and created.data and created.data.id - if id then - sessions[cwd] = id - write_panel_sessions(sessions) - end - return id -end - ---Show the OpenCode TUI panel on the right. --- ---Opens `opencode` in a right-hand split, reusing the existing terminal when one ----is already running. A dedicated session is created so prompts from Neovim reach ----this panel instead of whatever session the TUI would otherwise resume. +---is already running. The TUI runs in the Neovim working directory so opencode +---creates and manages sessions for THIS project — the server's `/api/session` +---cannot place a new session in a target directory, so prompts target the +---project's existing sessions instead. function M.open() if tui_win and vim.api.nvim_win_is_valid(tui_win) then return @@ -248,16 +149,10 @@ function M.open() if tui_alive() and tui_buf then vim.api.nvim_win_set_buf(0, tui_buf) else - local session_id = panel_session() - if session_id then - local info = require("opencode.server.discovery").registration() - if info then - require("opencode.server").panel_targets[info.url] = { id = session_id, seen = false } - end - vim.cmd("terminal opencode --session " .. session_id) - else - vim.cmd("terminal opencode") - end + -- Run the TUI in the Neovim working directory. The terminal inherits nvim's + -- cwd, so opencode builds sessions for this project; prompts then target + -- those project sessions (see :resolve_session_id). + vim.cmd("terminal opencode") tui_buf = vim.api.nvim_get_current_buf() end tui_win = vim.api.nvim_get_current_win() diff --git a/lua/opencode/api/command.lua b/lua/opencode/api/command.lua index 7735c935..3603891a 100644 --- a/lua/opencode/api/command.lua +++ b/lua/opencode/api/command.lua @@ -74,11 +74,6 @@ end function M.command(command, server) if command == "session.new" then return server:create_session():next(function(created) - local id = created and created.data and created.data.id - if id then - -- Make the new session the target for subsequent prompts. - require("opencode.server").panel_targets[server.url] = { id = id, seen = false } - end return Promise.resolve(created) end) elseif command == "session.interrupt" then diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 410b9d1d..4d4ccad5 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -25,10 +25,10 @@ local defaults = { password = vim.env.OPENCODE_SERVER_PASSWORD, start = function() -- OpenCode v2 runs a background service that the TUI attaches to. Start it - -- first so the plugin can discover it via the service registration file, - -- then open a TUI connected to the same service. + -- so the plugin can discover it via the service registration file. The + -- panel TUI is opened separately by `open()`/`toggle()`; opening another + -- terminal here would produce a second panel alongside it. vim.fn.system({ "opencode", "service", "start" }) - vim.cmd("vsplit term://opencode | wincmd p") end, }, contexts = { diff --git a/lua/opencode/server/init.lua b/lua/opencode/server/init.lua index 70b3728f..5125b04d 100644 --- a/lua/opencode/server/init.lua +++ b/lua/opencode/server/init.lua @@ -52,7 +52,8 @@ Server.__index = Server ---@class opencode.server.Session ---@field id string ---@field title string ----@field time { created: integer, updated: integer } +---@field location? { directory: string } +---@field time { created: integer, updated: integer, viewed?: integer } ---@class opencode.server.Agent ---@field name string @@ -95,11 +96,6 @@ Server.__index = Server ---@type table Server.credentials = {} ----Panel session targets, keyed by server URL so a target is never reused against ----a different service. `seen` flips once the TUI's `tabs.json` catches up. ----@type table -Server.panel_targets = {} - ---Attempt to connect to an OpenCode server and fetch its health and details. ---Rejects if the health fails — the last line of defense against false-positive server discovery. ---Rejection message is non-empty if from a valid OpenCode server. @@ -423,9 +419,15 @@ function Server:tui_current_session() end ---Resolve the session to act on. ----Prefers the plugin's explicit panel session until the TUI catches up, then ----the session the TUI is currently viewing, then the most recently viewed ----(else updated) session. +--- +---OpenCode v2 (2.0.x) cannot create a new session in a target directory via +---`/api/session` — it always lands in the shared service's ambient cwd (often +---the home directory). To keep prompts operating on the project Neovim is in, +---the resolved session is scoped to the current working directory: +--- +---1. The TUI's current session for this directory (its per-directory tabs). +---2. The most recently viewed/updated existing session in this directory. +---3. As a last resort, the most recently viewed/updated session anywhere. --- ---@return Promise function Server:resolve_session_id() @@ -434,35 +436,30 @@ function Server:resolve_session_id() return Promise.resolve(self.session_id) end - local target = Server.panel_targets[self.url] local from_tui = self:tui_current_session() - if target and from_tui and from_tui == target.id then - -- The TUI now shows the panel session; trust it from here on. - target.seen = true - end - - if target and not target.seen then - return Promise.resolve(target.id) - end - if from_tui then return Promise.resolve(from_tui) end - if target then - return Promise.resolve(target.id) - end - + local cwd = vim.fn.getcwd() return self:get_sessions():next(function(sessions) - if #sessions == 0 then + local project = {} + for _, session in ipairs(sessions) do + local directory = session and session.location and session.location.directory + if directory == cwd then + table.insert(project, session) + end + end + local pool = #project > 0 and project or sessions + if #pool == 0 then return Promise.reject("No OpenCode sessions found") end - table.sort(sessions, function(a, b) + table.sort(pool, function(a, b) local av = (a.time and (a.time.viewed or a.time.updated)) or 0 local bv = (b.time and (b.time.viewed or b.time.updated)) or 0 return av > bv end) - return Promise.resolve(sessions[1].id) + return Promise.resolve(pool[1].id) end) end From 44e9eb3b83e152d86bc9ff227de94cf91c00eb90 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Sat, 19 Sep 2026 17:52:32 +0200 Subject: [PATCH 6/8] fix(panel): resume the last open tab instead of a fresh session When opencode has already recorded open session tabs for the working directory (tabs.json), launching the bare TUI presented a fresh session next to them. Open the TUI on the last open tab so tabs restore without creating a new session. --- lua/opencode.lua | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/lua/opencode.lua b/lua/opencode.lua index 5a5b6320..17bf52ae 100644 --- a/lua/opencode.lua +++ b/lua/opencode.lua @@ -126,6 +126,29 @@ local function tui_alive() return tui_buf ~= nil and vim.api.nvim_buf_is_valid(tui_buf) and (vim.bo[tui_buf].channel or 0) > 0 end +---The last open session tab opencode recorded for the current working directory, +---if any. Launching the TUI on that tab returns to already-open tabs instead of +---presenting a fresh session alongside them. +--- +---@return string? +local function panel_open_session() + local state_home = vim.env.XDG_STATE_HOME + local dir = state_home and state_home ~= "" and vim.fs.joinpath(state_home, "opencode") + or vim.fs.joinpath(vim.env.HOME or "", ".local", "state", "opencode") + local path = vim.fs.joinpath(dir, "latest", "tui", "tabs.json") + local ok, lines = pcall(vim.fn.readfile, path) + if not ok or not lines or #lines == 0 then + return nil + end + local decoded_ok, decoded = pcall(vim.fn.json_decode, table.concat(lines, "\n")) + if not decoded_ok or type(decoded) ~= "table" or type(decoded.cwd) ~= "table" then + return nil + end + local tabs = decoded.cwd[vim.fn.getcwd()] and decoded.cwd[vim.fn.getcwd()].tabs + local last = tabs and tabs[#tabs] + return last and last.sessionID or nil +end + ---Show the OpenCode TUI panel on the right. --- ---Opens `opencode` in a right-hand split, reusing the existing terminal when one @@ -151,8 +174,14 @@ function M.open() else -- Run the TUI in the Neovim working directory. The terminal inherits nvim's -- cwd, so opencode builds sessions for this project; prompts then target - -- those project sessions (see :resolve_session_id). - vim.cmd("terminal opencode") + -- those project sessions (see :resolve_session_id). When the TUI already has + -- open tabs here, resume the last one instead of opening a fresh session. + local session_id = panel_open_session() + if session_id and session_id ~= "" then + vim.cmd("terminal opencode --session " .. session_id) + else + vim.cmd("terminal opencode") + end tui_buf = vim.api.nvim_get_current_buf() end tui_win = vim.api.nvim_get_current_win() From e05e253b85c7ed7bf593d4156633198fd87254ee Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Sat, 19 Sep 2026 17:58:21 +0200 Subject: [PATCH 7/8] refactor: share tabs.json state helpers; drop dead session short-circuit - Add lua/opencode/util/state.lua exposing the opencode state dir and the last open session tab for a directory, removing duplicated parsing of tabs.json in open() and Server:tui_current_session(). - Drop the unused self.session_id short-circuit in resolve_session_id() so a non-project active session can never bypass the directory-scoped resolution. --- lua/opencode.lua | 25 +------------------------ lua/opencode/server/init.lua | 25 ++++--------------------- lua/opencode/util/state.lua | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 45 deletions(-) create mode 100644 lua/opencode/util/state.lua diff --git a/lua/opencode.lua b/lua/opencode.lua index 17bf52ae..f37d8dcc 100644 --- a/lua/opencode.lua +++ b/lua/opencode.lua @@ -126,29 +126,6 @@ local function tui_alive() return tui_buf ~= nil and vim.api.nvim_buf_is_valid(tui_buf) and (vim.bo[tui_buf].channel or 0) > 0 end ----The last open session tab opencode recorded for the current working directory, ----if any. Launching the TUI on that tab returns to already-open tabs instead of ----presenting a fresh session alongside them. ---- ----@return string? -local function panel_open_session() - local state_home = vim.env.XDG_STATE_HOME - local dir = state_home and state_home ~= "" and vim.fs.joinpath(state_home, "opencode") - or vim.fs.joinpath(vim.env.HOME or "", ".local", "state", "opencode") - local path = vim.fs.joinpath(dir, "latest", "tui", "tabs.json") - local ok, lines = pcall(vim.fn.readfile, path) - if not ok or not lines or #lines == 0 then - return nil - end - local decoded_ok, decoded = pcall(vim.fn.json_decode, table.concat(lines, "\n")) - if not decoded_ok or type(decoded) ~= "table" or type(decoded.cwd) ~= "table" then - return nil - end - local tabs = decoded.cwd[vim.fn.getcwd()] and decoded.cwd[vim.fn.getcwd()].tabs - local last = tabs and tabs[#tabs] - return last and last.sessionID or nil -end - ---Show the OpenCode TUI panel on the right. --- ---Opens `opencode` in a right-hand split, reusing the existing terminal when one @@ -176,7 +153,7 @@ function M.open() -- cwd, so opencode builds sessions for this project; prompts then target -- those project sessions (see :resolve_session_id). When the TUI already has -- open tabs here, resume the last one instead of opening a fresh session. - local session_id = panel_open_session() + local session_id = require("opencode.util.state").last_tab_session(vim.fn.getcwd()) if session_id and session_id ~= "" then vim.cmd("terminal opencode --session " .. session_id) else diff --git a/lua/opencode/server/init.lua b/lua/opencode/server/init.lua index 5125b04d..8de2a556 100644 --- a/lua/opencode/server/init.lua +++ b/lua/opencode/server/init.lua @@ -395,24 +395,11 @@ end --- ---@return string? function Server:tui_current_session() - local state_home = vim.env.XDG_STATE_HOME - local dir = state_home and state_home ~= "" and vim.fs.joinpath(state_home, "opencode") - or vim.fs.joinpath(vim.env.HOME or "", ".local", "state", "opencode") - local path = vim.fs.joinpath(dir, "latest", "tui", "tabs.json") - local ok, lines = pcall(vim.fn.readfile, path) - if not ok or not lines or #lines == 0 then - return nil - end - local decoded_ok, decoded = pcall(vim.fn.json_decode, table.concat(lines, "\n")) - if not decoded_ok or type(decoded) ~= "table" or type(decoded.cwd) ~= "table" then - return nil - end + local tabs = require("opencode.util.state") for _, directory in ipairs({ self.cwd, vim.fn.getcwd() }) do - local entry = directory and decoded.cwd[directory] - local tabs = entry and entry.tabs - local last = tabs and tabs[#tabs] - if last and last.sessionID then - return last.sessionID + local session_id = directory and tabs.last_tab_session(directory) + if session_id then + return session_id end end return nil @@ -432,10 +419,6 @@ end ---@return Promise function Server:resolve_session_id() local Promise = require("opencode.promise") - if self.session_id then - return Promise.resolve(self.session_id) - end - local from_tui = self:tui_current_session() if from_tui then return Promise.resolve(from_tui) diff --git a/lua/opencode/util/state.lua b/lua/opencode/util/state.lua new file mode 100644 index 00000000..ebb74bd7 --- /dev/null +++ b/lua/opencode/util/state.lua @@ -0,0 +1,35 @@ +---Shared helpers for reading OpenCode's on-disk state. +local M = {} + +---Resolve OpenCode's state directory, honoring `$XDG_STATE_HOME`. +--- +---@return string +function M.state_dir() + local state_home = vim.env.XDG_STATE_HOME + if state_home and state_home ~= "" then + return vim.fs.joinpath(state_home, "opencode") + end + return vim.fs.joinpath(vim.env.HOME or "", ".local", "state", "opencode") +end + +---The last open session tab opencode recorded for a directory in its per-directory +---tabs, if any. +--- +---@param directory string +---@return string? +function M.last_tab_session(directory) + local path = vim.fs.joinpath(M.state_dir(), "latest", "tui", "tabs.json") + local ok, lines = pcall(vim.fn.readfile, path) + if not ok or not lines or #lines == 0 then + return nil + end + local decoded_ok, decoded = pcall(vim.fn.json_decode, table.concat(lines, "\n")) + if not decoded_ok or type(decoded) ~= "table" or type(decoded.cwd) ~= "table" then + return nil + end + local tabs = decoded.cwd[directory] and decoded.cwd[directory].tabs + local last = tabs and tabs[#tabs] + return last and last.sessionID or nil +end + +return M From 19082f564361ac59d0606edd98fbd22f7f24b2b0 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Sat, 19 Sep 2026 17:59:22 +0200 Subject: [PATCH 8/8] chore: document leftover session-directory TODOs --- lua/opencode.lua | 3 +++ lua/opencode/api/command.lua | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/lua/opencode.lua b/lua/opencode.lua index f37d8dcc..147f1ba7 100644 --- a/lua/opencode.lua +++ b/lua/opencode.lua @@ -155,6 +155,9 @@ function M.open() -- open tabs here, resume the last one instead of opening a fresh session. local session_id = require("opencode.util.state").last_tab_session(vim.fn.getcwd()) if session_id and session_id ~= "" then + -- TODO: a tab recorded before the project-directory fix could point at a + -- ~ session, and a stale/deleted tab id makes `--session` fail. Consider + -- validating that the session still exists and lives in the cwd here. vim.cmd("terminal opencode --session " .. session_id) else vim.cmd("terminal opencode") diff --git a/lua/opencode/api/command.lua b/lua/opencode/api/command.lua index 3603891a..4881fda9 100644 --- a/lua/opencode/api/command.lua +++ b/lua/opencode/api/command.lua @@ -73,6 +73,10 @@ end ---@return Promise function M.command(command, server) if command == "session.new" then + -- TODO: `create_session()` POSTs /api/session, which on the shared service + -- always creates the session in the service's ambient cwd (often ~) — the + -- same limitation as the retired panel session. Revisit once opencode lets + -- the API place a session in a target directory. return server:create_session():next(function(created) return Promise.resolve(created) end)