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
+104 -3
View File
@@ -1609,11 +1609,45 @@ export const api = {
/**
* POST /api/cc-config/plugins/action - enable/disable/update/uninstall an
* installed plugin by its `<name>@<marketplace>` key.
*
* `reload` additionally types `/reload-plugins` + `/reload-skills` into the
* live Claude Code lane panes, but only when the action itself succeeded —
* "update one, reload that one", as opposed to the batch below which
* reloads once after everything.
*/
pluginAction: (key: string, action: CcPluginAction) =>
request<CcCliResult>("/cc-config/plugins/action", {
pluginAction: (key: string, action: CcPluginAction, reload?: boolean) =>
request<CcCliResult & { reloaded?: CcReloadResult }>("/cc-config/plugins/action", {
method: "POST",
body: JSON.stringify({ key, action }),
body: JSON.stringify({ key, action, reload }),
}),
/**
* POST /api/cc-config/reload - tell the Claude Code sessions the dashboard
* has open to re-read plugins and/or skills from disk.
*
* Both slash commands are scoped to the session that runs them, so this
* types them into the live lane panes over tmux. Sessions running outside
* the dashboard cannot be reached and must run the command themselves.
*/
reload: (kinds?: CcReloadKind[]) =>
request<{ ok: true } & CcReloadResult>("/cc-config/reload", {
method: "POST",
body: JSON.stringify({ kinds }),
}),
/** GET /api/cc-config/plugins/update-all - progress of the current or last batch. */
updateAllStatus: () =>
request<{ batch: CcUpdateAllBatch | null }>("/cc-config/plugins/update-all"),
/**
* POST /api/cc-config/plugins/update-all - update every marketplace, then
* every installed plugin, then reload the open sessions.
*
* Returns as soon as the detached worker is spawned: the batch ends with
* AgentHub itself when the dashboard runs from the plugin cache, and that
* step restarts this server. Follow it with {@link updateAllStatus}.
*/
updateAll: () =>
request<CcUpdateAllStarted>("/cc-config/plugins/update-all", {
method: "POST",
body: JSON.stringify({}),
}),
/**
* POST /api/cc-config/marketplaces/add - register a marketplace from a
@@ -2761,6 +2795,73 @@ export type CcPluginAction = "enable" | "disable" | "update" | "uninstall";
/** What POST /api/cc-config/marketplaces/action can do to a marketplace. */
export type CcMarketplaceAction = "update" | "remove";
/** Which reload slash command to send. The command strings themselves are
* fixed server-side — a pane's stdin is not a place to forward client input. */
export type CcReloadKind = "plugins" | "skills";
/** What POST /api/cc-config/reload reached. `lanes_total` counts only panes
* that were running a live `claude`; a lane sitting at a shell prompt has no
* REPL to reload and is skipped rather than reported as failed. */
export interface CcReloadResult {
commands: string[];
lanes_total: number;
lanes: {
id: string;
laneId: number | null;
/** Commands actually delivered — empty when the pane died mid-send. */
sent: string[];
error?: string;
}[];
}
/** One step of the update-all batch. `key` is stable (`marketplaces`,
* `plugin:<id>`, `reload`) so the UI can keep a row's identity across polls. */
export interface CcUpdateAllStep {
key: string;
label: string;
status: "running" | "ok" | "failed" | "skipped";
started_at?: string;
finished_at?: string;
error?: string;
note?: string;
/** Set on the AgentHub step that triggers the dashboard restart. */
restarted?: boolean;
/** Set on the reload step — the lane sessions that were typed into. */
lanes?: string[];
lanes_total?: number;
}
/**
* GET /api/cc-config/plugins/update-all - the batch worker's on-disk journal.
*
* `abandoned` is derived, not written: the worker is detached, so a host reboot
* leaves a `running` journal with nobody to finish it. `partial` means the
* batch ran to the end with at least one failed step.
*/
export interface CcUpdateAllBatch {
status: "running" | "done" | "partial" | "failed" | "abandoned";
steps: CcUpdateAllStep[];
started_at?: string;
finished_at?: string;
error?: string;
/** Set when the final `plugin-bootstrap --force` failed, i.e. the old server
* was stopped and no replacement came up. */
bootstrap_error?: string;
note?: string;
}
/** POST /api/cc-config/plugins/update-all - acknowledgement, not a result. */
export interface CcUpdateAllStarted {
ok: true;
spawned: true;
runtime_dir: string;
/** How many installed plugins the worker will walk. */
planned: number;
/** The plugin this dashboard itself runs from, updated LAST because its
* bootstrap restarts the server. Null for a plain git checkout. */
self_plugin: { marketplace: string; plugin: string } | null;
}
/** Result of a `claude plugin …` run the dashboard triggered. A non-zero CLI
* exit arrives as an HTTP 422 error instead of one of these. */
export interface CcCliResult {