feat(cc-config): update every plugin and marketplace at once, then reload the open sessions
The plugins tab could already update one plugin or one marketplace at a time. It gains an install-wide batch and, separately, the reload step that actually makes an update take effect. Update all runs in a DETACHED worker rather than the request handler, for a structural reason: the batch ends with the plugin the dashboard ITSELF runs from, and that step's plugin-bootstrap.js --force SIGTERMs this very server. An in-process loop would die partway through with the remaining plugins unrun and nothing left to report. Order is marketplaces (one call, no name = all) -> every other installed plugin -> AgentHub last -> one reload. Each transition is journalled to cc-update-all.json in the runtime dir, outside the plugin cache the update replaces, so whichever server is alive serves progress from it; the POST's response is only an acknowledgement. readUpdateAll DERIVES an abandoned status for a running journal older than an hour, because a detached worker killed by a reboot leaves nobody to write the terminal entry and the button would stay wedged forever. isRunning gates a concurrent batch: two claude plugin update chains over one config dir race. Reload is its own capability (server/lib/cc-reload.js, POST /api/cc-config/reload). /reload-plugins and /reload-skills are scoped BY CLAUDE CODE to the session that runs them, so nothing can reload a session from outside - typing into a session already running is the only real mechanism, and that is what this does, over tmux send-keys into the dashboard's live lane panes. It spawns NO claude process on purpose: a one-shot claude -p "/reload-plugins" would reload only its own short-lived session, exit, and leave a junk session in the dashboard's history - a headless run gets a full sessions row and there is no filter anywhere to suppress it. Reusing live panes means no session is created at all. Panes at a shell prompt are skipped (the text would run as a shell command) and only claude lanes are addressed, since Codex has no such command. Update-all reloads once at the end; updating a single plugin passes reload:true and reloads immediately, and never after a failed action. Fixes a real pre-existing bug on the way: pluginUpdate omitted -y while runClaude closes stdin, and the CLI requires it whenever stdin is not a TTY - so the per-plugin Update button failed for any plugin whose marketplace declares an install command. Also completes CcConfigChangedPayload's action union, which claimed write|delete while nine other values were already being emitted. Verified: server 1683/1683, client 486/486 (27 new tests), build, prettier, file-header audit, openapi.yaml regenerated (117 paths). Update all was NOT executed against this machine - it would update 7 real plugins and restart the running dashboard.
This commit is contained in:
@@ -226,7 +226,10 @@ async function pluginDisable({ spec }) {
|
||||
|
||||
async function pluginUpdate({ spec }) {
|
||||
const s = assertPluginSpec(spec);
|
||||
return serialize(() => runClaude(["plugin", "update", s]));
|
||||
// `-y` for the same reason as pluginInstall: the CLI requires it whenever
|
||||
// stdin is not a TTY (runClaude closes stdin), and without it an update whose
|
||||
// marketplace declares an install command fails on a prompt nobody can answer.
|
||||
return serialize(() => runClaude(["plugin", "update", s, "-y"]));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @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-<id>`),
|
||||
* 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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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 };
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @file Starts and reports on the "Update all" batch: every registered
|
||||
* marketplace, then every installed plugin, then a reload of the Claude Code
|
||||
* sessions the dashboard has open.
|
||||
*
|
||||
* Why a detached worker with an on-disk journal instead of a plain request
|
||||
* handler: the batch includes AgentHub ITSELF when the dashboard runs from the
|
||||
* plugin cache. Updating that plugin ends in `plugin-bootstrap.js --force`,
|
||||
* which SIGTERMs this very server, so an in-process loop would die partway
|
||||
* through with the remaining steps unrun and no way to report anything. The
|
||||
* worker outlives the swap, keeps going, and writes each step's outcome to
|
||||
* `cc-update-all.json` under the runtime directory — outside the plugin cache,
|
||||
* which the update replaces — so whichever server is alive can serve progress
|
||||
* from it. AgentHub is ordered LAST for the same reason: every other plugin is
|
||||
* already done by the time the restart happens.
|
||||
*
|
||||
* The reload step comes after all updates, not per-plugin, because that is what
|
||||
* "update everything, then reload" means for a session: one `/reload-plugins`
|
||||
* picks up every pending change at once, where N of them would just re-scan the
|
||||
* same directory N times. A single-plugin update reloads immediately instead —
|
||||
* see the `reload` flag on `POST /api/cc-config/plugins/action`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
const { getDataDir } = require("./claude-home");
|
||||
|
||||
const JOURNAL_NAME = "cc-update-all.json";
|
||||
/** A batch still "running" after this long is treated as abandoned, so a worker
|
||||
* killed by a reboot can't wedge the button forever. */
|
||||
const STALE_MS = 60 * 60 * 1000;
|
||||
|
||||
function runtimeDir() {
|
||||
return path.join(getDataDir(), "runtime");
|
||||
}
|
||||
|
||||
function journalPath(rt = runtimeDir()) {
|
||||
return path.join(rt, JOURNAL_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Current (or last) batch state, or null when none has ever run.
|
||||
*
|
||||
* A `running` journal older than `STALE_MS` is reported as `abandoned` rather
|
||||
* than `running`: the worker is detached, so a host reboot leaves no one to
|
||||
* write the terminal entry and the UI would otherwise show a spinner forever.
|
||||
*
|
||||
* @returns {{status: string, steps: object[], started_at?: string,
|
||||
* finished_at?: string} | null}
|
||||
*/
|
||||
function readUpdateAll(rt = runtimeDir()) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(journalPath(rt), "utf8"));
|
||||
} catch {
|
||||
return null; // never run, unreadable, or malformed — all "nothing to report"
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.steps)) return null;
|
||||
if (parsed.status === "running") {
|
||||
const started = Date.parse(parsed.started_at || "");
|
||||
if (Number.isFinite(started) && Date.now() - started > STALE_MS) {
|
||||
return { ...parsed, status: "abandoned" };
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** True when a batch is in flight — used to refuse a concurrent start rather
|
||||
* than run two `claude plugin update` chains over the same config dir. */
|
||||
function isRunning(rt = runtimeDir()) {
|
||||
return readUpdateAll(rt)?.status === "running";
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the detached batch worker.
|
||||
*
|
||||
* The caller must have written its HTTP response first: when `selfPlugin` is
|
||||
* set, this process is going to be stopped before the batch finishes.
|
||||
*
|
||||
* @param {{plugins: string[], selfPlugin: {marketplace: string, plugin: string}|null,
|
||||
* selfRoot: string|null, reload: boolean}} plan
|
||||
* `plugins` are `name@marketplace` ids, already validated by the caller.
|
||||
* @returns {{spawned: true, runtime_dir: string}}
|
||||
*/
|
||||
function startUpdateAll(plan) {
|
||||
const rt = runtimeDir();
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
// Clear the previous batch so a stale terminal state is never read as this
|
||||
// run's progress in the window before the worker writes its first entry.
|
||||
try {
|
||||
fs.rmSync(journalPath(rt), { force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
|
||||
const helper = path.join(__dirname, "..", "..", "scripts", "cc-update-all-helper.js");
|
||||
const child = spawn(process.execPath, [helper, rt, JSON.stringify(plan)], {
|
||||
// Never the plugin root: when selfPlugin is set that directory is replaced
|
||||
// mid-run, and a cwd inside a replaced directory can wedge the process.
|
||||
cwd: rt,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
env: process.env,
|
||||
});
|
||||
child.unref();
|
||||
return { spawned: true, runtime_dir: rt };
|
||||
}
|
||||
|
||||
module.exports = { startUpdateAll, readUpdateAll, isRunning, runtimeDir, journalPath, STALE_MS };
|
||||
Reference in New Issue
Block a user