diff --git a/lua/agent-deck/backend.lua b/lua/agent-deck/backend.lua new file mode 100644 index 0000000..d6a643f --- /dev/null +++ b/lua/agent-deck/backend.lua @@ -0,0 +1,57 @@ +-- backend.lua — dispatch layer for backend abstraction +-- +-- Single entry point for all backend operations. Callers use +-- require("agent-deck.backend") instead of require("agent-deck.cli"). +-- +-- Supports two backends: +-- "agent-deck" (default) — wraps cli.lua, sessions in tmux via agent-deck daemon +-- "cmux" — native cmux surfaces, plugin is the daemon +-- +-- init() must be called from setup() before any method is invoked. +-- All interface methods are forwarded to the active backend implementation. +local M = {} + +local log = require("agent-deck.logger") +local _impl = nil +local _name = "agent-deck" -- default backend + +--- Initialize the backend. Must be called once from setup(). +--- @param backend_name string|nil "agent-deck" (default) or "cmux" +function M.init(backend_name) + _name = backend_name or "agent-deck" + if _name == "cmux" then + _impl = require("agent-deck.backend.cmux") + else + _impl = require("agent-deck.backend.agent_deck") + end + log.info("backend.init: using '" .. _name .. "' backend") + -- Health check for cmux: verify app is running + if _impl.health_check then + _impl.health_check() + end +end + +--- Return the active backend name ("agent-deck" or "cmux"). +function M.name() + return _name +end + +-- ── Method forwarding ──────────────────────────────────────────────────────── +-- Every interface method is forwarded to the active backend implementation. +-- assert(_impl) guards against calling before init(). + +local METHODS = { + "status", "list_sessions", "session_show", "session_send", + "session_output", "session_start", "session_stop", "session_restart", + "session_delete", "launch", "group_list", "group_create", + "group_move", "session_set", "focus_session", +} + +for _, method in ipairs(METHODS) do + M[method] = function(...) + assert(_impl, "backend.init() not called — call require('agent-deck').setup() first") + return _impl[method](...) + end +end + +return M diff --git a/lua/agent-deck/backend/agent_deck.lua b/lua/agent-deck/backend/agent_deck.lua new file mode 100644 index 0000000..7ef1c87 --- /dev/null +++ b/lua/agent-deck/backend/agent_deck.lua @@ -0,0 +1,23 @@ +-- backend/agent_deck.lua — thin wrapper over cli.lua for the backend interface +-- +-- Re-exports all cli.lua methods unchanged and adds focus_session() as a +-- no-op since agent-deck sessions live inside Neovim terminal buffers +-- (focusing is handled by the picker/parallel UI layer, not the backend). +local cli = require("agent-deck.cli") +local M = setmetatable({}, { __index = cli }) + +--- No-op for agent-deck backend: sessions live in Neovim terminals, +--- so "focusing" is handled by the UI layer (picker/parallel). +function M.focus_session(_, cb) + if cb then cb(true, nil) end +end + +--- Health check: verify agent-deck binary is reachable. +function M.health_check() + local bin = vim.fn.exepath("agent-deck") + if bin == "" then + require("agent-deck.logger").warn("agent_deck backend: binary not found in PATH") + end +end + +return M diff --git a/lua/agent-deck/backend/ansi.lua b/lua/agent-deck/backend/ansi.lua new file mode 100644 index 0000000..7e74203 --- /dev/null +++ b/lua/agent-deck/backend/ansi.lua @@ -0,0 +1,21 @@ +-- backend/ansi.lua — ANSI escape sequence stripping utility +-- +-- Used by the cmux backend to clean raw terminal output from +-- `cmux read-screen` before presenting it to the user. +-- Strips CSI sequences, OSC sequences, single-char escapes, +-- and carriage returns. +local M = {} + +--- Strip ANSI escape sequences from terminal output text. +--- Returns clean plain text suitable for display in a Neovim buffer. +function M.strip(text) + if not text then return "" end + text = text:gsub("\27%[[\032-\063]*[\064-\126]", "") -- CSI sequences (e.g. \27[0m, \27[38;5;12m) + text = text:gsub("\27%].-\a", "") -- OSC sequences (BEL terminated) + text = text:gsub("\27%].-\27\\", "") -- OSC sequences (ST terminated) + text = text:gsub("\27.", "") -- single-char escape sequences + text = text:gsub("\r", "") -- carriage returns + return text +end + +return M diff --git a/lua/agent-deck/backend/cmux.lua b/lua/agent-deck/backend/cmux.lua new file mode 100644 index 0000000..5ab6b27 --- /dev/null +++ b/lua/agent-deck/backend/cmux.lua @@ -0,0 +1,835 @@ +-- backend/cmux.lua — cmux backend implementation +-- +-- When backend="cmux", this module replaces the agent-deck CLI as the session +-- management engine. Instead of delegating to an external daemon, the Neovim +-- plugin directly creates cmux surfaces, sends commands, and tracks metadata +-- in persist.lua. +-- +-- cmux is a native macOS terminal app with a CLI + Unix socket API. Sessions +-- run in cmux's own GPU-accelerated UI (libghostty) rather than in Neovim +-- terminal buffers. +-- +-- CLI command mapping (cmux v0.63+): +-- tree --all --json → list all surfaces across workspaces +-- send --surface → send text to a surface +-- send-key --surface → send keypress (enter, ctrl+c, etc.) +-- new-split → create split; returns "OK surface:X workspace:Y" +-- new-workspace --name → create workspace; returns "OK workspace:X" +-- close-surface --surface → close a surface +-- read-screen --surface → read terminal output +-- select-workspace → switch to workspace (for focus) +-- close-workspace → close a workspace +-- +-- Spawn pattern: identical to cli.lua — vim.uv.spawn + stdout pipe + +-- vim.schedule for Neovim API safety. +local M = {} + +local log = require("agent-deck.logger") +local persist = require("agent-deck.persist") +local ansi = require("agent-deck.backend.ansi") +local cmux_status = require("agent-deck.backend.cmux_status") + +-- ── Binary resolution ───────────────────────────────────────────────────────── +local _bin = vim.fn.exepath("cmux") +if _bin == "" then + -- cmux ships its CLI inside the app bundle; check common locations + local fallbacks = { + "/Applications/cmux.app/Contents/Resources/bin/cmux", + "/usr/local/bin/cmux", + } + for _, fb in ipairs(fallbacks) do + if vim.fn.executable(fb) == 1 then + _bin = fb + break + end + end + if _bin == "" then + _bin = "cmux" -- last resort; will surface spawn-failed error + end +end +log.info("cmux backend: resolved binary → " .. _bin) + +-- ── Low-level async spawn ───────────────────────────────────────────────────── + +--- Spawn the cmux binary with the given args list. +--- Callback receives (success:bool, raw_stdout:string). +--- Identical pattern to cli.lua's run_raw. +local function run_raw(args, callback) + local stdout_chunks = {} + local stdout = vim.uv.new_pipe() + local handle + log.debug("cmux run_raw: " .. table.concat(args, " ")) + handle = vim.uv.spawn(_bin, { + args = args, + stdio = { nil, stdout, nil }, + }, function(code) + stdout:close() + handle:close() + vim.schedule(function() + if code ~= 0 then + log.warn("cmux run_raw: exit code " .. code .. " for: " .. table.concat(args, " ")) + end + callback(code == 0, table.concat(stdout_chunks)) + end) + end) + if not handle then + stdout:close() + log.error("cmux run_raw: spawn failed for: " .. table.concat(args, " ")) + vim.schedule(function() + callback(false, "cmux: spawn failed (binary not found?)") + end) + return + end + stdout:read_start(function(_, chunk) + if chunk then + table.insert(stdout_chunks, chunk) + end + end) +end + +--- Run a cmux command that returns JSON output. +--- Callback receives (success:bool, decoded_table_or_raw_string). +local function run_json(args, callback) + run_raw(args, function(ok, raw) + if not ok then + callback(false, raw) + return + end + if raw == "" then + callback(true, nil) + return + end + local dec_ok, data = pcall(vim.json.decode, raw) + if not dec_ok then + log.warn("cmux run_json: JSON decode failed for: " .. table.concat(args, " ") + .. " — raw: " .. raw:sub(1, 200)) + end + callback(dec_ok, dec_ok and data or raw) + end) +end + +-- ── Helpers ─────────────────────────────────────────────────────────────────── + +--- Parse cmux "OK [...]" response lines. +--- Returns a table of ref strings, e.g. {"surface:3", "workspace:2"}. +local function parse_ok_refs(raw) + local refs = {} + if not raw or not raw:match("^OK") then return refs end + for ref in raw:gmatch("([%w]+:[%w%-]+)") do + table.insert(refs, ref) + end + return refs +end + +--- Extract a specific ref type from parsed refs. +--- E.g. extract_ref(refs, "surface") → "surface:3" +local function extract_ref(refs, prefix) + for _, r in ipairs(refs) do + if r:match("^" .. prefix .. ":") then return r end + end + return nil +end + +--- Fetch all live surfaces via `tree --all --json`. +--- Callback receives (success, {surface_ref = {workspace_ref=..., pane_ref=..., title=...}}). +local function get_live_surfaces(callback) + run_json({ "tree", "--all", "--json" }, function(ok, data) + if not ok then + callback(false, data) + return + end + local surfaces = {} + local windows = (type(data) == "table" and data.windows) or {} + for _, win in ipairs(windows) do + for _, ws in ipairs(win.workspaces or {}) do + local ws_ref = ws.ref or "" + local ws_title = ws.title or "" + for _, pane in ipairs(ws.panes or {}) do + for _, surf in ipairs(pane.surfaces or {}) do + local sref = surf.ref or "" + if sref ~= "" then + surfaces[sref] = { + workspace_ref = ws_ref, + workspace_title = ws_title, + pane_ref = pane.ref or "", + title = surf.title or "", + type = surf.type or "terminal", + } + end + end + end + end + end + callback(true, surfaces) + end) +end + +--- Extract workspaces from tree JSON data. +--- Returns list of {ref, title, description, surface_count}. +local function extract_workspaces(tree_data) + local result = {} + local windows = (type(tree_data) == "table" and tree_data.windows) or {} + for _, win in ipairs(windows) do + for _, ws in ipairs(win.workspaces or {}) do + local surf_count = 0 + for _, pane in ipairs(ws.panes or {}) do + surf_count = surf_count + #(pane.surfaces or {}) + end + table.insert(result, { + ref = ws.ref or "", + title = ws.title or "", + description = ws.description, + surface_count = surf_count, + }) + end + end + return result +end + +-- ── Interface methods ───────────────────────────────────────────────────────── + +--- Derive aggregate status counts from the session list. +--- Mirrors the shape of `agent-deck status --json`: +--- { running, waiting, idle, error, stopped, total } +function M.status(cb) + M.list_sessions(function(ok, sessions) + if not ok then + log.warn("cmux status: list_sessions failed") + cb(false, sessions) + return + end + local counts = { running = 0, waiting = 0, idle = 0, error = 0, stopped = 0, total = 0 } + for _, s in ipairs(sessions) do + counts.total = counts.total + 1 + local st = s.status or "idle" + counts[st] = (counts[st] or 0) + 1 + end + log.debug("cmux status: " .. counts.total .. " total, " + .. counts.running .. " running, " .. counts.waiting .. " waiting, " + .. counts.stopped .. " stopped") + cb(true, counts) + end) +end + +--- List all tracked cmux sessions. +--- Cross-references persist metadata with live cmux surfaces via `tree --all --json`. +function M.list_sessions(cb) + get_live_surfaces(function(ok, live_surfaces) + if not ok then + log.error("cmux list_sessions: tree failed — " .. tostring(live_surfaces)) + cb(false, live_surfaces) + return + end + + local all_cmux = persist.all_cmux_sessions() + local result = {} + local live_count = 0 + local tracked_count = 0 + + for ref, _ in pairs(live_surfaces) do live_count = live_count + 1 end + + for surface_id, meta in pairs(all_cmux) do + tracked_count = tracked_count + 1 + local alive = live_surfaces[surface_id] ~= nil + local status + + if not alive then + status = "stopped" + elseif meta.tool == "claude" and meta.claude_session_id and meta.claude_session_id ~= "" then + status = cmux_status.detect(meta.path, meta.claude_session_id) + else + status = "running" + end + + table.insert(result, { + id = surface_id, + title = meta.title or surface_id, + path = meta.path, + group = meta.group, + tool = meta.tool, + command = meta.command, + status = status, + claude_session_id = meta.claude_session_id, + created_at = meta.created_at, + }) + end + + log.debug("cmux list_sessions: " .. live_count .. " live surfaces, " + .. tracked_count .. " tracked sessions, " .. #result .. " returned") + cb(true, result) + end) +end + +--- Show details for a specific session. +--- Returns the persisted metadata + verifies surface is alive. +function M.session_show(id, cb) + local meta = persist.get_cmux_session(id) + if not meta then + log.warn("cmux session_show: session not found in persist — " .. tostring(id)) + vim.schedule(function() + cb(false, "cmux: session not found — " .. tostring(id)) + end) + return + end + + get_live_surfaces(function(ok, live_surfaces) + if not ok then + log.warn("cmux session_show: tree failed for " .. id) + cb(false, live_surfaces) + return + end + + local alive = live_surfaces[id] ~= nil + local status + if not alive then + status = "stopped" + elseif meta.tool == "claude" and meta.claude_session_id and meta.claude_session_id ~= "" then + status = cmux_status.detect(meta.path, meta.claude_session_id) + else + status = "running" + end + + log.debug("cmux session_show: " .. id .. " alive=" .. tostring(alive) .. " status=" .. status) + cb(true, vim.tbl_extend("force", meta, { status = status })) + end) +end + +--- Send text to a cmux surface. +--- Uses `cmux send --surface -- ` + `cmux send-key --surface enter`. +function M.session_send(id, text, opts, cb) + log.info("cmux session_send: sending " .. #text .. " chars to surface " .. id) + run_raw({ "send", "--surface", id, "--", text }, function(ok, raw) + if not ok then + log.error("cmux session_send: send failed for " .. id .. " — " .. tostring(raw)) + vim.notify("agent-deck [cmux]: failed to send to " .. id, vim.log.levels.ERROR) + if cb then cb(false, raw) end + return + end + -- Send enter key to submit + run_raw({ "send-key", "--surface", id, "enter" }, function(ok2, raw2) + if not ok2 then + log.warn("cmux session_send: send-key enter failed for " .. id) + else + log.debug("cmux session_send: sent successfully to " .. id) + end + if cb then cb(ok2, raw2) end + end) + end) +end + +--- Read screen output from a cmux surface. +--- Uses `cmux read-screen --surface --scrollback --lines 200` with ANSI stripping. +function M.session_output(id, cb) + log.debug("cmux session_output: reading screen for " .. id) + run_raw({ "read-screen", "--surface", id, "--scrollback", "--lines", "200" }, function(ok, raw) + if not ok then + log.warn("cmux session_output: read-screen failed for " .. id .. " — returning empty") + vim.notify("agent-deck [cmux]: read-screen failed for " .. id, vim.log.levels.WARN) + cb(true, { output = "" }) + return + end + local cleaned = ansi.strip(raw) + log.debug("cmux session_output: got " .. #cleaned .. " chars (stripped from " .. #raw .. " raw)") + cb(true, { output = cleaned }) + end) +end + +--- Start a session by rebuilding and sending the tool command. +--- Unlike the initial launch (which uses --session-id for new sessions), +--- restart always uses --resume since the conversation already exists. +function M.session_start(id, cb) + local meta = persist.get_cmux_session(id) + if not meta then + log.error("cmux session_start: session not found in persist — " .. tostring(id)) + vim.notify("agent-deck [cmux]: session not found — " .. tostring(id), vim.log.levels.ERROR) + vim.schedule(function() + cb(false, "cmux: session not found — " .. tostring(id)) + end) + return + end + + -- Rebuild command for current state using session_cmd (picks --resume + -- for existing conversations, handles claude wrapper + env) + local cmd + local tool = meta.tool or "claude" + if tool == "claude" and meta.claude_session_id and meta.claude_session_id ~= "" then + local scmd = require("agent-deck.session_cmd") + cmd = scmd.build_cmd(meta) -- always --resume for existing sessions + else + cmd = meta.command or tool + end + + if not cmd then + log.error("cmux session_start: could not build command for " .. id) + vim.schedule(function() cb(false, "cmux: no command for " .. id) end) + return + end + + local full_cmd = "cd " .. vim.fn.shellescape(meta.path or vim.fn.getcwd()) .. " && " .. cmd + log.info("cmux session_start: sending '" .. full_cmd .. "' to " .. id) + + -- Send command to the surface shell (assumes tool has been stopped and + -- the surface is at a shell prompt — use session_stop first if needed) + run_raw({ "send", "--surface", id, "--", full_cmd }, function(ok, raw) + if not ok then + log.error("cmux session_start: send failed for " .. id) + vim.notify("agent-deck [cmux]: failed to start " .. (meta.title or id), vim.log.levels.ERROR) + cb(false, raw) + return + end + run_raw({ "send-key", "--surface", id, "enter" }, function(ok2, raw2) + if ok2 then + log.info("cmux session_start: started " .. (meta.title or id)) + vim.notify("agent-deck [cmux]: started " .. (meta.title or id)) + end + cb(ok2, raw2) + end) + end) +end + +--- Stop a session by creating a new empty surface then closing the old one. +--- cmux has no process-kill API; close-surface is the only way to terminate +--- the running tool. New surface is created FIRST to avoid "cannot close +--- last surface" error. +function M.session_stop(id, cb) + local meta = persist.get_cmux_session(id) + local wref = meta and meta.workspace_id + log.info("cmux session_stop: create+close " .. id .. " in " .. (wref or "?")) + + if not wref then + log.warn("cmux session_stop: no workspace for " .. id) + cb(false, "cmux: no workspace for " .. id) + return + end + + -- Step 1: create new surface FIRST (so old one is never the last) + run_raw({ "new-split", "right", "--workspace", wref }, function(ok, raw) + if not ok then + log.warn("cmux session_stop: new-split failed — " .. tostring(raw)) + cb(false, raw) + return + end + + local refs = parse_ok_refs(raw) + local new_ref = extract_ref(refs, "surface") + + -- Step 2: close the old surface (safe now) + run_raw({ "close-surface", "--surface", id }, function(ok2, _) + if not ok2 then + log.warn("cmux session_stop: close-surface failed for " .. id .. " (continuing)") + end + + -- Update persist to point to the new surface + if new_ref and meta then + persist.remove_cmux_session(id) + meta.surface_id = new_ref + meta.id = new_ref + persist.set_cmux_session(new_ref, meta) + if meta.group then + persist.remove_session(meta.group, id) + persist.add_session(meta.group, new_ref) + end + log.info("cmux session_stop: replaced " .. id .. " → " .. new_ref) + end + + vim.notify("agent-deck [cmux]: stopped " .. (meta and meta.title or id)) + cb(true, raw) + end) + end) +end + +--- Restart a session: create new surface first, close old one, run the tool command. +--- Order matters: new-split BEFORE close-surface, because cmux refuses to close +--- the last surface in a workspace. +function M.session_restart(id, cb) + local meta = persist.get_cmux_session(id) + if not meta then + log.error("cmux session_restart: session not found — " .. tostring(id)) + cb(false, "cmux: session not found — " .. tostring(id)) + return + end + + -- Rebuild command for current state. + -- Always use the bare tool name as base — never meta.command, which may + -- contain a resume command from a previous restart and would double up. + local cmd + local tool = meta.tool or "claude" + if tool == "claude" and meta.claude_session_id and meta.claude_session_id ~= "" then + local scmd = require("agent-deck.session_cmd") + meta.id = meta.id or meta.surface_id or id -- ensure id for build_cmd logging + cmd = scmd.build_cmd(vim.tbl_extend("force", meta, { command = nil })) + elseif tool == "codex" and meta.codex_thread_id and meta.codex_thread_id ~= "" then + cmd = "codex resume " .. meta.codex_thread_id + else + cmd = tool + end + + if not cmd then + log.error("cmux session_restart: could not build command for " .. id) + cb(false, "cmux: no command for " .. id) + return + end + + local wref = meta.workspace_id + local path = meta.path or vim.fn.getcwd() + local full_cmd = "cd " .. vim.fn.shellescape(path) .. " && " .. cmd + log.info("cmux session_restart: " .. id .. " → " .. full_cmd) + + -- Step 1: create a new surface FIRST (so the old one is never the last) + run_raw({ "new-split", "right", "--workspace", wref }, function(ok, raw) + if not ok then + log.error("cmux session_restart: new-split failed in " .. (wref or "?")) + cb(false, "cmux: failed to create new surface") + return + end + + local refs = parse_ok_refs(raw) + local new_ref = extract_ref(refs, "surface") + if not new_ref then + log.error("cmux session_restart: could not extract surface ref") + cb(false, "cmux: could not determine new surface ref") + return + end + + -- Step 2: close the OLD surface (safe now — new one exists) + run_raw({ "close-surface", "--surface", id }, function(ok2, _) + if not ok2 then + log.warn("cmux session_restart: close-surface failed for " .. id .. " (continuing)") + end + + -- Step 3: update persist with new surface ref. + -- Keep command as bare tool name (not the resume variant) so future + -- restarts don't double up resume arguments. + persist.remove_cmux_session(id) + meta.surface_id = new_ref + meta.id = new_ref + meta.command = tool -- bare tool name, not the resume command + persist.set_cmux_session(new_ref, meta) + if meta.group then + persist.remove_session(meta.group, id) + persist.add_session(meta.group, new_ref) + end + + -- Step 4: send the tool command to the new surface + run_raw({ "send", "--surface", new_ref, "--", full_cmd }, function(ok3, _) + if not ok3 then + log.error("cmux session_restart: send failed for " .. new_ref) + cb(false, "cmux: failed to send command") + return + end + run_raw({ "send-key", "--surface", new_ref, "enter" }, function(ok4, raw4) + if ok4 then + log.info("cmux session_restart: restarted " .. (meta.title or id) .. " as " .. new_ref) + vim.notify("agent-deck [cmux]: restarted " .. (meta.title or id)) + end + cb(ok4, raw4) + end) + end) + end) + end) +end + +--- Delete a session by closing the cmux surface and removing persist metadata. +function M.session_delete(id, cb) + local meta = persist.get_cmux_session(id) + log.info("cmux session_delete: closing surface " .. id + .. " (title=" .. (meta and meta.title or "?") .. ")") + run_raw({ "close-surface", "--surface", id }, function(ok, raw) + persist.remove_cmux_session(id) + if meta and meta.group then + persist.remove_session(meta.group, id) + end + if ok then + vim.notify("agent-deck [cmux]: deleted " .. (meta and meta.title or id)) + log.info("cmux session_delete: closed and removed " .. id) + else + log.error("cmux session_delete: close-surface failed for " .. id .. " — " .. tostring(raw)) + vim.notify("agent-deck [cmux]: failed to delete " .. id, vim.log.levels.ERROR) + end + cb(ok, raw) + end) +end + +--- Launch a new session in cmux. +--- +--- Flow: +--- 1. Find/create workspace for the group +--- 2. Create a new split surface in the workspace +--- 3. Generate UUID for claude_session_id +--- 4. Build and send the tool command +--- 5. Store metadata in persist +function M.launch(path, opts, cb) + opts = opts or {} + local tool = opts.tool or "claude" + local title = opts.title or tool + local group = opts.group or "default" + + log.info("cmux launch: tool=" .. tool .. " title='" .. title + .. "' group=" .. group .. " path=" .. path) + + -- Step 1: list existing workspaces via tree to find one for this group + run_json({ "tree", "--all", "--json" }, function(ok, tree_data) + if not ok then + log.error("cmux launch: tree failed") + vim.notify("agent-deck [cmux]: failed to list workspaces", vim.log.levels.ERROR) + cb(false, tree_data) + return + end + + local workspaces = extract_workspaces(tree_data) + local workspace_ref = nil + for _, ws in ipairs(workspaces) do + if ws.title == group and ws.ref ~= "" then + workspace_ref = ws.ref + log.debug("cmux launch: found existing workspace " .. ws.ref .. " for group " .. group) + break + end + end + + local function create_surface(wref) + -- Step 2: create a new split surface in the workspace + log.debug("cmux launch: creating split in workspace " .. wref) + run_raw({ "new-split", "right", "--workspace", wref }, function(ok2, raw) + if not ok2 then + log.error("cmux launch: new-split failed in workspace " .. wref .. " — " .. tostring(raw)) + vim.notify("agent-deck [cmux]: failed to create surface", vim.log.levels.ERROR) + cb(false, raw) + return + end + + local refs = parse_ok_refs(raw) + local surface_ref = extract_ref(refs, "surface") + if not surface_ref then + log.error("cmux launch: could not extract surface ref from new-split response: " .. raw) + cb(false, "cmux: could not determine surface ref from new-split response") + return + end + log.info("cmux launch: created " .. surface_ref .. " in " .. wref) + + -- Step 3: generate UUID for claude_session_id + local uuid = vim.fn.system("uuidgen"):gsub("%s+", "") + log.debug("cmux launch: generated UUID " .. uuid .. " for claude_session_id") + + -- Step 4: build command (uses agent-deck config for claude wrapper/env) + local cmd + if tool == "claude" then + local scmd = require("agent-deck.session_cmd") + cmd = scmd.build_cmd_new( + { tool = "claude", claude_session_id = uuid, id = "cmux-new", path = path }, + function() return false end -- new session, conv never exists yet + ) + elseif tool == "codex" then + cmd = "codex" + elseif tool == "opencode" then + cmd = "opencode" + else + cmd = tool + end + log.info("cmux launch: sending command '" .. cmd .. "' to " .. surface_ref) + + -- cd to project path first, then send the tool command + -- cmux surfaces default to ~ ; we need the correct cwd for + -- Claude's .jsonl path encoding and project context. + local full_cmd = "cd " .. vim.fn.shellescape(path) .. " && " .. cmd + log.debug("cmux launch: full command: " .. full_cmd) + run_raw({ "send", "--surface", surface_ref, "--", full_cmd }, function(ok3, _) + if not ok3 then + log.error("cmux launch: send failed for " .. surface_ref) + vim.notify("agent-deck [cmux]: failed to send command", vim.log.levels.ERROR) + cb(false, "cmux: failed to send command to " .. surface_ref) + return + end + run_raw({ "send-key", "--surface", surface_ref, "enter" }, function(ok4, _) + if not ok4 then + log.error("cmux launch: send-key enter failed for " .. surface_ref) + cb(false, "cmux: failed to send enter to " .. surface_ref) + return + end + + -- Step 5: store metadata in persist + -- Use UTC date string (not unix int) so codex.infer_thread_id's + -- SQLite strftime('%s', created_at) converts correctly to epoch. + -- Local time would cause a timezone offset mismatch. + local now = os.date("!%Y-%m-%d %H:%M:%S") + persist.set_cmux_session(surface_ref, { + surface_id = surface_ref, + workspace_id = wref, + tool = tool, + title = title, + path = path, + group = group, + command = cmd, + claude_session_id = (tool == "claude") and uuid or nil, + created_at = now, + }) + persist.add_session(group, surface_ref) + + log.info("cmux launch: session '" .. title .. "' launched successfully" + .. " (surface=" .. surface_ref .. ", workspace=" .. wref .. ")") + vim.notify("agent-deck [cmux]: launched '" .. title .. "'") + + cb(true, { + id = surface_ref, + title = title, + path = path, + group = group, + tool = tool, + command = cmd, + claude_session_id = (tool == "claude") and uuid or nil, + created_at = now, + }) + end) + end) + end) + end + + if workspace_ref then + create_surface(workspace_ref) + else + -- Step 1b: create a new named workspace for this group + log.info("cmux launch: creating new workspace for group " .. group) + run_raw({ "new-workspace", "--name", group, "--cwd", path }, function(ok2, raw) + if not ok2 then + log.error("cmux launch: new-workspace failed for group " .. group .. " — " .. tostring(raw)) + vim.notify("agent-deck [cmux]: failed to create workspace", vim.log.levels.ERROR) + cb(false, raw) + return + end + local refs = parse_ok_refs(raw) + local wref = extract_ref(refs, "workspace") + if not wref then + log.error("cmux launch: could not extract workspace ref from response: " .. raw) + cb(false, "cmux: could not determine workspace ref from new-workspace response") + return + end + log.info("cmux launch: created " .. wref .. " for group " .. group) + create_surface(wref) + end) + end + end) +end + +--- List cmux workspaces as groups. +--- Returns { groups: [{name, session_count, ...}] } matching agent-deck format. +function M.group_list(cb) + log.debug("cmux group_list: fetching workspaces via tree") + run_json({ "tree", "--all", "--json" }, function(ok, tree_data) + if not ok then + log.error("cmux group_list: tree failed") + cb(false, tree_data) + return + end + + local workspaces = extract_workspaces(tree_data) + + -- Count tracked surfaces per workspace + local all_cmux = persist.all_cmux_sessions() + local ws_counts = {} + for _, meta in pairs(all_cmux) do + local wid = meta.workspace_id + if wid then + ws_counts[wid] = (ws_counts[wid] or 0) + 1 + end + end + + local groups = {} + for _, ws in ipairs(workspaces) do + table.insert(groups, { + name = ws.title, + workspace_id = ws.ref, + session_count = ws_counts[ws.ref] or 0, + }) + end + + log.debug("cmux group_list: " .. #groups .. " workspace(s)") + cb(true, { groups = groups }) + end) +end + +--- Create a new cmux workspace. +function M.group_create(name, cb) + log.info("cmux group_create: creating workspace '" .. name .. "'") + run_raw({ "new-workspace", "--name", name }, function(ok, raw) + if ok then + log.info("cmux group_create: workspace '" .. name .. "' created — " .. raw:gsub("%s+$", "")) + else + log.error("cmux group_create: failed to create workspace '" .. name .. "'") + vim.notify("agent-deck [cmux]: failed to create workspace '" .. name .. "'", vim.log.levels.ERROR) + end + cb(ok, raw) + end) +end + +--- Move a session to a different group (persist-only for cmux). +--- cmux does not support cross-workspace surface moves natively. +function M.group_move(id, group, cb) + local meta = persist.get_cmux_session(id) + if meta then + meta.group = group + persist.set_cmux_session(id, meta) + log.info("cmux group_move: updated group for " .. id .. " → " .. group .. " (persist-only)") + else + log.warn("cmux group_move: session " .. id .. " not found in persist") + end + vim.schedule(function() + cb(true, nil) + end) +end + +--- Update a session field in persist (cmux metadata is plugin-managed). +function M.session_set(id, field, value, cb) + local meta = persist.get_cmux_session(id) + if meta then + local key = field:gsub("-", "_") + meta[key] = value + persist.set_cmux_session(id, meta) + log.debug("cmux session_set: " .. id .. "." .. key .. " = " .. tostring(value)) + else + log.warn("cmux session_set: session " .. id .. " not found in persist") + end + vim.schedule(function() + cb(true, nil) + end) +end + +--- Focus a cmux surface: bring its workspace to the front. +--- Uses `cmux select-workspace --workspace ` since cmux has no +--- direct focus-surface command. +function M.focus_session(id, cb) + local meta = persist.get_cmux_session(id) + local wref = meta and meta.workspace_id + log.info("cmux focus_session: focusing " .. id + .. " (workspace=" .. (wref or "?") .. ")") + + if wref then + run_raw({ "select-workspace", "--workspace", wref }, function(ok, raw) + if not ok then + log.warn("cmux focus_session: select-workspace failed for " .. wref) + vim.notify("agent-deck [cmux]: failed to focus " .. id, vim.log.levels.WARN) + else + log.debug("cmux focus_session: selected workspace " .. wref) + end + if cb then cb(ok, raw) end + end) + else + log.warn("cmux focus_session: no workspace_id for " .. id) + vim.notify("agent-deck [cmux]: no workspace info for " .. id, vim.log.levels.WARN) + if cb then vim.schedule(function() cb(false, "no workspace_id") end) end + end +end + +--- Health check: verify cmux is reachable via `cmux ping`. +function M.health_check() + run_raw({ "ping" }, function(ok, raw) + if ok then + log.info("cmux health_check: ping succeeded — cmux is reachable") + vim.notify("agent-deck [cmux]: connected to cmux") + else + log.error("cmux health_check: ping failed — " .. (raw or "unknown error") + .. ". Is cmux running? Ensure socket access is enabled in cmux Settings.") + vim.notify("agent-deck [cmux]: cannot reach cmux! Is it running?", vim.log.levels.ERROR) + end + end) +end + +return M diff --git a/lua/agent-deck/backend/cmux_status.lua b/lua/agent-deck/backend/cmux_status.lua new file mode 100644 index 0000000..3e3ba9a --- /dev/null +++ b/lua/agent-deck/backend/cmux_status.lua @@ -0,0 +1,96 @@ +-- backend/cmux_status.lua — .jsonl tail parser for session status detection +-- +-- Claude stores conversation state in .jsonl files under: +-- ~/.claude/projects//.jsonl +-- +-- This module reads the tail of a .jsonl file (last 8KB) and determines +-- the session's status based on the last JSON entry: +-- +-- type == "assistant" + stop_reason == "end_turn" → "waiting" +-- (Claude finished responding, awaiting user input) +-- +-- type == "human" or "user" → "running" +-- (user sent a message, Claude is processing) +-- +-- file missing or empty → "idle" +-- (session exists but no conversation yet) +-- +-- Performance: single seek + read of at most 8KB — sub-millisecond for any +-- file size. No full file scan needed. +local M = {} + +local log = require("agent-deck.logger") +local claude_paths = require("agent-deck.claude_paths") + +--- Detect the status of a Claude session by reading the tail of its .jsonl file. +--- +--- @param path string The project working directory (absolute path) +--- @param session_id string The claude_session_id (UUID) +--- @return string One of: "waiting", "running", "idle" +function M.detect(path, session_id) + if not path or not session_id or session_id == "" then + return "idle" + end + + local fpath = claude_paths.conv_path(path, session_id) + local f = io.open(fpath, "r") + if not f then + log.debug("cmux_status.detect: file not found — " .. fpath) + return "idle" + end + + -- Seek to end minus 8KB and read the tail + local size = f:seek("end") + if size == 0 then + f:close() + return "idle" + end + + local seek_pos = math.max(0, size - 8192) + f:seek("set", seek_pos) + local tail = f:read("*a") + f:close() + + if not tail or tail == "" then + return "idle" + end + + -- Collect all non-empty lines from the tail, then walk backwards + -- to find the last meaningful entry (assistant/human/user). + -- The .jsonl contains system, attachment, and other entries that + -- should be skipped for status detection. + local lines = {} + for line in tail:gmatch("[^\n]+") do + if line:match("%S") then + lines[#lines + 1] = line + end + end + + -- Walk backwards to find the last assistant/human/user entry + for i = #lines, 1, -1 do + local ok, entry = pcall(vim.json.decode, lines[i]) + if ok and type(entry) == "table" then + local entry_type = entry.type + if entry_type == "assistant" then + -- stop_reason may be at entry.stop_reason or entry.message.stop_reason + local sr = entry.stop_reason + if not sr and type(entry.message) == "table" then + sr = entry.message.stop_reason + end + if sr == "end_turn" then + return "waiting" + end + -- Assistant message without end_turn — still generating + return "running" + elseif entry_type == "human" or entry_type == "user" then + return "running" + end + -- Skip system, attachment, and other non-message entries + end + end + + -- No assistant/human entry found — idle + return "idle" +end + +return M diff --git a/lua/agent-deck/claude_paths.lua b/lua/agent-deck/claude_paths.lua new file mode 100644 index 0000000..3088460 --- /dev/null +++ b/lua/agent-deck/claude_paths.lua @@ -0,0 +1,45 @@ +-- claude_paths.lua — Claude conversation file path utilities +-- +-- Extracted from picker.lua to share with cmux_status.lua and other modules +-- that need to locate Claude's .jsonl conversation files. +-- +-- Claude stores conversations under: +-- ~/.claude/projects//.jsonl +-- where is the absolute project path with every "/" replaced by "-". +local M = {} + +--- Encode an absolute path the way Claude does for its projects directory. +--- Every "/" is replaced with "-". +--- @param path string Absolute project path (e.g. "/Users/me/project") +--- @return string Encoded path (e.g. "-Users-me-project") +function M.encode_path(path) + if not path then return "" end + -- Claude replaces both "/" and "." with "-" in project directory encoding + return path:gsub("[/.]", "-") +end + +--- Return the full .jsonl conversation file path for a given project path +--- and claude session ID. +--- @param path string The project working directory (absolute path) +--- @param session_id string The claude_session_id (UUID) +--- @return string Full path to the .jsonl file +function M.conv_path(path, session_id) + local encoded = M.encode_path(path) + return vim.fn.expand("~/.claude/projects/") .. encoded .. "/" .. session_id .. ".jsonl" +end + +--- Return true if a .jsonl conversation file exists for the given claude session ID +--- under the encoded project path in ~/.claude/projects/. +--- +--- This predicate is the single source of truth for distinguishing a brand-new +--- session (file absent) from an existing conversation (file present). +--- @param path string The project working directory (absolute path) +--- @param session_id string The claude_session_id (UUID) +--- @return boolean +function M.conv_exists(path, session_id) + if not path or not session_id or session_id == "" then return false end + local fpath = M.conv_path(path, session_id) + return vim.fn.filereadable(fpath) == 1 +end + +return M diff --git a/lua/agent-deck/init.lua b/lua/agent-deck/init.lua index a6e0cec..9f9f1e4 100644 --- a/lua/agent-deck/init.lua +++ b/lua/agent-deck/init.lua @@ -32,11 +32,11 @@ local function is_active() end local function do_poll() - local cli = require("agent-deck.cli") - local state = require("agent-deck.state") + local backend = require("agent-deck.backend") + local state = require("agent-deck.state") -- Step 1: fetch aggregate status counts (cheap, always runs) - cli.status(function(ok, data) + backend.status(function(ok, data) if not ok or type(data) ~= "table" then log.debug("do_poll: status fetch failed or returned non-table") return @@ -57,7 +57,7 @@ local function do_poll() if changed or state._picker_open then log.debug("do_poll: fetching full session list (changed=" .. tostring(changed) .. ", picker_open=" .. tostring(state._picker_open) .. ")") - cli.list_sessions(function(ok2, sessions) + backend.list_sessions(function(ok2, sessions) if ok2 and type(sessions) == "table" then state.set_sessions(sessions) local project = state.current_project @@ -75,6 +75,68 @@ local function do_poll() end -- Prune any errored sessions from the persist map persist.load_project(project) + + -- ── Codex thread sync ────────────────────────────────────────────── + -- Codex thread IDs live only in Codex's own SQLite DB. The plugin + -- resolves them via codex.enrich_session (shared util) and persists + -- the mapping. Threads appear only after the user sends the first + -- message, so poll-based sync catches them after launch. + -- + -- agent-deck and cmux backends use separate persist stores: + -- agent-deck: persist._codex_threads[agent-deck-session-id] + -- cmux: persist._cmux_sessions[surface-ref].codex_thread_id + local codex = require("agent-deck.codex") + + if backend.name() == "cmux" then + -- cmux backend: read from cmux persist, enrich, write back + for _, s in ipairs(sessions) do + if (s.tool or "") == "codex" + and s.status ~= "error" and s.status ~= "stopped" + and grp.slugify(s.group or "") == project then + local cmeta = persist.get_cmux_session(s.id) + if cmeta and (not cmeta.codex_thread_id or cmeta.codex_thread_id == "") then + log.debug("poll (cmux): codex session " .. s.id .. " — no thread, enriching") + -- codex.enrich_session needs: tool, path, created_at, id + cmeta.id = cmeta.id or cmeta.surface_id or s.id + codex.enrich_session(cmeta, function(enriched) + local tid = enriched and enriched.codex_thread_id + if tid then + -- Write back to cmux persist so session_restart can use it + cmeta.codex_thread_id = tid + persist.set_cmux_session(s.id, cmeta) + log.info("poll (cmux): codex thread synced — " .. s.id .. " → " .. tid) + else + log.debug("poll (cmux): codex thread not yet available for " .. s.id) + end + end) + end + end + end + else + -- agent-deck backend: uses _codex_threads persist + session_show + for _, s in ipairs(sessions) do + if (s.tool or "") == "codex" + and s.status ~= "error" and s.status ~= "stopped" + and grp.slugify(s.group or "") == project then + local saved = persist.get_codex_thread(s.id) + if not saved or saved == "" then + log.debug("poll: codex session " .. s.id .. " in project — no persisted thread, enriching") + backend.session_show(s.id, function(ok3, detail) + if ok3 and type(detail) == "table" then + codex.enrich_session(detail, function(enriched) + local tid = enriched and enriched.codex_thread_id + if tid then + log.info("poll: codex thread synced — " .. s.id .. " → " .. tid) + else + log.debug("poll: codex thread not yet available for " .. s.id) + end + end) + end + end) + end + end + end + end end else log.warn("do_poll: list_sessions failed or returned non-table") @@ -102,6 +164,15 @@ end function M.setup(opts) opts = opts or {} + -- Apply custom_claude_cmd override before backend init (cmux launch uses it too) + if opts.custom_claude_cmd then + require("agent-deck.session_cmd").set_custom_claude_cmd(opts.custom_claude_cmd) + end + + -- Initialize backend dispatch layer before anything else uses it + local backend = require("agent-deck.backend") + backend.init(opts.backend) + local persist = require("agent-deck.persist") local group = require("agent-deck.group") local state = require("agent-deck.state") @@ -177,8 +248,11 @@ function M.setup(opts) end, }) - -- Start the staleness detection timer (2-min periodic CLI-ahead check) - require("agent-deck.sync").start_timer() + -- Start the staleness detection timer (2-min periodic CLI-ahead check). + -- Only relevant for agent-deck backend — cmux sessions don't have CLI-ahead drift. + if backend.name() ~= "cmux" then + require("agent-deck.sync").start_timer() + end vim.api.nvim_create_autocmd("VimLeavePre", { group = ag, @@ -204,13 +278,36 @@ end --- kills and respawns the Neovide-side terminal buffers. Dar operates on the --- external daemon; DaR operates on Neovide's internal buffers. function M.refresh() - local state = require("agent-deck.state") - local cli = require("agent-deck.cli") - local ps = state.project_sessions() + local state = require("agent-deck.state") + local backend = require("agent-deck.backend") + local ps = state.project_sessions() if #ps == 0 then vim.notify("agent-deck: no sessions for current project", vim.log.levels.WARN) return end + + -- ── cmux backend: simple restart via respawn-pane ───────────────────────── + -- No agent-deck-specific workarounds needed (codex stop→set→start, + -- claude_session_id restore). respawn-pane kills the process and starts + -- the new command atomically. + if backend.name() == "cmux" then + log.info("refresh (Dar/cmux): restarting " .. #ps .. " session(s) via respawn-pane") + local done = 0 + for _, s in ipairs(ps) do + backend.session_restart(s.id, function(ok2) + done = done + 1 + if ok2 then + vim.notify("agent-deck: restarted " .. (s.title or s.id)) + else + vim.notify("agent-deck: failed to restart " .. (s.title or s.id), vim.log.levels.ERROR) + end + if done == #ps then do_poll() end + end) + end + return + end + + -- ── agent-deck backend: full restart with workarounds ───────────────────── log.info("refresh (Dar): restarting " .. #ps .. " session(s) in external agent-deck") -- Step 1: fetch full details for all sessions to capture their claude_session_id @@ -219,7 +316,7 @@ function M.refresh() local details = {} -- agent-deck session id → full session data (including claude_session_id) for _, s in ipairs(ps) do - cli.session_show(s.id, function(ok, data) + backend.session_show(s.id, function(ok, data) fetched = fetched + 1 if ok and type(data) == "table" then details[s.id] = data @@ -229,31 +326,60 @@ function M.refresh() log.warn("refresh (Dar): session_show failed for " .. s.id .. " — restart may collide") end if fetched == count then - -- Step 2: restart each session, then restore its original claude_session_id - local done = 0 + -- Step 2: look up codex thread IDs from persist + local persist = require("agent-deck.persist") + local codex_threads = {} for _, s2 in ipairs(ps) do - cli.session_restart(s2.id, function(ok2, _) - done = done + 1 - if ok2 then - vim.notify("agent-deck: restarted " .. (s2.title or s2.id)) - -- Restore the original claude_session_id so agent-deck keeps - -- distinct conversations even when sessions share a path. - -- Without this, agent-deck would assign the "last used in dir" - -- conversation to whichever session was restarted last. - local orig_id = details[s2.id] and details[s2.id].claude_session_id - if orig_id and orig_id ~= "" then - log.debug("refresh (Dar): restoring claude_session_id=" .. orig_id - .. " for " .. s2.id) - cli.session_set(s2.id, "claude-session-id", orig_id, function() end) - end - else - log.error("refresh (Dar): session_restart failed for " .. (s2.title or s2.id)) - vim.notify("agent-deck: failed to restart " .. (s2.title or s2.id), vim.log.levels.ERROR) + local tool = (details[s2.id] or {}).tool or s2.tool or "" + if tool == "codex" then + local saved = persist.get_codex_thread(s2.id) + if saved and saved ~= "" then + codex_threads[s2.id] = saved + log.debug("refresh (Dar): persisted codex_thread_id=" .. saved .. " for " .. s2.id) end - if done == count then - do_poll() -- refresh state cache after all restarts complete + end + end + + -- Step 3: restart each session + local done = 0 + local function on_restart_done(s2, ok2) + done = done + 1 + local tool = (details[s2.id] or {}).tool or s2.tool or "" + if ok2 then + vim.notify("agent-deck: restarted " .. (s2.title or s2.id)) + local orig_id = details[s2.id] and details[s2.id].claude_session_id + if orig_id and orig_id ~= "" then + log.debug("refresh (Dar): restoring claude_session_id=" .. orig_id .. " for " .. s2.id) + backend.session_set(s2.id, "claude-session-id", orig_id, function() end) end - end) + if tool == "codex" and codex_threads[s2.id] then + backend.session_set(s2.id, "command", "codex", function() end) + end + else + log.error("refresh (Dar): session_restart failed for " .. (s2.title or s2.id)) + vim.notify("agent-deck: failed to restart " .. (s2.title or s2.id), vim.log.levels.ERROR) + end + if done == count then do_poll() end + end + + for _, s2 in ipairs(ps) do + local tool = (details[s2.id] or {}).tool or s2.tool or "" + local thread_id = codex_threads[s2.id] + if tool == "codex" and thread_id then + -- agent-deck "session restart" uses its own thread resolution which + -- picks the wrong thread. Workaround: stop → set command → start. + log.debug("refresh (Dar): stop→set→start for codex " .. s2.id + .. " thread=" .. thread_id) + backend.session_stop(s2.id, function() + backend.session_set(s2.id, "command", "codex resume " .. thread_id, function() + backend.session_start(s2.id, function(ok2) + on_restart_done(s2, ok2) + end) + end) + end) + else + backend.session_restart(s2.id, function(ok2) on_restart_done(s2, ok2) end) + end end end end) @@ -278,7 +404,7 @@ function M.import_sessions() end local cwd = vim.fn.getcwd() - require("agent-deck.cli").list_sessions(function(ok, sessions) + require("agent-deck.backend").list_sessions(function(ok, sessions) if not ok or type(sessions) ~= "table" then vim.notify("agent-deck: failed to list sessions", vim.log.levels.ERROR) return @@ -310,15 +436,15 @@ end --- Stop all sessions belonging to the current project. function M.kill_all() - local state = require("agent-deck.state") - local cli = require("agent-deck.cli") - local ps = state.project_sessions() + local state = require("agent-deck.state") + local backend = require("agent-deck.backend") + local ps = state.project_sessions() if #ps == 0 then vim.notify("agent-deck: no sessions for project", vim.log.levels.WARN) return end for _, s in ipairs(ps) do - cli.session_stop(s.id, function(ok, _) + backend.session_stop(s.id, function(ok, _) if ok then vim.notify("agent-deck: stopped " .. (s.title or s.id)) end @@ -340,12 +466,12 @@ end --- Internally we use slugs (e.g. "post-service") as map keys. Slugifying --- ensures consistency with auto-detected project names from group.lua. local function attach_group() - local cli = require("agent-deck.cli") + local backend = require("agent-deck.backend") local state = require("agent-deck.state") local persist = require("agent-deck.persist") local grp = require("agent-deck.group") - cli.group_list(function(ok, data) + backend.group_list(function(ok, data) if not ok or type(data) ~= "table" then log.error("attach_group: group_list failed") vim.notify("agent-deck: failed to list groups", vim.log.levels.ERROR) @@ -373,7 +499,7 @@ local function attach_group() log.info("attach_group: selected group='" .. group_name .. "', slug=" .. slug) -- Fetch the full session list; filter to only this group's sessions - cli.list_sessions(function(ok2, sessions) + backend.list_sessions(function(ok2, sessions) if not ok2 or type(sessions) ~= "table" then log.error("attach_group: list_sessions failed after group pick") vim.notify("agent-deck: failed to list sessions", vim.log.levels.ERROR) @@ -432,7 +558,7 @@ local function create_group() local state = require("agent-deck.state") local persist = require("agent-deck.persist") local grp = require("agent-deck.group") - local cli = require("agent-deck.cli") + local backend = require("agent-deck.backend") local current = state.current_project or grp.current_project() vim.ui.input({ @@ -461,13 +587,13 @@ local function create_group() local ps = state.project_sessions() -- Must create group first — group_move fails silently if group doesn't exist - cli.group_create(slug, function(ok, _) + backend.group_create(slug, function(ok, _) if not ok then log.warn("create_group: group_create failed for " .. slug .. " (may already exist — continuing)") vim.notify("agent-deck: failed to create group " .. slug, vim.log.levels.WARN) end for _, s in ipairs(ps) do - cli.group_move(s.id, slug, function(ok2, _) + backend.group_move(s.id, slug, function(ok2, _) if not ok2 then log.warn("create_group: group_move failed for " .. (s.title or s.id) .. " → " .. slug) vim.notify("agent-deck: failed to move " .. (s.title or s.id) .. " → " .. slug, vim.log.levels.WARN) diff --git a/lua/agent-deck/persist.lua b/lua/agent-deck/persist.lua index b48f797..2b31ca9 100644 --- a/lua/agent-deck/persist.lua +++ b/lua/agent-deck/persist.lua @@ -236,4 +236,37 @@ function M.load_project(project) -- No M.save() — transient pruning, not a user-initiated mutation end +-- ── cmux session metadata ──────────────────────────────────────────────────── +-- When backend="cmux", session metadata is managed by the plugin (not an +-- external daemon). These accessors store per-surface data under the +-- "_cmux_sessions" key in map.json. + +--- Return the cmux session metadata for a surface ID (or nil). +function M.get_cmux_session(surface_id) + local m = _map["_cmux_sessions"] + return m and surface_id and m[surface_id] or nil +end + +--- Store cmux session metadata for a surface ID. +--- data = { surface_id, workspace_id, tool, title, path, group, +--- command, claude_session_id, created_at } +function M.set_cmux_session(surface_id, data) + _map["_cmux_sessions"] = _map["_cmux_sessions"] or {} + _map["_cmux_sessions"][surface_id] = data + M.save() +end + +--- Remove cmux session metadata for a surface ID. +function M.remove_cmux_session(surface_id) + if _map["_cmux_sessions"] then + _map["_cmux_sessions"][surface_id] = nil + M.save() + end +end + +--- Return all cmux session metadata (table: surface_id → data). +function M.all_cmux_sessions() + return _map["_cmux_sessions"] or {} +end + return M diff --git a/lua/agent-deck/session_cmd.lua b/lua/agent-deck/session_cmd.lua new file mode 100644 index 0000000..bb437cd --- /dev/null +++ b/lua/agent-deck/session_cmd.lua @@ -0,0 +1,169 @@ +-- session_cmd.lua — shared command-building logic for terminal sessions +-- +-- Extracted from picker.lua:spawn_terminal and parallel.lua:build_cmd to +-- eliminate duplication. Both the agent-deck and cmux backends need to +-- construct the right command string for spawning AI tool sessions. +-- +-- Two entry points: +-- build_cmd(session) — for existing sessions (always uses --resume) +-- build_cmd_new(session) — for new sessions (uses --session-id if no conv file) +local M = {} + +local log = require("agent-deck.logger") + +-- ── Agent-deck config integration ─────────────────────────────────────────── +-- Reads ~/.agent-deck/config.toml to use the same claude command, env file, +-- and flags that agent-deck uses when launching sessions in tmux. + +local _ad_config = nil -- lazy-loaded +local _custom_claude_cmd = nil -- set via setup({ custom_claude_cmd = "..." }) + +--- Parse the agent-deck config.toml for [claude] section. +--- Returns { command = "...", env_file = "...", auto_mode = bool } +local function get_ad_config() + if _ad_config then return _ad_config end + _ad_config = {} + local path = vim.fn.expand("~/.agent-deck/config.toml") + local f = io.open(path, "r") + if not f then return _ad_config end + local content = f:read("*a") + f:close() + -- Parse [claude] section + local in_claude = false + for line in content:gmatch("[^\r\n]+") do + if line:match("^%[claude%]") then + in_claude = true + elseif line:match("^%[") then + in_claude = false + elseif in_claude then + local k, v = line:match("^%s*(%w+)%s*=%s*(.+)$") + if k and v then + v = v:match('^"(.*)"$') or v -- strip quotes + if v == "true" then v = true + elseif v == "false" then v = false end + _ad_config[k] = v + end + end + end + return _ad_config +end + +--- Set the custom claude command override from setup(). +--- @param cmd string|nil +function M.set_custom_claude_cmd(cmd) + _custom_claude_cmd = cmd +end + +--- Get the claude base command. +--- Priority: custom_claude_cmd (setup) > agent-deck config.toml > "claude" +local function claude_base_cmd() + if _custom_claude_cmd and _custom_claude_cmd ~= "" then return _custom_claude_cmd end + local cfg = get_ad_config() + return (cfg.command and cfg.command ~= "") and cfg.command or "claude" +end + +--- Build env prefix string from agent-deck's claude env_file. +local function env_prefix() + local cfg = get_ad_config() + if not cfg.env_file or cfg.env_file == "" then return "" end + local f = io.open(cfg.env_file, "r") + if not f then return "" end + local parts = {} + for line in f:lines() do + -- Skip comments and blank lines + if not line:match("^%s*#") and line:match("=") then + local k, v = line:match("^%s*([%w_]+)%s*=%s*(.+)$") + if k and v then + v = v:match('^"(.*)"$') or v -- strip quotes + parts[#parts + 1] = k .. "=" .. vim.fn.shellescape(v) + end + end + end + f:close() + if #parts == 0 then return "" end + return table.concat(parts, " ") .. " " +end + +--- Build the command string for an EXISTING session. +--- +--- This is used by parallel.lua and any code path that opens a previously-started +--- session. By the time a session reaches this function, its .jsonl conversation +--- file exists on disk and the claude_session_id is the real UUID. +--- Therefore `--resume` is always correct here. +--- +--- Decision tree: +--- 1. Tool is "claude" AND we have claude_session_id → `claude --resume ` +--- 2. Tool is "codex" AND we have codex_thread_id → `codex resume ` +--- 3. Any other tool or no identifier → session.command or tool name +function M.build_cmd(session) + local tool = session.tool or "claude" + local base_cmd = session.command or tool + if tool == "claude" and session.claude_session_id and session.claude_session_id ~= "" then + local cmd = env_prefix() .. claude_base_cmd() .. " --resume " .. session.claude_session_id + log.debug("session_cmd.build_cmd: " .. cmd .. " for session " .. session.id) + return cmd + end + if tool == "codex" and session.codex_thread_id and session.codex_thread_id ~= "" then + log.debug("session_cmd.build_cmd: codex resume " .. session.codex_thread_id + .. " for session " .. session.id) + return base_cmd .. " resume " .. session.codex_thread_id + end + log.debug("session_cmd.build_cmd: using command '" .. base_cmd .. "' for session " .. session.id) + return base_cmd +end + +--- Build the command string for a NEW or possibly-new session. +--- +--- This is used by picker.lua:spawn_terminal when opening a session that may +--- have just been created. The distinction from build_cmd() is: +--- +--- - If the .jsonl file EXISTS → use --resume (conversation already started) +--- - If the .jsonl file is MISSING → use --session-id (new session, stay in +--- sync with the UUID agent-deck already assigned) +--- +--- @param session table Session object with tool, claude_session_id, path, etc. +--- @param conv_exists_fn function (path, session_id) → bool; injected for testability +function M.build_cmd_new(session, conv_exists_fn) + local tool = session.tool or "claude" + local cwd = session.path or vim.fn.getcwd() + local base_cmd = session.command or tool + + if tool == "claude" and session.claude_session_id and session.claude_session_id ~= "" then + local prefix = env_prefix() + local bin = claude_base_cmd() + local status = session.status or "" + local has_conv = conv_exists_fn and conv_exists_fn(cwd, session.claude_session_id) + log.debug("session_cmd.build_cmd_new: sid=" .. session.id + .. " claude_session_id=" .. session.claude_session_id + .. " status=" .. status .. " has_conv=" .. tostring(has_conv) + .. " cwd=" .. cwd) + if has_conv then + -- Conv file exists → --resume always works + local cmd = prefix .. bin .. " --resume " .. session.claude_session_id + log.info("session_cmd.build_cmd_new: " .. cmd .. " for session " .. session.id) + return cmd + elseif status == "running" or status == "waiting" then + -- Session running in agent-deck tmux but no .jsonl yet → can't open in + -- Neovim (--session-id = "already in use", --resume = "no conversation"). + -- Send first message in agent-deck tmux to create .jsonl, then retry. + log.warn("session_cmd.build_cmd_new: session " .. session.id + .. " is " .. status .. " but no .jsonl — need first message in agent-deck first") + return nil + else + -- Session not running, no conv file → --session-id claims the UUID + local cmd = prefix .. bin .. " --session-id " .. session.claude_session_id + log.info("session_cmd.build_cmd_new: " .. cmd .. " (new) for session " .. session.id) + return cmd + end + elseif tool == "codex" and session.codex_thread_id and session.codex_thread_id ~= "" then + log.info("session_cmd.build_cmd_new: codex resume " .. session.codex_thread_id + .. " for session " .. session.id) + return base_cmd .. " resume " .. session.codex_thread_id + end + + -- Non-claude tool or no known resume identifier + log.info("session_cmd.build_cmd_new: '" .. base_cmd .. "' for session " .. session.id) + return base_cmd +end + +return M diff --git a/lua/agent-deck/ui/info.lua b/lua/agent-deck/ui/info.lua index 84bf2ec..51437be 100644 --- a/lua/agent-deck/ui/info.lua +++ b/lua/agent-deck/ui/info.lua @@ -36,6 +36,11 @@ function M.show() table.insert(lines, " agent-deck.nvim — Info / Debug" ) table.insert(lines, " " .. string.rep("━", 50)) + -- ── Backend ───────────────────────────────────────────────────────────── + local backend_name = require("agent-deck.backend").name() + section(lines, "Backend") + row(lines, " active ", backend_name) + -- ── Project ───────────────────────────────────────────────────────────── section(lines, "Project") row(lines, " current ", state.current_project or "(none)") @@ -142,8 +147,9 @@ function M.show() -- ── Binary ────────────────────────────────────────────────────────────── section(lines, "Binary") - local bin = vim.fn.exepath("agent-deck") - row(lines, " ", bin ~= "" and bin or "(not found in PATH)") + local bin_name = backend_name == "cmux" and "cmux" or "agent-deck" + local bin = vim.fn.exepath(bin_name) + row(lines, " " .. bin_name .. " ", bin ~= "" and bin or "(not found in PATH)") -- ── Footer ────────────────────────────────────────────────────────────── table.insert(lines, "") diff --git a/lua/agent-deck/ui/output.lua b/lua/agent-deck/ui/output.lua index 4b756f4..a38225f 100644 --- a/lua/agent-deck/ui/output.lua +++ b/lua/agent-deck/ui/output.lua @@ -3,7 +3,7 @@ local M = {} function M.show() local state = require("agent-deck.state") - local cli = require("agent-deck.cli") + local backend = require("agent-deck.backend") local session = state.primary_session() if not session then @@ -16,7 +16,7 @@ function M.show() local is_running = session.status == "running" - cli.session_output(session.id, function(ok, data) + backend.session_output(session.id, function(ok, data) if not ok then vim.notify("agent-deck: failed to get output", vim.log.levels.ERROR) return diff --git a/lua/agent-deck/ui/parallel.lua b/lua/agent-deck/ui/parallel.lua index ead0dea..ee327e8 100644 --- a/lua/agent-deck/ui/parallel.lua +++ b/lua/agent-deck/ui/parallel.lua @@ -91,43 +91,42 @@ local function register_process_exit(buf, session_id) }) end +local session_cmd = require("agent-deck.session_cmd") +local claude_paths = require("agent-deck.claude_paths") + --- Build the command string used to spawn a terminal for a session. ---- ---- parallel.lua only ever opens sessions that were selected from the picker or ---- loaded from the last-layout persist. By the time a session reaches this ---- function it is always an existing, previously-started session: its .jsonl ---- conversation file exists on disk and agent-deck's claude_session_id is the ---- real UUID (not the placeholder written at launch-time before claude runs). ---- Therefore `--resume` is always correct here — there is no need to check for ---- file existence or fall back to `--session-id` as picker.lua's spawn_terminal ---- does for brand-new sessions opened immediately after `Dan`. ---- ---- Decision tree: ---- 1. Tool is "claude" AND we have a claude_session_id from session_show ---- → `claude --resume ` attaches to the exact conversation. ---- Critical for multiple sessions sharing the same cwd: without --resume, ---- both would land on the "last conversation in that directory", clobbering ---- each other's context on every parallel refresh. ---- 2. Tool is "codex" AND we have a resolved Codex thread ID ---- → `codex resume ` attaches to the exact Codex conversation. ---- 3. Any other tool, or no known resume identifier ---- → use session.command (e.g. "codex", "opencode") or fall back to tool name. +--- Uses build_cmd_new to handle new sessions (no .jsonl yet) correctly +--- by falling back to --session-id instead of --resume. +--- Agent-deck backend only. local function build_cmd(session) - local tool = session.tool or "claude" - local base_cmd = session.command or tool - if tool == "claude" and session.claude_session_id and session.claude_session_id ~= "" then - log.debug("build_cmd: using claude --resume " .. session.claude_session_id - .. " for session " .. session.id) - return "claude --resume " .. session.claude_session_id - end - if tool == "codex" and session.codex_thread_id and session.codex_thread_id ~= "" then - log.debug("build_cmd: using codex resume " .. session.codex_thread_id - .. " for session " .. session.id) - return base_cmd .. " resume " .. session.codex_thread_id + return session_cmd.build_cmd_new(session, claude_paths.conv_exists) +end + +-- ── cmux-specific helpers ───────────────────────────────────────────────────── +-- These are separate from the agent-deck flow (prefetch_sessions + build_cmd_new) +-- because cmux sessions have different metadata sources (persist, not CLI) and +-- always use --resume (sessions already exist in cmux surfaces). + +--- Enrich sessions from cmux persist metadata synchronously. +--- Returns a list of sessions with command and path filled in via +--- session_cmd.build_cmd (always --resume for existing sessions). +local function enrich_cmux_sessions(sessions) + local persist = require("agent-deck.persist") + local result = {} + for _, s in ipairs(sessions) do + local meta = persist.get_cmux_session(s.id) + if meta then + -- Ensure id is always set (persist stores surface_id, not id) + meta.id = meta.id or meta.surface_id or s.id + local cmd = session_cmd.build_cmd(meta) + result[#result + 1] = vim.tbl_extend("force", s, meta, { command = cmd, _cmd_ready = true }) + log.debug("enrich_cmux_sessions: " .. s.id .. " cmd=" .. (cmd or "nil")) + else + result[#result + 1] = s + log.warn("enrich_cmux_sessions: no persist data for " .. s.id) + end end - local cmd = base_cmd - log.debug("build_cmd: using command '" .. cmd .. "' for session " .. session.id) - return cmd + return result end --- Pre-fetch full session details for all sessions before opening any windows. @@ -139,14 +138,14 @@ end --- (fast, possibly wrong cmd) while later windows wait. Prefetching ensures --- all windows open with the correct --resume flag simultaneously. local function prefetch_sessions(sessions, callback) - local cli = require("agent-deck.cli") - local count = #sessions - local done = 0 - local result = {} + local backend = require("agent-deck.backend") + local count = #sessions + local done = 0 + local result = {} log.debug("prefetch_sessions: fetching details for " .. count .. " session(s)") for i, s in ipairs(sessions) do result[i] = s -- default: use list data as-is if show fails - cli.session_show(s.id, function(ok, data) + backend.session_show(s.id, function(ok, data) local merged = s if ok and type(data) == "table" then -- Merge show fields (claude_session_id, profile, etc.) onto the session object @@ -196,8 +195,19 @@ local function start_terminal(win, session) log.debug("start_terminal: reusing buf " .. existing .. " for session " .. session.id) vim.api.nvim_win_set_buf(win, existing) else - -- Slow path: spawn a new native terminal process - local cmd = build_cmd(session) + -- Slow path: spawn a new native terminal process. + -- cmux-enriched sessions have _cmd_ready=true — command is already the + -- final terminal command (built by enrich_cmux_sessions via build_cmd). + -- Agent-deck sessions go through build_cmd_new which handles --session-id + -- vs --resume based on .jsonl existence. + local cmd = session._cmd_ready and session.command or build_cmd(session) + if not cmd then + vim.notify("agent-deck: '" .. (session.title or session.id) + .. "' is waiting in agent-deck (needs permission or first message). " + .. "Resolve in agent-deck terminal, then retry.", vim.log.levels.WARN) + vim.api.nvim_win_close(win, true) + return + end local cwd = session.path or vim.fn.getcwd() log.info("start_terminal: spawning terminal '" .. cmd .. "' in " .. cwd .. " for session " .. session.id) @@ -216,6 +226,77 @@ local function start_terminal(win, session) return cur_buf end +-- ── Shared window creation ──────────────────────────────────────────────────── +-- Extracted so both the agent-deck path (async prefetch) and cmux path +-- (sync persist enrichment) can share the same layout logic. + +--- Create horizontal split windows for enriched sessions. +local function do_open_split(enriched) + vim.cmd("stopinsert") + + local height = math.floor(vim.o.lines * 0.35) + vim.cmd("botright " .. height .. "split") + start_terminal(vim.api.nvim_get_current_win(), enriched[1]) + + for i = 2, #enriched do + vim.api.nvim_set_current_win(_par_wins[1].win) + vim.cmd("vsplit") + start_terminal(vim.api.nvim_get_current_win(), enriched[i]) + end + + vim.cmd("wincmd =") + + for _, entry in ipairs(_par_wins) do + vim.api.nvim_set_current_win(entry.win) + vim.cmd("startinsert") + end +end + +--- Create floating tile windows for enriched sessions. +local function do_open_float(enriched) + vim.cmd("stopinsert") + + local n = #enriched + local usable_h = vim.o.lines + - vim.o.cmdheight + - (vim.o.laststatus > 0 and 1 or 0) + - (vim.o.showtabline > 0 and 1 or 0) + + local total_w = math.floor(vim.o.columns * 0.90) + local height = math.floor(usable_h * 0.60) + local gap = 1 + local win_w = math.floor((total_w - (n - 1) * gap) / n) + local start_c = math.floor((vim.o.columns - total_w) / 2) + local row = math.floor((usable_h - height) / 2) + + for i, session in ipairs(enriched) do + local col = start_c + (i - 1) * (win_w + gap) + log.debug("do_open_float: window " .. i .. " at col=" .. col .. " w=" .. win_w) + + local buf = vim.api.nvim_create_buf(false, true) + local win = vim.api.nvim_open_win(buf, true, { + relative = "editor", + row = row, + col = col, + width = win_w, + height = height, + border = "rounded", + title = " " .. (session.title or session.id) .. " ", + title_pos = "center", + style = "minimal", + }) + + start_terminal(win, session) + end + + for _, entry in ipairs(_par_wins) do + if vim.api.nvim_win_is_valid(entry.win) then + vim.api.nvim_set_current_win(entry.win) + vim.cmd("startinsert") + end + end +end + -- ── Public API ──────────────────────────────────────────────────────────────── --- Deduplicate sessions by ID (safety guard for picker multi-select edge cases). @@ -239,6 +320,20 @@ end --- accidentally overwrite the user's persisted loaded state. function M.open_split(sessions) if #sessions == 0 then return end + + -- cmux backend: enrich sessions from persist (not session_show + codex.enrich). + -- Uses session_cmd.build_cmd (always --resume) instead of build_cmd_new. + if require("agent-deck.backend").name() == "cmux" then + M.close_all() + _last_layout = "split" + sessions = dedup(sessions) + _last_sessions = sessions + log.info("open_split (cmux): opening " .. #sessions .. " session(s) in splits") + local enriched = enrich_cmux_sessions(sessions) + do_open_split(enriched) + return + end + M.close_all() _last_layout = "split" sessions = dedup(sessions) @@ -248,28 +343,7 @@ function M.open_split(sessions) -- Prefetch claude_session_id for all sessions before creating any windows. -- This ensures every termopen() call uses --resume . prefetch_sessions(sessions, function(enriched) - vim.cmd("stopinsert") - - local height = math.floor(vim.o.lines * 0.35) - vim.cmd("botright " .. height .. "split") - start_terminal(vim.api.nvim_get_current_win(), enriched[1]) - - -- Additional sessions: vsplit within the first row so they tile horizontally - for i = 2, #enriched do - vim.api.nvim_set_current_win(_par_wins[1].win) - vim.cmd("vsplit") - start_terminal(vim.api.nvim_get_current_win(), enriched[i]) - end - - vim.cmd("wincmd =") -- equalize widths so all terminals share space evenly - - -- Enter insert mode in each terminal after ALL windows exist. - -- Doing this inside start_terminal would focus each window as it's created, - -- causing flickering and leaving focus on the wrong (last) window. - for _, entry in ipairs(_par_wins) do - vim.api.nvim_set_current_win(entry.win) - vim.cmd("startinsert") - end + do_open_split(enriched) end) end @@ -282,6 +356,19 @@ end --- _last_sessions updated in memory only (see open_split note above). function M.open_float(sessions) if #sessions == 0 then return end + + -- cmux backend: enrich sessions from persist (not session_show + codex.enrich). + if require("agent-deck.backend").name() == "cmux" then + M.close_all() + _last_layout = "float" + sessions = dedup(sessions) + _last_sessions = sessions + log.info("open_float (cmux): opening " .. #sessions .. " session(s) as floats") + local enriched = enrich_cmux_sessions(sessions) + do_open_float(enriched) + return + end + M.close_all() _last_layout = "float" sessions = dedup(sessions) @@ -289,48 +376,7 @@ function M.open_float(sessions) log.info("open_float: opening " .. #sessions .. " session(s) as floating tiles") prefetch_sessions(sessions, function(enriched) - vim.cmd("stopinsert") - - local n = #enriched - -- Subtract UI chrome (cmdline, statusline, tabline) from usable height - local usable_h = vim.o.lines - - vim.o.cmdheight - - (vim.o.laststatus > 0 and 1 or 0) - - (vim.o.showtabline > 0 and 1 or 0) - - local total_w = math.floor(vim.o.columns * 0.90) - local height = math.floor(usable_h * 0.60) - local gap = 1 - local win_w = math.floor((total_w - (n - 1) * gap) / n) - local start_c = math.floor((vim.o.columns - total_w) / 2) - local row = math.floor((usable_h - height) / 2) - - for i, session in ipairs(enriched) do - local col = start_c + (i - 1) * (win_w + gap) - log.debug("open_float: window " .. i .. " at col=" .. col .. " w=" .. win_w) - - local buf = vim.api.nvim_create_buf(false, true) - local win = vim.api.nvim_open_win(buf, true, { - relative = "editor", - row = row, - col = col, - width = win_w, - height = height, - border = "rounded", - title = " " .. (session.title or session.id) .. " ", - title_pos = "center", - style = "minimal", - }) - - start_terminal(win, session) - end - - for _, entry in ipairs(_par_wins) do - if vim.api.nvim_win_is_valid(entry.win) then - vim.api.nvim_set_current_win(entry.win) - vim.cmd("startinsert") - end - end + do_open_float(enriched) end) end @@ -456,8 +502,8 @@ function M.load_last() log.debug("load_last: resolving against already-cached " .. #state.sessions .. " sessions") resolve_and_open(state.sessions) else - log.debug("load_last: state empty — fetching fresh session list from CLI") - require("agent-deck.cli").list_sessions(function(ok, sessions) + log.debug("load_last: state empty — fetching fresh session list from backend") + require("agent-deck.backend").list_sessions(function(ok, sessions) if not ok or type(sessions) ~= "table" then log.error("load_last: list_sessions failed") vim.notify("agent-deck: failed to fetch sessions for Dal", vim.log.levels.ERROR) @@ -514,7 +560,43 @@ function M.refresh() state._session_bufs = {} log.debug("refresh: killed " .. killed .. " job(s); buf cache cleared") - -- Respawn in the same layout so the user lands in the same view + -- cmux backend: always fetch fresh sessions and respawn. + -- Surface refs change after Dar (close+recreate), so _par_wins is stale. + -- Fetch live session list from backend to get current refs. + if require("agent-deck.backend").name() == "cmux" then + local backend = require("agent-deck.backend") + local grp = require("agent-deck.group") + local project = state.current_project + log.info("refresh (DaR/cmux): fetching fresh sessions from backend") + backend.list_sessions(function(ok, sessions) + if not ok or type(sessions) ~= "table" then + log.error("refresh (DaR/cmux): list_sessions failed") + return + end + state.set_sessions(sessions) + local fresh = {} + for _, s in ipairs(sessions) do + if project and grp.slugify(s.group or "") == project then + table.insert(fresh, s) + end + end + if #fresh == 0 then + log.warn("refresh (DaR/cmux): no project sessions found") + return + end + log.info("refresh (DaR/cmux): respawning " .. #fresh .. " session(s)") + vim.schedule(function() + if layout == "float" then + M.open_float(fresh) + else + M.open_split(fresh) + end + end) + end) + return + end + + -- agent-deck backend: respawn with current sessions if windows were open if was_open and #current_sessions > 0 then if layout == "float" then M.open_float(current_sessions) diff --git a/lua/agent-deck/ui/picker.lua b/lua/agent-deck/ui/picker.lua index 8ebfe6a..17281af 100644 --- a/lua/agent-deck/ui/picker.lua +++ b/lua/agent-deck/ui/picker.lua @@ -56,28 +56,8 @@ local function time_ago(created_at) return string.format("%dh", math.floor(diff / 3600)) end ---- Return true if a .jsonl conversation file exists for the given claude session ID ---- under the encoded project path in ~/.claude/projects/. ---- ---- Background: agent-deck pre-generates a UUID as claude_session_id when a session ---- is created via `launch`. It immediately starts claude in a tmux pane with: ---- claude --session-id ---- Claude creates the .jsonl file only once the TUI is fully initialised and the ---- first turn begins. Until then the file does not exist on disk. ---- ---- This predicate is the single source of truth for distinguishing a brand-new ---- session (file absent) from an existing conversation (file present). The ---- distinction determines which claude flag to pass — see spawn_terminal below. ---- ---- Path encoding: claude stores conversations under ---- ~/.claude/projects//.jsonl ---- where is the absolute project path with every "/" replaced by "-". -local function claude_conv_exists(path, session_id) - if not path or not session_id or session_id == "" then return false end - local encoded = path:gsub("/", "-") - local fpath = vim.fn.expand("~/.claude/projects/") .. encoded .. "/" .. session_id .. ".jsonl" - return vim.fn.filereadable(fpath) == 1 -end +local claude_paths = require("agent-deck.claude_paths") +local session_cmd = require("agent-deck.session_cmd") --- Spawn a new terminal for a session using the enriched session object. --- @@ -114,31 +94,15 @@ end --- Either path caches the buffer with bufhidden=hide for instant reattach. local function spawn_terminal(session) local state = require("agent-deck.state") - local tool = session.tool or "claude" local cwd = session.path or vim.fn.getcwd() - local cmd - local base_cmd = session.command or tool - if tool == "claude" and session.claude_session_id and session.claude_session_id ~= "" then - if claude_conv_exists(cwd, session.claude_session_id) then - -- Case 1: conversation file exists → resume the existing session - cmd = "claude --resume " .. session.claude_session_id - log.info("spawn_terminal: claude --resume " .. session.claude_session_id - .. " for session " .. session.id) - else - -- Case 2: file absent → new session; use --session-id to stay in sync with - -- the UUID agent-deck already assigned and started in its tmux pane. - cmd = "claude --session-id " .. session.claude_session_id - log.info("spawn_terminal: claude --session-id " .. session.claude_session_id - .. " (new, no conv file yet) for session " .. session.id) - end - elseif tool == "codex" and session.codex_thread_id and session.codex_thread_id ~= "" then - cmd = base_cmd .. " resume " .. session.codex_thread_id - log.info("spawn_terminal: codex resume " .. session.codex_thread_id - .. " for session " .. session.id) - else - -- Case 4: non-claude tool or no known resume identifier - cmd = base_cmd - log.info("spawn_terminal: '" .. cmd .. "' for session " .. session.id) + -- cmux-enriched sessions have _cmd_ready=true — command already built by + -- enrich_cmux_sessions. Agent-deck sessions go through build_cmd_new. + local cmd = session._cmd_ready and session.command + or session_cmd.build_cmd_new(session, claude_paths.conv_exists) + if not cmd then + vim.notify("agent-deck: session is running in agent-deck but has no conversation yet.\n" + .. "Send a first message in the agent-deck terminal, then retry.", vim.log.levels.WARN) + return end -- Helper: mark buf as hidden (survives window close), set keymaps, register TermClose @@ -226,9 +190,34 @@ local function open_session_terminal(session) return end - -- Slow path: fetch full details to get claude_session_id, then spawn + -- cmux backend: build command from persist metadata directly. + -- Uses session_cmd.build_cmd (shared util, always --resume for existing + -- sessions). Does NOT use the agent-deck path (session_show callback, + -- codex.enrich_session, build_cmd_new decision tree). + if require("agent-deck.backend").name() == "cmux" then + local persist = require("agent-deck.persist") + local scmd = require("agent-deck.session_cmd") + local meta = persist.get_cmux_session(session.id) + if not meta then + log.warn("open_session_terminal (cmux): no persist data for " .. session.id) + vim.notify("agent-deck: session metadata not found for " .. session.id, vim.log.levels.WARN) + return + end + -- Ensure id is set (persist stores surface_id, not id) + meta.id = meta.id or meta.surface_id or session.id + -- build_cmd uses --resume for claude sessions with claude_session_id, + -- bare command for other tools + local cmd = scmd.build_cmd(meta) + local cwd = meta.path or vim.fn.getcwd() + log.info("open_session_terminal (cmux): spawning terminal for " .. session.id + .. " cmd=" .. cmd .. " cwd=" .. cwd) + spawn_terminal(vim.tbl_extend("force", session, meta, { command = cmd, path = cwd, _cmd_ready = true })) + return + end + + -- Slow path (agent-deck backend): fetch full details to get claude_session_id, then spawn log.debug("open_session_terminal: no live buf for " .. session.id .. " — fetching session_show") - require("agent-deck.cli").session_show(session.id, function(ok, data) + require("agent-deck.backend").session_show(session.id, function(ok, data) local enriched = (ok and type(data) == "table") and vim.tbl_extend("force", session, data) or session @@ -293,11 +282,11 @@ function M.spawn_sessions(sessions) end function M.pick() - local cli = require("agent-deck.cli") + local backend = require("agent-deck.backend") local state = require("agent-deck.state") local persist = require("agent-deck.persist") - cli.list_sessions(function(ok, sessions) + backend.list_sessions(function(ok, sessions) if not ok then log.error("pick: list_sessions failed") vim.notify("agent-deck: failed to list sessions", vim.log.levels.ERROR) @@ -399,7 +388,7 @@ function M.pick() ad_delete = function(picker, item) if not item or not item.session then return end local id = item.session.id - require("agent-deck.cli").session_delete(id, function(ok2, _) + require("agent-deck.backend").session_delete(id, function(ok2, _) vim.schedule(function() if ok2 then vim.notify("agent-deck: deleted " .. id) @@ -419,7 +408,7 @@ function M.pick() ad_stop = function(_, item) if not item or not item.session then return end - require("agent-deck.cli").session_stop(item.session.id, function(ok2, _) + require("agent-deck.backend").session_stop(item.session.id, function(ok2, _) if ok2 then vim.notify("agent-deck: stopped " .. (item.session.title or item.session.id)) end @@ -428,7 +417,7 @@ function M.pick() ad_restart = function(_, item) if not item or not item.session then return end - require("agent-deck.cli").session_restart(item.session.id, function(ok2, _) + require("agent-deck.backend").session_restart(item.session.id, function(ok2, _) if ok2 then vim.notify("agent-deck: restarted " .. (item.session.title or item.session.id)) end @@ -471,7 +460,7 @@ function M.new_session() if not tool then return end vim.ui.input({ prompt = "Session title: " }, function(title) if not title or title == "" then return end - require("agent-deck.cli").launch(cwd, { + require("agent-deck.backend").launch(cwd, { tool = tool, title = title, group = project, @@ -479,9 +468,27 @@ function M.new_session() if ok and type(data) == "table" and data.id then persist.add_session(project, data.id) vim.notify("agent-deck: launched '" .. title .. "' (" .. data.id .. ")") - require("agent-deck.cli").list_sessions(function(ok2, sessions) + require("agent-deck.backend").list_sessions(function(ok2, sessions) if ok2 then state.set_sessions(sessions) end end) + -- For claude: check after 2s if session is still waiting (needs + -- permission or approval in agent-deck terminal before Neovim can open it) + if tool == "claude" then + vim.defer_fn(function() + require("agent-deck.backend").session_show(data.id, function(ok3, detail) + if ok3 and type(detail) == "table" and detail.status == "waiting" then + local claude_paths = require("agent-deck.claude_paths") + local sid = detail.claude_session_id or "" + local has_conv = sid ~= "" and claude_paths.conv_exists(cwd, sid) + if not has_conv then + vim.notify("agent-deck: '" .. title .. "' is waiting in agent-deck.\n" + .. "Approve permissions or send first message there before opening in Neovim.", + vim.log.levels.WARN) + end + end + end) + end, 2000) + end else vim.notify("agent-deck: launch failed", vim.log.levels.ERROR) end @@ -490,4 +497,43 @@ function M.new_session() end) end +--- Focus a session in cmux's UI (cmux backend only). +--- Opens a single-select picker; on confirm, calls backend.focus_session +--- to bring the cmux surface/workspace to the front. +function M.focus_pick() + local backend = require("agent-deck.backend") + if backend.name() ~= "cmux" then + vim.notify("agent-deck: focus_pick is only available with the cmux backend", vim.log.levels.WARN) + return + end + + local state = require("agent-deck.state") + local sessions = state.get_sessions() + if not sessions or #sessions == 0 then + vim.notify("agent-deck: no sessions to focus", vim.log.levels.INFO) + return + end + + -- Build picker items + local items = {} + for _, s in ipairs(sessions) do + local icon = ICONS[s.status] or "?" + local label = icon .. " " .. (s.title or s.id) .. " [" .. (s.group or "") .. "]" + table.insert(items, { text = label, session = s }) + end + + vim.ui.select(items, { + prompt = "Focus session in cmux:", + format_item = function(item) return item.text end, + }, function(choice) + if not choice then return end + log.info("focus_pick: focusing " .. choice.session.id .. " in cmux") + backend.focus_session(choice.session.id, function(ok, _) + if not ok then + vim.notify("agent-deck: failed to focus " .. choice.session.id, vim.log.levels.ERROR) + end + end) + end) +end + return M diff --git a/lua/agent-deck/ui/send.lua b/lua/agent-deck/ui/send.lua index 62ceddd..5f6dd5a 100644 --- a/lua/agent-deck/ui/send.lua +++ b/lua/agent-deck/ui/send.lua @@ -30,7 +30,7 @@ local function ensure_sendable(session, cb) return end if session.status == "idle" or session.status == "stopped" then - require("agent-deck.cli").session_start(session.id, function(ok, _) + require("agent-deck.backend").session_start(session.id, function(ok, _) if ok then -- Small delay to let the agent process reach waiting state vim.defer_fn(function() cb(true) end, 600) @@ -61,7 +61,7 @@ local function do_send(text) vim.notify("agent-deck: could not start session for send", vim.log.levels.ERROR) return end - require("agent-deck.cli").session_send(session.id, text, { no_wait = true }, function(ok, _) + require("agent-deck.backend").session_send(session.id, text, { no_wait = true }, function(ok, _) if ok then vim.notify("agent-deck: sent → " .. (session.title or session.id)) else