fix(lanes,reload): count a pipeline run by declarations, and stop the two reload commands merging
Closes #26, #25. `runs` is the denominator every pass rate on the Workflows page is read against, and any backwards move opened a new one. Stage detection bounces by design — one feature reads `implement → tests → implement` as the session edits, runs the suite and edits again — so a single feature was recorded as five or six runs, and a lane in constant use reported a handful of passes spread across dozens of phantom runs. A backwards move now opens a run only when it is DECLARED; a backwards detection continues the run in flight. Reaching the final node still closes the run for either, since work after `done` is new work however it was observed. Replayed over this repo's own lane the rule takes 26 recorded runs down to the 5 features that actually shipped through it. The panel itself is unchanged: passes are still declarations only. `/reload-plugins` and `/reload-skills` were typed back to back. Claude Code opens its slash-command menu the moment `/` is typed and the Enter that follows lands on that menu, so the second command was appended to the line still in the composer and the pane submitted `/reload-plugins/reload-skills` — which Claude Code runs as `/reload-plugins` with an argument, never reloading skills, and leaving a stray prompt turn in the scrollback. Reproduced and fixed against a live pane: SETTLE_MS (1s) between the two sends, which makes `reloadOpenSessions` async rather than block the event loop, and the same pause in the detached update-all worker.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Claude Code Working Guide
|
# 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
|
## Project mission
|
||||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||||
|
|||||||
+1
-1
@@ -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.
|
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.
|
`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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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`).
|
`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=<id>` narrows the same aggregation to one lane and its own archived
|
`?lane=<id>` 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
|
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
|
response is never narrowed — it populates that picker, so it has to keep
|
||||||
|
|||||||
+1
-1
@@ -8044,7 +8044,7 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- CcConfig
|
- CcConfig
|
||||||
summary: Tell the open Claude Code sessions to re-read plugins and skills
|
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
|
operationId: ccConfigReload
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
|
|||||||
@@ -160,7 +160,15 @@ function reloadPanes() {
|
|||||||
// a shell command instead.
|
// a shell command instead.
|
||||||
if (SHELL_COMMANDS.has(paneCmd)) continue;
|
if (SHELL_COMMANDS.has(paneCmd)) continue;
|
||||||
try {
|
try {
|
||||||
|
let first = true;
|
||||||
for (const command of ["/reload-plugins", "/reload-skills"]) {
|
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, "-l", command], { timeout: 15_000 });
|
||||||
execFileSync("tmux", ["send-keys", "-t", name, "Enter"], { timeout: 15_000 });
|
execFileSync("tmux", ["send-keys", "-t", name, "Enter"], { timeout: 15_000 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,11 +103,11 @@ describe("reloadableLanes", () => {
|
|||||||
describe("reloadOpenSessions", () => {
|
describe("reloadOpenSessions", () => {
|
||||||
beforeEach(() => tmux.__reset());
|
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
|
// Order matters: a skill shipped BY a plugin only exists on disk once the
|
||||||
// plugin itself has been reloaded.
|
// plugin itself has been reloaded.
|
||||||
const calls = stubTmux(["agenthub-lane-7"]);
|
const calls = stubTmux(["agenthub-lane-7"]);
|
||||||
const out = reloadOpenSessions();
|
const out = await reloadOpenSessions({ settleMs: 0 });
|
||||||
|
|
||||||
assert.deepEqual(out.commands, ["/reload-plugins", "/reload-skills"]);
|
assert.deepEqual(out.commands, ["/reload-plugins", "/reload-skills"]);
|
||||||
assert.equal(out.lanes_total, 1);
|
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);
|
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 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"]);
|
assert.deepEqual(out.commands, ["/reload-skills"]);
|
||||||
const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]);
|
const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]);
|
||||||
assert.deepEqual(typed, ["/reload-skills"]);
|
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
|
// The command set is fixed in the module; a pane's stdin is not a place to
|
||||||
// forward caller-supplied strings.
|
// forward caller-supplied strings.
|
||||||
const calls = stubTmux(["agenthub-lane-1"]);
|
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"]);
|
assert.deepEqual(out.commands, ["/reload-plugins"]);
|
||||||
const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]);
|
const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]);
|
||||||
assert.deepEqual(typed, ["/reload-plugins"]);
|
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"]);
|
stubTmux(["agenthub-lane-1", "agenthub-lane-2"]);
|
||||||
const out = reloadOpenSessions();
|
const out = await reloadOpenSessions({ settleMs: 0 });
|
||||||
assert.equal(out.lanes_total, 2);
|
assert.equal(out.lanes_total, 2);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
out.lanes.map((l) => l.laneId).sort((a, b) => a - b),
|
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__" });
|
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 failed = out.lanes.find((l) => l.id === "agenthub-lane-1");
|
||||||
const ok = out.lanes.find((l) => l.id === "agenthub-lane-2");
|
const ok = out.lanes.find((l) => l.id === "agenthub-lane-2");
|
||||||
assert.match(failed.error, /died mid-send/);
|
assert.match(failed.error, /died mid-send/);
|
||||||
@@ -158,9 +176,9 @@ describe("reloadOpenSessions", () => {
|
|||||||
assert.equal(ok.error, undefined);
|
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([]);
|
stubTmux([]);
|
||||||
const out = reloadOpenSessions();
|
const out = await reloadOpenSessions({ settleMs: 0 });
|
||||||
assert.equal(out.lanes_total, 0);
|
assert.equal(out.lanes_total, 0);
|
||||||
assert.deepEqual(out.lanes, []);
|
assert.deepEqual(out.lanes, []);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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", () => {
|
it("starts a new run when the pipeline's last node is left behind", () => {
|
||||||
lanesLib.setStage(laneId, { stage: "done" });
|
lanesLib.setStage(laneId, { stage: "done" });
|
||||||
lanesLib.setStage(laneId, { stage: "intake" });
|
lanesLib.setStage(laneId, { stage: "intake" });
|
||||||
|
|||||||
+41
-12
@@ -29,6 +29,20 @@ const { listRuns } = require("./pty-run");
|
|||||||
/** Shell process names — a pane sitting at one of these has no REPL to reload. */
|
/** 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"]);
|
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:
|
/** 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. */
|
* a pane's stdin is an arbitrary-code boundary, so the set is fixed here. */
|
||||||
const RELOAD_COMMANDS = {
|
const RELOAD_COMMANDS = {
|
||||||
@@ -61,30 +75,39 @@ function reloadableLanes() {
|
|||||||
/**
|
/**
|
||||||
* Type the requested reload commands into every live `claude` pane.
|
* 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
|
* Sends to a pane even when its agent is mid-turn: the keystrokes land in the
|
||||||
* into the REPL's input buffer, so the command lands when the turn ends rather
|
* REPL's composer, so the command runs when the turn ends rather than being
|
||||||
* than being lost. That is a deliberate choice — a reload that arrives late is
|
* lost. That is a deliberate choice — a reload that arrives late is still a
|
||||||
* still a reload, whereas skipping busy panes silently leaves exactly the
|
* reload, whereas skipping busy panes silently leaves exactly the sessions the
|
||||||
* sessions the user is actually working in running stale code.
|
* 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)
|
* Never throws: one unreachable pane (killed between the listing and the send)
|
||||||
* must not fail a batch that updated everything else successfully.
|
* must not fail a batch that updated everything else successfully.
|
||||||
*
|
*
|
||||||
* @param {{kinds?: ("plugins"|"skills")[]}} [options] which commands to send;
|
* Async because of SETTLE_MS above: the two commands must reach a pane as two
|
||||||
* defaults to both, plugins first (a skill shipped BY a plugin only exists on
|
* separate submissions, and waiting for that without blocking the event loop is
|
||||||
* disk once the plugin has been reloaded).
|
* the only way a live dashboard keeps broadcasting while it types.
|
||||||
* @returns {{lanes: {id: string, laneId: number|null, sent: string[], error?: string}[],
|
*
|
||||||
* commands: string[], lanes_total: number}}
|
* @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 kinds = (options.kinds || ["plugins", "skills"]).filter((k) => k in RELOAD_COMMANDS);
|
||||||
const commands = kinds.map((k) => RELOAD_COMMANDS[k]);
|
const commands = kinds.map((k) => RELOAD_COMMANDS[k]);
|
||||||
|
const settleMs = options.settleMs ?? SETTLE_MS;
|
||||||
const lanes = [];
|
const lanes = [];
|
||||||
|
|
||||||
for (const lane of reloadableLanes()) {
|
for (const lane of reloadableLanes()) {
|
||||||
const sent = [];
|
const sent = [];
|
||||||
let error;
|
let error;
|
||||||
for (const command of commands) {
|
for (const command of commands) {
|
||||||
|
if (sent.length && settleMs > 0) await new Promise((r) => setTimeout(r, settleMs));
|
||||||
try {
|
try {
|
||||||
tmux.sendText(lane.id, command);
|
tmux.sendText(lane.id, command);
|
||||||
sent.push(command);
|
sent.push(command);
|
||||||
@@ -99,4 +122,10 @@ function reloadOpenSessions(options = {}) {
|
|||||||
return { commands, lanes, lanes_total: lanes.length };
|
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,
|
||||||
|
};
|
||||||
|
|||||||
+13
-4
@@ -398,9 +398,17 @@ function reservedPorts(excludeLaneId = null) {
|
|||||||
*
|
*
|
||||||
* The run a row belongs to is decided here rather than stored on the lane, so
|
* 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
|
* 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
|
* the lane is DECLARED backwards (the next feature begins at plan) or when the
|
||||||
* event sat on the pipeline's final node (the previous feature finished). Any
|
* last event sat on the pipeline's final node (the previous feature finished).
|
||||||
* other move continues the run in flight.
|
* 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
|
* 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.
|
* 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) {
|
if (last) {
|
||||||
const lastIdx = phaseIdx(pipeline, last.node_id);
|
const lastIdx = phaseIdx(pipeline, last.node_id);
|
||||||
const nextIdx = phaseIdx(pipeline, nodeId);
|
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;
|
const finished = lastIdx > -1 && lastIdx === pipeline.nodes.length - 1 && nextIdx !== lastIdx;
|
||||||
runSeq = last.run_seq + (wentBack || finished ? 1 : 0);
|
runSeq = last.run_seq + (wentBack || finished ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1634,7 +1634,7 @@ const paths = {
|
|||||||
tags: ["CcConfig"],
|
tags: ["CcConfig"],
|
||||||
summary: "Tell the open Claude Code sessions to re-read plugins and skills",
|
summary: "Tell the open Claude Code sessions to re-read plugins and skills",
|
||||||
description:
|
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",
|
operationId: "ccConfigReload",
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
|
|||||||
@@ -334,7 +334,7 @@ router.post("/plugins/action", async (req, res) => {
|
|||||||
let reloaded;
|
let reloaded;
|
||||||
if (reload && result.ok) {
|
if (reload && result.ok) {
|
||||||
try {
|
try {
|
||||||
reloaded = reloadOpenSessions();
|
reloaded = await reloadOpenSessions();
|
||||||
} catch {
|
} catch {
|
||||||
/* a reload that cannot be delivered must not fail the update itself */
|
/* 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
|
* sessions to re-read what is already there — so it neither queues on the CLI
|
||||||
* mutation chain nor emits `cc_config_changed`.
|
* mutation chain nor emits `cc_config_changed`.
|
||||||
*/
|
*/
|
||||||
router.post("/reload", (req, res) => {
|
router.post("/reload", async (req, res) => {
|
||||||
const { kinds } = req.body || {};
|
const { kinds } = req.body || {};
|
||||||
if (kinds !== undefined && !Array.isArray(kinds)) {
|
if (kinds !== undefined && !Array.isArray(kinds)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -359,7 +359,7 @@ router.post("/reload", (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
res.json({ ok: true, ...reloadOpenSessions({ kinds }) });
|
res.json({ ok: true, ...(await reloadOpenSessions({ kinds })) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: { code: "ERELOAD", message: err.message || String(err) },
|
error: { code: "ERELOAD", message: err.message || String(err) },
|
||||||
|
|||||||
Reference in New Issue
Block a user