From ca9251cf86fb8c415f75bca25e004a8a6c8fdbe0 Mon Sep 17 00:00:00 2001 From: Pedro Pombeiro Date: Tue, 10 Feb 2026 00:33:42 +0100 Subject: [PATCH 01/14] fix(snacks): terminate opencode process on VimLeavePre Track the terminal's PID at toggle/start time and use shell 'kill' to terminate it during VimLeavePre. This is more reliable than jobstop() because snacks.terminal cleanup can invalidate the job before our stop() is called. --- lua/opencode/provider/snacks.lua | 44 ++++++++++++++++++++++++++++-- lua/opencode/provider/terminal.lua | 6 ++++ plugin/provider.lua | 2 +- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/lua/opencode/provider/snacks.lua b/lua/opencode/provider/snacks.lua index f8ab8e66..4fd71671 100644 --- a/lua/opencode/provider/snacks.lua +++ b/lua/opencode/provider/snacks.lua @@ -4,6 +4,8 @@ ---@class opencode.provider.Snacks : opencode.Provider --- ---@field opts snacks.terminal.Opts +---@field private _job_id number|nil +---@field private _pid number|nil local Snacks = {} Snacks.__index = Snacks Snacks.name = "snacks" @@ -15,6 +17,8 @@ Snacks.name = "snacks" function Snacks.new(opts) local self = setmetatable({}, Snacks) self.opts = opts or {} + self._job_id = nil + self._pid = nil return self end @@ -44,19 +48,55 @@ end function Snacks:toggle() require("snacks.terminal").toggle(self.cmd, self.opts) + -- Capture the job ID and PID after terminal opens (may be a new terminal) + vim.defer_fn(function() + local win = self:get() + if win and win.buf and vim.api.nvim_buf_is_valid(win.buf) then + self._job_id = vim.b[win.buf].terminal_job_id + if self._job_id then + pcall(function() + self._pid = vim.fn.jobpid(self._job_id) + end) + end + end + end, 100) end function Snacks:start() if not self:get() then require("snacks.terminal").open(self.cmd, self.opts) + -- Capture the job ID and PID after terminal opens + vim.defer_fn(function() + local win = self:get() + if win and win.buf and vim.api.nvim_buf_is_valid(win.buf) then + self._job_id = vim.b[win.buf].terminal_job_id + if self._job_id then + pcall(function() + self._pid = vim.fn.jobpid(self._job_id) + end) + end + end + end, 100) end end function Snacks:stop() + -- Kill via PID using shell kill (most reliable during VimLeavePre, + -- as vim.uv.kill and jobstop may not work when Neovim is shutting down) + if self._pid then + vim.fn.system("kill -TERM " .. self._pid .. " 2>/dev/null") + self._pid = nil + end + + -- Also try jobstop as a fallback + if self._job_id then + pcall(vim.fn.jobstop, self._job_id) + self._job_id = nil + end + + -- Close the window via snacks local win = self:get() if win then - -- TODO: Stop the job first so we don't get error exit code. - -- Not sure how to get the job ID from snacks API though. win:close() end end diff --git a/lua/opencode/provider/terminal.lua b/lua/opencode/provider/terminal.lua index 8b1b61c6..9cfd9b3d 100644 --- a/lua/opencode/provider/terminal.lua +++ b/lua/opencode/provider/terminal.lua @@ -88,6 +88,12 @@ end ---Close the window, delete the buffer. function Terminal:stop() + if self.bufnr ~= nil and vim.api.nvim_buf_is_valid(self.bufnr) then + local job_id = vim.b[self.bufnr].terminal_job_id + if job_id then + vim.fn.jobstop(job_id) + end + end if self.winid ~= nil and vim.api.nvim_win_is_valid(self.winid) then vim.api.nvim_win_close(self.winid, true) self.winid = nil diff --git a/plugin/provider.lua b/plugin/provider.lua index f467ab79..0027805c 100644 --- a/plugin/provider.lua +++ b/plugin/provider.lua @@ -1,4 +1,4 @@ -vim.api.nvim_create_autocmd("VimLeave", { +vim.api.nvim_create_autocmd("VimLeavePre", { group = vim.api.nvim_create_augroup("OpencodeProvider", { clear = true }), pattern = "*", callback = function() From 77dedc45b9a84a68b23715bd8877f9f900a9dda7 Mon Sep 17 00:00:00 2001 From: Pedro Pombeiro Date: Thu, 19 Feb 2026 22:56:12 +0100 Subject: [PATCH 02/14] refactor(snacks): deduplicate PID capture via on_buf callback and add Windows support Move PID capture logic into an on_buf callback in the constructor so it fires automatically for both toggle() and open(), eliminating duplicated code. Wrap the user's existing on_buf callback to preserve custom behavior (e.g., keymap application). Guard the shell 'kill -TERM' call with a Unix platform check and fall back to vim.uv.kill() on non-Unix systems for cross-platform compatibility. --- lua/opencode/provider/snacks.lua | 55 +++++++++++++++++--------------- 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/lua/opencode/provider/snacks.lua b/lua/opencode/provider/snacks.lua index 4fd71671..a28bde5a 100644 --- a/lua/opencode/provider/snacks.lua +++ b/lua/opencode/provider/snacks.lua @@ -19,6 +19,29 @@ function Snacks.new(opts) self.opts = opts or {} self._job_id = nil self._pid = nil + + -- Hook into on_buf to capture the terminal job's PID when the buffer is created. + -- This ensures PID capture happens automatically regardless of how the terminal is + -- started (toggle, open), without duplicating the logic in each method. + self.opts.win = self.opts.win or {} + local user_on_buf = self.opts.win.on_buf + self.opts.win.on_buf = function(win) + if user_on_buf then + user_on_buf(win) + end + -- Deferred because on_buf fires before the terminal job is fully started + vim.defer_fn(function() + if win.buf and vim.api.nvim_buf_is_valid(win.buf) then + self._job_id = vim.b[win.buf].terminal_job_id + if self._job_id then + pcall(function() + self._pid = vim.fn.jobpid(self._job_id) + end) + end + end + end, 100) + end + return self end @@ -48,43 +71,23 @@ end function Snacks:toggle() require("snacks.terminal").toggle(self.cmd, self.opts) - -- Capture the job ID and PID after terminal opens (may be a new terminal) - vim.defer_fn(function() - local win = self:get() - if win and win.buf and vim.api.nvim_buf_is_valid(win.buf) then - self._job_id = vim.b[win.buf].terminal_job_id - if self._job_id then - pcall(function() - self._pid = vim.fn.jobpid(self._job_id) - end) - end - end - end, 100) end function Snacks:start() if not self:get() then require("snacks.terminal").open(self.cmd, self.opts) - -- Capture the job ID and PID after terminal opens - vim.defer_fn(function() - local win = self:get() - if win and win.buf and vim.api.nvim_buf_is_valid(win.buf) then - self._job_id = vim.b[win.buf].terminal_job_id - if self._job_id then - pcall(function() - self._pid = vim.fn.jobpid(self._job_id) - end) - end - end - end, 100) end end function Snacks:stop() - -- Kill via PID using shell kill (most reliable during VimLeavePre, + -- Kill via PID (most reliable during VimLeavePre, -- as vim.uv.kill and jobstop may not work when Neovim is shutting down) if self._pid then - vim.fn.system("kill -TERM " .. self._pid .. " 2>/dev/null") + if vim.fn.has("unix") == 1 then + vim.fn.system("kill -TERM " .. self._pid .. " 2>/dev/null") + else + pcall(vim.uv.kill, self._pid, "sigterm") + end self._pid = nil end From c819b08a858969e8d151e4501dcde434e5bdf182 Mon Sep 17 00:00:00 2001 From: Pedro Pombeiro Date: Thu, 19 Feb 2026 23:30:17 +0100 Subject: [PATCH 03/14] refactor(provider): extract shared process kill logic and fix terminal provider Extract process kill and PID capture logic into opencode.provider.util, shared by both snacks and terminal providers. This eliminates duplicated code and ensures consistent behavior. Key fix: use os.execute() instead of vim.fn.system() for process group kill during VimLeavePre, and skip jobstop when PID kill succeeds to prevent SIGHUP from causing the process to respawn. Apply the same PID-based termination to the terminal provider, which suffered from the same orphaned process issue as the snacks provider. --- lua/opencode/provider/snacks.lua | 26 ++++------------- lua/opencode/provider/terminal.lua | 20 ++++++++++--- lua/opencode/provider/util.lua | 46 ++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 24 deletions(-) create mode 100644 lua/opencode/provider/util.lua diff --git a/lua/opencode/provider/snacks.lua b/lua/opencode/provider/snacks.lua index a28bde5a..e69b1277 100644 --- a/lua/opencode/provider/snacks.lua +++ b/lua/opencode/provider/snacks.lua @@ -1,5 +1,7 @@ ---@module 'snacks' +local util = require("opencode.provider.util") + ---Provide an embedded `opencode` via [`snacks.terminal`](https://github.com/folke/snacks.nvim/blob/main/docs/terminal.md). ---@class opencode.provider.Snacks : opencode.Provider --- @@ -34,9 +36,7 @@ function Snacks.new(opts) if win.buf and vim.api.nvim_buf_is_valid(win.buf) then self._job_id = vim.b[win.buf].terminal_job_id if self._job_id then - pcall(function() - self._pid = vim.fn.jobpid(self._job_id) - end) + self._pid = util.capture_pid(self._job_id) end end end, 100) @@ -80,24 +80,10 @@ function Snacks:start() end function Snacks:stop() - -- Kill via PID (most reliable during VimLeavePre, - -- as vim.uv.kill and jobstop may not work when Neovim is shutting down) - if self._pid then - if vim.fn.has("unix") == 1 then - vim.fn.system("kill -TERM " .. self._pid .. " 2>/dev/null") - else - pcall(vim.uv.kill, self._pid, "sigterm") - end - self._pid = nil - end - - -- Also try jobstop as a fallback - if self._job_id then - pcall(vim.fn.jobstop, self._job_id) - self._job_id = nil - end + util.kill(self._pid, self._job_id) + self._pid = nil + self._job_id = nil - -- Close the window via snacks local win = self:get() if win then win:close() diff --git a/lua/opencode/provider/terminal.lua b/lua/opencode/provider/terminal.lua index 9cfd9b3d..69ae9e3a 100644 --- a/lua/opencode/provider/terminal.lua +++ b/lua/opencode/provider/terminal.lua @@ -1,3 +1,5 @@ +local util = require("opencode.provider.util") + ---Provide an embedded `opencode` via a [Neovim terminal](https://neovim.io/doc/user/terminal.html) buffer. ---@class opencode.provider.Terminal : opencode.Provider --- @@ -5,6 +7,7 @@ --- ---@field bufnr? integer ---@field winid? integer +---@field private _pid number|nil local Terminal = {} Terminal.__index = Terminal Terminal.name = "terminal" @@ -16,6 +19,7 @@ function Terminal.new(opts) self.opts = opts or {} self.winid = nil self.bufnr = nil + self._pid = nil return self end @@ -71,6 +75,11 @@ function Terminal:start() once = true, callback = function(event) require("opencode.keymaps").apply(event.buf) + -- Cache PID at terminal open time for reliable process termination during VimLeavePre + local job_id = vim.b[event.buf].terminal_job_id + if job_id then + self._pid = util.capture_pid(job_id) + end end, }) @@ -88,12 +97,15 @@ end ---Close the window, delete the buffer. function Terminal:stop() + -- Resolve job_id for fallback before we close the buffer + local job_id = nil if self.bufnr ~= nil and vim.api.nvim_buf_is_valid(self.bufnr) then - local job_id = vim.b[self.bufnr].terminal_job_id - if job_id then - vim.fn.jobstop(job_id) - end + job_id = vim.b[self.bufnr].terminal_job_id end + + util.kill(self._pid, job_id) + self._pid = nil + if self.winid ~= nil and vim.api.nvim_win_is_valid(self.winid) then vim.api.nvim_win_close(self.winid, true) self.winid = nil diff --git a/lua/opencode/provider/util.lua b/lua/opencode/provider/util.lua new file mode 100644 index 00000000..ce5b264b --- /dev/null +++ b/lua/opencode/provider/util.lua @@ -0,0 +1,46 @@ +---Shared process management utilities for providers that run opencode inside Neovim's job system. +local M = {} + +---Capture the PID associated with a Neovim terminal job. +---@param job_id number The terminal job ID (from `vim.b[buf].terminal_job_id`) +---@return number|nil pid The process ID, or nil if it could not be resolved +function M.capture_pid(job_id) + local ok, pid = pcall(vim.fn.jobpid, job_id) + if ok then + return pid + end + return nil +end + +---Terminate the process and its children reliably. +--- +---Uses the cached PID to kill the entire process group, which is more reliable +---than jobstop during VimLeavePre because: +--- 1. os.execute is synchronous (vim.fn.system spawns a job that Neovim kills during shutdown) +--- 2. Negative PID sends SIGTERM to the entire process group (children included) +--- 3. jobstop sends SIGHUP which can cause the process to daemonize/respawn +--- +---Falls back to jobstop only when no PID is available. +---@param pid number|nil The cached process ID +---@param job_id number|nil The Neovim job ID (for jobstop fallback) +---@return boolean killed Whether the process was successfully terminated +function M.kill(pid, job_id) + if pid then + if vim.fn.has("unix") == 1 then + return os.execute("kill -TERM -" .. pid .. " 2>/dev/null") ~= nil + else + return pcall(vim.uv.kill, pid, "sigterm") + end + end + + -- Fall back to jobstop if we don't have a PID. + -- Avoid combining both: jobstop sends SIGHUP which can cause the process to respawn. + if job_id then + pcall(vim.fn.jobstop, job_id) + return true + end + + return false +end + +return M From 70ea1075fac09280d886a5caac018cc1262d8d77 Mon Sep 17 00:00:00 2001 From: Pedro Pombeiro Date: Thu, 19 Feb 2026 23:46:38 +0100 Subject: [PATCH 04/14] fix(snacks): suppress lua-language-server invisible field warnings Add diagnostic disable/enable annotations around private field access in the on_buf closure, which is part of the constructor but lua-ls treats as an external context. --- lua/opencode/provider/snacks.lua | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lua/opencode/provider/snacks.lua b/lua/opencode/provider/snacks.lua index e69b1277..e233870e 100644 --- a/lua/opencode/provider/snacks.lua +++ b/lua/opencode/provider/snacks.lua @@ -34,10 +34,12 @@ function Snacks.new(opts) -- Deferred because on_buf fires before the terminal job is fully started vim.defer_fn(function() if win.buf and vim.api.nvim_buf_is_valid(win.buf) then + ---@diagnostic disable: invisible -- accessing private fields from closure within constructor self._job_id = vim.b[win.buf].terminal_job_id if self._job_id then self._pid = util.capture_pid(self._job_id) end + ---@diagnostic enable: invisible end end, 100) end From b7513ec518c84c217233d2bbb5eff73b2592fb6a Mon Sep 17 00:00:00 2001 From: Pedro Pombeiro Date: Fri, 20 Feb 2026 00:48:41 +0100 Subject: [PATCH 05/14] fix(tmux): terminate process group before killing pane to prevent orphaned processes Add PID-based process group kill to the tmux provider, matching the approach used by the snacks and terminal providers. Capture the pane PID via tmux display-message and kill the process group before tmux kill-pane. This is a workaround for https://github.com/anomalyco/opencode/issues/13001 (opencode does not handle SIGHUP gracefully). Document the upstream issue in provider.util so the workaround is easy to identify and remove later. --- lua/opencode/provider/tmux.lua | 25 ++++++++++++++++++++++--- lua/opencode/provider/util.lua | 16 +++++++++++++++- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/lua/opencode/provider/tmux.lua b/lua/opencode/provider/tmux.lua index bf4d443c..d1421c32 100644 --- a/lua/opencode/provider/tmux.lua +++ b/lua/opencode/provider/tmux.lua @@ -5,6 +5,9 @@ --- ---The `tmux` pane ID where `opencode` is running (internal use only). ---@field pane_id? string +--- +---The PID of the process running in the tmux pane (internal use only). +---@field _pid? number local Tmux = {} Tmux.__index = Tmux Tmux.name = "tmux" @@ -39,6 +42,7 @@ function Tmux.new(opts) local self = setmetatable({}, Tmux) self.opts = opts or {} self.pane_id = nil + self._pid = nil return self end @@ -97,9 +101,17 @@ function Tmux:start() self.pane_id = vim.fn.system( string.format("tmux split-window %s -P -F '#{pane_id}' %s '%s'", detach_flag, self.opts.options or "", self.cmd) ) - local disable_passthrough = self.opts.allow_passthrough ~= true -- default true (disable passthrough) - if disable_passthrough and self.pane_id and self.pane_id ~= "" then - vim.fn.system(string.format("tmux set-option -t %s -p allow-passthrough off", vim.trim(self.pane_id))) + if self.pane_id and self.pane_id ~= "" then + self.pane_id = vim.trim(self.pane_id) + + -- Capture PID for reliable termination (see stop() and opencode issue #13001) + local util = require("opencode.provider.util") + self._pid = util.capture_tmux_pid(self.pane_id) + + local disable_passthrough = self.opts.allow_passthrough ~= true -- default true (disable passthrough) + if disable_passthrough then + vim.fn.system(string.format("tmux set-option -t %s -p allow-passthrough off", self.pane_id)) + end end end end @@ -108,6 +120,13 @@ end function Tmux:stop() local pane_id = self:get_pane_id() if pane_id then + -- Workaround: kill the process group before the pane to prevent orphaned processes. + -- tmux kill-pane sends SIGHUP which causes opencode to daemonize instead of exiting. + -- See: https://github.com/anomalyco/opencode/issues/13001 + local util = require("opencode.provider.util") + util.kill(self._pid) + self._pid = nil + vim.fn.system("tmux kill-pane -t " .. pane_id) self.pane_id = nil end diff --git a/lua/opencode/provider/util.lua b/lua/opencode/provider/util.lua index ce5b264b..0ec27f3a 100644 --- a/lua/opencode/provider/util.lua +++ b/lua/opencode/provider/util.lua @@ -1,4 +1,10 @@ ----Shared process management utilities for providers that run opencode inside Neovim's job system. +---Shared process management utilities for opencode providers. +--- +---WORKAROUND: This module exists to work around an upstream bug where the opencode process +---does not terminate cleanly when it receives SIGHUP (it daemonizes/respawns instead). +---See: https://github.com/anomalyco/opencode/issues/13001 +---Once that issue is fixed, this module can be removed and providers can use their +---native stop mechanisms (jobstop, tmux kill-pane, etc.) directly. local M = {} ---Capture the PID associated with a Neovim terminal job. @@ -12,6 +18,14 @@ function M.capture_pid(job_id) return nil end +---Capture the PID of the process running in a tmux pane. +---@param pane_id string The tmux pane ID (e.g., "%42") +---@return number|nil pid The process ID, or nil if it could not be resolved +function M.capture_tmux_pid(pane_id) + local pid_str = vim.trim(vim.fn.system("tmux display-message -p -t " .. pane_id .. " '#{pane_pid}'")) + return tonumber(pid_str) +end + ---Terminate the process and its children reliably. --- ---Uses the cached PID to kill the entire process group, which is more reliable From 047ee65ccb51ff1dae473d85313447e8c4a84986 Mon Sep 17 00:00:00 2001 From: Pedro Pombeiro Date: Fri, 20 Feb 2026 17:17:55 +0100 Subject: [PATCH 06/14] refactor(provider): remove job_id tracking and document eager PID capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove job_id parameter from util.kill() and the jobstop fallback, since jobstop sends SIGHUP which causes the process to respawn — making it counterproductive. Only PID-based process group kill is effective. Add comments explaining why PID must be captured eagerly at startup: terminal_job_id is no longer available by the time VimLeavePre/stop() runs. --- lua/opencode/provider/snacks.lua | 17 ++++++++--------- lua/opencode/provider/terminal.lua | 12 ++++-------- lua/opencode/provider/util.lua | 24 +++++++----------------- 3 files changed, 19 insertions(+), 34 deletions(-) diff --git a/lua/opencode/provider/snacks.lua b/lua/opencode/provider/snacks.lua index 72b62e9e..fd66636b 100644 --- a/lua/opencode/provider/snacks.lua +++ b/lua/opencode/provider/snacks.lua @@ -6,7 +6,6 @@ local util = require("opencode.provider.util") ---@class opencode.provider.Snacks : opencode.Provider --- ---@field opts snacks.terminal.Opts ----@field private _job_id number|nil ---@field private _pid number|nil local Snacks = {} Snacks.__index = Snacks @@ -19,12 +18,13 @@ Snacks.name = "snacks" function Snacks.new(opts) local self = setmetatable({}, Snacks) self.opts = opts or {} - self._job_id = nil self._pid = nil -- Hook into on_buf to capture the terminal job's PID when the buffer is created. - -- This ensures PID capture happens automatically regardless of how the terminal is - -- started (toggle, open), without duplicating the logic in each method. + -- We must capture the PID eagerly at startup because by the time VimLeavePre fires + -- and stop() is called, the terminal job has been cleared and terminal_job_id is no + -- longer available. This also ensures PID capture happens automatically regardless + -- of how the terminal is started (toggle, open), without duplicating the logic. self.opts.win = self.opts.win or {} local user_on_buf = self.opts.win.on_buf self.opts.win.on_buf = function(win) @@ -35,9 +35,9 @@ function Snacks.new(opts) vim.defer_fn(function() if win.buf and vim.api.nvim_buf_is_valid(win.buf) then ---@diagnostic disable: invisible -- accessing private fields from closure within constructor - self._job_id = vim.b[win.buf].terminal_job_id - if self._job_id then - self._pid = util.capture_pid(self._job_id) + local job_id = vim.b[win.buf].terminal_job_id + if job_id then + self._pid = util.capture_pid(job_id) end ---@diagnostic enable: invisible end @@ -83,9 +83,8 @@ function Snacks:start() end function Snacks:stop() - util.kill(self._pid, self._job_id) + util.kill(self._pid) self._pid = nil - self._job_id = nil local win = self:get() if win then diff --git a/lua/opencode/provider/terminal.lua b/lua/opencode/provider/terminal.lua index 69ae9e3a..b2a85fa7 100644 --- a/lua/opencode/provider/terminal.lua +++ b/lua/opencode/provider/terminal.lua @@ -75,7 +75,9 @@ function Terminal:start() once = true, callback = function(event) require("opencode.keymaps").apply(event.buf) - -- Cache PID at terminal open time for reliable process termination during VimLeavePre + -- Cache PID eagerly at terminal open time because by the time VimLeavePre fires + -- and stop() is called, the terminal job has been cleared and terminal_job_id + -- is no longer available. local job_id = vim.b[event.buf].terminal_job_id if job_id then self._pid = util.capture_pid(job_id) @@ -97,13 +99,7 @@ end ---Close the window, delete the buffer. function Terminal:stop() - -- Resolve job_id for fallback before we close the buffer - local job_id = nil - if self.bufnr ~= nil and vim.api.nvim_buf_is_valid(self.bufnr) then - job_id = vim.b[self.bufnr].terminal_job_id - end - - util.kill(self._pid, job_id) + util.kill(self._pid) self._pid = nil if self.winid ~= nil and vim.api.nvim_win_is_valid(self.winid) then diff --git a/lua/opencode/provider/util.lua b/lua/opencode/provider/util.lua index 0ec27f3a..bf1fc777 100644 --- a/lua/opencode/provider/util.lua +++ b/lua/opencode/provider/util.lua @@ -33,28 +33,18 @@ end --- 1. os.execute is synchronous (vim.fn.system spawns a job that Neovim kills during shutdown) --- 2. Negative PID sends SIGTERM to the entire process group (children included) --- 3. jobstop sends SIGHUP which can cause the process to daemonize/respawn ---- ----Falls back to jobstop only when no PID is available. ---@param pid number|nil The cached process ID ----@param job_id number|nil The Neovim job ID (for jobstop fallback) ---@return boolean killed Whether the process was successfully terminated -function M.kill(pid, job_id) - if pid then - if vim.fn.has("unix") == 1 then - return os.execute("kill -TERM -" .. pid .. " 2>/dev/null") ~= nil - else - return pcall(vim.uv.kill, pid, "sigterm") - end +function M.kill(pid) + if not pid then + return false end - -- Fall back to jobstop if we don't have a PID. - -- Avoid combining both: jobstop sends SIGHUP which can cause the process to respawn. - if job_id then - pcall(vim.fn.jobstop, job_id) - return true + if vim.fn.has("unix") == 1 then + return os.execute("kill -TERM -" .. pid .. " 2>/dev/null") ~= nil + else + return pcall(vim.uv.kill, pid, "sigterm") end - - return false end return M From b3a2d683238218e2d37af2d73e3f034651ba89da Mon Sep 17 00:00:00 2001 From: Pedro Pombeiro Date: Fri, 20 Feb 2026 17:23:14 +0100 Subject: [PATCH 07/14] refactor(snacks): use TermOpen autocmd for more reliable PID capture TermOpen fires exactly when the terminal job starts, rather than relying on a constant delay that may be too early or too late. --- lua/opencode/provider/snacks.lua | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lua/opencode/provider/snacks.lua b/lua/opencode/provider/snacks.lua index fd66636b..821736fe 100644 --- a/lua/opencode/provider/snacks.lua +++ b/lua/opencode/provider/snacks.lua @@ -31,17 +31,18 @@ function Snacks.new(opts) if user_on_buf then user_on_buf(win) end - -- Deferred because on_buf fires before the terminal job is fully started - vim.defer_fn(function() - if win.buf and vim.api.nvim_buf_is_valid(win.buf) then - ---@diagnostic disable: invisible -- accessing private fields from closure within constructor + ---@diagnostic disable: invisible -- accessing private fields from closure within constructor + vim.api.nvim_create_autocmd("TermOpen", { + buffer = win.buf, + once = true, + callback = function() local job_id = vim.b[win.buf].terminal_job_id if job_id then self._pid = util.capture_pid(job_id) end - ---@diagnostic enable: invisible - end - end, 100) + end, + }) + ---@diagnostic enable: invisible end return self @@ -50,12 +51,11 @@ end ---Check if `snacks.terminal` is available and enabled. function Snacks.health() local snacks_ok, snacks = pcall(require, "snacks") - ---@cast snacks Snacks if not snacks_ok then return "`snacks.nvim` is not available.", { "Install `snacks.nvim` and enable `snacks.terminal.`", } - elseif not snacks.config.get("terminal", {}).enabled then + elseif not snacks and snacks.config.get("terminal", {}).enabled then return "`snacks.terminal` is not enabled.", { "Enable `snacks.terminal` in your `snacks.nvim` configuration.", From 1fe20fc84d249685c44cf12def3f576b586b83c2 Mon Sep 17 00:00:00 2001 From: Nick van Dyke Date: Fri, 20 Feb 2026 13:38:38 -0700 Subject: [PATCH 08/14] not sure this boolean check is right? --- lua/opencode/provider/snacks.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lua/opencode/provider/snacks.lua b/lua/opencode/provider/snacks.lua index 821736fe..d3b836cc 100644 --- a/lua/opencode/provider/snacks.lua +++ b/lua/opencode/provider/snacks.lua @@ -51,11 +51,12 @@ end ---Check if `snacks.terminal` is available and enabled. function Snacks.health() local snacks_ok, snacks = pcall(require, "snacks") + ---@cast snacks Snacks if not snacks_ok then return "`snacks.nvim` is not available.", { "Install `snacks.nvim` and enable `snacks.terminal.`", } - elseif not snacks and snacks.config.get("terminal", {}).enabled then + elseif not snacks.config.get("terminal", {}).enabled then return "`snacks.terminal` is not enabled.", { "Enable `snacks.terminal` in your `snacks.nvim` configuration.", From dd20eac6631c8a84829182fd95926d15d05a4e38 Mon Sep 17 00:00:00 2001 From: Nick van Dyke Date: Fri, 20 Feb 2026 13:46:50 -0700 Subject: [PATCH 09/14] localize getting pid to providers --- lua/opencode/provider/snacks.lua | 22 ++++++++++++++++++---- lua/opencode/provider/terminal.lua | 21 +++++++++++++++++---- lua/opencode/provider/tmux.lua | 10 ++++++++++ lua/opencode/provider/util.lua | 19 ------------------- 4 files changed, 45 insertions(+), 27 deletions(-) diff --git a/lua/opencode/provider/snacks.lua b/lua/opencode/provider/snacks.lua index d3b836cc..04866658 100644 --- a/lua/opencode/provider/snacks.lua +++ b/lua/opencode/provider/snacks.lua @@ -36,10 +36,7 @@ function Snacks.new(opts) buffer = win.buf, once = true, callback = function() - local job_id = vim.b[win.buf].terminal_job_id - if job_id then - self._pid = util.capture_pid(job_id) - end + self:get_pid() end, }) ---@diagnostic enable: invisible @@ -93,4 +90,21 @@ function Snacks:stop() end end +---Capture and cache the PID of the terminal job. +---@return number? +function Snacks:get_pid() + local buf = self:get() and self:get().buf + if not self._pid and buf then + local job_id = vim.b[buf].terminal_job_id + if job_id then + local ok, pid = pcall(vim.fn.jobpid, job_id) + if ok then + self._pid = pid + end + end + end + + return self._pid +end + return Snacks diff --git a/lua/opencode/provider/terminal.lua b/lua/opencode/provider/terminal.lua index b2a85fa7..f3e214eb 100644 --- a/lua/opencode/provider/terminal.lua +++ b/lua/opencode/provider/terminal.lua @@ -78,10 +78,7 @@ function Terminal:start() -- Cache PID eagerly at terminal open time because by the time VimLeavePre fires -- and stop() is called, the terminal job has been cleared and terminal_job_id -- is no longer available. - local job_id = vim.b[event.buf].terminal_job_id - if job_id then - self._pid = util.capture_pid(job_id) - end + self:get_pid() end, }) @@ -111,4 +108,20 @@ function Terminal:stop() end end +---Capture and cache the PID of the terminal job. +---@return number? +function Terminal:get_pid() + if not self._pid then + local job_id = vim.b[self.bufnr].terminal_job_id + if job_id then + local ok, pid = pcall(vim.fn.jobpid, job_id) + if ok then + self._pid = pid + end + end + end + + return self._pid +end + return Terminal diff --git a/lua/opencode/provider/tmux.lua b/lua/opencode/provider/tmux.lua index 620819b9..b72a8a8c 100644 --- a/lua/opencode/provider/tmux.lua +++ b/lua/opencode/provider/tmux.lua @@ -117,4 +117,14 @@ function Tmux:stop() end end +---Capture the PID of the process running in the pane. +---@return number? +function Tmux:get_pid() + if not self.pane_id then + return nil + end + + return tonumber(vim.trim(vim.fn.system("tmux display-message -p -t " .. self.pane_id .. " '#{pane_pid}'"))) +end + return Tmux diff --git a/lua/opencode/provider/util.lua b/lua/opencode/provider/util.lua index bf1fc777..dcb2321b 100644 --- a/lua/opencode/provider/util.lua +++ b/lua/opencode/provider/util.lua @@ -7,25 +7,6 @@ ---native stop mechanisms (jobstop, tmux kill-pane, etc.) directly. local M = {} ----Capture the PID associated with a Neovim terminal job. ----@param job_id number The terminal job ID (from `vim.b[buf].terminal_job_id`) ----@return number|nil pid The process ID, or nil if it could not be resolved -function M.capture_pid(job_id) - local ok, pid = pcall(vim.fn.jobpid, job_id) - if ok then - return pid - end - return nil -end - ----Capture the PID of the process running in a tmux pane. ----@param pane_id string The tmux pane ID (e.g., "%42") ----@return number|nil pid The process ID, or nil if it could not be resolved -function M.capture_tmux_pid(pane_id) - local pid_str = vim.trim(vim.fn.system("tmux display-message -p -t " .. pane_id .. " '#{pane_pid}'")) - return tonumber(pid_str) -end - ---Terminate the process and its children reliably. --- ---Uses the cached PID to kill the entire process group, which is more reliable From 6b927b67cd5889c2d6e3268b046d187e5885b9e4 Mon Sep 17 00:00:00 2001 From: Nick van Dyke Date: Fri, 20 Feb 2026 13:47:59 -0700 Subject: [PATCH 10/14] comments --- lua/opencode/provider/util.lua | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/lua/opencode/provider/util.lua b/lua/opencode/provider/util.lua index dcb2321b..f825cbf8 100644 --- a/lua/opencode/provider/util.lua +++ b/lua/opencode/provider/util.lua @@ -1,13 +1,7 @@ ----Shared process management utilities for opencode providers. ---- ----WORKAROUND: This module exists to work around an upstream bug where the opencode process ----does not terminate cleanly when it receives SIGHUP (it daemonizes/respawns instead). ----See: https://github.com/anomalyco/opencode/issues/13001 ----Once that issue is fixed, this module can be removed and providers can use their ----native stop mechanisms (jobstop, tmux kill-pane, etc.) directly. local M = {} ---Terminate the process and its children reliably. +---HACK: for upstream issue described in https://github.com/anomalyco/opencode/issues/13001. --- ---Uses the cached PID to kill the entire process group, which is more reliable ---than jobstop during VimLeavePre because: From 681afb36952b774821d890fa083cb56572ccd9f3 Mon Sep 17 00:00:00 2001 From: Nick van Dyke Date: Fri, 20 Feb 2026 13:57:58 -0700 Subject: [PATCH 11/14] use kill for tmux for consistency --- lua/opencode/provider/tmux.lua | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/lua/opencode/provider/tmux.lua b/lua/opencode/provider/tmux.lua index b72a8a8c..dcd88799 100644 --- a/lua/opencode/provider/tmux.lua +++ b/lua/opencode/provider/tmux.lua @@ -109,11 +109,9 @@ end ---Kill the `opencode` pane. function Tmux:stop() - local pane_id = self:get_pane_id() - if pane_id then - -- HACK: https://github.com/nickjvandyke/opencode.nvim/issues/118 - vim.fn.system("tmux send-keys -t " .. pane_id .. " C-c") - self.pane_id = nil + local pid = self:get_pid() + if pid then + require("opencode.provider.util").kill(pid) end end From 3ee215dc763be31941825edc8fdbebd57335296c Mon Sep 17 00:00:00 2001 From: Nick van Dyke Date: Fri, 20 Feb 2026 13:58:09 -0700 Subject: [PATCH 12/14] nit --- lua/opencode/provider/snacks.lua | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lua/opencode/provider/snacks.lua b/lua/opencode/provider/snacks.lua index 04866658..de5c10c1 100644 --- a/lua/opencode/provider/snacks.lua +++ b/lua/opencode/provider/snacks.lua @@ -1,7 +1,5 @@ ---@module 'snacks' -local util = require("opencode.provider.util") - ---Provide an embedded `opencode` via [`snacks.terminal`](https://github.com/folke/snacks.nvim/blob/main/docs/terminal.md). ---@class opencode.provider.Snacks : opencode.Provider --- @@ -81,7 +79,7 @@ function Snacks:start() end function Snacks:stop() - util.kill(self._pid) + require("opencode.provider.util").kill(self:get_pid()) self._pid = nil local win = self:get() From 9bcd2560339e1d9f6f024d61dbd19adc854fee54 Mon Sep 17 00:00:00 2001 From: Nick van Dyke Date: Fri, 20 Feb 2026 14:06:23 -0700 Subject: [PATCH 13/14] argh --- lua/opencode/provider/terminal.lua | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lua/opencode/provider/terminal.lua b/lua/opencode/provider/terminal.lua index f3e214eb..709b0b81 100644 --- a/lua/opencode/provider/terminal.lua +++ b/lua/opencode/provider/terminal.lua @@ -1,5 +1,3 @@ -local util = require("opencode.provider.util") - ---Provide an embedded `opencode` via a [Neovim terminal](https://neovim.io/doc/user/terminal.html) buffer. ---@class opencode.provider.Terminal : opencode.Provider --- @@ -85,6 +83,7 @@ function Terminal:start() vim.fn.jobstart(self.cmd, { term = true, on_exit = function() + self._pid = nil self.winid = nil self.bufnr = nil end, @@ -96,7 +95,8 @@ end ---Close the window, delete the buffer. function Terminal:stop() - util.kill(self._pid) + -- FIX: Doesn't work when calling `:stop()` when Neovim *isn't* stopping? + require("opencode.provider.util").kill(self:get_pid()) self._pid = nil if self.winid ~= nil and vim.api.nvim_win_is_valid(self.winid) then @@ -105,13 +105,14 @@ function Terminal:stop() end if self.bufnr ~= nil and vim.api.nvim_buf_is_valid(self.bufnr) then vim.api.nvim_buf_delete(self.bufnr, { force = true }) + self.bufnr = nil end end ---Capture and cache the PID of the terminal job. ---@return number? function Terminal:get_pid() - if not self._pid then + if not self._pid and self.bufnr then local job_id = vim.b[self.bufnr].terminal_job_id if job_id then local ok, pid = pcall(vim.fn.jobpid, job_id) From bce487556d62ed9973f114c43250e2c716f65567 Mon Sep 17 00:00:00 2001 From: Nick van Dyke Date: Fri, 20 Feb 2026 14:11:42 -0700 Subject: [PATCH 14/14] properly stop terminal process when not exiting --- lua/opencode/provider/terminal.lua | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lua/opencode/provider/terminal.lua b/lua/opencode/provider/terminal.lua index 709b0b81..e7a3cbf7 100644 --- a/lua/opencode/provider/terminal.lua +++ b/lua/opencode/provider/terminal.lua @@ -93,9 +93,13 @@ function Terminal:start() end end ----Close the window, delete the buffer. function Terminal:stop() - -- FIX: Doesn't work when calling `:stop()` when Neovim *isn't* stopping? + local job_id = vim.b[self.bufnr].terminal_job_id + if job_id then + -- Apparently we still have to do this ourselves when Neovim *isn't* stopping. + -- Not needed for snacks.terminal - I guess it does it internally? + vim.fn.jobstop(job_id) + end require("opencode.provider.util").kill(self:get_pid()) self._pid = nil