diff --git a/lua/opencode/cli/server.lua b/lua/opencode/cli/server.lua index fe3769d..128e91f 100644 --- a/lua/opencode/cli/server.lua +++ b/lua/opencode/cli/server.lua @@ -297,6 +297,19 @@ function M.get_port(launch) local Promise = require("opencode.promise") return Promise.new(function(resolve, reject) + -- Check if provider can supply port directly + local provider = require("opencode.config").provider + if provider and provider.get_port then + local provider_port = provider:get_port() + if provider_port then + local ok, _ = pcall(test_port, provider_port) + if ok then + resolve(provider_port) + return + end + end + end + local configured_port = require("opencode.config").opts.port local find_port_fn = configured_port and function() return test_port(configured_port) diff --git a/lua/opencode/provider/init.lua b/lua/opencode/provider/init.lua index ffcf844..6b8b65e 100644 --- a/lua/opencode/provider/init.lua +++ b/lua/opencode/provider/init.lua @@ -31,6 +31,10 @@ ---Should return `true` if the provider is available, ---else an error string and optional advice (for `vim.health.warn`). ---@field health? fun(): boolean|string, ...string|string[] +--- +---Get the port of the `opencode` server started by this provider. +---Returns `nil` if the provider cannot determine the port (falls back to CWD-based discovery). +---@field get_port? fun(self: opencode.Provider): number|nil ---Configure and enable built-in providers. ---@class opencode.provider.Opts diff --git a/lua/opencode/provider/kitty.lua b/lua/opencode/provider/kitty.lua index 7a818d2..e995de9 100644 --- a/lua/opencode/provider/kitty.lua +++ b/lua/opencode/provider/kitty.lua @@ -152,4 +152,9 @@ function Kitty:stop() end end +---@return number|nil +function Kitty:get_port() + return nil +end + return Kitty diff --git a/lua/opencode/provider/snacks.lua b/lua/opencode/provider/snacks.lua index ee3fc9f..60fdf72 100644 --- a/lua/opencode/provider/snacks.lua +++ b/lua/opencode/provider/snacks.lua @@ -59,4 +59,9 @@ function Snacks:stop() end end +---@return number|nil +function Snacks:get_port() + return nil +end + return Snacks diff --git a/lua/opencode/provider/terminal.lua b/lua/opencode/provider/terminal.lua index 240a54b..51c7b0a 100644 --- a/lua/opencode/provider/terminal.lua +++ b/lua/opencode/provider/terminal.lua @@ -72,4 +72,9 @@ function Terminal:stop() end end +---@return number|nil +function Terminal:get_port() + return nil +end + return Terminal diff --git a/lua/opencode/provider/tmux.lua b/lua/opencode/provider/tmux.lua index 2895c8c..5fe9370 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 +--- +---Cached port of the `opencode` server (internal use only). +---@field port? number local Tmux = {} Tmux.__index = Tmux Tmux.name = "tmux" @@ -20,6 +23,7 @@ function Tmux.new(opts) local self = setmetatable({}, Tmux) self.opts = opts or {} self.pane_id = nil + self.port = nil return self end @@ -85,7 +89,41 @@ function Tmux:stop() if pane_id then vim.fn.system("tmux kill-pane -t " .. pane_id) self.pane_id = nil + self.port = nil end end +---Get the PID of the shell process running in the opencode pane. +---@return number|nil pid +function Tmux:get_pane_process_pid() + local pane_id = self:get_pane_id() + if not pane_id then + return nil + end + + local output = vim.fn.system("tmux list-panes -t " .. pane_id .. " -F '#{pane_pid}'") + local pid = tonumber(vim.trim(output)) + return pid +end + +---Get the port of the opencode server started in this pane. +---Traces from pane PID through descendants to find the listening port. +---Caches the result for subsequent calls. +---@return number|nil port +function Tmux:get_port() + -- Return cached port if pane still exists + if self.port and self:get_pane_id() then + return self.port + end + + local pane_pid = self:get_pane_process_pid() + if not pane_pid then + return nil + end + + local process = require("opencode.util.process") + self.port = process.get_descendant_listening_port(pane_pid, 3) + return self.port +end + return Tmux diff --git a/lua/opencode/provider/wezterm.lua b/lua/opencode/provider/wezterm.lua index 9eb3db6..154c360 100644 --- a/lua/opencode/provider/wezterm.lua +++ b/lua/opencode/provider/wezterm.lua @@ -140,4 +140,9 @@ function Wezterm:stop() end end +---@return number|nil +function Wezterm:get_port() + return nil +end + return Wezterm diff --git a/lua/opencode/util/process.lua b/lua/opencode/util/process.lua new file mode 100644 index 0000000..7e4558d --- /dev/null +++ b/lua/opencode/util/process.lua @@ -0,0 +1,60 @@ +---Process utilities for provider implementations. +local M = {} + +---Get all descendant PIDs of a process (children, grandchildren, etc.) +---@param pid number The parent process ID +---@param max_depth? number Maximum recursion depth (default 3) +---@return number[] pids List of descendant PIDs +function M.get_descendants(pid, max_depth) + max_depth = max_depth or 3 + + local function recurse(current_pid, depth) + if depth > max_depth then + return {} + end + + local children = {} + local output = vim.fn.system("pgrep -P " .. current_pid .. " 2>/dev/null") + for child_pid in output:gmatch("%d+") do + local child = tonumber(child_pid) + table.insert(children, child) + for _, descendant in ipairs(recurse(child, depth + 1)) do + table.insert(children, descendant) + end + end + return children + end + + return recurse(pid, 1) +end + +---Find the TCP port a process is listening on. +---@param pid number The process ID +---@return number|nil port The listening port, or nil if not found +function M.get_listening_port(pid) + local lsof_output = vim.fn.system("lsof -w -iTCP -sTCP:LISTEN -P -n -a -p " .. pid .. " 2>/dev/null") + for line in lsof_output:gmatch("[^\r\n]+") do + local port = line:match(":(%d+)%s+%(LISTEN%)") + if port then + return tonumber(port) + end + end + return nil +end + +---Find the listening port of any descendant of a process. +---@param pid number The ancestor process ID +---@param max_depth? number Maximum recursion depth (default 3) +---@return number|nil port The listening port, or nil if not found +function M.get_descendant_listening_port(pid, max_depth) + local descendants = M.get_descendants(pid, max_depth) + for _, desc_pid in ipairs(descendants) do + local port = M.get_listening_port(desc_pid) + if port then + return port + end + end + return nil +end + +return M