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:
2026-09-10 16:05:07 +07:00
parent cc85e35439
commit aca35dd4cd
17 changed files with 1745 additions and 20 deletions
+102 -4
View File
@@ -24,6 +24,7 @@
*/
const { Router } = require("express");
const path = require("path");
const cc = require("../lib/cc-discovery");
const ccMutate = require("../lib/cc-mutate");
const ccCli = require("../lib/cc-plugin-cli");
@@ -32,6 +33,13 @@ const ccProfiles = require("../lib/cc-profiles");
const projectMemory = require("../lib/project-memory");
const { sameOriginGuard } = require("../lib/same-origin");
const { broadcast } = require("../websocket");
const { reloadOpenSessions } = require("../lib/cc-reload");
const { startUpdateAll, readUpdateAll, isRunning } = require("../lib/cc-update-all");
const { detectPluginInstall } = require("../lib/update-check");
/** Mirrors cc-plugin-cli's own spec rule — ids from the CLI's JSON still reach
* an argv in the detached batch worker, so they are filtered before they go. */
const PLUGIN_SPEC_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(@[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
const router = Router();
@@ -265,7 +273,7 @@ function cliError(res, err) {
}
/** Map a finished CLI run onto a response; a non-zero exit is a 422, not a 500. */
function cliResult(res, result, changed) {
function cliResult(res, result, changed, extra) {
if (!result.ok) {
return res.status(422).json({
error: {
@@ -278,7 +286,7 @@ function cliResult(res, result, changed) {
});
}
emitChanged(changed);
res.json({ ok: true, stdout: result.stdout, stderr: result.stderr });
res.json({ ok: true, stdout: result.stdout, stderr: result.stderr, ...extra });
}
router.get("/plugins/available", async (_req, res) => {
@@ -307,7 +315,7 @@ const PLUGIN_ACTIONS = {
};
router.post("/plugins/action", async (req, res) => {
const { key, action } = req.body || {};
const { key, action, reload } = req.body || {};
const run = PLUGIN_ACTIONS[action];
if (!run) {
return res.status(400).json({
@@ -319,12 +327,102 @@ router.post("/plugins/action", async (req, res) => {
}
try {
const result = await run({ spec: key });
return cliResult(res, result, { action, type: "plugin", name: key });
// "Update one, reload that one" — the counterpart to the batch, which
// reloads once at the end. Only on success, and only when asked: an
// enable/disable already needs a reload but the caller decides, and a
// reload after a FAILED update would tell the session to activate nothing.
let reloaded;
if (reload && result.ok) {
try {
reloaded = reloadOpenSessions();
} catch {
/* a reload that cannot be delivered must not fail the update itself */
}
}
return cliResult(res, result, { action, type: "plugin", name: key }, { reloaded });
} catch (err) {
return cliError(res, err);
}
});
/**
* Type `/reload-plugins` + `/reload-skills` into the live Claude Code lane
* panes. Not a config mutation — it changes nothing on disk, it just tells
* sessions to re-read what is already there — so it neither queues on the CLI
* mutation chain nor emits `cc_config_changed`.
*/
router.post("/reload", (req, res) => {
const { kinds } = req.body || {};
if (kinds !== undefined && !Array.isArray(kinds)) {
return res.status(400).json({
error: { code: "EBADARG", message: "kinds must be an array of 'plugins' | 'skills'" },
});
}
try {
res.json({ ok: true, ...reloadOpenSessions({ kinds }) });
} catch (err) {
res.status(500).json({
error: { code: "ERELOAD", message: err.message || String(err) },
});
}
});
// ── Update all ─────────────────────────────────────────────────────────
//
// Runs detached (see server/lib/cc-update-all.js): the batch ends with AgentHub
// itself when this server runs from the plugin cache, and that step stops the
// process. The response is therefore only an acknowledgement — progress is
// polled from GET below, which reads the worker's on-disk journal.
router.get("/plugins/update-all", (_req, res) => {
res.json({ batch: readUpdateAll() });
});
router.post("/plugins/update-all", async (req, res) => {
if (isRunning()) {
return res.status(409).json({
error: { code: "EBUSY", message: "an update-all batch is already running" },
batch: readUpdateAll(),
});
}
let installed;
try {
installed = (await ccCli.listAvailable()).installed || [];
} catch (err) {
return cliError(res, err);
}
// Ids come from the CLI's own JSON, never from the request body, but they
// reach an argv in a detached process — so they are filtered here too.
const plugins = installed.map((p) => String(p.id || "")).filter((id) => PLUGIN_SPEC_RE.test(id));
// Which plugin, if any, is the one this dashboard is running from. Derived
// from our own path — never from the request — so no caller can nominate a
// different plugin as "self" and get a bootstrap run against it.
const selfRoot = path.resolve(__dirname, "..", "..");
const detected = detectPluginInstall(selfRoot);
const selfPlugin = detected
? { marketplace: detected.marketplace, plugin: detected.plugin }
: null;
const plan = {
plugins,
selfPlugin,
selfRoot: selfPlugin ? selfRoot : null,
reload: req.body?.reload !== false,
};
try {
const started = startUpdateAll(plan);
res.json({ ok: true, ...started, planned: plugins.length, self_plugin: selfPlugin });
} catch (err) {
res.status(500).json({
error: { code: "EUPDATEALL", message: err.message || String(err) },
});
}
});
router.post("/marketplaces/add", async (req, res) => {
const { source, scope } = req.body || {};
try {