Conversation
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
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical issues affect discovery, project routing, authentication, session targeting, and permission handling.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Ports the plugin to OpenCode v2’s authenticated background service, /api endpoints, new event model, and optional TUI panel.
Changes:
- Replaces process scanning with registration-based discovery and Basic authentication.
- Migrates sessions, prompts, commands, permissions, and events to v2.
- Adds TUI panel support, health checks, configuration, and documentation updates.
File summaries
| File | Summary |
|---|---|
README.md |
Documents v2 setup, authentication, commands, and events. |
plugin/events/status.lua |
Updates status event subscriptions. |
plugin/events/reload.lua |
Handles filesystem change events. |
plugin/events/permissions/init.lua |
Updates permission replies. |
plugin/events/permissions/edits.lua |
Updates edit permission replies. |
lua/opencode/ui/select.lua |
Simplifies server and command selection. |
lua/opencode/ui/select_session.lua |
Removes the obsolete session picker. |
lua/opencode/ui/select_server.lua |
Removes the obsolete server picker. |
lua/opencode/server/init.lua |
Implements the v2 API, authentication, SSE, and sessions. |
lua/opencode/server/discovery/process/windows.lua |
Removes Windows process discovery. |
lua/opencode/server/discovery/process/unix.lua |
Removes Unix process discovery. |
lua/opencode/server/discovery/process/init.lua |
Removes the process discovery abstraction. |
lua/opencode/server/discovery/init.lua |
Adds registration-based service discovery. |
lua/opencode/health.lua |
Updates v2 health checks. |
lua/opencode/events/status.lua |
Handles v2 status events. |
lua/opencode/events/permissions/init.lua |
Formats v2 permission prompts. |
lua/opencode/events/permissions/edits.lua |
Handles v2 edit patches. |
lua/opencode/config.lua |
Starts the v2 service and TUI. |
lua/opencode/api/prompt.lua |
Sends prompts through v2 sessions. |
lua/opencode/api/command.lua |
Maps commands to v2 APIs. |
lua/opencode.lua |
Adds TUI panel management and session caching. |
AGENTS.md |
Updates architecture and dependency guidance. |
Review details
Suppressed comments (4)
lua/opencode.lua:235
- The per-directory cache is bypassed once a terminal is alive: after changing Neovim's cwd, this branch reuses the old TUI process and never updates
current_session_id. Prompts from the new directory can consequently continue targeting the previous directory's panel session, contrary to the per-working-directory behavior documented above.
if tui_alive() and tui_buf then
vim.api.nvim_win_set_buf(0, tui_buf)
else
local session_id = panel_session()
lua/opencode/events/permissions/edits.lua:23
metadata.filesis an array, but the preview always selects onlyfiles[1]. Approving or rejecting the permission applies to the whole request, so any additional file patches are silently omitted from the review and can be accepted without being shown. Render every file in the request or avoid treating a multi-file request as a single-file diff.
local file = files and files[1]
lua/opencode/server/init.lua:510
- The v2 route for clearing a staged revert is
POST /api/session/{id}/revert/clear; this method currently callsDELETE /api/session/{id}/revert, which is not the v2 endpoint and will fail whenever the method is used.
function Server:revert_clear(session_id)
return self:request("/api/session/" .. session_id .. "/revert", "DELETE")
lua/opencode/server/init.lua:309
- The new empty-body success callback also runs for the persistent SSE job. If the server closes
/api/eventcleanly without a final event,connect()receivesniland immediately evaluatesresponse.type, causing the subscription callback to error instead of disconnecting cleanly. Only resolve an empty response for non-persistent requests.
elseif on_success then
-- Empty success body (e.g. 204 No Content from DELETE): resolve anyway.
vim.schedule(function()
on_success(nil)
end)
- Files reviewed: 22/22 changed files
- Comments generated: 11
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- 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.
Known limitation: session tabs can redirect promptsWorth flagging (not a blocker): the TUI panel this PR opens can end up showing — and switching between — sessions you didn't intend. What happens
Why it matters here The plugin targets the session the TUI is showing ( Workaround today Disable the tab strip in the TUI: A panel-scoped config would be ideal, but Possible directions (future)
Happy to adjust scope if you'd rather the plugin not open a panel at all, or to drop this from the PR and track it separately. |
There was a problem hiding this comment.
🟡 Changes recommended
Critical API, authentication, permission, and session-targeting defects remain unresolved.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (12)
Previously missed (1) — in code that hasn't changed since the last review.
lua/opencode/ui/select.lua:155
- When
server.connect = false(the documented manual-connection mode),discovery.get()returns the server without callingconnect()becauseM.get()honors that option. Consequently, selecting “Connect to a server” still leaves the server disconnected; this branch should explicitly connect the returned server.
lua/opencode.lua:196
M.open()is called synchronously at the start ofask(),select(),prompt(), andcommand(), so thisvim.fn.system()runs on Neovim's main loop before asynchronous discovery can proceed. A stale or unreachable registration can block the editor for up to the three-second curl timeout (and potentially once per panel request); use an async job/Promise or defer panel creation until discovery completes.
local out = vim.fn.system(cmd)
lua/opencode.lua:259
- These fallback branches start
opencodedirectly, butM.open()is now invoked by every public operation before discovery. That bypassesopts.server.start(includingfalseand custom starters such as the documented Snacks setup), so a prompt can unexpectedly launch a second TUI/server instead of honoring the configured startup policy.
vim.cmd("terminal opencode --session " .. session_id)
else
vim.cmd("terminal opencode")
lua/opencode.lua:222
- The panel's
POST /api/sessionresponse is the newly created session object, so extractingcreated.data.idleavesidnil. The TUI then starts without--session, defeating the dedicated per-directory panel target and preventing it from being persisted.
local id = created and created.data and created.data.id
lua/opencode.lua:216
GET /api/session/{id}returns the session object directly, so a valid cached panel session hasexisting.id, notexisting.data. This condition treats every cached session as missing and creates a new panel session after each Neovim restart.
if existing and existing.data then
lua/opencode/api/command.lua:77
- The session-create response is a
Sessionobject (as reflected bycreate_session()'s return type), so its ID is atcreated.id. Checkingcreated.data.idnever finds an ID;session.newconsequently creates a session without making it the target for subsequent prompts.
local id = created and created.data and created.data.id
lua/opencode/config.lua:31
- Every public entrypoint now calls
M.open()before discovery. When no registration exists, that call already runsterminal opencode(the fallback atlua/opencode.lua:259), and the defaultstartthen opens another TUI here after starting the service. First use therefore creates two panels, with the first one not associated with the dedicated panel session; the startup path needs one coordinated terminal launch.
vim.fn.system({ "opencode", "service", "start" })
vim.cmd("vsplit term://opencode | wincmd p")
lua/opencode/server/init.lua:234
- These requests must send the current Neovim working directory verbatim.
self.cwdis the server's/api/locationvalue and can become stale after:cd, whilevim.uri_encodeturns a path such as/home/me/projectinto%2Fhome%2Fme%2Fproject; this header is not a URI path, so a shared service will route requests to the wrong instance. Usevim.fn.getcwd()without URI encoding.
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))
lua/opencode/server/init.lua:523
- The v2 permission reply schema names this field
reply, notdecision. Every permission response sent through this method will therefore fail request validation and leave the OpenCode permission pending.
decision = reply,
lua/opencode/server/init.lua:514
revert_clearis mapped to the wrong v2 route and method. The API exposesPOST /api/session/{id}/revert/clear; this implementation callsDELETE /revert, so callers cannot clear a staged revert.
return self:request("/api/session/" .. session_id .. "/revert", "DELETE")
lua/opencode/server/init.lua:377
/api/agentlikewise returns the agent array directly. Readingresponse.datamakesget_agents()always return an empty table, which disables subagent discovery and leavesagent.cyclewith no agents to switch to.
return Promise.resolve(response.data or {})
lua/opencode/server/init.lua:308
- A persistent SSE job can exit cleanly without a final event. Calling
on_success(nil)in that case invokesconnect()'s callback withresponse == nil, which then indexesresponse.typeand leaves the connection handling broken. Empty-body success should only be synthesized for non-persistent requests.
elseif on_success then
- Files reviewed: 22/22 changed files
- Comments generated: 5
- Review effort level: Lite
| 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 |
There was a problem hiding this comment.
Good catch — fixed in 2875ae6. edit_preview() now returns nil unless there is exactly one file, so multi-file edits fall back to the generic permission prompt instead of approving unseen changes.
| 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 |
There was a problem hiding this comment.
Fixed in 2875ae6. Server.credentials is now a table keyed by the normalized URL, and explicit credentials still win, so a registered service's password is no longer reused for a configured/remote URL.
| function Server:get_sessions() | ||
| local Promise = require("opencode.promise") | ||
| return self:request("/api/session", "GET"):next(function(response) | ||
| return Promise.resolve(response.data or {}) |
There was a problem hiding this comment.
Thanks — but I don't think this one holds against v2.0.7. GET /api/session does return an object with a top-level data array. Repro against a running server:
$ curl -s -u opencode:$PW -H 'Accept: application/json' $URL/api/session | head -c 80
{"data":[{"id":"ses_f4c4d6b2dffet6D1F291pEEhCT","projectID":"8a0d6c809141b113acf56615702db32cb97f4238"
So the response.data unwrap is correct, and resolve_session_id() works (sessions are listed successfully).
| 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 }) |
There was a problem hiding this comment.
I don't think this matches v2.0.7. The live OpenAPI document (GET /openapi.json) for POST /api/session/{sessionID}/prompt declares text at the top level with required: ["text"]:
{ "type": "object", "properties": { "id": {...}, "text": { "type": "string" }, "files": [...], "agents": [...], "skills": [...], "metadata": {...}, "delivery": {...}, "resume": {...} }, "required": ["text"] }Sending { "text": "..." } is admitted (observed ~10 ms, returns the inbox entry). There is no top-level prompt object in this schema.
| "-H", | ||
| "Accept: application/json", | ||
| "-H", | ||
| "x-opencode-directory: " .. vim.uri_encode(vim.fn.getcwd()), |
There was a problem hiding this comment.
I think this is a false positive: vim.uri_encode does not escape /, so a normal path is sent verbatim.
:lua print(vim.uri_encode('/home/patrice'))
/home/patrice
And the directory header demonstrably routes correctly (checked GET /api/location with x-opencode-directory):
$ curl -s -u opencode:$PW -H 'Accept: application/json' -H 'x-opencode-directory: %2Ftmp%2Fopencode' $URL/api/location
{"directory":"/tmp/opencode","project":{"id":"3c45a4f7...","canonical":"/tmp/opencode"}}
(The v2 SDK's own rewrite uses encodeURIComponent for this header, so an encoded value is fine too.)
- 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.
In OpenCode you mean right? If so, maybe we wait for that to merge and release to reduce our workload. OpenCode seems to release frequently. We'd still consider other improvements from leveraging the new API. |
|
Thanks so much for the review and the pointers — I really appreciate the direction. Just to confirm: yes, that's on the OpenCode side. On "waiting for the release": I can see the appeal of reducing workload, but I'm a little worried about the experience for people moving to v2. The PR detects whether My concern is that people who update OpenCode to v2 will land in the same spot I was in: commands that silently do nothing, with no obvious reason why. If we wait for the OpenCode release that re-adds I'm happy to keep the PR scoped to the v2 API migration and track the TUI-command restoration separately. And I'm very open to the "other improvements from leveraging the new API" — happy to look at what the new surface offers (e.g. |
# Conflicts: # README.md
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.
How much of this PR is still necessary after OpenCode v2 exposes /tui/*? This is a lot of code to review and maintain for a temporary fix. I'd rather let OpenCode close the gap as much as possible. They make money from this; I don't 😀
I already tried this a while ago but the OpenCode server no-op'd when calling custom commands, so it was pointless (and confusing) unfortunately. Maybe v2 fixed that. |
|
I think the framing is worth clarifying: this PR isn't really a "temporary fix" for What's actually
Everything else — basic auth, background-service discovery, the And that small part isn't even "temporary." It's a graceful degradation path that works for both v2.0.x (no The practical reality: v2.0.x is released now (v2.0.9 shipped today). Users who upgrade to v2 have a broken plugin without this PR — every v1 endpoint 404s on a v2 server. Concretely, launching the plugin on v2 errors out with To reduce your review burden, I'm happy to split it:
Or, if you'd rather, I can drop the TUI shim entirely and let the TUI-driven commands hard-error on v2.0.x. To be clear, this isn't about scrolling the view — scrolling in the TUI is native. The commands affected are the TUI-driven ones the default config exposes: On |
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.
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.
- 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.
|
I explored this myself and ended up with +402 / -527 for non-TUI v2 migrations. Thanks for your effort! However I feel more confident in my own changes because they are smaller and I have the most context. I'd love to review your PR and iterate instead, but unfortunately I don't have time for that. |
|
Thanks for the reply, and for the work you put into opencode.nvim — it's a genuinely nice plugin, and the reason this fork exists. You did add a I'm also not convinced That's also why my fork is necessarily larger: OpenCode's API was entirely redesigned in v2, so supporting it means rebuilding on Thanks again for your time and for the plugin. |
Summary
Add support for OpenCode v2 (
opencode serve/ background service). Closes #322.OpenCode v2 changed the server model substantially:
/api/*(published as an OpenAPI spec at/openapi.json),/tui/*control endpoints were removed in v2.0.x (re-added later ondev).What changed
Discovery & auth (
lua/opencode/server/discovery/init.lua)service.json→{ url, password }), also accepting the olderserver.jsonname.vim.g.opencode_opts.server.password, or$OPENCODE_SERVER_PASSWORD(same env vars as OpenCode).pgrep+lsof).API (
lua/opencode/server/init.lua)/api/*surface:/api/info,/api/location,/api/session,/api/session/active,/api/agent,/api/event(SSE).POST /api/session/{id}/prompt— fire-and-forget, since the endpoint admits and schedules the agent loop.POST /api/session/{id}/interrupt,…/compact,POST /api/session(new) andPOST /api/session/{id}/agent(cycle).POST /api/session/{id}/permission/{requestID}/reply.tabs.json), then the most recently viewed/updated session.Events
{ id, type, data }shape (v1 used{ type, properties }).permission.askednow carries{ action, resources, metadata }; edit diffs come frommetadata.files[].patch.file.edited→filesystem.changed;server.instance.disposed→global.disposed/location.shutdown.TUI
/tui/*availability is detected from the OpenAPI spec at connect time (server.tui). Commands needing them (session.half.page.*,session.first/last,prompt.clear/submit,session.select,session.share, undo/redo) work on servers that expose them and are no-ops otherwise.Compatibility
This branch targets OpenCode v2; v1 is no longer supported. If you'd rather keep both, the
/api/infoversionfield (already fetched) makes runtime auto-detection straightforward — happy to add that instead.Also included (separable)
require("opencode").open()/toggle()to show or hide an OpenCode TUI panel in a right-hand split, targeting its own session (per working directory). The upstream removed the terminal manager, so this restores a minimal version. Say the word and I'll drop it from this PR.Testing
:checkhealth opencodeagainst OpenCode v2.0.7.connect()+ heartbeat + statusline,agent.cycle, permission/edit flows.stylua --checkand the LuaLS type-check (same config as CI,.luarc.ci.json) are clean.Related: #239 (
x-opencode-directory), since v2 routes instance requests by directory.