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:
@@ -151,10 +151,13 @@ describe("cc-plugin-cli argv construction", () => {
|
||||
"disable",
|
||||
"demo@m",
|
||||
]);
|
||||
// `-y` is mandatory, not cosmetic: runClaude closes stdin, and the CLI
|
||||
// refuses to update without it whenever stdin is not a TTY.
|
||||
assert.deepEqual(argvOf(await cli.pluginUpdate({ spec: "demo@m" })), [
|
||||
"plugin",
|
||||
"update",
|
||||
"demo@m",
|
||||
"-y",
|
||||
]);
|
||||
assert.deepEqual(argvOf(await cli.marketplaceRemove({ name: "m" })), [
|
||||
"plugin",
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* @file Unit tests for typing `/reload-plugins` / `/reload-skills` into the
|
||||
* live Claude Code lane panes. Uses tmux.js's exec seam, so no real tmux binary
|
||||
* is invoked and no real session is ever typed into.
|
||||
*
|
||||
* The behaviours worth locking down are the ones that would silently do the
|
||||
* wrong thing: sending to a pane sitting at a shell prompt (the slash command
|
||||
* would run as a shell command instead), forwarding a caller-supplied string
|
||||
* into a REPL, and letting one dead pane fail the whole batch. The `tool ===
|
||||
* "claude"` filter is not covered here — it reads a dashboard-runs DB row,
|
||||
* which these stubs do not populate.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "cc-reload-test-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
|
||||
const tmux = require("../lib/tmux");
|
||||
const { reloadOpenSessions, reloadableLanes } = require("../lib/cc-reload");
|
||||
|
||||
after(() => {
|
||||
try {
|
||||
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Stub tmux so `listRuns()` sees `sessions` and each pane reports the command
|
||||
* given in `panes` (defaulting to a live `claude`).
|
||||
* @returns {any[][]} the recorded argv arrays
|
||||
*/
|
||||
function stubTmux(sessions, panes = {}) {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl((args) => {
|
||||
calls.push(args);
|
||||
if (args[0] === "list-sessions") return sessions.join("\n");
|
||||
if (args[0] === "has-session") {
|
||||
if (sessions.includes(args[2])) return "";
|
||||
const e = new Error("no such session");
|
||||
e.status = 1;
|
||||
throw e;
|
||||
}
|
||||
if (args[0] === "display-message") {
|
||||
const name = args[3];
|
||||
const cmd = panes[name] ?? "claude";
|
||||
if (cmd === null) {
|
||||
const e = new Error("pane gone");
|
||||
e.status = 1;
|
||||
throw e;
|
||||
}
|
||||
return `${cmd}\n`;
|
||||
}
|
||||
if (args[0] === "send-keys") {
|
||||
if (panes[args[2]] === "__send-fails__") throw new Error("pane died mid-send");
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
return calls;
|
||||
}
|
||||
|
||||
describe("reloadableLanes", () => {
|
||||
beforeEach(() => tmux.__reset());
|
||||
|
||||
it("lists a lane whose pane is running claude", () => {
|
||||
stubTmux(["agenthub-lane-1"]);
|
||||
const lanes = reloadableLanes();
|
||||
assert.equal(lanes.length, 1);
|
||||
assert.equal(lanes[0].id, "agenthub-lane-1");
|
||||
assert.equal(lanes[0].laneId, 1);
|
||||
});
|
||||
|
||||
it("skips a pane sitting at a shell prompt", () => {
|
||||
// The claude process there has exited, so `/reload-plugins` would be typed
|
||||
// at a bash prompt and run as a shell command instead.
|
||||
stubTmux(["agenthub-lane-1"], { "agenthub-lane-1": "bash" });
|
||||
assert.deepEqual(reloadableLanes(), []);
|
||||
});
|
||||
|
||||
it("skips every shell name, not just bash", () => {
|
||||
stubTmux(["agenthub-lane-1", "agenthub-lane-2", "agenthub-lane-3"], {
|
||||
"agenthub-lane-1": "zsh",
|
||||
"agenthub-lane-2": "fish",
|
||||
"agenthub-lane-3": "dash",
|
||||
});
|
||||
assert.deepEqual(reloadableLanes(), []);
|
||||
});
|
||||
|
||||
it("returns nothing when no lane sessions exist at all", () => {
|
||||
stubTmux([]);
|
||||
assert.deepEqual(reloadableLanes(), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reloadOpenSessions", () => {
|
||||
beforeEach(() => tmux.__reset());
|
||||
|
||||
it("types both reload commands, plugins before skills", () => {
|
||||
// Order matters: a skill shipped BY a plugin only exists on disk once the
|
||||
// plugin itself has been reloaded.
|
||||
const calls = stubTmux(["agenthub-lane-7"]);
|
||||
const out = reloadOpenSessions();
|
||||
|
||||
assert.deepEqual(out.commands, ["/reload-plugins", "/reload-skills"]);
|
||||
assert.equal(out.lanes_total, 1);
|
||||
assert.deepEqual(out.lanes[0].sent, ["/reload-plugins", "/reload-skills"]);
|
||||
|
||||
const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]);
|
||||
assert.deepEqual(typed, ["/reload-plugins", "/reload-skills"]);
|
||||
// Each command must be submitted, or it just sits in the input box.
|
||||
assert.equal(calls.filter((c) => c[0] === "send-keys" && c[3] === "Enter").length, 2);
|
||||
});
|
||||
|
||||
it("honours a narrowed kinds list", () => {
|
||||
const calls = stubTmux(["agenthub-lane-1"]);
|
||||
const out = reloadOpenSessions({ kinds: ["skills"] });
|
||||
assert.deepEqual(out.commands, ["/reload-skills"]);
|
||||
const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]);
|
||||
assert.deepEqual(typed, ["/reload-skills"]);
|
||||
});
|
||||
|
||||
it("ignores an unknown kind rather than typing it into a REPL", () => {
|
||||
// The command set is fixed in the module; a pane's stdin is not a place to
|
||||
// forward caller-supplied strings.
|
||||
const calls = stubTmux(["agenthub-lane-1"]);
|
||||
const out = reloadOpenSessions({ kinds: ["plugins", "rm -rf /"] });
|
||||
assert.deepEqual(out.commands, ["/reload-plugins"]);
|
||||
const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]);
|
||||
assert.deepEqual(typed, ["/reload-plugins"]);
|
||||
});
|
||||
|
||||
it("types into several lanes in one call", () => {
|
||||
stubTmux(["agenthub-lane-1", "agenthub-lane-2"]);
|
||||
const out = reloadOpenSessions();
|
||||
assert.equal(out.lanes_total, 2);
|
||||
assert.deepEqual(
|
||||
out.lanes.map((l) => l.laneId).sort((a, b) => a - b),
|
||||
[1, 2]
|
||||
);
|
||||
});
|
||||
|
||||
it("records a pane that dies mid-send without failing the others", () => {
|
||||
stubTmux(["agenthub-lane-1", "agenthub-lane-2"], { "agenthub-lane-1": "__send-fails__" });
|
||||
const out = reloadOpenSessions();
|
||||
const failed = out.lanes.find((l) => l.id === "agenthub-lane-1");
|
||||
const ok = out.lanes.find((l) => l.id === "agenthub-lane-2");
|
||||
assert.match(failed.error, /died mid-send/);
|
||||
assert.deepEqual(failed.sent, [], "nothing was delivered to the dead pane");
|
||||
assert.deepEqual(ok.sent, ["/reload-plugins", "/reload-skills"]);
|
||||
assert.equal(ok.error, undefined);
|
||||
});
|
||||
|
||||
it("reports an empty result instead of throwing when nothing is open", () => {
|
||||
stubTmux([]);
|
||||
const out = reloadOpenSessions();
|
||||
assert.equal(out.lanes_total, 0);
|
||||
assert.deepEqual(out.lanes, []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* @file Unit tests for the "Update all" batch journal — the only channel the UI
|
||||
* has for following a batch that deliberately outlives the server process.
|
||||
*
|
||||
* The batch worker itself is never run to completion here: with a valid plan it
|
||||
* would really update this machine's plugins and stop its dashboard. Only the
|
||||
* paths that must terminate WITHOUT touching anything are exercised against the
|
||||
* real script, because a worker that dies without a terminal journal entry
|
||||
* leaves the UI polling "running" forever — the exact failure this journal
|
||||
* exists to prevent.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const HELPER = path.join(__dirname, "..", "..", "scripts", "cc-update-all-helper.js");
|
||||
|
||||
let tmpDir;
|
||||
let prevDataDir;
|
||||
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cc-update-all-"));
|
||||
prevDataDir = process.env.DASHBOARD_DATA_DIR;
|
||||
// Redirect the runtime dir: these tests must never read or write the real
|
||||
// ~/.claude/agenthub/runtime of whoever runs the suite.
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(tmpDir, "data");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (prevDataDir === undefined) delete process.env.DASHBOARD_DATA_DIR;
|
||||
else process.env.DASHBOARD_DATA_DIR = prevDataDir;
|
||||
try {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
readUpdateAll,
|
||||
isRunning,
|
||||
journalPath,
|
||||
runtimeDir,
|
||||
STALE_MS,
|
||||
} = require("../lib/cc-update-all");
|
||||
|
||||
/** Write a journal into a fresh runtime dir and return that dir. */
|
||||
function withJournal(name, body) {
|
||||
const rt = path.join(tmpDir, name);
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(rt, "cc-update-all.json"),
|
||||
typeof body === "string" ? body : JSON.stringify(body)
|
||||
);
|
||||
return rt;
|
||||
}
|
||||
|
||||
describe("readUpdateAll", () => {
|
||||
it("returns null when no batch has ever run", () => {
|
||||
const rt = path.join(tmpDir, "never");
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
assert.equal(readUpdateAll(rt), null);
|
||||
});
|
||||
|
||||
it("returns null for malformed JSON rather than throwing on a poll", () => {
|
||||
assert.equal(readUpdateAll(withJournal("bad", "{nope")), null);
|
||||
});
|
||||
|
||||
it("returns null for JSON without a steps array", () => {
|
||||
// Guards against reporting a half-written file as a batch.
|
||||
assert.equal(readUpdateAll(withJournal("nosteps", { status: "running" })), null);
|
||||
});
|
||||
|
||||
it("reads back a finished batch with its per-step outcomes", () => {
|
||||
const rt = withJournal("done", {
|
||||
status: "partial",
|
||||
started_at: new Date().toISOString(),
|
||||
finished_at: new Date().toISOString(),
|
||||
steps: [
|
||||
{ key: "marketplaces", label: "Update all marketplaces", status: "ok" },
|
||||
{
|
||||
key: "plugin:ponytail@ponytail",
|
||||
label: "ponytail@ponytail",
|
||||
status: "failed",
|
||||
error: "boom",
|
||||
},
|
||||
],
|
||||
});
|
||||
const batch = readUpdateAll(rt);
|
||||
assert.equal(batch.status, "partial");
|
||||
assert.equal(batch.steps.length, 2);
|
||||
assert.equal(batch.steps[1].status, "failed");
|
||||
assert.match(batch.steps[1].error, /boom/);
|
||||
});
|
||||
|
||||
it("reports a fresh running batch as running", () => {
|
||||
const rt = withJournal("fresh", {
|
||||
status: "running",
|
||||
started_at: new Date().toISOString(),
|
||||
steps: [{ key: "marketplaces", status: "running" }],
|
||||
});
|
||||
assert.equal(readUpdateAll(rt).status, "running");
|
||||
assert.equal(isRunning(rt), true);
|
||||
});
|
||||
|
||||
it("reports a long-stale running batch as abandoned, so the button unwedges", () => {
|
||||
// The worker is detached: a reboot leaves nobody to write the terminal
|
||||
// entry, and the UI would otherwise spin forever.
|
||||
const rt = withJournal("stale", {
|
||||
status: "running",
|
||||
started_at: new Date(Date.now() - STALE_MS - 60_000).toISOString(),
|
||||
steps: [{ key: "marketplaces", status: "running" }],
|
||||
});
|
||||
assert.equal(readUpdateAll(rt).status, "abandoned");
|
||||
assert.equal(isRunning(rt), false, "an abandoned batch must not block a new one");
|
||||
});
|
||||
|
||||
it("treats an unparseable started_at as running rather than abandoned", () => {
|
||||
// Better to show a spinner than to start a second concurrent batch against
|
||||
// the same config dir on the strength of a garbled timestamp.
|
||||
const rt = withJournal("nodate", {
|
||||
status: "running",
|
||||
started_at: "not a date",
|
||||
steps: [{ key: "marketplaces", status: "running" }],
|
||||
});
|
||||
assert.equal(readUpdateAll(rt).status, "running");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtimeDir / journalPath", () => {
|
||||
it("puts the journal under the data dir's runtime folder, outside any plugin cache", () => {
|
||||
// The batch replaces the plugin cache directory it may be running from, so
|
||||
// a journal stored in there would vanish exactly when it is needed.
|
||||
const p = journalPath();
|
||||
assert.equal(p, path.join(runtimeDir(), "cc-update-all.json"));
|
||||
assert.ok(!p.includes(`${path.sep}cache${path.sep}`), p);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cc-update-all-helper", () => {
|
||||
it("records a terminal failure for an unreadable plan without running anything", () => {
|
||||
const rt = path.join(tmpDir, "badplan");
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
|
||||
execFileSync(process.execPath, [HELPER, rt, "{not json"], {
|
||||
stdio: "ignore",
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const batch = JSON.parse(fs.readFileSync(path.join(rt, "cc-update-all.json"), "utf8"));
|
||||
assert.equal(batch.status, "failed");
|
||||
assert.match(batch.error, /unreadable plan/);
|
||||
assert.ok(batch.finished_at, "a terminal entry is always timestamped");
|
||||
// Nothing was attempted, so there is no step log and no claude invocation.
|
||||
assert.deepEqual(batch.steps, []);
|
||||
const logPath = path.join(rt, "cc-update-all.log");
|
||||
const log = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf8") : "";
|
||||
assert.doesNotMatch(log, /claude plugin/, "no claude command may be logged");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -1556,6 +1556,118 @@ const paths = {
|
||||
},
|
||||
},
|
||||
|
||||
"/api/cc-config/plugins/update-all": {
|
||||
get: {
|
||||
tags: ["CcConfig"],
|
||||
summary: "Progress of the current or last update-all batch",
|
||||
description:
|
||||
"Read-only. Returns the detached batch worker's on-disk journal from ~/.claude/agenthub/runtime/cc-update-all.json, or `{batch:null}` when no batch has ever run. `status` is `running`|`done`|`partial`|`failed`|`abandoned`; `abandoned` is DERIVED, not stored — a `running` journal older than one hour is reported that way because the worker is detached and a host reboot leaves nobody to write the terminal entry. `partial` means the batch finished with at least one failed step. Malformed or half-written journals return null rather than erroring, since the UI polls this endpoint.",
|
||||
operationId: "ccConfigGetUpdateAll",
|
||||
responses: {
|
||||
200: {
|
||||
description: "The batch journal, or null.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { type: "object", additionalProperties: true },
|
||||
example: {
|
||||
batch: {
|
||||
status: "partial",
|
||||
started_at: "2026-09-10T08:30:00.000Z",
|
||||
finished_at: "2026-09-10T08:34:12.000Z",
|
||||
steps: [
|
||||
{ key: "marketplaces", label: "Update all marketplaces", status: "ok" },
|
||||
{ key: "plugin:ponytail@ponytail", label: "ponytail@ponytail", status: "ok" },
|
||||
{
|
||||
key: "plugin:vercel@claude-plugins-official",
|
||||
label: "vercel@claude-plugins-official",
|
||||
status: "failed",
|
||||
error: "marketplace unreachable",
|
||||
},
|
||||
{
|
||||
key: "reload",
|
||||
label: "Reload open Claude Code sessions",
|
||||
status: "ok",
|
||||
lanes_total: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["CcConfig"],
|
||||
summary: "Update every marketplace and plugin, then reload the open sessions",
|
||||
description:
|
||||
'Mutating. Spawns a DETACHED worker and returns immediately — the response is an acknowledgement, not a result; poll GET on this path to follow it. Order: `claude plugin marketplace update` (no name = all), then `claude plugin update <id> -y` for every installed plugin, then `/reload-plugins` + `/reload-skills` typed into the live lane panes. The plugin this dashboard itself runs from is updated LAST and followed by `plugin-bootstrap.js --force`, which stops this server and starts the replacement — which is why the work cannot happen in the request handler and why the journal lives outside the plugin cache. Plugin ids come from `claude plugin list --json`, never from the body, and the "self" plugin is derived from this server\'s own path, so no caller can nominate a different plugin for the restart. Answers 409 EBUSY when a batch is already in flight; the body is ignored apart from an optional `reload:false`.',
|
||||
operationId: "ccConfigUpdateAll",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Batch worker spawned.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { type: "object", additionalProperties: true },
|
||||
example: {
|
||||
ok: true,
|
||||
spawned: true,
|
||||
runtime_dir: "/Users/son/.claude/agenthub/runtime",
|
||||
planned: 7,
|
||||
self_plugin: { marketplace: "sg", plugin: "agenthub" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description: "A batch is already running.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/cc-config/reload": {
|
||||
post: {
|
||||
tags: ["CcConfig"],
|
||||
summary: "Tell the open Claude Code sessions to re-read plugins and skills",
|
||||
description:
|
||||
'Types `/reload-plugins` and `/reload-skills` into the dashboard\'s live lane panes over `tmux send-keys`. Both commands are scoped BY CLAUDE CODE to the session that runs them, so there is no way to reload a session from outside — typing into a session that is already running is what a human would do, and it is what this does. It spawns no `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session and leave a junk session behind. Changes nothing on disk, so it does not queue on the CLI mutation chain and emits no `cc_config_changed`. Panes sitting at a shell prompt are skipped (the text would run as a shell command); only `claude` lanes are addressed, since Codex has no such command. Optional body `{kinds:["plugins"|"skills"]}` narrows which commands are sent; unknown kinds are ignored, as the command strings are fixed server-side.',
|
||||
operationId: "ccConfigReload",
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Commands delivered. `lanes_total` counts only panes running a live claude; zero is a normal outcome, not a failure.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { type: "object", additionalProperties: true },
|
||||
example: {
|
||||
ok: true,
|
||||
commands: ["/reload-plugins", "/reload-skills"],
|
||||
lanes_total: 1,
|
||||
lanes: [
|
||||
{
|
||||
id: "agenthub-lane-3",
|
||||
laneId: 3,
|
||||
sent: ["/reload-plugins", "/reload-skills"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "`kinds` was present but not an array.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/cc-config/mcp": {
|
||||
get: {
|
||||
tags: ["CcConfig"],
|
||||
|
||||
+9
-6
@@ -2387,7 +2387,7 @@ function createOpenApiSpec() {
|
||||
"/api/updates/status": {
|
||||
get: {
|
||||
tags: ["Updates"],
|
||||
summary: "Check whether the dashboard git checkout is behind origin",
|
||||
summary: "Check whether a newer release of the dashboard has been published",
|
||||
operationId: "getUpdatesStatus",
|
||||
responses: {
|
||||
200: {
|
||||
@@ -2398,7 +2398,7 @@ function createOpenApiSpec() {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
description:
|
||||
"Includes git_repo, update_available, commits_behind, remote_ref, local_sha, remote_sha, manual_command, and optional error/message fields.",
|
||||
'Reads the newest published release from the forge API (derived from the origin remote) and compares its tag against this install\'s package.json version; falls back to a commit count against the canonical default branch when no release can be read, with the reason in release_check. Includes git_repo, update_available, install_kind ("plugin" | "checkout"), plugin_marketplace, local_version, shallow_clone, release_check, latest_release, latest_release_name, latest_release_url, latest_release_published_at, latest_release_notes, release_sha, commits_behind_release, commits_ahead_of_release, dev_build, commits_behind, remote_ref, local_sha, remote_sha, situation, manual_command, and optional error/message fields. dev_build is true when this install is strictly ahead of the newest release; commits_* fields are null on a shallow clone, where distances are meaningless.',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2443,18 +2443,21 @@ function createOpenApiSpec() {
|
||||
"/api/updates/apply": {
|
||||
post: {
|
||||
tags: ["Updates"],
|
||||
summary: "Pull, rebuild, and restart the dashboard when fast-forwardable",
|
||||
summary: "Update this install to the newest release and restart the dashboard",
|
||||
description:
|
||||
"Two modes, chosen by how this copy was installed and never by the request body (which is ignored). checkout: fast-forward onto the release tag, npm run setup, npm run build in production, then self-restart. plugin: the install runs from ~/.claude/plugins/cache/, so a detached helper runs `claude plugin marketplace update`, `claude plugin update -y`, then plugin-bootstrap.js --force from the NEW cache directory, which stops this server and starts the new one. In both modes the response is written before anything stops the process.",
|
||||
operationId: "applyUpdate",
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Checkout fast-forwarded and rebuilt; the process is about to self-restart",
|
||||
'Update is being applied; the process is about to be replaced. mode: "plugin" means the detached plugin chain is running, otherwise the checkout was fast-forwarded and rebuilt in-place',
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
description: "Same shape as GET /api/updates/status, plus applied: true.",
|
||||
description:
|
||||
'Same shape as GET /api/updates/status, plus applied: true. In plugin mode also mode: "plugin" and runtime_dir (where the chain writes its verdict, read back afterwards as last_plugin_update).',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2468,7 +2471,7 @@ function createOpenApiSpec() {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
description:
|
||||
'applied: false plus reason: "up_to_date" | "not_fast_forwardable".',
|
||||
'applied: false plus reason: "up_to_date" | "not_fast_forwardable" | "plugin_managed". plugin_managed appears here only when a plugin-cache install has nothing newer published; when a newer release exists the plugin chain runs and the answer is 200 instead. Run /agenthub-update to force a refresh.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+102
-4
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user