diff --git a/CLAUDE.md b/CLAUDE.md index f6c03131..742622f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v1.6.0 (2026-09-14) + the lane strip's drag/Alt-arrow reordering and inline rename (`client/src/lib/laneOrderStorage.ts`, `LaneStripCard`'s `index`/`onReorder` props, the Workspace detail header's rename field, and `normalizeTitle`/`bindable` in `server/lib/lanes.js`) + the uncommitted-files hover panel (`LaneGitFilesPanel` in `LaneCard`, and `files` from `GET /api/lanes/:id/git` now being `{path,status,added,deleted}` records off one extra `git diff --numstat HEAD`) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, its **Test** button / `POST /api/ai-routers/test`, the per-lane router select — labelled **Model**, `LaneRouterRow` exported from `LaneCard` and rendered in the Workspace lane-detail header row — and `lanes.router_profile`) + the bootstrap's superseded-build guard and plugin-cache pruner (`supersededBy`/`pruneRetiredPluginRoots` in `scripts/plugin-bootstrap.js`, `unitRoot` in `scripts/systemd-service.js`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. +_Last synced: v1.6.8 (2026-09-17) + a run being opened by a DECLARATION only — a backwards DETECTION now continues the run in flight (`appendStageEvent` in `server/lib/lanes.js`), because detection bounces within one feature and `runs` is what every pass rate on the Workflows page is divided by + `SETTLE_MS` in `server/lib/cc-reload.js` — the two reload commands are typed a second apart, and `reloadOpenSessions` is now async, because Claude Code swallows the Enter after a slash command and back-to-back sends merged into one composer line + v1.6.0 (2026-09-14) + the lane strip's drag/Alt-arrow reordering and inline rename (`client/src/lib/laneOrderStorage.ts`, `LaneStripCard`'s `index`/`onReorder` props, the Workspace detail header's rename field, and `normalizeTitle`/`bindable` in `server/lib/lanes.js`) + the uncommitted-files hover panel (`LaneGitFilesPanel` in `LaneCard`, and `files` from `GET /api/lanes/:id/git` now being `{path,status,added,deleted}` records off one extra `git diff --numstat HEAD`) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, its **Test** button / `POST /api/ai-routers/test`, the per-lane router select — labelled **Model**, `LaneRouterRow` exported from `LaneCard` and rendered in the Workspace lane-detail header row — and `lanes.router_profile`) + the bootstrap's superseded-build guard and plugin-cache pruner (`supersededBy`/`pruneRetiredPluginRoots` in `scripts/plugin-bootstrap.js`, `unitRoot` in `scripts/systemd-service.js`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. diff --git a/docs/API.md b/docs/API.md index 5c761ad8..2831bebc 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1677,7 +1677,7 @@ That indirection is structural, not stylistic: the plugin this dashboard *itself Batch `status` is `running`, `done`, `partial` (finished, at least one step failed), `failed`, or `abandoned`. `abandoned` is **derived, not stored**: the worker is detached, so a reboot leaves a `running` journal with nobody to finish it, and after an hour it is reported as abandoned so the button is usable again. -`POST /api/cc-config/reload` is the reload step on its own. It types `/reload-plugins` and `/reload-skills` into the dashboard's live lane panes via `tmux send-keys`. **Both commands are scoped by Claude Code to the session that runs them** (`/reload-plugins` activates pending plugin changes "in the current session"; `/reload-skills` picks up skills changed on disk "during this session"), so nothing can reload a session from the outside — typing into a session that is already running is the only real mechanism, and it is what this does. It never spawns a `claude` process: a one-shot `claude -p "/reload-plugins"` would reload its own short-lived session, exit, and leave a junk session behind. Panes sitting at a shell prompt are skipped, since the text would run as a shell command instead; only `claude` lanes are addressed, as Codex has no equivalent. `lanes_total: 0` is a normal outcome, not an error. Sessions running outside the dashboard cannot be reached and must run the command themselves. +`POST /api/cc-config/reload` is the reload step on its own. It types `/reload-plugins` and `/reload-skills` into the dashboard's live lane panes via `tmux send-keys`. **Both commands are scoped by Claude Code to the session that runs them** (`/reload-plugins` activates pending plugin changes "in the current session"; `/reload-skills` picks up skills changed on disk "during this session"), so nothing can reload a session from the outside — typing into a session that is already running is the only real mechanism, and it is what this does. It never spawns a `claude` process: a one-shot `claude -p "/reload-plugins"` would reload its own short-lived session, exit, and leave a junk session behind. Panes sitting at a shell prompt are skipped, since the text would run as a shell command instead; only `claude` lanes are addressed, as Codex has no equivalent. The two commands are typed **one second apart** (`SETTLE_MS` in `server/lib/cc-reload.js`), which is why the route takes about that long to answer: Claude Code opens its slash-command menu the moment `/` is typed and the Enter that follows lands on the menu, so back-to-back sends left both commands on one composer line and the pane submitted `/reload-plugins/reload-skills` — run as `/reload-plugins` with an argument, never reloading skills at all. `lanes_total: 0` is a normal outcome, not an error. Sessions running outside the dashboard cannot be reached and must run the command themselves. `POST /api/cc-config/plugins/action` accepts `reload: true` for the counterpart behaviour: update one plugin, reload immediately. The batch reloads once at the end instead — one reload picks up every pending change, where N of them would re-scan the same directory N times. A reload is never sent after a *failed* action. diff --git a/docs/LANES.md b/docs/LANES.md index 57484869..ae308834 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -599,6 +599,19 @@ contributes at most one record per node, however many times it ran — so this narrows the history, it does not skew a rate. The Workflows page renders this as one bar per node (see `client/src/components/workflows/LanePipelinePanel.tsx`). +**A new run is opened by a DECLARATION, never by a detection alone.** `runs` is +the denominator every rate on that page is read against, so what counts as a run +decides whether the page is worth reading. Stage detection bounces by design — +one feature's work reads `implement → tests → implement → tests` as the session +edits, runs the suite, and edits again — and when any backwards move opened a +run, a single feature was recorded as five or six of them. A real pass ends +where the session says it ends: a backwards move only starts a new run when it +is declared (`agenthub stage`, or a codex lane's ingest-driven declaration). A +detection that moves backwards continues the run in flight. Reaching the last +node still closes the run for both — work after `done` is new work by anyone's +reading. On this repo's own lane the rule took a 26-run history down to 5, which +is the number of features that were actually shipped through it. + `?lane=` narrows the same aggregation to one lane and its own archived features, which is what the panel's lane picker sends. The `lanes` array in the response is never narrowed — it populates that picker, so it has to keep diff --git a/openapi.yaml b/openapi.yaml index 8656faca..7ec7b27b 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -8044,7 +8044,7 @@ paths: 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.' + 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. The two commands are typed one second apart (`SETTLE_MS`): Claude Code opens its slash-command menu as soon as `/` is typed and swallows the Enter that follows, so back-to-back sends merged into the single line `/reload-plugins/reload-skills`, which runs as `/reload-plugins` with an argument and never reloads skills. 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': diff --git a/scripts/cc-update-all-helper.js b/scripts/cc-update-all-helper.js index db6a497b..18a17a5d 100644 --- a/scripts/cc-update-all-helper.js +++ b/scripts/cc-update-all-helper.js @@ -160,7 +160,15 @@ function reloadPanes() { // 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 }); } diff --git a/server/__tests__/cc-reload.test.js b/server/__tests__/cc-reload.test.js index 031360e8..06e60cb0 100644 --- a/server/__tests__/cc-reload.test.js +++ b/server/__tests__/cc-reload.test.js @@ -103,11 +103,11 @@ describe("reloadableLanes", () => { describe("reloadOpenSessions", () => { beforeEach(() => tmux.__reset()); - it("types both reload commands, plugins before skills", () => { + it("types both reload commands, plugins before skills", async () => { // 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(); + const out = await reloadOpenSessions({ settleMs: 0 }); assert.deepEqual(out.commands, ["/reload-plugins", "/reload-skills"]); assert.equal(out.lanes_total, 1); @@ -119,27 +119,45 @@ describe("reloadOpenSessions", () => { assert.equal(calls.filter((c) => c[0] === "send-keys" && c[3] === "Enter").length, 2); }); - it("honours a narrowed kinds list", () => { + it("waits between the two commands so they cannot share one composer line", async () => { + // Without the pause the first Enter lands on Claude Code's slash-command + // menu, the second command is appended to the line still in the composer, + // and the pane submits `/reload-plugins/reload-skills` as a prompt — the + // reported bug. Timing is the behaviour, so timing is what is asserted. const calls = stubTmux(["agenthub-lane-1"]); - const out = reloadOpenSessions({ kinds: ["skills"] }); + const at = []; + const typed = () => calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").length; + const before = typed(); + const started = Date.now(); + const done = reloadOpenSessions({ settleMs: 40 }); + at.push(typed() - before); + await done; + + assert.equal(at[0], 1, "only the first command is typed before the wait"); + assert.ok(Date.now() - started >= 35, "the second command waited for the first to submit"); + }); + + it("honours a narrowed kinds list", async () => { + const calls = stubTmux(["agenthub-lane-1"]); + const out = await reloadOpenSessions({ kinds: ["skills"], settleMs: 0 }); 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", () => { + it("ignores an unknown kind rather than typing it into a REPL", async () => { // 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 /"] }); + const out = await reloadOpenSessions({ kinds: ["plugins", "rm -rf /"], settleMs: 0 }); 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", () => { + it("types into several lanes in one call", async () => { stubTmux(["agenthub-lane-1", "agenthub-lane-2"]); - const out = reloadOpenSessions(); + const out = await reloadOpenSessions({ settleMs: 0 }); assert.equal(out.lanes_total, 2); assert.deepEqual( out.lanes.map((l) => l.laneId).sort((a, b) => a - b), @@ -147,9 +165,9 @@ describe("reloadOpenSessions", () => { ); }); - it("records a pane that dies mid-send without failing the others", () => { + it("records a pane that dies mid-send without failing the others", async () => { stubTmux(["agenthub-lane-1", "agenthub-lane-2"], { "agenthub-lane-1": "__send-fails__" }); - const out = reloadOpenSessions(); + const out = await reloadOpenSessions({ settleMs: 0 }); 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/); @@ -158,9 +176,9 @@ describe("reloadOpenSessions", () => { assert.equal(ok.error, undefined); }); - it("reports an empty result instead of throwing when nothing is open", () => { + it("reports an empty result instead of throwing when nothing is open", async () => { stubTmux([]); - const out = reloadOpenSessions(); + const out = await reloadOpenSessions({ settleMs: 0 }); assert.equal(out.lanes_total, 0); assert.deepEqual(out.lanes, []); }); diff --git a/server/__tests__/lane-stage-events.test.js b/server/__tests__/lane-stage-events.test.js index 049aa5c8..27c7d03d 100644 --- a/server/__tests__/lane-stage-events.test.js +++ b/server/__tests__/lane-stage-events.test.js @@ -93,6 +93,26 @@ describe("lane_stage_events", () => { ); }); + it("keeps the run in flight when a DETECTION moves backwards", () => { + // Detection bounces by design: one feature reads implement → tests → + // implement as the session edits, runs the suite and edits again. Counting + // each bounce as a new run recorded this repo's own lane as 26 features + // when 5 had shipped, and `runs` is what every pass rate is divided by. + lanesLib.recordDetection(laneId, { nodeId: "implement", signal: "`edit`" }); + lanesLib.recordDetection(laneId, { nodeId: "tests", signal: "`npm test`" }); + // The bounce back only reaches the log once the standing detection has aged + // past the TTL — forward-only holds while it is fresh (see recordDetection). + db.prepare("UPDATE lanes SET detected_at = '2020-01-01T00:00:00.000Z' WHERE id = ?").run( + laneId + ); + lanesLib.recordDetection(laneId, { nodeId: "implement", signal: "`edit`" }); + + assert.deepEqual( + events().map((r) => `${r.node_id}#${r.run_seq}`), + ["implement#1", "tests#1", "implement#1"] + ); + }); + it("starts a new run when the pipeline's last node is left behind", () => { lanesLib.setStage(laneId, { stage: "done" }); lanesLib.setStage(laneId, { stage: "intake" }); diff --git a/server/lib/cc-reload.js b/server/lib/cc-reload.js index 3d551897..508a8858 100644 --- a/server/lib/cc-reload.js +++ b/server/lib/cc-reload.js @@ -29,6 +29,20 @@ 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"]); +/** + * Pause between two commands typed into the SAME pane. + * + * Claude Code's REPL opens a slash-command menu as soon as `/` is typed, and + * the Enter that follows lands on that menu rather than submitting the line. + * With no pause the next command's text is appended to the line still sitting + * in the composer, and what gets submitted is the single string + * `/reload-plugins/reload-skills` — which Claude Code runs as `/reload-plugins` + * with `/reload-skills` as its argument, so the second reload never happens and + * the pane shows a stray prompt turn. Measured against a live pane: 0.3s is + * already enough on an idle one, 1s leaves headroom for a busy REPL. + */ +const SETTLE_MS = 1000; + /** 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 = { @@ -61,30 +75,39 @@ function reloadableLanes() { /** * 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. + * Sends to a pane even when its agent is mid-turn: the keystrokes land in the + * REPL's composer, so the command runs 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. It is also why SETTLE_MS + * exists: a composer that has not submitted yet is a line the next command + * would be appended to. * * 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}} + * Async because of SETTLE_MS above: the two commands must reach a pane as two + * separate submissions, and waiting for that without blocking the event loop is + * the only way a live dashboard keeps broadcasting while it types. + * + * @param {{kinds?: ("plugins"|"skills")[], settleMs?: number}} [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). `settleMs` + * is a test seam — no route passes it. + * @returns {Promise<{lanes: {id: string, laneId: number|null, sent: string[], error?: string}[], + * commands: string[], lanes_total: number}>} */ -function reloadOpenSessions(options = {}) { +async function reloadOpenSessions(options = {}) { const kinds = (options.kinds || ["plugins", "skills"]).filter((k) => k in RELOAD_COMMANDS); const commands = kinds.map((k) => RELOAD_COMMANDS[k]); + const settleMs = options.settleMs ?? SETTLE_MS; const lanes = []; for (const lane of reloadableLanes()) { const sent = []; let error; for (const command of commands) { + if (sent.length && settleMs > 0) await new Promise((r) => setTimeout(r, settleMs)); try { tmux.sendText(lane.id, command); sent.push(command); @@ -99,4 +122,10 @@ function reloadOpenSessions(options = {}) { return { commands, lanes, lanes_total: lanes.length }; } -module.exports = { reloadOpenSessions, reloadableLanes, RELOAD_COMMANDS, SHELL_COMMANDS }; +module.exports = { + reloadOpenSessions, + reloadableLanes, + RELOAD_COMMANDS, + SHELL_COMMANDS, + SETTLE_MS, +}; diff --git a/server/lib/lanes.js b/server/lib/lanes.js index fe0f9907..9faa67c0 100644 --- a/server/lib/lanes.js +++ b/server/lib/lanes.js @@ -398,9 +398,17 @@ function reservedPorts(excludeLaneId = null) { * * The run a row belongs to is decided here rather than stored on the lane, so * there is one rule and no column to fall out of sync. A new run starts when - * the lane moves BACKWARDS (the next feature begins at plan) or when the last - * event sat on the pipeline's final node (the previous feature finished). Any - * other move continues the run in flight. + * the lane is DECLARED backwards (the next feature begins at plan) or when the + * last event sat on the pipeline's final node (the previous feature finished). + * Any other move continues the run in flight. + * + * Only a declaration may open a run by going backwards. Detection bounces by + * design — one feature reads `implement → tests → implement` as the session + * edits, runs the suite and edits again — so counting every backwards detection + * as a new run recorded a single feature as five or six of them, and `runs` is + * the denominator the Workflows page reads every pass rate against. A pass ends + * where the session says it ends. Reaching the final node still closes the run + * for a detection too: work after `done` is new work however it was observed. * * Never throws: a lane's stage must still advance if this log cannot be * written — the dashboard losing a statistic beats an agent's pipeline dying. @@ -415,7 +423,8 @@ function appendStageEvent(lane, nodeId, { source, result, evidence } = {}) { if (last) { const lastIdx = phaseIdx(pipeline, last.node_id); const nextIdx = phaseIdx(pipeline, nodeId); - const wentBack = lastIdx > -1 && nextIdx > -1 && nextIdx < lastIdx; + const declared = source !== "detected"; + const wentBack = declared && lastIdx > -1 && nextIdx > -1 && nextIdx < lastIdx; const finished = lastIdx > -1 && lastIdx === pipeline.nodes.length - 1 && nextIdx !== lastIdx; runSeq = last.run_seq + (wentBack || finished ? 1 : 0); } diff --git a/server/openapi-extra/cc-config.js b/server/openapi-extra/cc-config.js index bbdd824f..75a28c1f 100644 --- a/server/openapi-extra/cc-config.js +++ b/server/openapi-extra/cc-config.js @@ -1634,7 +1634,7 @@ const paths = { 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.', + '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. The two commands are typed one second apart (`SETTLE_MS`): Claude Code opens its slash-command menu as soon as `/` is typed and swallows the Enter that follows, so back-to-back sends merged into the single line `/reload-plugins/reload-skills`, which runs as `/reload-plugins` with an argument and never reloads skills. 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: { diff --git a/server/routes/cc-config.js b/server/routes/cc-config.js index 6fb02799..48a3f1f2 100644 --- a/server/routes/cc-config.js +++ b/server/routes/cc-config.js @@ -334,7 +334,7 @@ router.post("/plugins/action", async (req, res) => { let reloaded; if (reload && result.ok) { try { - reloaded = reloadOpenSessions(); + reloaded = await reloadOpenSessions(); } catch { /* a reload that cannot be delivered must not fail the update itself */ } @@ -351,7 +351,7 @@ router.post("/plugins/action", async (req, res) => { * 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) => { +router.post("/reload", async (req, res) => { const { kinds } = req.body || {}; if (kinds !== undefined && !Array.isArray(kinds)) { return res.status(400).json({ @@ -359,7 +359,7 @@ router.post("/reload", (req, res) => { }); } try { - res.json({ ok: true, ...reloadOpenSessions({ kinds }) }); + res.json({ ok: true, ...(await reloadOpenSessions({ kinds })) }); } catch (err) { res.status(500).json({ error: { code: "ERELOAD", message: err.message || String(err) },