/** * @file Types `/reload-plugins` and `/reload-skills` into the Claude Code * sessions the dashboard already has open, so a plugin or skill update takes * effect without the user switching to a terminal. * * Both commands are, by Claude Code's own definition, scoped to the session * that runs them — `/reload-plugins` "activates pending plugin changes in the * current session", `/reload-skills` "picks up skills added or changed on disk * during this session". There is therefore NO way to reload a session from the * outside, and no background process can do it on a session's behalf. What a * dashboard CAN do is what a human would: type the command into a session that * is already running. Every lane pane is a tmux session (`agenthub-lane-`), * so `tmux send-keys` reaches a live REPL's stdin directly. * * This deliberately never spawns a `claude` process. A one-shot * `claude -p "/reload-plugins"` would reload only its own short-lived session * and then exit — no effect on anything the user is using — while still costing * an API round-trip and leaving a junk session in the dashboard's own history. * Reusing live panes means this module creates no sessions at all. * * Only `claude` lanes are addressed: Codex has no such command, and typing a * slash command into a Codex pane would land as literal prompt text. * @author Nguyễn Ngọc Trí Vĩ */ const tmux = require("./tmux"); const { listRuns } = require("./pty-run"); /** Shell process names — a pane sitting at one of these has no REPL to reload. */ const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]); /** The only commands this module is ever allowed to type. Not caller-supplied: * a pane's stdin is an arbitrary-code boundary, so the set is fixed here. */ const RELOAD_COMMANDS = { plugins: "/reload-plugins", skills: "/reload-skills", }; /** * Live `claude` panes that can accept a slash command right now. * * A pane whose `pane_current_command` is a shell is excluded: the `claude` * process there has exited (or never started), so the text would be typed at a * bash prompt and run as a shell command instead — `/reload-plugins` would come * back as "No such file or directory". * * @returns {{id: string, laneId: number|null, paneCommand: string|null}[]} */ function reloadableLanes() { const out = []; for (const run of listRuns()) { if (run.status !== "running") continue; if (run.tool !== "claude") continue; const cmd = tmux.paneCommand(run.id); if (SHELL_COMMANDS.has(cmd || "")) continue; out.push({ id: run.id, laneId: run.laneId, paneCommand: cmd }); } return out; } /** * Type the requested reload commands into every live `claude` pane. * * Sends to a pane even when its agent is mid-turn: tmux queues the keystrokes * into the REPL's input buffer, so the command lands when the turn ends rather * than being lost. That is a deliberate choice — a reload that arrives late is * still a reload, whereas skipping busy panes silently leaves exactly the * sessions the user is actually working in running stale code. * * Never throws: one unreachable pane (killed between the listing and the send) * must not fail a batch that updated everything else successfully. * * @param {{kinds?: ("plugins"|"skills")[]}} [options] which commands to send; * defaults to both, plugins first (a skill shipped BY a plugin only exists on * disk once the plugin has been reloaded). * @returns {{lanes: {id: string, laneId: number|null, sent: string[], error?: string}[], * commands: string[], lanes_total: number}} */ function reloadOpenSessions(options = {}) { const kinds = (options.kinds || ["plugins", "skills"]).filter((k) => k in RELOAD_COMMANDS); const commands = kinds.map((k) => RELOAD_COMMANDS[k]); const lanes = []; for (const lane of reloadableLanes()) { const sent = []; let error; for (const command of commands) { try { tmux.sendText(lane.id, command); sent.push(command); } catch (err) { error = err.message || String(err); break; // the pane is gone; the next command would fail identically } } lanes.push({ id: lane.id, laneId: lane.laneId, sent, ...(error ? { error } : {}) }); } return { commands, lanes, lanes_total: lanes.length }; } module.exports = { reloadOpenSessions, reloadableLanes, RELOAD_COMMANDS, SHELL_COMMANDS };