Files
AgentHub/scripts/cc-update-all-helper.js
T
nntrivi2001 99a2a8277c fix(tmux,run,hooks): the four pre-existing bugs the #37 evaluation found
- tmux targets are exact (`=name`, `=name:` for panes/options): a bare
  `-t agenthub-lane-1` resolves by prefix to agenthub-lane-12 once lane 1
  is gone, so killing a finished lane could kill another.
- getRun/killRun refuse ids that are not `agenthub-lane-<n>`, so
  DELETE /api/run/main can no longer kill a user's own tmux session.
- router/apply reports restart_required only for a running pane; getRun
  returns a `gone` handle, so it was always true.
- needs_action is cleared only by the session that raised it (new nullable
  lanes.needs_action_session); a second session in the same cwd used to
  clear it on its second hook.
2026-09-25 11:30:02 +07:00

307 lines
11 KiB
JavaScript

#!/usr/bin/env node
/**
* @file Detached worker for the dashboard's "Update all" button. Runs the whole
* batch and writes each step's outcome to `cc-update-all.json` in the runtime
* directory as it goes, so the UI can follow along by polling.
*
* Order, and why:
* 1. `claude plugin marketplace update` (no name = every marketplace) — a
* plugin update can only see a version its marketplace already knows about.
* 2. `claude plugin update <id> -y` for every installed plugin EXCEPT the one
* the dashboard itself runs from.
* 3. That one last, followed by `plugin-bootstrap.js --force` from the NEW
* cache directory — this stops the dashboard server and starts the
* replacement. Anything ordered after it would run while the server is
* down, which is exactly why it is last.
* 4. `/reload-plugins` + `/reload-skills` typed into the live lane panes.
* Deliberately after everything: one reload picks up every pending change.
*
* Spawned detached precisely because step 3 kills its parent. It must never
* throw its way out without a terminal journal entry — a silent death would
* leave the UI polling a batch that says "running" forever.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const path = require("path");
const { execFileSync, spawnSync } = require("child_process");
const [, , runtimeDirArg, planJson] = process.argv;
const JOURNAL = path.join(runtimeDirArg || ".", "cc-update-all.json");
const LOG_FILE = path.join(runtimeDirArg || ".", "cc-update-all.log");
// Ids reach an argv, so they are re-validated here rather than trusted across
// the process boundary — same reasoning as scripts/plugin-update-helper.js.
const PLUGIN_SPEC_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(@[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
const SAFE_NAME = /^[A-Za-z0-9][\w.-]*$/;
const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]);
/** @type {{status: string, started_at: string, finished_at?: string, steps: object[]}} */
const state = { status: "running", started_at: new Date().toISOString(), steps: [] };
function log(line) {
try {
fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${line}\n`);
} catch {
/* best effort */
}
}
/** Persist after every transition — the UI polls this file, and the process can
* be SIGTERMed at step 3 without warning. */
function flush() {
try {
fs.mkdirSync(runtimeDirArg, { recursive: true });
fs.writeFileSync(JOURNAL, JSON.stringify(state, null, 2));
} catch {
/* best effort */
}
}
function addStep(key, label) {
const step = { key, label, status: "running", started_at: new Date().toISOString() };
state.steps.push(step);
flush();
return step;
}
function finishStep(step, status, extra = {}) {
step.status = status;
step.finished_at = new Date().toISOString();
Object.assign(step, extra);
flush();
}
function resolveClaude() {
const candidates = ["claude", path.join(require("os").homedir(), ".local", "bin", "claude")];
for (const cmd of candidates) {
const probe = spawnSync(cmd, ["--version"], { stdio: "ignore", timeout: 20_000 });
if (!probe.error && probe.status === 0) return cmd;
}
return null;
}
function run(cmd, args, timeout) {
try {
return runOnce(cmd, args, timeout);
} catch (err) {
// An older Claude Code rejects `-y` ("unknown option") and never prompts,
// so the same command without the flag is the right retry.
if (!args.includes("-y") || !/unknown option '-y'/.test(String(err.stderr || ""))) throw err;
return runOnce(
cmd,
args.filter((a) => a !== "-y"),
timeout
);
}
}
function runOnce(cmd, args, timeout) {
log(`$ ${cmd} ${args.join(" ")}`);
const out = execFileSync(cmd, args, {
encoding: "utf8",
timeout,
maxBuffer: 10_000_000,
stdio: ["ignore", "pipe", "pipe"],
});
if (out) log(out.trim().slice(0, 2000));
return out;
}
/** Newest cache dir for the self plugin, excluding the one being replaced. */
function findNewRoot(marketplace, plugin, exclude) {
const base = path.join(
require("os").homedir(),
".claude",
"plugins",
"cache",
marketplace,
plugin
);
let entries;
try {
entries = fs.readdirSync(base, { withFileTypes: true }).filter((e) => e.isDirectory());
} catch {
return null;
}
const candidates = entries
.map((e) => path.join(base, e.name))
.filter((p) => p !== exclude)
.filter((p) => fs.existsSync(path.join(p, "scripts", "plugin-bootstrap.js")))
.map((p) => ({ p, mtime: fs.statSync(p).mtimeMs }))
.sort((a, b) => b.mtime - a.mtime);
return candidates.length ? candidates[0].p : null;
}
/**
* Type the reload commands into every live `claude` lane pane. Uses tmux
* directly rather than the server's lib: by this point the server may be a
* different process than the one that started the batch.
*/
function reloadPanes() {
const step = addStep("reload", "Reload open Claude Code sessions");
let names;
try {
const out = execFileSync("tmux", ["list-sessions", "-F", "#{session_name}"], {
encoding: "utf8",
timeout: 15_000,
stdio: ["ignore", "pipe", "pipe"],
});
names = out
.split("\n")
.map((s) => s.trim())
.filter((s) => s.startsWith("agenthub-lane-"));
} catch {
// No tmux server at all means no open sessions — nothing to reload, which
// is a normal outcome and not a batch failure.
finishStep(step, "skipped", { note: "no tmux sessions are open" });
return;
}
const reloaded = [];
for (const name of names) {
let paneCmd = null;
try {
paneCmd = execFileSync(
"tmux",
["display-message", "-p", "-t", `=${name}:`, "#{pane_current_command}"],
{ encoding: "utf8", timeout: 15_000, stdio: ["ignore", "pipe", "pipe"] }
).trim();
} catch {
continue; // pane vanished between listing and probe
}
// A pane at a shell prompt has no REPL to reload; the text would be run as
// a shell command instead.
if (SHELL_COMMANDS.has(paneCmd)) continue;
try {
let first = true;
for (const command of ["/reload-plugins", "/reload-skills"]) {
// Let the first command actually submit before typing the second, or
// both land on one composer line and Claude Code runs the pair as
// `/reload-plugins /reload-skills` — see SETTLE_MS in
// server/lib/cc-reload.js. Blocking is fine here: this worker is
// detached and serves no requests.
if (!first) execFileSync("sleep", ["1"], { timeout: 15_000 });
first = false;
execFileSync("tmux", ["send-keys", "-t", `=${name}:`, "-l", command], { timeout: 15_000 });
execFileSync("tmux", ["send-keys", "-t", `=${name}:`, "Enter"], { timeout: 15_000 });
}
reloaded.push(name);
} catch {
/* pane died mid-send; the rest of the batch stands */
}
}
finishStep(step, "ok", { lanes: reloaded, lanes_total: reloaded.length });
}
function main() {
let plan;
try {
plan = JSON.parse(planJson);
} catch {
state.status = "failed";
state.error = "unreadable plan";
state.finished_at = new Date().toISOString();
flush();
return;
}
const claude = resolveClaude();
if (!claude) {
const step = addStep("resolve_claude", "Locate the claude CLI");
finishStep(step, "failed", {
error: "the `claude` CLI is not on this server's PATH; run the update from a terminal",
});
state.status = "failed";
state.finished_at = new Date().toISOString();
flush();
return;
}
// 1. Every marketplace, in one call (no name = all).
const mktStep = addStep("marketplaces", "Update all marketplaces");
try {
run(claude, ["plugin", "marketplace", "update"], 300_000);
finishStep(mktStep, "ok");
} catch (err) {
// A marketplace that can't be refreshed (offline, auth) does not stop the
// batch: the plugins may still have updates from the marketplaces that did.
finishStep(mktStep, "failed", { error: String(err.stderr || err.message).slice(0, 2000) });
}
const self = plan.selfPlugin;
const selfId = self ? `${self.plugin}@${self.marketplace}` : null;
const others = (plan.plugins || []).filter((id) => PLUGIN_SPEC_RE.test(id) && id !== selfId);
// 2. Every other plugin.
for (const id of others) {
const step = addStep(`plugin:${id}`, id);
try {
run(claude, ["plugin", "update", id, "-y"], 600_000);
finishStep(step, "ok");
} catch (err) {
finishStep(step, "failed", { error: String(err.stderr || err.message).slice(0, 2000) });
}
}
// 3. AgentHub itself, last — its bootstrap stops this server.
if (self && SAFE_NAME.test(self.marketplace) && SAFE_NAME.test(self.plugin)) {
const step = addStep(`plugin:${selfId}`, `${selfId} (this dashboard)`);
try {
run(claude, ["plugin", "update", selfId, "-y"], 600_000);
} catch (err) {
finishStep(step, "failed", { error: String(err.stderr || err.message).slice(0, 2000) });
reloadPanes();
state.status = "failed";
state.finished_at = new Date().toISOString();
flush();
return;
}
const newRoot = findNewRoot(self.marketplace, self.plugin, plan.selfRoot);
if (!newRoot) {
// Nothing newer was published: the running server is correct as-is.
finishStep(step, "ok", { note: "already on the newest version; server left running" });
} else {
// Mark the step done BEFORE the restart: bootstrap SIGTERMs us mid-call,
// so a journal entry written afterwards may never be written at all.
finishStep(step, "ok", { note: `restarting the dashboard from ${newRoot}`, restarted: true });
reloadPanes();
state.status = "done";
state.finished_at = new Date().toISOString();
flush();
try {
run(
process.execPath,
[path.join(newRoot, "scripts", "plugin-bootstrap.js"), "--force"],
900_000
);
} catch (err) {
log(`bootstrap failed: ${err.message}`);
state.status = "failed";
state.bootstrap_error = String(err.stderr || err.message).slice(0, 2000);
state.note = "the old server was stopped; run /agenthub-update to recover";
flush();
}
return;
}
}
// 4. Reload, once, after every update.
if (plan.reload !== false) reloadPanes();
state.status = state.steps.some((s) => s.status === "failed") ? "partial" : "done";
state.finished_at = new Date().toISOString();
flush();
}
try {
main();
} catch (err) {
// A worker that dies without a terminal entry leaves the UI polling forever.
state.status = "failed";
state.error = err.message || String(err);
state.finished_at = new Date().toISOString();
flush();
}