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
|
||||
|
||||
_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.
|
||||
|
||||
+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.
|
||||
|
||||
`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.
|
||||
|
||||
|
||||
@@ -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=<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
|
||||
response is never narrowed — it populates that picker, so it has to keep
|
||||
|
||||
+1
-1
@@ -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':
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
|
||||
+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. */
|
||||
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,
|
||||
};
|
||||
|
||||
+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
|
||||
* 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);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) },
|
||||
|
||||
Reference in New Issue
Block a user