/** * @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"]); /** * Pause between two commands typed into the SAME pane. * * Claude Code's REPL opens a slash-command menu as soon as `/` is typed, and * the Enter that follows lands on that menu rather than submitting the line. * With no pause the next command's text is appended to the line still sitting * in the composer, and what gets submitted is the single string * `/reload-plugins/reload-skills` — which Claude Code runs as `/reload-plugins` * with `/reload-skills` as its argument, so the second reload never happens and * the pane shows a stray prompt turn. Measured against a live pane: 0.3s is * already enough on an idle one, 1s leaves headroom for a busy REPL. */ const SETTLE_MS = 1000; /** 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: the keystrokes land in the * REPL's composer, so the command runs 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. It is also why SETTLE_MS * exists: a composer that has not submitted yet is a line the next command * would be appended to. * * Never throws: one unreachable pane (killed between the listing and the send) * must not fail a batch that updated everything else successfully. * * Async because of SETTLE_MS above: the two commands must reach a pane as two * separate submissions, and waiting for that without blocking the event loop is * the only way a live dashboard keeps broadcasting while it types. * * @param {{kinds?: ("plugins"|"skills")[], settleMs?: number}} [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). `settleMs` * is a test seam — no route passes it. * @returns {Promise<{lanes: {id: string, laneId: number|null, sent: string[], error?: string}[], * commands: string[], lanes_total: number}>} */ async function reloadOpenSessions(options = {}) { const kinds = (options.kinds || ["plugins", "skills"]).filter((k) => k in RELOAD_COMMANDS); const commands = kinds.map((k) => RELOAD_COMMANDS[k]); const settleMs = options.settleMs ?? SETTLE_MS; const lanes = []; for (const lane of reloadableLanes()) { const sent = []; let error; for (const command of commands) { if (sent.length && settleMs > 0) await new Promise((r) => setTimeout(r, settleMs)); 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, SETTLE_MS, };