Compare commits

..

6 Commits

Author SHA1 Message Date
nntrivi2001 9156781d75 chore(release): bump version to 1.6.17 2026-09-18 16:53:56 +07:00
nntrivi2001 8d48a038c7 fix(ingest,ui): count each assistant message once, each turn once, and each event once
Three defects that all showed up as "the dashboard's numbers are wrong", plus
the notification and brand work asked for alongside them.

Tokens: Claude Code writes ONE JSONL line per content block of an assistant
message, and every one of those lines repeats the same message-level `usage`.
Both parsers summed them, so a tool-heavy session's tokens — and its cost — ran
1.5-2x high (one 4h session: $45.82 stored against $27.72 real). `lastUsageMsgId`
counts a message once, carried across the transcript cache's incremental chunk
boundaries. `replaceTokenUsage`'s high-water baselines mean the fixed parser
cannot correct what is already stored, so `setTokenUsage` (exact, baselines
cleared) is added for the deliberate re-parse path and `reconcileTokens` now
covers every session with a transcript on disk rather than imported ones only.

Turns: every `TurnDuration` row was stamped NOW while its dedup guard matched on
the transcript's timestamp, so each hook re-inserted the session's whole turn
history — 587k rows, 75% of a real events table, one session holding 68,747
copies of the same turns. The row now carries the transcript timestamp, which
fixes the guard and the feed's ordering together. Same class of bug in the
session metadata: `thinking_blocks` / `turn_count` / `total_turn_duration_ms`
are assigned from the full parse instead of `+=`, since `extract()` returns
whole-transcript totals on every call.

Events: leftover checkout hook entries in `~/.claude/settings.json` double every
event, every subagent row and every token figure. `stripLegacyHooks` has always
removed them — but only inside `bootstrap()`, which `fastPathOk` skips on
exactly the healthy installs where nothing else forces a full pass, so they
survived indefinitely. `hasLegacyHooks` (pure fs) now blocks the fast path.

A one-time repair at the first start after upgrading (`user_version` 3) deletes
the duplicated `TurnDuration` rows and re-parses every transcript with exact
writes. Marker 3 and not 2: an install that ran the earlier pass had its
corrected rows re-inflated by whatever old build was still ingesting hooks.

Router: switching a lane's AI Router while a pane is live only half-applies —
Claude Code re-applies `env` to a running session but keeps the model it
resolved at startup, so the old router's model name reaches the new endpoint and
the next prompt fails with "model not found". The apply route reports
`restart_required` / `run_id`, and the picker offers a Restart that respawns the
pane from the lane id alone (replaying the old `--model` would re-create the
mismatch).

Notifications (#29): the two moments Claude is BLOCKED on the user now have
their own switches and default to on — `onAwaitingInput` (a permission prompt or
a question) and `onTurnComplete` (end of turn). Before, the first was unswitchable
and the second hung off a preference that defaults to off, so the one thing a
user walks away from went unannounced. Prefs shape/defaults/storage move to
`client/src/lib/notifPrefs.ts`; Settings and the hook had private copies.

Brand: the sidebar shows the SmartGift wordmark with the AgentHub caption under
it, the collapsed rail shows the mark, and the favicon is vendored to match.
2026-09-18 16:52:05 +07:00
nntrivi2001 59b3ba9e39 chore(release): bump version to 1.6.14 2026-09-18 10:40:24 +07:00
nntrivi2001 eab7c437df feat(skills,pipelines): make the review node review, and make a long run followable and affordable
Closes #24.

**The review node ran nothing.** Both templates declared
`/superpowers:requesting-code-review` — the skill about how to HAND OVER a
review, not one that reviews anything — so `agenthub lanes node review`
performed no review at all. They now declare the bare `/code-review`, Claude
Code's own. It carries no `plugin:` prefix, so `requiredSkillPlugins` skips it
and the doctor's required-plugin row is unchanged (verified against the live
function: still exactly the five superpowers skills the templates name).

**The rest is the brainstorm → implement → review span, made cheaper to run and
harder to lose track of.** `ship-feature` gains four sections it never had:

- **Right-size the run.** The pipeline is not free — every dispatching phase
  costs a model call and minutes, and the fix-loop pays it again each lap. A
  typo does not enter the pipeline at all; a small single-layer change skips the
  plan-debate, independent-test and gate agents and lets the user's acceptance
  be the gate; medium/large runs everything. Sizing is a claim made out loud,
  and a task that grows re-sizes upward.
- **Dispatching a judgement.** The fresh-context/FOREGROUND/never-`fork`/
  hand-only-verifiable-artifacts/one-parseable-line contract, written once
  instead of re-argued in three phases — plus the rule that was missing
  entirely: never dispatch what a command answers. An agent is for judgement,
  not for typing.
- **Resuming after a compaction.** Long runs outlive the context window; the
  lane skill had this and this one did not. Re-derive position from the declared
  stage and the diff, and treat a phase whose evidence you cannot find as one
  that did not happen.
- **A `Leave when:` line on every phase.** Objective exit criteria are what stop
  both the silent skip and the endless polish: intake leaves when the answers
  are in hand, review leaves when the latest pass over the CURRENT diff is
  clean, the gate leaves when verification really ran AND the user said yes.

And, narrower: intake asks only what the repo cannot answer (a question the code
could have answered spends the user's round and earns a worse answer);
implement works the approved plan task by task, re-checks each acceptance
criterion, heartbeats on long work, and treats a plan that turns out wrong as a
return to `plan` rather than a quiet substitution; tests runs the suite the diff
needs rather than both by reflex.
2026-09-18 10:39:01 +07:00
nntrivi2001 e217314248 chore(release): bump version to 1.6.13 2026-09-18 10:22:49 +07:00
nntrivi2001 26b0fe53c7 feat(skills,pipelines): ask the user at the two points being wrong is cheap, and stop paying for reviews the diff does not need
The `default` 8-node pipeline walked past its own intake: the node declared
`agenthub stage intake`, a stage report with no work attached, so "intake" was
a label the run passed through without ever asking a question. It now declares
`/superpowers:brainstorming` (and detects it — that rule moves off `plan`),
and both driving skills make the brainstorm mandatory, asked with
AskUserQuestion and waited on rather than answered on the user's behalf.

Three stops replace three silent slides:

- After the plan is written and adversarially challenged, it goes to the USER
  for approval. Implementation does not start without it, and work that turns
  out to need a different approach comes back for a fresh one.
- Review picks its reviewers from `git diff --name-only`: Claude Code's own
  `code-review` always, a domain reviewer only for a layer the diff actually
  touched, and — new — only once the change is big enough to have seams. Three
  agents re-reading twenty lines buys one opinion at three times the tokens, on
  every fix-loop lap. Review is also a loop now: it ends on a clean pass over
  the current diff, not on one pass having happened.
- The gate asks the user to accept before anything is claimed done (and in the
  lane pipeline, before the PR is published — publishing is outward-facing).
  A NO-GO re-enters by SIZE: a defect goes back to implement, a wrong approach
  back to plan. Nothing carries a NO-GO forward with a note about it.

Also here, from a real stall seen on a lane: `ship-feature` had no waiting rule
at all, and a session at the gate invented one with `ScheduleWakeup` — a
/loop-only primitive that paces nothing here and is refused outright when
`auto` mode cannot reach its safety classifier. The rule the lane skill already
had now exists in both.

Cost, measured in what each agent is actually for: the suite run leaves its
agent (nothing is judged, so nothing is delegated — an agent there buys a
second model's tokens to read output the session can read for free), and
`backend-reviewer` drops from opus to sonnet like its frontend twin; both read
a scoped diff against a fixed checklist, and the senior gate above them is
still opus.

Skill bugs found in the same sweep: doc-map told agents not to recreate the
removed translations and then, three lines later, to mirror every README change
into them; the docs mapping still carried `+VN+CN` and the punctuation left by
their removal; Procedure jumped 2 → 6; Verify required `scripts/doc-coverage.sh`,
which does not exist in this repo.
2026-09-18 10:21:03 +07:00
50 changed files with 1199 additions and 349 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -2,7 +2,7 @@
name: backend-reviewer
description: Review backend route and hook logic for regressions, data integrity risks, and missing tests.
tools: Read, Grep, Glob, Bash
model: opus
model: sonnet
memory: project
---
+9 -9
View File
@@ -3,13 +3,13 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/favicon.ico" />
<link rel="manifest" href="/manifest.json" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="theme-color" content="#6366f1" />
<title>Agent Dashboard - SmartGift Solution</title>
<title>AgentHub - SmartGift Solution</title>
<meta
name="description"
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
@@ -23,8 +23,8 @@
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Agent Dashboard" />
<meta property="og:title" content="Agent Dashboard - SmartGift Solution" />
<meta property="og:site_name" content="AgentHub" />
<meta property="og:title" content="AgentHub - SmartGift Solution" />
<meta
property="og:description"
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
@@ -33,17 +33,17 @@
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:type" content="image/svg+xml" />
<meta property="og:image:alt" content="Agent Dashboard - real-time Claude Code monitoring" />
<meta property="og:image:alt" content="AgentHub - real-time Claude Code monitoring" />
<!-- Twitter / X Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Agent Dashboard - SmartGift Solution" />
<meta name="twitter:title" content="AgentHub - SmartGift Solution" />
<meta
name="twitter:description"
content="Real-time monitoring platform for Claude Code agent activity. Sessions, Kanban board, analytics, cost tracking, browser notifications, and WebSocket push."
/>
<meta name="twitter:image" content="/og-image.svg" />
<meta name="twitter:image:alt" content="Agent Dashboard - real-time Claude Code monitoring" />
<meta name="twitter:image:alt" content="AgentHub - real-time Claude Code monitoring" />
<meta name="twitter:creator" content="@smartgift" />
<!-- JSON-LD structured data -->
@@ -51,7 +51,7 @@
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Agent Dashboard",
"name": "AgentHub",
"description": "Real-time monitoring platform for Claude Code agent activity. Tracks sessions, agents, tool events, token usage analytics, cost tracking, and browser notifications via native hooks with live WebSocket push.",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Windows, macOS, Linux",
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+6 -6
View File
@@ -13,15 +13,15 @@
"categories": ["developer-tools", "productivity"],
"icons": [
{
"src": "/favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"src": "/favicon.ico",
"sizes": "32x32",
"type": "image/x-icon",
"purpose": "any"
},
{
"src": "/favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"src": "/favicon.ico",
"sizes": "32x32",
"type": "image/x-icon",
"purpose": "maskable"
}
]
+24 -10
View File
@@ -54,7 +54,6 @@ import { useTranslation } from "react-i18next";
import {
LayoutDashboard,
FolderOpen,
Activity,
BarChart3,
Workflow,
Play,
@@ -92,6 +91,13 @@ const NAV_KEYS = [
{ to: "/settings", icon: Settings, key: "nav:settings" },
] as const;
// SmartGift wordmark, replacing the old text subtitle. Remote URLs: the logo is
// owned by the brand, not vendored here — a machine with no network shows the
// alt text instead.
const BRAND_LOGO =
"https://smg-services.s3.ap-southeast-1.amazonaws.com/smartgift/assets/logo/logo-main.png";
const BRAND_MARK = "https://smartgift.vn/favicon.ico";
const STORAGE_KEY = "sidebar-collapsed";
const STATS_STORAGE_KEY = "sidebar-connection-stats";
const RECENT_EVENTS_CAP = 8;
@@ -397,15 +403,23 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
>
{/* Brand */}
<div className="px-3 py-4 border-b border-border flex-shrink-0">
<div className={`flex items-center ${collapsed ? "justify-center" : "gap-3 px-2"}`}>
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0">
<Activity className="w-4 h-4 text-accent" />
</div>
{!collapsed && (
<div className="min-w-0">
<h1 className="text-sm font-semibold text-fg-primary truncate">{t("nav:brand")}</h1>
<p className="text-[11px] text-fg-muted">{t("nav:brandSub")}</p>
</div>
<div className="flex flex-col items-center justify-center">
{collapsed ? (
<img src={BRAND_MARK} alt={t("nav:brandSub")} className="w-8 h-8 flex-shrink-0" />
) : (
<>
<img src={BRAND_LOGO} alt={t("nav:brandSub")} className="w-full h-auto px-2" />
{/* Wordmark under the logo: small caps + wide tracking so it reads
as a caption to the logo rather than competing with it. The
hairlines either side keep it centred on a narrow rail. */}
<div className="mt-3 flex w-full items-center gap-2 px-1">
<span className="h-px flex-1 bg-fg-muted/40" />
<span className="text-[11px] font-semibold uppercase tracking-[0.2em] text-fg-primary">
{t("nav:brand")}
</span>
<span className="h-px flex-1 bg-fg-muted/40" />
</div>
</>
)}
</div>
</div>
@@ -19,14 +19,19 @@ function renderSidebar(wsConnected: boolean, collapsed = false) {
}
describe("Sidebar", () => {
it("should render the brand name", () => {
it("should render the brand logo in place of the text subtitle", () => {
renderSidebar(true);
expect(screen.getByText("Agent Dashboard")).toBeInTheDocument();
// One wordmark in both themes — no light/dark variant.
expect(screen.getByAltText("SmartGift Solution")).toHaveAttribute(
"src",
expect.stringContaining("logo-main.png")
);
});
it("should render the subtitle", () => {
renderSidebar(true);
expect(screen.getByText("SmartGift Solution")).toBeInTheDocument();
it("should render the favicon mark when collapsed", () => {
renderSidebar(true, true);
const mark = screen.getByAltText("SmartGift Solution");
expect(mark).toHaveAttribute("src", "https://smartgift.vn/favicon.ico");
});
it("should render all navigation links", () => {
+46 -3
View File
@@ -159,7 +159,7 @@ export function LaneRouterRow({ lane }: { lane: Lane }) {
const [routers, setRouters] = useState<AiRouter[] | null>(null);
const [current, setCurrent] = useState(lane.router_profile || "native");
const [busy, setBusy] = useState(false);
const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null);
const [note, setNote] = useState<{ ok: boolean; text: string; runId?: string } | null>(null);
// The polled lane list is the source of truth; a switch elsewhere (the lane
// was reset to native by a profile delete, say) has to land here too.
@@ -204,9 +204,17 @@ export function LaneRouterRow({ lane }: { lane: Lane }) {
setBusy(true);
setNote(null);
try {
await api.lanes.routerApply(laneId, name);
const res = await api.lanes.routerApply(laneId, name);
if (shownLane.current !== laneId) return;
setNote({ ok: true, text: t("router.applied") });
// A live pane adopted the new base URL/key but kept the model names it
// resolved at startup, so it would send the OLD router's model to the NEW
// endpoint and fail with "model not found". Only that case gets the
// warning and the restart button.
setNote(
res.restart_required && res.run_id
? { ok: false, text: t("router.appliedRestart"), runId: res.run_id }
: { ok: true, text: t("router.applied") }
);
} catch (err) {
if (shownLane.current !== laneId) return;
setCurrent(previous);
@@ -218,6 +226,28 @@ export function LaneRouterRow({ lane }: { lane: Lane }) {
}
};
/**
* Kill the pane and open a fresh one for this lane. Started with the lane id
* alone on purpose: the previous run's `--model` flag names a model of the
* router the user just left, so replaying it would re-create the exact
* mismatch this button exists to clear.
*/
const restart = async (runId: string) => {
const laneId = lane.id;
setBusy(true);
try {
await api.run.kill(runId);
await api.run.start({ laneId });
if (shownLane.current !== laneId) return;
setNote({ ok: true, text: t("router.restarted") });
} catch (err) {
if (shownLane.current !== laneId) return;
setNote({ ok: false, text: err instanceof Error ? err.message : String(err) });
} finally {
setBusy(false);
}
};
return (
<label
className="flex items-center gap-1.5 text-xs text-fg-muted"
@@ -246,6 +276,19 @@ export function LaneRouterRow({ lane }: { lane: Lane }) {
{note.ok ? "✓" : "⚠"}
</span>
)}
{/* The one case with an action attached: restarting the pane is what
actually makes the switch take effect, so it is a button and not
another sentence the user has to act on somewhere else. */}
{note?.runId && (
<button
type="button"
disabled={busy}
onClick={() => void restart(note.runId as string)}
className="rounded border border-border px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-50"
>
{t("router.restart")}
</button>
)}
</label>
);
}
@@ -136,7 +136,13 @@ beforeEach(() => {
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
vi.mocked(api.lanes.routerApply)
.mockReset()
.mockResolvedValue({ tool: "claude", file: null, profile: "native" });
.mockResolvedValue({
tool: "claude",
file: null,
restart_required: false,
run_id: null,
profile: "native",
});
// Only the built-in profile by default, so the router radio stays on Native
// and no test has to opt out of it.
vi.mocked(api.aiRouters.list)
@@ -34,6 +34,10 @@ vi.mock("../../../lib/api", () => ({
aiRouters: {
list: vi.fn(),
},
run: {
kill: vi.fn(),
start: vi.fn(),
},
},
}));
@@ -503,6 +507,8 @@ describe("LaneRouterRow — the Model picker", () => {
tool: "claude",
file: "/work/demo/.claude/settings.local.json",
profile: "9router",
restart_required: false,
run_id: null,
});
render(<LaneRouterRow lane={makeLane()} />);
@@ -513,6 +519,53 @@ describe("LaneRouterRow — the Model picker", () => {
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
});
it("offers a restart when a live pane still holds the old router's models", async () => {
// Claude Code re-applies `env` to a running session but keeps the model it
// resolved at startup, so the switch is only half-applied until the pane is
// restarted — the server says so and the row must act on it.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
vi.mocked(api.lanes.routerApply).mockResolvedValue({
tool: "claude",
file: "/work/demo/.claude/settings.local.json",
profile: "9router",
restart_required: true,
run_id: "agenthub-lane-1",
});
vi.mocked(api.run.kill).mockResolvedValue({ ok: true });
vi.mocked(api.run.start).mockResolvedValue({} as never);
render(<LaneRouterRow lane={makeLane()} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
"9router",
]);
const restart = await screen.findByRole("button", { name: "Restart" });
await userEvent.click(restart);
await waitFor(() => expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1"));
// Started by lane id alone: replaying the old run's --model would name a
// model of the router the user just left.
expect(api.run.start).toHaveBeenCalledWith({ laneId: 1 });
});
it("leaves the restart button out when no pane is running", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
vi.mocked(api.lanes.routerApply).mockResolvedValue({
tool: "claude",
file: "/work/demo/.claude/settings.local.json",
profile: "9router",
restart_required: false,
run_id: null,
});
render(<LaneRouterRow lane={makeLane()} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
"9router",
]);
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalled());
expect(screen.queryByRole("button", { name: "Restart" })).toBeNull();
});
it("does not carry a pick made on one lane back to the lane selected before it", async () => {
// One LaneRouterRow instance serves every lane (it lives in the Workspace's
// lane-detail header), so its selected value is component state that
@@ -523,6 +576,8 @@ describe("LaneRouterRow — the Model picker", () => {
tool: "claude",
file: "/work/b/.claude/settings.local.json",
profile: "9router",
restart_required: false,
run_id: null,
});
const laneA = makeLane({ id: 1, router_profile: null });
const laneB = makeLane({ id: 2, router_profile: null });
@@ -0,0 +1,87 @@
/**
* @file Unit tests for useNotifications — which websocket events raise a
* browser notification, and which preference gates each one. Covers issue #29:
* "Claude needs confirmation" and "Claude finished a turn" must fire out of the
* box once notifications are enabled, since those are the two moments the user
* is the blocker.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { useNotifications } from "../useNotifications";
import { eventBus } from "../../lib/eventBus";
import { NOTIF_KEY, defaultNotifPrefs, type NotifPrefs } from "../../lib/notifPrefs";
import type { DashboardEvent } from "../../lib/types";
function enable(patch: Partial<NotifPrefs> = {}) {
localStorage.setItem(
NOTIF_KEY,
JSON.stringify({ ...defaultNotifPrefs, enabled: true, ...patch })
);
}
function emit(event_type: string, summary: string) {
eventBus.publish({
type: "new_event",
timestamp: new Date().toISOString(),
data: { event_type, summary } as DashboardEvent,
});
}
let fetchMock: ReturnType<typeof vi.fn>;
describe("useNotifications", () => {
beforeEach(() => {
localStorage.clear();
// notify() posts to /api/push/send first and only falls back to a local
// Notification when that throws, so the fetch spy is what we assert on.
fetchMock = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal("fetch", fetchMock);
vi.stubGlobal(
"Notification",
Object.assign(vi.fn(), { permission: "granted" }) as unknown as typeof Notification
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function sentTitles() {
return fetchMock.mock.calls
.filter((c) => c[0] === "/api/push/send")
.map((c) => JSON.parse((c[1] as RequestInit).body as string).title as string);
}
it("notifies on a confirmation request and on the end of a turn by default", async () => {
enable();
renderHook(() => useNotifications());
emit("Notification", "Claude needs your permission to use Bash");
emit("Stop", "Done");
await vi.waitFor(() => expect(sentTitles()).toHaveLength(2));
expect(sentTitles()[0]).toBe("Claude needs your confirmation");
expect(sentTitles()[1]).toBe("Claude Finished Responding");
});
it("respects each gate independently", async () => {
enable({ onAwaitingInput: false });
renderHook(() => useNotifications());
emit("Notification", "needs permission");
emit("Stop", "Done");
await vi.waitFor(() => expect(sentTitles()).toHaveLength(1));
expect(sentTitles()[0]).toBe("Claude Finished Responding");
});
it("stays silent while notifications are disabled", async () => {
renderHook(() => useNotifications());
emit("Notification", "needs permission");
emit("Stop", "Done");
await new Promise((r) => setTimeout(r, 20));
expect(sentTitles()).toHaveLength(0);
});
});
+16 -58
View File
@@ -52,56 +52,9 @@ import { useEffect } from "react";
import i18n from "../i18n";
import { eventBus } from "../lib/eventBus";
import { subscribeToPush } from "../lib/push";
import { loadNotifPrefs } from "../lib/notifPrefs";
import type { WSMessage, Session, Agent, DashboardEvent } from "../lib/types";
const NOTIF_KEY = "agent-monitor-notifications";
/** User's browser-notification preferences, persisted to `localStorage` under
* {@link NOTIF_KEY} (written by the Settings page's notifications panel). */
interface NotifPrefs {
/** Master switch; when false, no notification types fire regardless of the
* per-event flags below. */
enabled: boolean;
onNewSession: boolean;
onSessionError: boolean;
onSessionComplete: boolean;
onSubagentSpawn: boolean;
}
/** Reads {@link NotifPrefs} from `localStorage`, merging over safe defaults so
* a partial/older saved object (or none at all) still yields a valid result.
* `enabled` defaults to false (opt-in) even in the "no saved value" branch,
* while individual event toggles default to a sensible starting mix. */
function loadPrefs(): NotifPrefs {
try {
const raw = localStorage.getItem(NOTIF_KEY);
if (!raw)
return {
enabled: false,
onNewSession: true,
onSessionError: true,
onSessionComplete: false,
onSubagentSpawn: false,
};
return {
enabled: false,
onNewSession: true,
onSessionError: true,
onSessionComplete: false,
onSubagentSpawn: false,
...JSON.parse(raw),
};
} catch {
return {
enabled: false,
onNewSession: true,
onSessionError: true,
onSessionComplete: false,
onSubagentSpawn: false,
};
}
}
/**
* Shows a browser notification, preferring a server-relayed push (so it can
* arrive even if this tab isn't the active one, or the browser is backgrounded)
@@ -123,9 +76,9 @@ async function notify(title: string, body: string) {
try {
if ("serviceWorker" in navigator) {
const registration = await navigator.serviceWorker.ready;
await registration.showNotification(title, { body, icon: "/favicon.svg", silent: false });
await registration.showNotification(title, { body, icon: "/favicon.ico", silent: false });
} else {
new Notification(title, { body, icon: "/favicon.svg" });
new Notification(title, { body, icon: "/favicon.ico" });
}
} catch {
// Silently ignore
@@ -144,13 +97,13 @@ async function notify(title: string, body: string) {
*/
export function useNotifications() {
useEffect(() => {
const prefs = loadPrefs();
const prefs = loadNotifPrefs();
if (prefs.enabled && "Notification" in window && Notification.permission === "granted") {
subscribeToPush().catch(() => {});
}
return eventBus.subscribe((msg: WSMessage) => {
const prefs = loadPrefs();
const prefs = loadNotifPrefs();
if (!prefs.enabled) return;
switch (msg.type) {
@@ -183,7 +136,17 @@ export function useNotifications() {
}
case "new_event": {
const ev = msg.data as DashboardEvent;
if (ev.event_type === "Stop" && prefs.onSessionComplete) {
// The two states where Claude is BLOCKED on the user get their own
// flags (issue #29): `Notification` is a permission prompt or a
// question, `Stop` is the end of a turn. Both used to hang off
// `onSessionComplete` / nothing at all, so the one thing the user
// actually waits for was either off by default or unswitchable.
if (ev.event_type === "Notification" && prefs.onAwaitingInput) {
notify(
i18n.t("errors:notifications.awaitingInput"),
ev.summary || i18n.t("errors:notifications.defaultBody")
);
} else if (ev.event_type === "Stop" && prefs.onTurnComplete) {
notify(
i18n.t("errors:notifications.finishedResponding"),
ev.summary || i18n.t("errors:notifications.readyForInput")
@@ -193,11 +156,6 @@ export function useNotifications() {
i18n.t("errors:notifications.sessionCompleted"),
ev.summary || i18n.t("errors:notifications.sessionClosed")
);
} else if (ev.event_type === "Notification") {
notify(
i18n.t("errors:notifications.defaultTitle"),
ev.summary || i18n.t("errors:notifications.defaultBody")
);
}
break;
}
+8 -7
View File
@@ -7,16 +7,17 @@
"goBack": "Go Back"
},
"notifications": {
"awaitingInput": "Claude needs your confirmation",
"defaultBody": "Notification",
"defaultTitle": "Claude Code",
"finishedResponding": "Claude Finished Responding",
"newSession": "New Session",
"readyForInput": "Ready for input",
"sessionClosed": "Session closed",
"sessionCompleted": "Session Completed",
"sessionDefault": "Session ",
"sessionError": "Session Error",
"subagentSpawned": "Subagent Spawned",
"finishedResponding": "Claude Finished Responding",
"readyForInput": "Ready for input",
"sessionCompleted": "Session Completed",
"sessionClosed": "Session closed",
"defaultTitle": "Claude Code",
"defaultBody": "Notification"
"subagentSpawned": "Subagent Spawned"
},
"toolLegend": {
"read": "Read",
+32 -29
View File
@@ -31,30 +31,31 @@
"addLaneRepoHintAdopt": "An existing directory. The dashboard tracks it as-is — no worktree, no new branch.",
"addLaneRepoLabel": "Source repository",
"addLaneRepoLabelAdopt": "Directory",
"addLaneSetupClose": "Close",
"addLaneRouterCustom": "AI Router",
"addLaneRouterEmptyHint": "No router profile saved yet — add one under Settings › AI Router.",
"addLaneRouterHint": "Native talks to the vendor's own API. An AI Router profile is written into this lane's own config when it is created.",
"addLaneRouterLabel": "Model endpoint",
"addLaneRouterLoading": "Loading router profiles…",
"addLaneRouterNative": "Native",
"addLaneRouterProfileLabel": "Router profile",
"addLaneSetupAgents": "Agents",
"addLaneSetupClose": "Close",
"addLaneSetupMcp": "MCP servers",
"addLaneSetupProfile": "Profile",
"addLaneSetupRouter": "Model endpoint",
"addLaneSetupTitle": "Setup",
"addLaneTitleLabel": "Title",
"addLaneTitlePlaceholder": "Optional",
"addLaneToolClaude": "Claude Code",
"addLaneToolCodex": "Codex",
"addLaneToolHint": "Which CLI drives this lane. Cannot be changed later.",
"addLaneWriteDocsHint": "Opens the lane's terminal on creation and runs the /sg-all:sg-md-writing skill there.",
"addLaneWriteDocsLabel": "Create/update readme file",
"mode.repo": "Repo",
"mode.repoHint": "Adopt this directory as a lane, as-is — no worktree, no new branch.",
"mode.worktree": "Worktree",
"mode.worktreeHint": "Provision a new git worktree + branch from a source repo.",
"autoStage": "auto: {{stage}}",
"browse.button": "Browse",
"browse.empty": "This folder is empty.",
"browse.select": "Select this folder",
"browse.title": "Browse for a folder",
"worktrees.heading_one": "{{count}} worktree",
"worktrees.heading_other": "{{count}} worktrees",
"addLaneTitlePlaceholder": "Optional",
"autoStage": "auto: {{stage}}",
"reorderHint": "Drag to reorder · Alt+←/→",
"confirmRemoveCancel": "Cancel",
"confirmRemoveConfirm": "Remove",
"confirmRemoveMessage": "This action cannot be undone.",
@@ -93,6 +94,9 @@
"destructive.reset.title": "Reset managed worktree?",
"destructive.warning.no-remote": "No Git remote is configured, so nothing here is backed up remotely. You can still proceed.",
"emptyState": "No lanes yet. Create one from a working directory:",
"features.archived": "archived",
"features.live": "Live",
"features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the lane keeps running; this is a read-only snapshot.",
"git.filesHeading_one": "{{count}} file changed",
"git.filesHeading_other": "{{count}} files changed",
"git.filesMore_one": "… and {{count}} more file",
@@ -103,14 +107,25 @@
"kind.managed": "managed",
"laneDetail.hide": "Hide details",
"laneDetail.show": "Lane details",
"renameLabel": "Rename this lane",
"locks.held_one": "{{count}} lock held",
"locks.held_other": "{{count}} locks held",
"mode.repo": "Repo",
"mode.repoHint": "Adopt this directory as a lane, as-is — no worktree, no new branch.",
"mode.worktree": "Worktree",
"mode.worktreeHint": "Provision a new git worktree + branch from a source repo.",
"moreActions": "More actions",
"pipelinePicker.label": "Pipeline template",
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}",
"preflightError": "Could not load the current lane facts.",
"preflightErrorWithMessage": "Could not load the current lane facts: {{message}}",
"proof.ticketReport": "Task report",
"renameLabel": "Rename this lane",
"reorderHint": "Drag to reorder · Alt+←/→",
"router.applied": "Applied.",
"router.appliedRestart": "Applied, but the session running in this lane still uses the old models — restart it to take effect.",
"router.label": "Model",
"router.restart": "Restart",
"router.restarted": "Session restarted with the new router.",
"runtime.boot": "▶ up",
"runtime.busy.down": "stopping…",
"runtime.busy.up": "booting…",
@@ -121,30 +136,18 @@
"runtime.steppedAsideTitle": "Stepped aside from {{expected}}, which was already in use",
"runtime.stop": "■ down",
"runtime.toggleTitle": "Boot or stop this lane's own application stack (separate from its Claude run)",
"splitView.emptyPane": "No lane selected for this pane.",
"splitView.paneCount": "{{count}} pane",
"splitView.paneLaneLabel": "Pane lane selector",
"splitView.pickLane": "Pick a lane",
"stageUndeclared": "not declared",
"status.failed": "failed",
"status.idle": "idle",
"status.provisioning": "provisioning",
"status.running": "running",
"features.live": "Live",
"features.archived": "archived",
"features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the lane keeps running; this is a read-only snapshot.",
"proof.ticketReport": "Task report",
"splitView.emptyPane": "No lane selected for this pane.",
"splitView.paneLaneLabel": "Pane lane selector",
"splitView.paneCount": "{{count}} pane",
"splitView.pickLane": "Pick a lane",
"statusDead": "DEAD",
"title": "Lanes",
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message",
"addLaneRouterLabel": "Model endpoint",
"addLaneRouterNative": "Native",
"addLaneRouterCustom": "AI Router",
"addLaneRouterProfileLabel": "Router profile",
"addLaneRouterHint": "Native talks to the vendor's own API. An AI Router profile is written into this lane's own config when it is created.",
"addLaneRouterEmptyHint": "No router profile saved yet — add one under Settings › AI Router.",
"addLaneSetupRouter": "Model endpoint",
"addLaneRouterLoading": "Loading router profiles…",
"router.label": "Model",
"router.applied": "Applied. A session already running in this lane keeps the old one until it restarts."
"worktrees.heading_one": "{{count}} worktree",
"worktrees.heading_other": "{{count}} worktrees"
}
+1 -1
View File
@@ -3,7 +3,7 @@
"agentBoard": "Kanban Board",
"analytics": "Analytics",
"avg": "avg",
"brand": "Agent Dashboard",
"brand": "AgentHub",
"brandSub": "SmartGift Solution",
"ccConfig": "Claude Config",
"checkFailed": "Update check failed",
+9 -7
View File
@@ -71,21 +71,23 @@
"gcResult": "Reaped {{reaped}} process(es), capped {{capped}} log(s)"
},
"notifications": {
"title": "Notifications",
"awaitingInput": "Claude needs confirmation",
"blocked": "Permission blocked",
"description": "Browser notifications for important events. Requires permission.",
"disabledInfo": "Notifications are disabled - enable to get alerts for session events",
"enable": "Enable Browser Notifications",
"granted": "Permission granted",
"blocked": "Permission blocked",
"required": "Permission required",
"notifyWhen": "Notify me when...",
"newSession": "New session starts",
"notifyWhen": "Notify me when...",
"required": "Permission required",
"sendTest": "Send Test Notification",
"sessionComplete": "Session completes",
"sessionError": "Session errors",
"subagentSpawned": "Subagent spawned",
"testTitle": "Agent Monitor",
"testBody": "Notifications are working!",
"sendTest": "Send Test Notification",
"disabledInfo": "Notifications are disabled - enable to get alerts for session events"
"testTitle": "Agent Monitor",
"title": "Notifications",
"turnComplete": "Claude finishes a turn"
},
"import": {
"title": "Import History",
+8 -7
View File
@@ -7,16 +7,17 @@
"goBack": "Quay lại"
},
"notifications": {
"awaitingInput": "Claude cần bạn xác nhận",
"defaultBody": "Thông báo",
"defaultTitle": "Claude Code",
"finishedResponding": "Claude đã phản hồi xong",
"newSession": "Phiên mới",
"readyForInput": "Sẵn sàng nhận đầu vào",
"sessionClosed": "Phiên đã đóng",
"sessionCompleted": "Phiên đã hoàn tất",
"sessionDefault": "Phiên ",
"sessionError": "Lỗi phiên",
"subagentSpawned": "Đã tạo Subagent",
"finishedResponding": "Claude đã phản hồi xong",
"readyForInput": "Sẵn sàng nhận đầu vào",
"sessionCompleted": "Phiên đã hoàn tất",
"sessionClosed": "Phiên đã đóng",
"defaultTitle": "Claude Code",
"defaultBody": "Thông báo"
"subagentSpawned": "Đã tạo Subagent"
},
"toolLegend": {
"read": "Đọc",
+32 -29
View File
@@ -31,30 +31,31 @@
"addLaneRepoHintAdopt": "Một thư mục có sẵn. Dashboard theo dõi nguyên trạng — không tạo worktree, không tạo nhánh mới.",
"addLaneRepoLabel": "Repo nguồn",
"addLaneRepoLabelAdopt": "Thư mục",
"addLaneSetupClose": "Đóng",
"addLaneRouterCustom": "AI Router",
"addLaneRouterEmptyHint": "Chưa lưu profile router nào — thêm ở Settings › AI Router.",
"addLaneRouterHint": "Native là gọi thẳng API của hãng. Chọn profile AI Router thì cấu hình được ghi vào config riêng của lane này lúc tạo.",
"addLaneRouterLabel": "Endpoint model",
"addLaneRouterLoading": "Đang tải danh sách router…",
"addLaneRouterNative": "Native",
"addLaneRouterProfileLabel": "Profile router",
"addLaneSetupAgents": "Agent",
"addLaneSetupClose": "Đóng",
"addLaneSetupMcp": "MCP server",
"addLaneSetupProfile": "Profile",
"addLaneSetupRouter": "Endpoint model",
"addLaneSetupTitle": "Thiết lập",
"addLaneTitleLabel": "Tiêu đề",
"addLaneTitlePlaceholder": "Không bắt buộc",
"addLaneToolClaude": "Claude Code",
"addLaneToolCodex": "Codex",
"addLaneToolHint": "CLI nào điều khiển lane này. Không thể thay đổi sau này.",
"addLaneWriteDocsHint": "Khi tạo lane sẽ mở terminal của lane và chạy skill /sg-all:sg-md-writing tại đó.",
"addLaneWriteDocsLabel": "Tạo/cập nhật file readme",
"mode.repo": "Repo",
"mode.repoHint": "Gắn thẳng thư mục này làm lane, nguyên trạng — không worktree, không nhánh mới.",
"mode.worktree": "Worktree",
"mode.worktreeHint": "Tạo worktree git mới + nhánh mới từ repo nguồn.",
"autoStage": "tự động: {{stage}}",
"browse.button": "Duyệt",
"browse.empty": "Thư mục này trống.",
"browse.select": "Chọn thư mục này",
"browse.title": "Duyệt chọn thư mục",
"worktrees.heading_one": "{{count}} worktree",
"worktrees.heading_other": "{{count}} worktree",
"addLaneTitlePlaceholder": "Không bắt buộc",
"autoStage": "tự động: {{stage}}",
"reorderHint": "Kéo để sắp xếp · Alt+←/→",
"confirmRemoveCancel": "Hủy",
"confirmRemoveConfirm": "Xóa",
"confirmRemoveMessage": "Hành động này không thể hoàn tác.",
@@ -93,6 +94,9 @@
"destructive.reset.title": "Đặt lại worktree được quản lý?",
"destructive.warning.no-remote": "Chưa cấu hình Git remote, nên không có bản sao lưu ở xa. Bạn vẫn có thể tiếp tục.",
"emptyState": "Chưa có làn đường nào. Tạo một từ thư mục làm việc:",
"features.archived": "đã lưu trữ",
"features.live": "Phiên bản trực tiếp",
"features.viewingArchived": "Xem tính năng đã lưu trữ \"{{slug}}\" — lane tiếp tục chạy; đây là ảnh chụp nhanh chỉ đọc.",
"git.filesHeading_one": "{{count}} tệp thay đổi",
"git.filesHeading_other": "{{count}} tệp thay đổi",
"git.filesMore_one": "… và {{count}} tệp nữa",
@@ -103,14 +107,25 @@
"kind.managed": "được quản lý",
"laneDetail.hide": "Ẩn chi tiết",
"laneDetail.show": "Chi tiết lane",
"renameLabel": "Đổi tên làn đường này",
"locks.held_one": "Đang giữ {{count}} khóa",
"locks.held_other": "Đang giữ {{count}} khóa",
"mode.repo": "Repo",
"mode.repoHint": "Gắn thẳng thư mục này làm lane, nguyên trạng — không worktree, không nhánh mới.",
"mode.worktree": "Worktree",
"mode.worktreeHint": "Tạo worktree git mới + nhánh mới từ repo nguồn.",
"moreActions": "Thêm hành động",
"pipelinePicker.label": "Mẫu pipeline",
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" không khớp node nào trong \"{{pipeline}}\" — khai báo một trong: {{nodes}}",
"preflightError": "Không thể tải trạng thái làn đường hiện tại.",
"preflightErrorWithMessage": "Không thể tải trạng thái làn đường hiện tại: {{message}}",
"proof.ticketReport": "Báo cáo nhiệm vụ",
"renameLabel": "Đổi tên làn đường này",
"reorderHint": "Kéo để sắp xếp · Alt+←/→",
"router.applied": "Đã áp dụng.",
"router.appliedRestart": "Đã áp dụng, nhưng phiên đang chạy trong lane này vẫn dùng model cũ — khởi động lại phiên để có hiệu lực.",
"router.label": "Model",
"router.restart": "Khởi động lại",
"router.restarted": "Đã khởi động lại phiên với router mới.",
"runtime.boot": "▶ chạy",
"runtime.busy.down": "đang dừng…",
"runtime.busy.up": "đang khởi động…",
@@ -121,30 +136,18 @@
"runtime.steppedAsideTitle": "Đã lùi khỏi {{expected}} vì cổng đó đang bận",
"runtime.stop": "■ dừng",
"runtime.toggleTitle": "Chạy hoặc dừng stack ứng dụng của lane này (khác với phiên Claude của nó)",
"splitView.emptyPane": "Chưa chọn lane cho ô này.",
"splitView.paneCount": "{{count}} ô",
"splitView.paneLaneLabel": "Bộ chọn lane cho ô",
"splitView.pickLane": "Chọn lane",
"stageUndeclared": "chưa khai báo",
"status.failed": "thất bại",
"status.idle": "rảnh",
"status.provisioning": "đang khởi tạo",
"status.running": "đang chạy",
"features.live": "Phiên bản trực tiếp",
"features.archived": "đã lưu trữ",
"features.viewingArchived": "Xem tính năng đã lưu trữ \"{{slug}}\" — lane tiếp tục chạy; đây là ảnh chụp nhanh chỉ đọc.",
"proof.ticketReport": "Báo cáo nhiệm vụ",
"splitView.emptyPane": "Chưa chọn lane cho ô này.",
"splitView.paneLaneLabel": "Bộ chọn lane cho ô",
"splitView.paneCount": "{{count}} ô",
"splitView.pickLane": "Chọn lane",
"statusDead": "ĐÃ CHẾT",
"title": "Làn đường",
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn",
"addLaneRouterLabel": "Endpoint model",
"addLaneRouterNative": "Native",
"addLaneRouterCustom": "AI Router",
"addLaneRouterProfileLabel": "Profile router",
"addLaneRouterHint": "Native là gọi thẳng API của hãng. Chọn profile AI Router thì cấu hình được ghi vào config riêng của lane này lúc tạo.",
"addLaneRouterEmptyHint": "Chưa lưu profile router nào — thêm ở Settings › AI Router.",
"addLaneSetupRouter": "Endpoint model",
"addLaneRouterLoading": "Đang tải danh sách router…",
"router.label": "Model",
"router.applied": "Đã áp dụng. Session đang chạy trong lane này vẫn dùng router cũ cho tới khi khởi động lại."
"worktrees.heading_one": "{{count}} worktree",
"worktrees.heading_other": "{{count}} worktree"
}
+1 -1
View File
@@ -3,7 +3,7 @@
"agentBoard": "Bảng Kanban",
"analytics": "Phân tích",
"avg": "TB",
"brand": "Bảng điều khiển Agent",
"brand": "AgentHub",
"brandSub": "SmartGift Solution",
"ccConfig": "Cấu hình Claude",
"checkFailed": "Kiểm tra cập nhật thất bại",
+9 -7
View File
@@ -71,21 +71,23 @@
"gcResult": "Đã dọn {{reaped}} tiến trình, cắt {{capped}} log"
},
"notifications": {
"title": "Thông báo",
"awaitingInput": "Claude cần xác nhận",
"blocked": "Đã chặn quyền",
"description": "Thông báo trình duyệt cho các sự kiện quan trọng. Cần cấp quyền.",
"disabledInfo": "Thông báo đang tắt - bật để nhận cảnh báo sự kiện phiên",
"enable": "Bật thông báo trình duyệt",
"granted": "Đã cấp quyền",
"blocked": "Đã chặn quyền",
"required": "Cần cấp quyền",
"notifyWhen": "Thông báo cho tôi khi...",
"newSession": "Phiên mới bắt đầu",
"notifyWhen": "Thông báo cho tôi khi...",
"required": "Cần cấp quyền",
"sendTest": "Gửi thông báo thử",
"sessionComplete": "Phiên hoàn tất",
"sessionError": "Phiên gặp lỗi",
"subagentSpawned": "Subagent được tạo",
"testTitle": "Agent Monitor",
"testBody": "Thông báo đang hoạt động!",
"sendTest": "Gửi thông báo thử",
"disabledInfo": "Thông báo đang tắt - bật để nhận cảnh báo sự kiện phiên"
"testTitle": "Agent Monitor",
"title": "Thông báo",
"turnComplete": "Claude trả lời xong"
},
"import": {
"title": "Nhập lịch sử",
+8 -1
View File
@@ -2498,7 +2498,14 @@ export const api = {
* profile NAME comes from here.
*/
routerApply: (id: number, profile: string) =>
request<{ tool: string; file: string | null; profile: string }>(`/lanes/${id}/router/apply`, {
request<{
tool: string;
file: string | null;
profile: string;
/** A pane is live and keeps the old models until it restarts. */
restart_required: boolean;
run_id: string | null;
}>(`/lanes/${id}/router/apply`, {
method: "POST",
body: JSON.stringify({ profile }),
}),
+57
View File
@@ -0,0 +1,57 @@
/**
* @file Browser-notification preferences — the single definition of the shape,
* the defaults, and the `localStorage` read/write. Settings (which edits them)
* and `useNotifications` (which obeys them) both import from here; they used to
* carry private copies, so adding a notification type meant editing the same
* literal twice and a miss went unnoticed until a toggle did nothing.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export const NOTIF_KEY = "agent-monitor-notifications";
export interface NotifPrefs {
/** Master switch; when false no notification fires, whatever the flags say. */
enabled: boolean;
onNewSession: boolean;
onSessionError: boolean;
/** Claude is BLOCKED on the user — a permission prompt or a question. */
onAwaitingInput: boolean;
/** Claude finished its turn and is waiting for the next prompt (`Stop`). */
onTurnComplete: boolean;
/** The whole session closed (`SessionEnd`), not just one turn. */
onSessionComplete: boolean;
onSubagentSpawn: boolean;
}
// The two "Claude is waiting for you" types default ON: they are the reason
// notifications exist (issue #29 — the user walks away during a long run and
// needs to know when they are the blocker). The rest stay as they were.
export const defaultNotifPrefs: NotifPrefs = {
enabled: false,
onNewSession: true,
onSessionError: true,
onAwaitingInput: true,
onTurnComplete: true,
onSessionComplete: false,
onSubagentSpawn: false,
};
/** Reads prefs, merged over the defaults so a stored blob written by an older
* build (missing the newer flags) still yields a complete object. */
export function loadNotifPrefs(): NotifPrefs {
try {
const raw = localStorage.getItem(NOTIF_KEY);
if (!raw) return { ...defaultNotifPrefs };
return { ...defaultNotifPrefs, ...(JSON.parse(raw) as Partial<NotifPrefs>) };
} catch {
return { ...defaultNotifPrefs };
}
}
export function saveNotifPrefs(prefs: NotifPrefs) {
try {
localStorage.setItem(NOTIF_KEY, JSON.stringify(prefs));
} catch {
/* private mode / quota — the toggle still applies for this tab */
}
}
+19 -34
View File
@@ -71,6 +71,8 @@ import {
AlertTriangle,
RotateCcw,
CheckCircle,
HelpCircle,
MessageSquare,
XCircle,
Server,
Bell,
@@ -109,6 +111,7 @@ import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { sagiPrefs } from "../components/Sagi/prefs";
import { fmt, fmtCost, getCurrentLocale } from "../lib/format";
import { subscribeToPush, unsubscribeFromPush } from "../lib/push";
import { loadNotifPrefs, saveNotifPrefs, type NotifPrefs } from "../lib/notifPrefs";
import { Tip } from "../components/Tip";
import { ImportHistory } from "../components/ImportHistory";
import { RemoteSources } from "../components/RemoteSources";
@@ -142,40 +145,6 @@ const SETTINGS_SECTIONS: {
{ id: "about", labelKey: "about.title", Icon: Server },
];
// ─── Notification preferences ───
const NOTIF_KEY = "agent-monitor-notifications";
interface NotifPrefs {
enabled: boolean;
onNewSession: boolean;
onSessionError: boolean;
onSessionComplete: boolean;
onSubagentSpawn: boolean;
}
const defaultNotif: NotifPrefs = {
enabled: false,
onNewSession: true,
onSessionError: true,
onSessionComplete: false,
onSubagentSpawn: false,
};
function loadNotifPrefs(): NotifPrefs {
try {
const raw = localStorage.getItem(NOTIF_KEY);
if (!raw) return { ...defaultNotif };
return { ...defaultNotif, ...JSON.parse(raw) };
} catch {
return { ...defaultNotif };
}
}
function saveNotifPrefs(prefs: NotifPrefs) {
localStorage.setItem(NOTIF_KEY, JSON.stringify(prefs));
}
// ─── Helpers ───
interface EditRow {
@@ -1577,6 +1546,22 @@ export function Settings() {
{t("notifications.notifyWhen")}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className="flex items-center gap-3 bg-surface-2 rounded-lg px-3.5 py-3">
<HelpCircle className="w-4 h-4 text-status-warning flex-shrink-0" />
<Toggle
checked={notifPrefs.onAwaitingInput}
onChange={(v) => updateNotifPrefs({ onAwaitingInput: v })}
label={t("notifications.awaitingInput")}
/>
</div>
<div className="flex items-center gap-3 bg-surface-2 rounded-lg px-3.5 py-3">
<MessageSquare className="w-4 h-4 text-accent flex-shrink-0" />
<Toggle
checked={notifPrefs.onTurnComplete}
onChange={(v) => updateNotifPrefs({ onTurnComplete: v })}
label={t("notifications.turnComplete")}
/>
</div>
<div className="flex items-center gap-3 bg-surface-2 rounded-lg px-3.5 py-3">
<Play className="w-4 h-4 text-status-success flex-shrink-0" />
<Toggle
@@ -9578,7 +9578,7 @@ exports[`screen snapshots > Settings 1`] = `
<p
class="text-[10px] text-status-warning/90 mt-1"
>
UI build v1.6.12 (rebuild client to match)
UI build v1.6.17 (rebuild client to match)
</p>
</div>
<div
+1 -1
View File
@@ -1799,7 +1799,7 @@ The stored key is only ever sent to the endpoint it was stored against. A blank
`native` is a built-in profile, not the absence of one: empty `baseUrl`/`apiKey` and the vendor's own model names, so switching back is the same write path as switching to a router. It answers `409 EBUILTIN` to any update, delete, or attempt to shadow its name. Other errors: `400 EBADLABEL` / `EBADURL` (an endpoint must be `http:` or `https:`) / `EBADMODEL`, `404 ENOTFOUND`.
**`POST /api/lanes/:id/router/apply`** — Body: `{ profile: "<name>" }` — is where a profile reaches disk. Only the name comes from the request; what is written, and where, is derived from the lane row:
**`POST /api/lanes/:id/router/apply`** — Body: `{ profile: "<name>" }` — is where a profile reaches disk. The response carries `restart_required` and `run_id`: a pane already running adopts the new connection but keeps the model it resolved at startup, so it must be restarted before the switch is real. Only the name comes from the request; what is written, and where, is derived from the lane row:
- A **claude** lane gets eight env keys merged into `<lane.cwd>/.claude/settings.local.json` (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `CLAUDE_CODE_SUBAGENT_MODEL`). Every other key in that file, including every other env var, is preserved; a file that is not valid JSON is reported as `409 EBADJSON` and left untouched. `.local.json` rather than `settings.json` because the written value includes an API key.
- A **codex** lane gets `$CODEX_HOME/<name>.config.toml`, the layer `codex -p <name>` loads on top of the base config — Codex has no project-level config, so the base `config.toml` is never touched. The layer sets `wire_api = "responses"`; `"chat"` is not an option, codex-cli 0.149 refuses to load a config containing it. `env_key` is written only when the profile has a key — naming a variable that is never set is itself a startup error — and the key stays out of the file: the lane's spawn hands `AGENTHUB_ROUTER_KEY` to the pane through tmux's own `-e`, never as typed text.
+7 -3
View File
@@ -160,9 +160,13 @@ npm run install-hooks
> the same eight hooks itself (inline in `.claude-plugin/plugin.json`, each
> running `${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js`). Running both means
> every event is POSTed twice — events carry no id, so ingest cannot deduplicate
> them and every token and cost figure doubles. `scripts/plugin-bootstrap.js`
> them and every token and cost figure doubles — as does every event in the feed
> and every subagent row (one `Agent` call producing two cards). `scripts/plugin-bootstrap.js`
> removes checkout-installed entries on session start (backup:
> `~/.claude/settings.json.agenthub-bak`), `install-hooks.js` warns when it detects
> `~/.claude/settings.json.agenthub-bak`) — and since v1.6.17 a settings file that
> still carries them also blocks the bootstrap's fast path (`hasLegacyHooks`),
> because the strip runs only in the full pass and a healthy install skipped it
> every session, keeping the duplicates alive indefinitely. `install-hooks.js` warns when it detects
> a plugin install, and `/agenthub-doctor` reports any duplicates that remain. See
> [PLUGINS.md](PLUGINS.md).
@@ -422,7 +426,7 @@ Triggered when Claude Code sends a system notification.
- Log the event for the activity feed
- If the message matches a permission/input-prompt pattern (`permission`, `waiting for input`, `needs your approval`, `awaiting your response`, …), stamp `awaiting_input_since` (with `awaiting_reason` = `notification`) so the session lands in **Waiting**
- If the message matches a compaction pattern, tag as a `Compaction` event
- Trigger a browser notification when the user has notifications enabled
- Trigger a browser notification when the user has notifications enabled — this event and `Stop` are the two "Claude is waiting for YOU" moments, so each has its own switch in Settings → Notifications (`onAwaitingInput` / `onTurnComplete` in `client/src/lib/notifPrefs.ts`) and both default to ON once notifications are enabled
---
+10 -8
View File
@@ -43,7 +43,7 @@ The modal has a segmented toggle mirroring the CLI's two modes:
- **Repo** — adopts the given directory as-is (maps to `agenthub lanes add --cwd`). No branch fields; the auto-setup summary above does not run (adopting is instant, nothing to wait on).
- **Worktree** (default) — provisions a managed worktree (maps to `agenthub lanes add --repo`). Unlike the CLI, the modal requires you to type the new branch's name yourself rather than deriving one from the title — the underlying route still derives one when `branch` is omitted, so the CLI's behavior is unchanged.
Under the *Pipeline template* select, a **Model endpoint** radio chooses between **Native** (the vendor's own API) and **AI Router** — a profile saved in Settings › AI Router (9Router, OpenRouter, a self-hosted gateway). Choosing one writes it into the new lane's own config as the lane is created: a claude lane gets the `env` block of its `.claude/settings.local.json`, a codex lane gets a `$CODEX_HOME/<name>.config.toml` layer plus a `-p <name>` on every spawn. With no profile saved the radio stays on Native and says so. Unlike the lane's tool, this one is not immutable: an existing lane's detail header in Workspace carries a **Model** select (beside the pipeline picker) showing its current profile, and picking another calls `POST /api/lanes/:id/router/apply`, rewriting the lane's config and its `router_profile` column together. (The select is hidden when `native` is the only profile — there would be nothing to choose.) A failed apply puts the select back rather than leaving the row claiming a router the lane did not get, and marks itself `⚠` with the error in its tooltip. A session already running in the pane keeps the endpoint it started with — Claude Code reads `settings.local.json` at startup — so the change takes effect on the next run. Editing or deleting the profile itself in Settings also reaches every lane using it: an edit re-applies it (so a rotated key actually lands), a delete resets those lanes to Native. See `docs/API.md` § AI Router profiles.
Under the *Pipeline template* select, a **Model endpoint** radio chooses between **Native** (the vendor's own API) and **AI Router** — a profile saved in Settings › AI Router (9Router, OpenRouter, a self-hosted gateway). Choosing one writes it into the new lane's own config as the lane is created: a claude lane gets the `env` block of its `.claude/settings.local.json`, a codex lane gets a `$CODEX_HOME/<name>.config.toml` layer plus a `-p <name>` on every spawn. With no profile saved the radio stays on Native and says so. Unlike the lane's tool, this one is not immutable: an existing lane's detail header in Workspace carries a **Model** select (beside the pipeline picker) showing its current profile, and picking another calls `POST /api/lanes/:id/router/apply`, rewriting the lane's config and its `router_profile` column together. (The select is hidden when `native` is the only profile — there would be nothing to choose.) A failed apply puts the select back rather than leaving the row claiming a router the lane did not get, and marks itself `⚠` with the error in its tooltip. A session already running in the pane is only HALF switched: Claude Code re-applies an `env` block to a live session when the file changes, but the session's model was resolved at startup, so the previous router's model name keeps being sent to the new endpoint and the next prompt fails with a model-not-found error (switching back to Native included). The route therefore answers `restart_required: true` plus the `run_id` whenever a pane is live, and the select renders a **Restart** button that kills that pane and opens a fresh one — started from the lane id alone, since replaying the old run's `--model` would re-create the same mismatch. Editing or deleting the profile itself in Settings also reaches every lane using it: an edit re-applies it (so a rotated key actually lands), a delete resets those lanes to Native. See `docs/API.md` § AI Router profiles.
Under the *Pipeline template* select, a **Create/update readme file** checkbox (Claude lanes only — Codex has no slash commands) starts the new lane's terminal run right after creation with `/sg-all:sg-md-writing` as its first message, so the lane opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set. It requires the `sg-all` plugin to be installed in Claude Code; without it the session simply reports an unknown command. Leave it unticked to create the lane without starting a run.
@@ -556,6 +556,7 @@ The lane is resolved from the working directory, so no id is needed (pass one to
agenthub lanes node # list every node of this lane's pipeline and its command
agenthub lanes node intake # → claude "/superpowers:brainstorming"
agenthub lanes node intake the export button # extra words are appended to the prompt
agenthub lanes node review # → claude "/code-review"
agenthub lanes node gates # → agenthub lanes hook ci-gate
agenthub lanes node pr-open --print # show the argv, run nothing
```
@@ -1070,11 +1071,12 @@ command: it's whichever of the fields above the matching rule's `tool` carried.
### Superpowers skill invocations
The built-in `default` pipeline's `plan`, `implement`, `review`, and `ship`
nodes each carry a `{"tool": "Skill", "match": "..."}` rule matching the
Superpowers workflow skill names (`brainstorming`/`writing-plans`,
`executing-plans`/`subagent-driven-development`, `code-review`/
`requesting-code-review`, `finishing-a-development-branch`). Invoking one of
The built-in `default` pipeline's `intake`, `plan`, `implement`, `review`, and
`ship` nodes each carry a `{"tool": "Skill", "match": "..."}` rule matching the
workflow skill names they expect (`brainstorming`, `writing-plans`,
`executing-plans`/`subagent-driven-development`, Claude Code's own
`code-review` plus `requesting-code-review`/`receiving-code-review`,
`finishing-a-development-branch`). Invoking one of
these skills is a much stronger signal than a matched Bash command, but it is
still detection, not declaration — it renders dashed amber and never `done`,
same as every other detected stage.
@@ -1123,8 +1125,8 @@ a `detect` array on each node. The built-in `default.json`
| Node | Detect rules |
|---|---|
| `intake` | `Agent` matching subagent `Explore` — a lane sitting at nothing else yet, but already exploring, shows *something* instead of idle |
| `plan` | `Skill` matching `brainstorming\|writing-plans`; `Write` matching `docs/.*plan.*\.md`; `Agent` matching `Plan\|Software Architect\|Backend Architect\|UX Architect`; `TaskCreate` (any) |
| `intake` | `Agent` matching subagent `Explore` — a lane sitting at nothing else yet, but already exploring, shows *something* instead of idle; `Skill` matching `brainstorming`, which is intake's own declared command |
| `plan` | `Skill` matching `writing-plans`; `Write` matching `docs/.*plan.*\.md`; `Agent` matching `Plan\|Software Architect\|Backend Architect\|UX Architect`; `TaskCreate` (any) |
| `implement` | `Skill` matching `executing-plans\|subagent-driven-development\|test-driven-development`; `Edit` (any); `Write` to any path NOT under a `docs/` directory (`^(?!.*(?:^\|/)docs/)`) |
| `tests` | `Bash` matching `\b(npm (run )?test\|pytest\|vitest\|jest\|go test\|cargo test\|node\s+--test\|rspec\|phpunit\|dotnet test\|mvn test\|gradle test)\b`; `Agent` matching `Test Results Analyzer\|API Tester\|Performance Benchmarker` |
| `review` | `Skill` matching `code-review\|requesting-code-review\|receiving-code-review`; `Bash` matching `\bgh\b[^;&\|]*\bpr diff\b`; `Agent` matching a name containing `reviewer`/`Reviewer` (excluding `senior-gate-reviewer`, a lookbehind), `Evidence Collector`, or `Accessibility Auditor` |
+6 -4
View File
@@ -212,8 +212,8 @@ any of them is ever deleted:
| The directory `agenthub.service` names (`unitRoot`, **foreign unit included**) | A service is executing from it. Not ours to rewrite is still not ours to delete. |
| Any directory named in the user's `settings.json` hooks | A Claude Code process that has not been restarted is still executing from there — this is the same directory the section above describes, seen from the other side. Deleting it would break that session's hooks mid-flight. |
Plus the newest `KEEP_RETIRED_ROOTS` (2) of whatever is left, as a rollback
cushion. Outside `~/.claude/plugins/cache/` the pruner does nothing at all — for
Plus the newest `KEEP_RETIRED_ROOTS` (1) of whatever is left, as a rollback
cushion — one build deep, because a full copy of this repo is ~130 MB. Outside `~/.claude/plugins/cache/` the pruner does nothing at all — for
a git checkout the parent directory holds the user's own work. Best effort
throughout: a directory that will not delete is left alone, never an error.
@@ -312,8 +312,10 @@ string a template names — as the pipeline's own `skill` or as one node's
`command` — has to come from an installed, enabled plugin. Today that means
**`superpowers`**, which `/agenthub:ship-feature` and `/agenthub:ship-feature-lane`
drive step by step (`brainstorming`, `writing-plans`,
`test-driven-development`, `requesting-code-review`,
`verification-before-completion`, `finishing-a-development-branch`). Install it
`test-driven-development`, `verification-before-completion`,
`finishing-a-development-branch`). The `review` node is the exception: it names
the bare `/code-review`, Claude Code's own review skill, which carries no
`plugin:` prefix and so requires nothing to be installed. Install it
with `claude plugin install superpowers@claude-plugins-official`.
The row is a **WARN, never a FAIL**: the dashboard, hooks and CLI all work
+1 -1
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 1.6.12
version: 1.6.17
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact:
name: Nguyễn Ngọc Trí Vĩ
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "1.6.12",
"version": "1.6.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "1.6.12",
"version": "1.6.17",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "1.6.12",
"version": "1.6.17",
"bin": {
"agenthub": "bin/agenthub.js"
},
+28 -11
View File
@@ -161,6 +161,10 @@ async function parseSessionFile(filePath) {
// First real user prompt (tool-result / meta / command entries skipped) —
// fallback descriptor for sessions that never got a title. First wins.
let firstUserMessage = null;
// Claude Code writes one JSONL line per content block of an assistant
// message, each repeating the SAME message-level `usage`. Count each message
// once — see `lastUsageMsgId` in server/lib/transcript-cache.js.
let lastUsageMsgId = null;
for await (const line of rl) {
if (!line.trim()) continue;
@@ -268,7 +272,10 @@ async function parseSessionFile(filePath) {
const msg = entry.message || {};
const msgModel = msg.model || null;
if (!model && msgModel && msgModel !== "<synthetic>") model = msgModel;
if (msgModel && msgModel !== "<synthetic>" && msg.usage) {
const usageMsgId = typeof msg.id === "string" ? msg.id : null;
const repeatedLine = usageMsgId !== null && usageMsgId === lastUsageMsgId;
if (usageMsgId !== null && msg.usage) lastUsageMsgId = usageMsgId;
if (msgModel && msgModel !== "<synthetic>" && msg.usage && !repeatedLine) {
const usage = msg.usage;
const key = bucketKey(
msgModel,
@@ -402,6 +409,8 @@ async function parseSubagentFile(filePath) {
// Inverting these across a session reconstructs the real spawn hierarchy so
// nested subagents nest under their spawner instead of flattening to main.
const spawnedChildren = new Set();
// One assistant message = several JSONL lines repeating the same `usage`.
let lastUsageMsgId = null;
for await (const line of rl) {
if (!line.trim()) continue;
@@ -453,7 +462,10 @@ async function parseSubagentFile(filePath) {
const msg = entry.message || {};
const msgModel = msg.model || null;
if (!model && msgModel && msgModel !== "<synthetic>") model = msgModel;
if (msgModel && msgModel !== "<synthetic>" && msg.usage) {
const usageMsgId = typeof msg.id === "string" ? msg.id : null;
const repeatedLine = usageMsgId !== null && usageMsgId === lastUsageMsgId;
if (usageMsgId !== null && msg.usage) lastUsageMsgId = usageMsgId;
if (msgModel && msgModel !== "<synthetic>" && msg.usage && !repeatedLine) {
const usage = msg.usage;
const key = bucketKey(
msgModel,
@@ -772,12 +784,16 @@ function combineSessionTokens(session) {
}
/**
* Write a session's per-model token totals via replaceTokenUsage. Safe to call
* repeatedly: the underlying SQL preserves the highest-seen value via the
* baseline_* columns, so a re-run never reduces totals.
* Write a session's per-model token totals. By default uses replaceTokenUsage,
* whose baseline_* columns preserve the highest-seen value so a re-run never
* reduces totals. `opts.exact` switches to setTokenUsage, which overwrites the
* row and clears the baselines — for the reconcile path, where this full
* re-parse of an append-only transcript IS the truth and must be able to
* correct a previously over-counted row.
*/
function writeSessionTokens(dbModule, sessionId, tokensByModel) {
function writeSessionTokens(dbModule, sessionId, tokensByModel, opts = {}) {
const { stmts } = dbModule;
const write = opts.exact ? stmts.setTokenUsage : stmts.replaceTokenUsage;
let written = 0;
for (const tokens of Object.values(tokensByModel || {})) {
if (
@@ -789,7 +805,7 @@ function writeSessionTokens(dbModule, sessionId, tokensByModel) {
(tokens.webFetch || 0) > 0 ||
(tokens.codeExec || 0) > 0
) {
stmts.replaceTokenUsage.run(
write.run(
sessionId,
tokens.model,
tokens.speed,
@@ -2000,16 +2016,17 @@ async function reconcileTokens(dbModule, options = {}) {
}
}
const known = dbModule.db
.prepare("SELECT id FROM sessions WHERE metadata LIKE '%\"imported\":true%'")
.all();
// Every session we can find a transcript for — not just imported ones. A
// live-hook session that has ENDED never gets another event, so a re-parse
// here is the only way its stored totals can ever be corrected.
const known = dbModule.db.prepare("SELECT id FROM sessions").all();
const total = known.length;
let processed = 0;
const tx = dbModule.db.transaction((batch) => {
for (const { sessionId, tokens } of batch) {
const written = writeSessionTokens(dbModule, sessionId, tokens);
const written = writeSessionTokens(dbModule, sessionId, tokens, { exact: true });
if (written > 0) {
counters.sessionsTouched++;
counters.modelsWritten += written;
+29 -1
View File
@@ -170,6 +170,15 @@ function fastPathOk(rt = runtimeDir()) {
// Claude Code deleted long ago. Reading one file is cheap enough for a hot
// path; `unitIsStale` deliberately spawns nothing.
if (require("./systemd-service").unitIsStale("agenthub", PLUGIN_ROOT)) return false;
// Leftover checkout hooks are the one defect that corrupts the DATA rather
// than the install: events carry no id, so a second handler doubles every
// event, every subagent row and every turn. `stripLegacyHooks` removes them —
// but it runs inside `bootstrap()`, which this fast path skips, so on a
// machine that is otherwise healthy they survived every session forever.
// Reading one JSON file is cheap enough here; the strip still happens in the
// worker. (Observed: 271 PreToolUse events for ~135 real tool calls, and one
// Task spawning two subagent rows.)
if (hasLegacyHooks()) return false;
return serverIsLive();
}
@@ -291,6 +300,24 @@ function isCheckoutHookEntry(claudeHome = getClaudeHome()) {
*
* @returns {number} how many entries were removed
*/
/**
* Does `~/.claude/settings.json` still carry hook entries from a checkout
* install? Pure fs, no writes — the fast path calls it on every SessionStart.
*
* @returns {boolean}
*/
function hasLegacyHooks(settingsPath = getSettingsPath()) {
let settings;
try {
settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
} catch {
return false;
}
if (!settings.hooks || typeof settings.hooks !== "object") return false;
const isLegacy = isCheckoutHookEntry();
return Object.values(settings.hooks).some((e) => Array.isArray(e) && e.some(isLegacy));
}
function stripLegacyHooks(settingsPath = getSettingsPath()) {
let settings;
try {
@@ -507,7 +534,7 @@ function autoInstallSkillPlugins(rt, { force = false } = {}) {
/* ------------------------------------------------------ plugin-cache pruning */
/** How many retired builds to leave behind, newest first, as a rollback cushion. */
const KEEP_RETIRED_ROOTS = 2;
const KEEP_RETIRED_ROOTS = 1;
/**
* Delete the cache directories earlier updates left behind.
@@ -875,6 +902,7 @@ module.exports = {
releaseLock,
isCheckoutHookEntry,
stripLegacyHooks,
hasLegacyHooks,
linkCli,
installDeps,
serverIsLive,
+15
View File
@@ -713,6 +713,21 @@ calculations match real-time captured sessions exactly. Re-imports are
idempotent (dedupe by session ID; compaction `baseline_*` columns
prevent token double-counting).
Both parsers count each assistant message's `usage` **once**: Claude Code
writes one JSONL line per content block (thinking / text / each `tool_use`)
and every one repeats the same message-level totals, so summing lines
inflated a tool-heavy session's tokens — and its cost — by ~1.5–2×. The
guard is the previous message id (`lastUsageMsgId`), carried across
incremental reads so a message split over a chunk boundary still counts once.
`reconcileTokens` (`node scripts/import-history.js --reconcile-tokens`)
re-parses every session that still has a transcript on disk and writes the
result with `stmts.setTokenUsage`, which overwrites the row and clears the
baselines. That is deliberate: the hook writer's `replaceTokenUsage` keeps a
high-water mark, so without an exact write a previously over-counted total
could never be corrected downwards. The server runs this reconcile once, in
the background, at the first start after upgrading (`user_version` 2).
Imported and live-scanned subagents also get their **nested hierarchy**
rebuilt: rows are inserted flat under the main agent, then
`reconcileSubagentParents` recovers each spawner from the subagent
+12
View File
@@ -157,6 +157,18 @@ describe("per-node driver command", () => {
// `agenthub lanes node <id>` runnable for every node in the template.
assert.equal(byId.done, "agenthub stage done");
assert.equal(byId["e2e-feature-passed"], "agenthub stage e2e-feature-passed");
// The 8-node `default` template drives the same way. `intake` in particular
// runs the brainstorm — it used to declare only `agenthub stage intake`,
// which made "intake" a label the pipeline walked past without ever asking
// the user a question.
const def = getPipeline("default");
const defById = Object.fromEntries(def.nodes.map((n) => [n.id, n.command]));
assert.equal(defById.intake, "/superpowers:brainstorming");
assert.equal(defById.plan, "/superpowers:writing-plans");
assert.equal(defById.implement, "/superpowers:test-driven-development");
assert.equal(defById.gate, "/superpowers:verification-before-completion");
assert.equal(defById.done, "agenthub stage done");
});
it("defaults a template node that declares no command to the empty string", () => {
+42 -3
View File
@@ -268,6 +268,45 @@ describe("legacy hook cleanup", () => {
fs.rmSync(`${settings}.agenthub-bak`, { force: true });
});
it("reports a settings file that still carries checkout hooks", () => {
// What fastPathOk consults on every SessionStart. The strip itself only
// runs in the worker, so without this the one defect that DOUBLES every
// event survives on an otherwise-healthy install forever.
fs.writeFileSync(settings, JSON.stringify({ hooks: {} }));
assert.equal(boot.hasLegacyHooks(settings), false);
fs.writeFileSync(
settings,
JSON.stringify({
hooks: {
PreToolUse: [
{ matcher: "*", hooks: [{ type: "command", command: "node /other/tool.js" }] },
],
},
})
);
assert.equal(boot.hasLegacyHooks(settings), false, "an unrelated hook is not ours");
fs.writeFileSync(
settings,
JSON.stringify({
hooks: {
PreToolUse: [
{
matcher: "*",
hooks: [
{ type: "command", command: "node /repo/scripts/hook-handler.js PreToolUse" },
],
},
],
},
})
);
assert.equal(boot.hasLegacyHooks(settings), true);
// Pure read: the caller is a hot path, and the file is the user's.
assert.equal(fs.existsSync(`${settings}.agenthub-bak`), false);
});
it("removes checkout-installed hook entries and backs the file up first", () => {
fs.writeFileSync(
settings,
@@ -571,12 +610,12 @@ describe("pruneRetiredPluginRoots", () => {
fs.writeFileSync(path.join(pluginsDir, "installed_plugins.json"), "{}");
});
it("keeps the current build and the two newest retired ones", () => {
it("keeps the current build and the single newest retired one", () => {
const removed = boot.pruneRetiredPluginRoots(RT, dir("current"));
assert.equal(removed, 3); // v1 v2 v3 go; v4 v5 are the cushion
assert.equal(removed, 4); // v1..v4 go; v5 is the whole cushion
assert.ok(fs.existsSync(dir("current")));
assert.ok(fs.existsSync(dir("v5")));
assert.ok(fs.existsSync(dir("v4")));
assert.ok(!fs.existsSync(dir("v4")));
assert.ok(!fs.existsSync(dir("v1")));
});
+2 -1
View File
@@ -176,7 +176,8 @@ test("detects stage through the real default pipeline template, end-to-end via g
test("every shipped default.json rule fires end-to-end via getPipeline", () => {
const pipeline = getPipeline("default");
const cases = [
["plan", "Skill", { skill: "brainstorming" }],
// The brainstorm is intake's own work now, so it paints intake, not plan.
["intake", "Skill", { skill: "brainstorming" }],
["plan", "Skill", { skill: "writing-plans" }],
["implement", "Edit", { file_path: "server/lib/app.js" }],
["implement", "Write", { file_path: "server/lib/app.js" }],
@@ -0,0 +1,125 @@
/**
* @file Regression test for transcript-derived turn bookkeeping. Every hook
* event re-reads the whole transcript, so anything derived from it must be
* idempotent: `TurnDuration` events are inserted once per real turn (their
* dedup guard matches on `created_at`, which therefore has to be the
* TRANSCRIPT's timestamp, not the moment we read the file), and the session
* metadata counters are ASSIGNED from the full parse rather than accumulated.
* Before this, one session held 68,747 copies of the same handful of turns and
* reported tens of thousands of thinking blocks.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const http = require("http");
const STAMP = `turn-dur-${Date.now()}-${process.pid}`;
const TMP = path.join(os.tmpdir(), STAMP);
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.CLAUDE_HOME = path.join(TMP, "home");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
const { createApp, startServer } = require("../index");
const { db, stmts } = require("../db");
let server;
let BASE;
function post(urlPath, body) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const payload = JSON.stringify(body);
const r = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
},
},
(res) => {
res.resume();
res.on("end", () => resolve(res.statusCode));
}
);
r.on("error", reject);
r.write(payload);
r.end();
});
}
before(async () => {
fs.mkdirSync(TMP, { recursive: true });
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("turn bookkeeping is idempotent across re-reads", () => {
it("inserts each turn once and does not accumulate the metadata counters", async () => {
const cwd = path.join(TMP, "work");
const sid = "77777777-8888-9999-aaaa-bbbbbbbbbbbb";
const tpath = path.join(TMP, `${sid}.jsonl`);
fs.mkdirSync(cwd, { recursive: true });
fs.writeFileSync(
tpath,
[
{
type: "assistant",
message: {
id: "msg_1",
model: "claude-sonnet-5",
content: [{ type: "thinking" }, { type: "text", text: "hi" }],
usage: { input_tokens: 5, output_tokens: 7 },
},
},
{
type: "system",
subtype: "turn_duration",
durationMs: 1200,
timestamp: "2026-09-18T01:00:00.000Z",
},
{
type: "system",
subtype: "turn_duration",
durationMs: 3400,
timestamp: "2026-09-18T01:05:00.000Z",
},
]
.map((o) => JSON.stringify(o))
.join("\n") + "\n"
);
const event = { session_id: sid, cwd, transcript_path: tpath };
for (let i = 0; i < 3; i++) {
assert.equal(await post("/api/hooks/event", { hook_type: "Stop", data: event }), 200);
}
const turns = db
.prepare("SELECT created_at FROM events WHERE session_id = ? AND event_type = 'TurnDuration'")
.all(sid);
assert.equal(turns.length, 2, "three hooks over two turns must leave two rows");
// Stamped from the transcript — both the dedup guard and the feed's
// ordering depend on it.
assert.deepEqual(turns.map((t) => t.created_at).sort(), [
"2026-09-18T01:00:00.000Z",
"2026-09-18T01:05:00.000Z",
]);
const meta = JSON.parse(stmts.getSession.get(sid).metadata || "{}");
assert.equal(meta.turn_count, 2);
assert.equal(meta.total_turn_duration_ms, 4600);
assert.equal(meta.thinking_blocks, 1, "one thinking block, however often it is re-read");
});
});
+7 -3
View File
@@ -8,7 +8,7 @@
"label": "Intake",
"icon": "📝",
"gate": false,
"command": "agenthub stage intake",
"command": "/superpowers:brainstorming",
"aliases": [
"assigned",
"claimed",
@@ -18,6 +18,10 @@
{
"tool": "Agent",
"match": "^Explore$"
},
{
"tool": "Skill",
"match": "brainstorming"
}
]
},
@@ -35,7 +39,7 @@
"detect": [
{
"tool": "Skill",
"match": "brainstorming|writing-plans"
"match": "writing-plans"
},
{
"tool": "Write",
@@ -103,7 +107,7 @@
"label": "Review",
"icon": "👀",
"gate": true,
"command": "/superpowers:requesting-code-review",
"command": "/code-review",
"aliases": [
"reviewing",
"code-review",
+1 -1
View File
@@ -142,7 +142,7 @@
"label": "Code review",
"icon": "👀",
"gate": true,
"command": "/superpowers:requesting-code-review",
"command": "/code-review",
"aliases": [
"reviewing",
"code-review",
+36
View File
@@ -1479,6 +1479,15 @@ const stmts = {
insertEvent: db.prepare(
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))"
),
// Same insert with an EXPLICIT created_at, for events recovered from a
// transcript: their real time is in the file, not the moment we happened to
// re-read it. `TurnDuration` needs it twice over — its own dedup guard
// matches on `created_at`, so stamping NOW made every guard miss and every
// re-parse re-insert the whole turn history (587k rows across this DB, 75%
// of the events table, one session holding 68,747 copies of the same turns).
insertEventAt: db.prepare(
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
),
listEvents: db.prepare("SELECT * FROM events ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?"),
listEventsBySession: db.prepare(
"SELECT * FROM events WHERE session_id = ? ORDER BY created_at DESC, id DESC"
@@ -1562,6 +1571,33 @@ const stmts = {
web_fetch_requests = excluded.web_fetch_requests,
code_execution_requests = excluded.code_execution_requests
`),
// EXACT (non-monotonic) write, used only by the deliberate re-parse/repair
// path (`reconcileTokens`). Same args as replaceTokenUsage. The high-water
// mark above is right for the incremental hook writer, but it makes a
// CORRECTION impossible: when a parser bug inflated the stored total, every
// re-parse of the (lower) truth just rolls the inflation into baseline_*.
// This statement overwrites the row and clears the baselines, so a full
// re-parse of an append-only transcript can restore the real numbers.
setTokenUsage: db.prepare(`
INSERT INTO token_usage (session_id, model, speed, inference_geo, service_tier,
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, cache_write_1h_tokens,
web_search_requests, web_fetch_requests, code_execution_requests,
baseline_input, baseline_output, baseline_cache_read, baseline_cache_write, baseline_cache_write_1h,
baseline_web_search, baseline_web_fetch, baseline_code_execution)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, 0, 0, 0, 0, 0)
ON CONFLICT(session_id, model, speed, inference_geo, service_tier) DO UPDATE SET
input_tokens = excluded.input_tokens,
output_tokens = excluded.output_tokens,
cache_read_tokens = excluded.cache_read_tokens,
cache_write_tokens = excluded.cache_write_tokens,
cache_write_1h_tokens = excluded.cache_write_1h_tokens,
web_search_requests = excluded.web_search_requests,
web_fetch_requests = excluded.web_fetch_requests,
code_execution_requests = excluded.code_execution_requests,
baseline_input = 0, baseline_output = 0, baseline_cache_read = 0,
baseline_cache_write = 0, baseline_cache_write_1h = 0,
baseline_web_search = 0, baseline_web_fetch = 0, baseline_code_execution = 0
`),
getTokenTotals: db.prepare(`
SELECT
COALESCE(SUM(input_tokens + baseline_input), 0) as total_input,
+51
View File
@@ -391,6 +391,57 @@ function startBackgroundServices() {
if (t.unref) t.unref();
}
// One-time repair (user_version 3) of two parser bugs fixed in v1.6.16:
//
// 1. The transcript parser summed the `usage` of EVERY JSONL line of an
// assistant message, and Claude Code writes one line per content block —
// each repeating the same message-level totals. Stored tokens (and cost)
// ran ~1.5-2x high, and `replaceTokenUsage`'s high-water baselines mean
// the fixed parser alone can never bring them down: only the exact-write
// reconcile can.
// 2. Every `TurnDuration` row was stamped NOW while its dedup guard matched
// on the transcript timestamp, so each hook re-inserted the session's
// whole turn history — 75% of a typical events table.
//
// Marker 3 and not 2: an install that ran the v1.6.15 pass had its corrected
// rows immediately re-inflated by whatever old build was still ingesting
// hooks, so that marker cannot be trusted to mean "already correct".
// Deferred and non-blocking; the marker is written only on success, so a
// crash mid-run just retries on the next start.
{
const dbModule = require("./db");
if (dbModule.db.pragma("user_version", { simple: true }) < 3) {
const { reconcileTokens } = require("../scripts/import-history");
const t = setTimeout(() => {
Promise.resolve()
.then(() => {
// Duplicates differ only by their insertion time, so the earliest
// row of each (session, payload) group is the one real turn.
const del = dbModule.db
.prepare(
`DELETE FROM events WHERE event_type = 'TurnDuration' AND id NOT IN (
SELECT MIN(id) FROM events WHERE event_type = 'TurnDuration'
GROUP BY session_id, data
)`
)
.run();
if (del.changes > 0) {
console.log(`Removed ${del.changes} duplicated TurnDuration event(s)`);
}
})
.then(() => reconcileTokens(dbModule))
.then((c) => {
console.log(
`Token re-parse: corrected ${c.sessionsTouched} session(s), ${c.modelsWritten} model row(s)`
);
dbModule.db.pragma("user_version = 3");
})
.catch((err) => console.warn("token re-parse failed:", err?.message || err));
}, 1_000);
if (t.unref) t.unref();
}
}
const { startUpdateScheduler } = require("./update-scheduler");
const { broadcast } = require("./websocket");
startUpdateScheduler({ broadcast });
@@ -666,4 +666,58 @@ describe("TranscriptCache", () => {
assert.strictEqual(result.lastInterruptTs, null);
assert.strictEqual(result.pendingInterrupt, false);
});
// Claude Code writes ONE line per content block of an assistant message, each
// repeating the whole message's usage. Counting them all inflated cost.
const M1 = ["m1", "standard", "global", "standard"].join(String.fromCharCode(1));
const blockLine = (id, block) => ({
type: "assistant",
message: {
id,
model: "m1",
content: [block],
usage: { input_tokens: 10, output_tokens: 40, cache_read_input_tokens: 1000 },
},
});
it("counts one assistant message once, however many block lines repeat its usage", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
blockLine("msg_a", { type: "thinking" }),
blockLine("msg_a", { type: "text", text: "hi" }),
blockLine("msg_a", { type: "tool_use", name: "Bash" }),
blockLine("msg_b", { type: "text", text: "next turn" }),
]);
const result = new TranscriptCache().extract(file);
assert.deepStrictEqual(result.tokensByModel[M1], {
model: "m1",
speed: "standard",
geo: "global",
tier: "standard",
input: 20,
output: 80,
cacheRead: 2000,
cacheWrite: 0,
cacheWrite1h: 0,
webSearch: 0,
webFetch: 0,
codeExec: 0,
});
assert.strictEqual(result.thinkingBlockCount, 1, "thinking blocks still counted per line");
});
it("does not re-count a message whose block lines straddle an incremental read", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [blockLine("msg_a", { type: "thinking" })]);
const cache = new TranscriptCache();
assert.strictEqual(cache.extract(file).tokensByModel[M1].output, 40);
fs.appendFileSync(file, JSON.stringify(blockLine("msg_a", { type: "text", text: "x" })) + "\n");
assert.strictEqual(cache.extract(file).tokensByModel[M1].output, 40, "same message, one count");
fs.appendFileSync(file, JSON.stringify(blockLine("msg_b", { type: "text", text: "y" })) + "\n");
assert.strictEqual(cache.extract(file).tokensByModel[M1].output, 80, "new message counts");
});
});
+43 -12
View File
@@ -151,7 +151,12 @@ class TranscriptCache {
// File grew → incremental read from last position
if (stat.size > cached.bytesRead) {
const incremental = this._streamRange(transcriptPath, cached.bytesRead, stat.size);
const incremental = this._streamRange(
transcriptPath,
cached.bytesRead,
stat.size,
cached.result?.lastUsageMsgId || null
);
if (incremental) {
const merged = this._merge(cached, incremental);
const hasTokens = Object.keys(merged.tokensByModel).length > 0;
@@ -174,6 +179,7 @@ class TranscriptCache {
firstUserMessage: merged.firstUserMessage || null,
lastInterruptTs: merged.lastInterruptTs || null,
lastTurnTs: merged.lastTurnTs || null,
lastUsageMsgId: merged.lastUsageMsgId || null,
pendingInterrupt: computePendingInterrupt(merged.lastInterruptTs, merged.lastTurnTs),
};
if (
@@ -256,8 +262,11 @@ class TranscriptCache {
* straddling a chunk boundary are not corrupted. Never builds a string
* larger than a single line, so V8 string-length limits cannot be hit.
*/
_streamRange(filePath, startOffset, endOffset) {
_streamRange(filePath, startOffset, endOffset, seedUsageMsgId = null) {
const state = this._initParseState();
// Carry the last counted message id across the chunk boundary: an
// incremental read can start in the middle of one message's block lines.
state.lastUsageMsgId = seedUsageMsgId;
if (endOffset <= startOffset) return this._finalizeState(state);
const CHUNK = 4 * 1024 * 1024; // 4 MiB
@@ -391,6 +400,15 @@ class TranscriptCache {
// comparison cannot, since the UserPromptSubmit event is stamped later.
lastInterruptTs: null,
lastTurnTs: null,
// Id of the assistant message whose usage was last counted. Claude Code
// writes ONE JSONL line PER CONTENT BLOCK of a single assistant message
// (thinking, text, each tool_use), and every one of those lines repeats
// the SAME `message.usage` totals for the whole message. Summing them
// inflates a session's tokens (and therefore its cost) by the average
// number of blocks per message — ~1.6x on a tool-heavy session. The
// repeated lines are always consecutive among usage-bearing lines, so
// remembering just the previous id is enough to count each message once.
lastUsageMsgId: null,
};
}
@@ -500,17 +518,23 @@ class TranscriptCache {
const model = msg.model;
if (!model || model === "<synthetic>" || !msg.usage) return;
state.latestModel = model;
// Bucket tokens by the pricing dimensions (speed / geo / tier) so cost can
// apply fast-mode, data-residency, and Batch modifiers per bucket. The value
// carries those dimensions so the DB writer can key the row correctly.
const speed = normalizeSpeed(msg.usage);
const geo = normalizeGeo(msg.usage);
const tier = normalizeTier(msg.usage);
const key = bucketKey(model, speed, geo, tier);
if (!state.tokensByModel[key]) {
state.tokensByModel[key] = emptyBucket(model, speed, geo, tier);
// One message, many lines (see lastUsageMsgId): count its usage once.
const usageMsgId = typeof msg.id === "string" ? msg.id : null;
const repeatedLine = usageMsgId !== null && usageMsgId === state.lastUsageMsgId;
if (usageMsgId !== null) state.lastUsageMsgId = usageMsgId;
if (!repeatedLine) {
// Bucket tokens by the pricing dimensions (speed / geo / tier) so cost can
// apply fast-mode, data-residency, and Batch modifiers per bucket. The value
// carries those dimensions so the DB writer can key the row correctly.
const speed = normalizeSpeed(msg.usage);
const geo = normalizeGeo(msg.usage);
const tier = normalizeTier(msg.usage);
const key = bucketKey(model, speed, geo, tier);
if (!state.tokensByModel[key]) {
state.tokensByModel[key] = emptyBucket(model, speed, geo, tier);
}
accumulateBucket(state.tokensByModel[key], extractUsageFields(msg.usage));
}
accumulateBucket(state.tokensByModel[key], extractUsageFields(msg.usage));
if (msg.usage.service_tier) state.usageExtras.service_tiers.add(msg.usage.service_tier);
if (msg.usage.speed) state.usageExtras.speeds.add(msg.usage.speed);
@@ -578,6 +602,7 @@ class TranscriptCache {
firstUserMessage: state.firstUserMessage,
lastInterruptTs: state.lastInterruptTs,
lastTurnTs: state.lastTurnTs,
lastUsageMsgId: state.lastUsageMsgId,
pendingInterrupt: computePendingInterrupt(state.lastInterruptTs, state.lastTurnTs),
};
}
@@ -696,6 +721,11 @@ class TranscriptCache {
(incremental && incremental.lastInterruptTs) || cached.result?.lastInterruptTs || null;
const lastTurnTs = (incremental && incremental.lastTurnTs) || cached.result?.lastTurnTs || null;
// Newest chunk wins when it counted any usage; else keep the cached id so
// the next chunk still recognizes a message split across the boundary.
const lastUsageMsgId =
(incremental && incremental.lastUsageMsgId) || cached.result?.lastUsageMsgId || null;
return {
tokensByModel,
compaction,
@@ -709,6 +739,7 @@ class TranscriptCache {
firstUserMessage,
lastInterruptTs,
lastTurnTs,
lastUsageMsgId,
};
}
+17 -6
View File
@@ -1053,13 +1053,17 @@ const processEvent = db.transaction((hookType, data) => {
if (existing) continue;
const tdSummary = `Turn completed in ${(td.durationMs / 1000).toFixed(1)}s`;
stmts.insertEvent.run(
// Stamped with the TRANSCRIPT's timestamp, not NOW — that is what the
// dedup query above matches on, and a turn that happened at 10:00
// belongs at 10:00 in the feed regardless of when we re-read the file.
stmts.insertEventAt.run(
sessionId,
mainAgentId,
"TurnDuration",
null,
tdSummary,
JSON.stringify({ durationMs: td.durationMs })
JSON.stringify({ durationMs: td.durationMs }),
tdTs
);
broadcast("new_event", {
session_id: sessionId,
@@ -1080,13 +1084,20 @@ const processEvent = db.transaction((hookType, data) => {
if (result.usageExtras) {
meta.usage_extras = result.usageExtras;
}
// ASSIGNED, never accumulated. `extract()` returns the totals for the
// WHOLE transcript every time (the cache merges each incremental
// chunk into the cached total), so `+=` re-added the entire history
// on every hook — a 4-hour session reported 24,746 thinking blocks
// and a turn count in the thousands.
if (result.thinkingBlockCount > 0) {
meta.thinking_blocks = (meta.thinking_blocks || 0) + result.thinkingBlockCount;
meta.thinking_blocks = result.thinkingBlockCount;
}
if (result.turnDurations) {
meta.turn_count = (meta.turn_count || 0) + result.turnDurations.length;
const totalMs = result.turnDurations.reduce((s, t) => s + t.durationMs, 0);
meta.total_turn_duration_ms = (meta.total_turn_duration_ms || 0) + totalMs;
meta.turn_count = result.turnDurations.length;
meta.total_turn_duration_ms = result.turnDurations.reduce(
(s, t) => s + t.durationMs,
0
);
}
stmts.updateSession.run(null, null, null, JSON.stringify(meta), sessionId);
}
+11 -1
View File
@@ -647,11 +647,21 @@ router.post("/:id/router/apply", sameOriginGuard, (req, res) => {
try {
const result = applyRouterToLane(lane, profile);
lanesLib.setRouterProfile(lane.id, profile);
// A pane that is already running does NOT fully adopt the switch. Claude
// Code re-applies an `env` block to a live session when the settings file
// changes, but the session's model was resolved at startup — so the OLD
// router's model name keeps being sent to the NEW endpoint, which answers
// "model not found". (Switching to `native` is the same story: the router's
// model name reaches api.anthropic.com.) Nothing here can fix that from the
// outside; the pane has to be restarted. Report it instead of letting the
// switch look clean and then fail on the user's next prompt.
const runId = runs.runIdForLane(lane.id);
const live = !!runs.getRun(runId);
// Every other mutating lane route broadcasts; without it the client's
// cached row keeps the OLD router_profile, and the next lane switch
// re-reads that stale row and flips the picker back.
broadcastLane(lane.id);
res.json(result);
res.json({ ...result, restart_required: live, run_id: live ? runId : null });
} catch (err) {
const status = { ENOTFOUND: 404, EBADJSON: 409, EBADNAME: 400 }[err.code] || 500;
res.status(status).json({ error: { code: err.code || "ERUNTIME", message: err.message } });
+20 -11
View File
@@ -1,11 +1,11 @@
---
name: ship-feature-lane
description: "Autonomous end-to-end feature pipeline for ONE AgentHub lane. Invoke inside a lane's working directory with a requirement: `/ship-feature-lane <requirement>`. Frontloads ALL clarifying questions once, then runs unattended: implement (TDD) → independent unit-test agent → pre-push CI gates + dev preflight → e2e on the feature branch (agent-run) → code review (fresh-context agent) → local QC → senior GO/NO-GO gate → push branch + open PR (base `development`) → CI watch → report, then watches the PR (new comments → gated fix-loop; base conflicts → sync development into the branch) until a HUMAN merges it. The PR is only published after all local gates pass and the senior gate says GO — it's finalized when reviewers see it. Declares lane stage at every step via `agenthub stage` for the dashboard. Use when the user wants to build/ship/implement a feature in a AgentHub lane. NOTE: ticket-filing (Stage 9) and post-merge dev-site QC (Stage 13's second half) are currently OFF (F's integrations aren't built yet) — Stage 13's CI-wait half now runs via agenthub lanes ci status."
description: "Autonomous end-to-end feature pipeline for ONE AgentHub lane. Invoke inside a lane's working directory with a requirement: `/ship-feature-lane <requirement>`. Brainstorms with the user and frontloads ALL clarifying questions once (`AskUserQuestion`), stops for their approval of the plan, then runs unattended: implement (TDD) → independent unit-test agent → pre-push CI gates + dev preflight → e2e on the feature branch (agent-run) → code review (fresh-context agent) → local QC → senior GO/NO-GO gate → the user's acceptance → push branch + open PR (base `development`) → CI watch → report, then watches the PR (new comments → gated fix-loop; base conflicts → sync development into the branch) until a HUMAN merges it. The PR is only published after all local gates pass, the senior gate says GO and the user accepts — it's finalized when reviewers see it. A NO-GO loops back to the gates, or to replanning when the approach itself was wrong. Declares lane stage at every step via `agenthub stage` for the dashboard. Use when the user wants to build/ship/implement a feature in a AgentHub lane. NOTE: ticket-filing (Stage 9) and post-merge dev-site QC (Stage 13's second half) are currently OFF (F's integrations aren't built yet) — Stage 13's CI-wait half now runs via agenthub lanes ci status."
---
# Ship Feature Lane (AgentHub lane pipeline)
You are running the autonomous feature pipeline for **one AgentHub lane**. The human's only interactive touchpoints are **Stage 0 (frontloaded Q&A)** and **merging the PR on GitHub**; everything else runs to completion or to a `blocked` escalation, reporting progress through `agenthub stage` (which the dashboard renders).
You are running the autonomous feature pipeline for **one AgentHub lane**. The human's interactive touchpoints are exactly three — **Stage 0 (frontloaded Q&A)**, **Stage 0b (approving the plan)** and **Stage 7 (accepting the work before the PR is published)** — plus **merging the PR on GitHub**; everything between them runs to completion or to a `blocked` escalation, reporting progress through `agenthub stage` (which the dashboard renders). Those three are not optional politeness: they are the points where being wrong is cheapest to fix (before a plan), and where being wrong becomes public (before a PR).
## Current status
@@ -48,7 +48,7 @@ Resume from the stage shown. If state says `stage=X status=running`, you were mi
## Hard rules
- **Publish ONLY after the senior-gate-reviewer returns `VERDICT: GO`.** "Publish" = push the feature branch + open/update the PR (Stage 8). Nothing reviewer-visible exists before GO, and nothing else authorizes it.
- **Publish ONLY after the senior-gate-reviewer returns `VERDICT: GO` AND the human accepts it (Stage 7).** "Publish" = push the feature branch + open/update the PR (Stage 8). Nothing reviewer-visible exists before both, and nothing else authorizes it.
- **Testing and reviewing run in a FRESH-CONTEXT agent — never in this session.** The session that wrote the code is its worst judge: it already believes the code is correct and it remembers the reasoning the tests are supposed to challenge. Every stage whose job is to *judge* the work — Stage 0b (plan debate), Stage 1b (unit tests), Stage 3 (e2e run + triage), Stage 4 (code review), Stage 6 (browser QC), Stage 7 (senior gate) — is dispatched with the Agent tool to a subagent that starts with an EMPTY context and receives only artifacts it can verify itself: the lane's working directory, the lane spec file, the diff range, the commands to run, and the other agents' reports verbatim. The stages you keep are the ones that *do* rather than judge: intake, implementing, the mechanical gates (Stage 2), publishing (Stage 8), the CI/PR watch (Stages 10–12), and applying every fix the judges ask for. Rules: **never `subagent_type: fork`** for these (a fork inherits this session's context, which defeats the whole point); never "save a round-trip" by doing the pass yourself; never paste your own conclusions ("I already checked X", "this is safe because…") into the brief — hand facts and paths, not verdicts. You implement and you apply fixes; you do not grade your own work.
- Launch them **FOREGROUND** (they gate the pipeline, and their completion re-invokes you, which satisfies the turn-liveness rule).
- Each returns ONE parseable final line (`UNIT-TESTS:` / `E2E:` / `REVIEW:` / `LOCAL-QC:` / `VERDICT:`). Parse that line; never infer a pass from prose.
@@ -63,7 +63,7 @@ Resume from the stage shown. If state says `stage=X status=running`, you were mi
- **Run long helpers so they can't be killed mid-flight or hang your turn.** `agenthub lanes hook ci-gate`, `agenthub lanes up`, `agenthub lanes hook e2e` legitimately run 3–20+ minutes (builds, tests, Playwright, lock waits). NEVER invoke them with the default Bash timeout (2 min kills them mid-flight and strands the lane half-done): use `run_in_background: true` and poll the output file until done, or set `timeout: 600000` for the shorter gates. If a helper does die mid-run, don't panic: every hook is idempotent — re-run the step (e.g. re-run `agenthub lanes up --no-build` to revive a stack).
- **Waiting + polling NEVER use a foreground `sleep`** (the Bash tool blocks it) or `ScheduleWakeup` (that's a `/loop`-only primitive — this pipeline is not a `/loop` session, so it won't sustain your watch). To pace a poll loop or wait out a timer, **background the wait**: run `sleep <secs>` with `run_in_background: true` — you're re-invoked when it exits, and re-invoked the moment a backgrounded Agent or helper finishes, so you never busy-poll for background work. **If you ever can't sustain a wait/loop in this session, set `agenthub stage <current> --note "<honest note>"` and STOP — never narrate a watch or loop you are not actually running.**
- **e2e: actively poll — never wait on the completion re-invoke alone.** This binds whoever actually runs the hook — normally Stage 3's e2e agent, so pass this paragraph into its brief verbatim; you only follow it yourself for an ad-hoc run. A hung suite never fires the completion re-invoke, stranding the lane at `stage=e2e-feature`. When running `agenthub lanes hook e2e`: start it `run_in_background: true` AND background a `sleep 90` beside it. Each wake — finished → parse PASS/FAIL; still running → read the e2e log tail (`agenthub lanes logs <id> e2e`), bump the heartbeat, and re-background `sleep 90`, UNLESS it's erroring or has run past ~22 min, in which case kill the e2e task and treat it as FAIL → re-enter Stage 2.
- **Turn-liveness: never let the pipeline die silently.** A "stalled" lane usually died one of two ways: (a) a turn ended with NOTHING pending — no backgrounded wait, no running helper, no background agent — so nothing ever re-invoked the session; or (b) a transient API error (rate-limit, 529/overload, connection refused) killed the turn mid-stage. Rules: while the pipeline is anywhere between Stage 1 and Stage 14 (done), every turn you end MUST leave at least one re-invoker pending (a `run_in_background` helper/sleep or a background agent) — check before ending the turn. And on ANY resume after an error or a human nudge ("continue"), do not ask questions: re-derive position from lane state (Context recovery above) and continue the stage. If you truly cannot leave a re-invoker, set `--note "watch needs re-trigger: <what to do>"` so the dashboard shows it honestly.
- **Turn-liveness: never let the pipeline die silently.** A "stalled" lane usually died one of two ways: (a) a turn ended with NOTHING pending — no backgrounded wait, no running helper, no background agent — so nothing ever re-invoked the session; or (b) a transient API error (rate-limit, 529/overload, connection refused) killed the turn mid-stage. Rules: while the pipeline is anywhere between Stage 1 and Stage 14 (done), every turn you end MUST leave at least one re-invoker pending (a `run_in_background` helper/sleep or a background agent) — check before ending the turn. The ONE legitimate exception is a turn that ends on an `AskUserQuestion` at a scheduled touchpoint (Stage 0, 0b, 7): the human's answer is the re-invoker, and a pipeline waiting on a question it actually asked is not a stalled one. And on ANY resume after an error or a human nudge ("continue"), do not ask questions: re-derive position from lane state (Context recovery above) and continue the stage. If you truly cannot leave a re-invoker, set `--note "watch needs re-trigger: <what to do>"` so the dashboard shows it honestly.
- **No retry cap — the phase clock is the signal.** A failing gate/QC/CI/e2e just re-enters the loop (fix on the feature branch, re-run from Stage 2); there is NO automatic block after N attempts. The dashboard shows how long the lane has sat in its current stage, so the human can spot a stuck or endlessly-looping lane and step in. Reserve `--status blocked` for GENUINE blockers you cannot resolve (an ambiguous merge conflict, a hard/unrecoverable error).
- **Commit only on the feature branch.** Never commit on `development`/`main`. Stage only intended files (never `git add .` blindly — this repo collects stray build/QA artifacts).
- Keep the lane's state truthful: on any stop, set an accurate `agenthub stage <stage> --status <status> --note "<why>"`.
@@ -76,8 +76,10 @@ Resume from the stage shown. If state says `stage=X status=running`, you were mi
### 0 — Intake & frontloaded Q&A *(the only interactive part)*
Do NOT jump to code. Understand the requirement first.
- **Restate + quick scan.** Restate the requirement. Do a fast targeted scan of the relevant code (use the `Explore` agent for breadth; the **brainstorming** skill if the requirement is fuzzy) so your questions are grounded in what actually exists.
- **Frontloaded Q&A.** Ask the human **every** clarifying question in ONE batch: acceptance criteria, scope / non-goals, UI/UX specifics, data shapes, edge cases, which existing flows it touches. **Sibling-surface check (mandatory):** if your scan shows the app has N parallel surfaces of the pattern the requirement touches (e.g. several foldered areas, several list pages sharing a component) and the requirement names fewer than N, explicitly ask "this exists in [all N places] — apply to all, or only [the named ones]?" A missed sibling here costs a full second pipeline pass when a reviewer catches it on the PR. Then activate a clean feature slot for this run: `agenthub feature activate <slug> --title "<short title>"` — Task B's `activate` archives whatever feature was previously active on this lane automatically, so this run's dashboard state starts clean without a separate "init" step. Then mark intake: `agenthub stage intake --status running`. Announce "Questions answered — going autonomous now." After this, don't ask the human anything unless you hit a `blocked` escalation.
- **Restate + quick scan.** Restate the requirement. Do a fast targeted scan of the relevant code (use the `Explore` agent for breadth) so your questions are grounded in what actually exists.
- **The brainstorm is mandatory, not conditional.** Run the **superpowers:brainstorming** skill here on every run — not only "if the requirement is fuzzy". A one-line requirement always leaves scope, edge cases and acceptance criteria unstated, and this stage is the only place left to find that out cheaply.
- **Ask only what the repo cannot answer.** Read first, ask second: a question whose answer is in the code, the tests, `CLAUDE.md` or the existing UI spends the human's one interactive round on something you could have looked up. What belongs to them is intent — scope, priorities, trade-offs, what "done" means — never facts.
- **Frontloaded Q&A — asked with `AskUserQuestion`, and you WAIT for the answers.** Put the questions to the human with the `AskUserQuestion` tool (batched, up to 4 per round, each with real options — the way plan mode asks), not as prose they may not answer. Ask **every** clarifying question in ONE batch: acceptance criteria, scope / non-goals, UI/UX specifics, data shapes, edge cases, which existing flows it touches. You may not answer them on the human's behalf and you may not go on to Stage 0b until they are answered — deciding for them here is what makes a whole autonomous pipeline build the wrong feature. Only if the human explicitly says to decide yourself do you proceed, and then you write the assumptions into the lane spec. **Sibling-surface check (mandatory):** if your scan shows the app has N parallel surfaces of the pattern the requirement touches (e.g. several foldered areas, several list pages sharing a component) and the requirement names fewer than N, explicitly ask "this exists in [all N places] — apply to all, or only [the named ones]?" A missed sibling here costs a full second pipeline pass when a reviewer catches it on the PR. Then activate a clean feature slot for this run: `agenthub feature activate <slug> --title "<short title>"` — Task B's `activate` archives whatever feature was previously active on this lane automatically, so this run's dashboard state starts clean without a separate "init" step. Then mark intake: `agenthub stage intake --status running`. After this the human is asked exactly twice more — to approve the plan (Stage 0b) and to accept the work before the PR is published (Stage 7) — plus any `blocked` escalation. Nothing else is interactive.
### 0b — Investigate & plan *(autonomous)*
- `agenthub stage plan` — now design a real plan and have it independently challenged before you implement.
@@ -85,6 +87,8 @@ Do NOT jump to code. Understand the requirement first.
- **Plan.** Produce a concrete implementation plan (the **writing-plans** skill): approach, files to change, the test strategy (which behaviors/edge cases each test will pin), migration/contract impact, and how each acceptance criterion is met.
- **Debate the plan (adversarial review) — fresh context, never `fork`.** Spawn a SEPARATE sub-agent (Agent tool — `Plan` or `general-purpose`, FOREGROUND) to critique the plan + investigation. Hand it the requirement, the Stage-0 answers and the plan as text plus the paths to read; do NOT hand it your reasoning for why the plan is right — that is exactly what it is there to test. Ask for: missed requirements, wrong assumptions, a simpler approach, unhandled edge cases, acceptance-criteria gaps. Apply the worthwhile critiques (use **receiving-code-review** judgment — verify each point, don't blindly accept or reject). Iterate once or twice until the plan holds up.
- Write the Q&A answers **and the agreed plan** to a lane spec file `docs/superpowers/specs/lane-<slug>.md` (gitignored, or add it to `.gitignore` if this is the first one) — the acceptance contract the senior gate checks against.
- **STOP — the human approves the plan before any code exists.** This is the second of the three scheduled interactive touchpoints. Show them the plan: what changes, which files, what is deliberately out of scope, the test strategy, and how each acceptance criterion is met — plus the lane spec path. Ask with `AskUserQuestion`: approve / revise / change approach. "Revise" means edit the plan and the lane spec and ask again. **Stage 1 does not start without an approval in this turn** — an adversarial agent agreeing with your plan is not the human agreeing with it. Announce "Plan approved — going autonomous now" and only then continue.
- A later stage that turns out to need a materially different approach comes BACK here for a fresh plan and a fresh approval, rather than drifting under the old one.
### 1 — Implement (TDD, to the plan)
- Choose a **single-segment slug** for the feature — lowercase, hyphens, NO slashes. Cut the feature branch from **development** (the PR base): `git fetch origin && git checkout -b feat/<slug> origin/development`.
@@ -125,15 +129,16 @@ The e2e hook doesn't run migrations itself — it tests the already-running stac
### 4 — Code review *(no open PR yet — use local diff)*
- `agenthub stage review --status running`, then launch a fresh-context reviewer agent (Agent tool, `subagent_type: Code Reviewer` — FOREGROUND, **never `fork`**; a reviewer who already watched you write the code is not a reviewer). Hand it ONLY: the lane's working directory, the lane spec path (requirement + acceptance criteria), the diff commands, and the brief below. Do NOT summarize your own implementation for it, and do NOT pre-empt findings — let it read the diff cold.
- Run the **`code-review` skill at effort `high`** on the feature diff vs `origin/development` — the deterministic gate, not an ad-hoc read. No PR is open yet, so point it at the local diff: `git diff origin/development...feat/<slug>` (and `git log origin/development..feat/<slug>` for commits). ONLY if the `code-review` skill is unavailable in its session, fall back to a manual review of that diff (correctness, security, tests, migration/contract safety, this repo's `CLAUDE.md` + `.claude/rules/` boundaries).
- Run **Claude Code's own `code-review` skill** (the official plugin's `/code-review`) **at effort `high`** on the feature diff vs `origin/development` — the deterministic gate, not an ad-hoc read, and not a reviewer summarizing the diff in its own words. No PR is open yet, so point it at the local diff: `git diff origin/development...feat/<slug>` (and `git log origin/development..feat/<slug>` for commits). ONLY if the `code-review` skill is unavailable in its session, fall back to a manual review of that diff (correctness, security, tests, migration/contract safety, this repo's `CLAUDE.md` + `.claude/rules/` boundaries).
- Final line: `REVIEW: CLEAN` or `REVIEW: FINDINGS — <n>`, with the findings listed above it as `path:line — severity — problem — suggested fix`.
- **Scale the pass to the diff.** A handful of lines in one layer gets the `code-review` pass alone — three agents re-reading a 20-line change buys one opinion at three times the tokens, and this stage re-runs on every fix-loop lap. Add the domain reviewer(s) once the change has seams: multiple files in a layer, a route/schema/migration/hook touched, or anything you would want a second reader on.
- **Domain reviewers, in parallel with the above (same message, separate Agent calls).** Check `git diff --name-only origin/development...feat/<slug>` for touched layers and launch every matching one — FOREGROUND, fresh context, **never `fork`**, addressed as `agenthub:backend-reviewer` / `agenthub:frontend-reviewer` (plugin-qualified — no lane install needed, unlike `qc-local`/`senior-gate-reviewer`):
- Any path under `server/` → `agenthub:backend-reviewer`.
- Any path under `client/` → `agenthub:frontend-reviewer`.
- Neither touched (root/`skills/`/`agents/`/`scripts/`/docs-only change) → skip both; the generic Code Reviewer above is the only gate.
- Hand each ONLY the lane's working directory and the diff range `git diff origin/development...feat/<slug>` — each scopes itself to its own domain's files even though the diff range is the same for both.
- Each ends with its own parseable line: `BACKEND-REVIEW:` / `FRONTEND-REVIEW:` — `CLEAN` or `FINDINGS — <n>`.
- Triage ALL findings — the generic review plus every domain reviewer that ran — together with **receiving-code-review** judgment (verify each, neither blind agreement nor blind rejection) and apply what's worth applying on the feature branch. Changed source → re-run **from Stage 1b**; changed only tests → **from Stage 2**. Keep the findings + your resolutions — the senior gate reads them.
- Triage ALL findings — the generic review plus every domain reviewer that ran — together with **receiving-code-review** judgment (verify each, neither blind agreement nor blind rejection) and apply what's worth applying on the feature branch. A finding you reject is answered with a reason, never with silence. Changed source → re-run **from Stage 1b**; changed only tests → **from Stage 2**. Either way the re-entry passes through THIS stage again: review ends when a pass over the current diff comes back clean (or with only findings you explicitly and defensibly rejected), not when one pass has been performed. Keep the findings + your resolutions — the senior gate reads them.
- The Stage-6 `qc-local` report covers the user-flow review for the senior gate.
- `agenthub stage review --evidence "<total findings count across all reviewers that ran> findings, resolved"`
@@ -154,9 +159,13 @@ The e2e hook doesn't run migrations itself — it tests the already-running stac
### 7 — Senior GO/NO-GO gate *(authorizes the publish)*
- Launch the **senior-gate-reviewer** agent (Agent tool, `subagent_type: senior-gate-reviewer`, or `agenthub:senior-gate-reviewer` if the lane copy isn't installed — FOREGROUND, fresh context, **never `fork`**; installed the same way as `qc-local`, see Stage 6). Give it: the lane's working directory, the requirement + Stage-0 answers (the lane spec file), the feature branch, the Stage-4 code-review findings + resolutions **as the other agents wrote them** (verbatim/by path — never your paraphrase of why each was fine), the Stage-6 `qc-local` report (the user-flow review), and the raw results of gates/e2e/QC. The agent inspects the local diff with `git diff origin/development...feat/<slug>` — no open PR is required (and none exists yet).
- Parse its final line:
- `VERDICT: GO` → proceed to Stage 8.
- `VERDICT: NO-GO — <fixes>` → `agenthub stage gate-blocked --evidence "NO-GO — <reason>"` (an alias of `gate`, so the map holds while the lane's stage name says the gate refused), then fix on the feature branch and re-run **from Stage 2**. No attempt cap — the loop re-enters; the dashboard's time-on-stage surfaces a lane stuck cycling so the human can step in. Set `--status blocked` only for a genuine blocker you can't resolve.
- `agenthub stage gate --evidence "GO"` (or `NO-GO — <reason>`)
- `VERDICT: GO` → the agent gate is satisfied; now the human's.
- `VERDICT: NO-GO — <fixes>` → `agenthub stage gate-blocked --evidence "NO-GO — <reason>"` (an alias of `gate`, so the map holds while the lane's stage name says the gate refused), then **re-enter by the SIZE of what is wrong**, not always at the same place:
- A defect, a missed case, a wrong detail → fix on the feature branch and re-run **from Stage 2** (or Stage 1b if non-test source changed).
- Wrong approach, wrong scope, or a requirement that turns out to mean something else → back to **Stage 0b**: replan, and take the new plan through its approval stop again before any further code.
- No attempt cap — the loop re-enters; the dashboard's time-on-stage surfaces a lane stuck cycling so the human can step in. Set `--status blocked` only for a genuine blocker you can't resolve. Two NO-GOs on the same root issue means the loop is not converging: stop and ask the human instead of a third lap.
- **Then ask the human to accept, before anything reviewer-visible exists.** Publishing is outward-facing and irreversible in the social sense — a PR reviewers have seen cannot be un-seen. Present: what was built, what was verified and how (gates/e2e/QC with their real outcomes), what the reviews found and what you did about it, and anything you could not run. Ask with `AskUserQuestion`: publish the PR / send back for fixes / rethink the approach. `VERDICT: GO` is the agent's opinion about evidence; it is not the human's decision to publish. Not accepted → re-enter exactly as for a NO-GO above.
- `agenthub stage gate --evidence "GO; human accepted"` (or `NO-GO — <reason>`)
### 8 — Publish: push branch + open/update PR *(GATED — only on GO)*
- `agenthub stage publishing --status running`
+220 -47
View File
@@ -1,6 +1,6 @@
---
name: ship-feature
description: Implement a feature safely end-to-end in this repository. Use when adding or changing functionality across backend, frontend, or MCP with required verification and documentation updates. Drives the Superpowers workflow skills and declares each phase with `agenthub stage` so the dashboard's pipeline map follows along.
description: Implement a feature safely end-to-end in this repository. Use when adding or changing functionality across backend, frontend, or MCP with required verification and documentation updates. Drives the Superpowers workflow skills and declares each phase with `agenthub stage` so the dashboard's pipeline map follows along. Brainstorms WITH the user at intake, stops for their approval of the plan before any code, reviews with Claude Code's own `code-review` plus only the domain reviewers the diff actually needs, and stops again at the gate for their acceptance — a NO-GO loops back to implement or plan rather than shipping.
---
# Ship Feature
@@ -37,17 +37,117 @@ never overrides a declaration — and detection cannot move a lane BACKWARD past
a declared stage, so a rework loop is invisible unless you re-declare the phase
you dropped back to.
## Waiting for an agent or a long command
A phase that dispatches a foreground agent (tests, review, gate) or runs a long
command is **already** wired to wake you: you are re-invoked the moment a
backgrounded Agent or helper finishes. So:
- Do not poll for it, and do not invent a timer. **`ScheduleWakeup` is a
`/loop`-only primitive** — this pipeline is not a `/loop` session, so it
neither paces nor sustains anything here, and in `auto` permission mode it can
simply be refused (the safety classifier is a model call, which fails when
that model is briefly unavailable). Never let a phase's progress depend on it.
- Need to pace a wait anyway (a CI run, an external queue)? Background the wait:
`sleep <secs>` with `run_in_background: true`.
- Never narrate a wait you are not actually running. If you cannot sustain one,
say so plainly and stop.
## The shape of a run
Forward: `intake → plan → implement → tests → review → gate → ship → done`.
Two back-edges, and they are the point of the pipeline, not its failure mode:
- **`tests`/`review` → `implement`** — a failure or a finding sends you to the
code, then forward through `tests` and `review` again.
- **`gate` → `plan`** — a NO-GO about the APPROACH (wrong scope, wrong shape,
the requirement meant something else) replans and re-earns the user's
approval. A NO-GO about a DEFECT goes to `implement` instead.
Going back is a declaration: re-run `agenthub stage <node>` for the node you
dropped to. Detection cannot move a lane backward on its own, so a silent
rework leaves the dashboard showing a stage you already left, and the nodes
ahead keep the previous pass's evidence.
## Right-size the run before you start it
This pipeline is not free: every phase that dispatches an agent costs a model
call and minutes of wall clock, and the fix-loop pays it again on each lap.
Match its weight to the work, and say in one line which weight you picked.
| Work | What runs |
|---|---|
| **Trivial** — typo, rename, one-line fix, a question | Not this skill. Do it, run the touched suite, say what you did. |
| **Small** — one layer, a few files, no new interface | `intake` questions → `implement` → `tests` → `review` with `code-review` alone → `gate` (the user's acceptance IS the gate). Skip the plan-debate agent, the independent-test agent and the gate agent. |
| **Medium / large** — multi-file, new route/schema/interface, cross-layer, or anything you would defend in a review | Every phase below, in full. |
Sizing is a claim you make out loud, not a shortcut you take quietly. When
torn, take the heavier one. A small task that grows re-sizes upward mid-run —
re-declare the phase you re-enter and pick up the skipped agents from there.
## Dispatching a judgement (used by `tests`, `review`, `gate`)
One contract, written once, so the phases below don't each re-argue it:
- **Fresh context, FOREGROUND, never `fork`.** A fork inherits your context —
precisely the bias the dispatch exists to escape. Foreground because the phase
gates on the answer, and the agent's completion is what re-invokes you.
- **Hand only what the agent can verify for itself**: the repo path, the
requirement/plan, the `git diff` range, the commands to run. Never your
conclusions — "I already checked X", "this is safe because…" — those are the
very claims it is there to test.
- **One parseable final line** is the verdict (`UNIT-TESTS:`, `REVIEW:`,
`BACKEND-REVIEW:`, `FRONTEND-REVIEW:`, `GATE:`). Read that line; never infer a
pass from prose around it.
- **Never dispatch what a command answers.** Running a suite, reading a log,
grepping a path — do it yourself. An agent is for judgement, not for typing.
- Agents for the same phase go out in **one message** so they run concurrently.
## Resuming after a compaction
A long run outlives the context window. Re-derive your position, don't guess it:
- `agenthub lanes` — the lane's declared stage, status and notes.
- `git status` + `git diff --stat <base>...` — what is actually built.
- The plan file — what was actually approved.
Resume at the declared stage and re-run it from the top; every `agenthub`
command is idempotent. **A phase whose evidence you cannot find did not
happen** — re-run it rather than assuming a past self did.
## Phases
### intake — understand before touching anything
### intake — brainstorm WITH the user, always
- `agenthub stage intake --status running`
- Restate the requirement and its success criteria.
- Explore the impacted modules: `Explore` agent for breadth, then read the key
files yourself. Identify which layers are hit (server / client / docs /
scripts) — `repo-onboarding` if the area is unfamiliar.
- Requirement fuzzy or open to more than one reading? **superpowers:brainstorming**.
- **superpowers:brainstorming is MANDATORY here, not a fallback for a fuzzy
requirement.** Run it before any plan exists. Skipping it because the ask
"seems clear" is the single most common way this pipeline builds the wrong
thing: a requirement typed in one line always leaves scope, edge cases and
acceptance criteria unstated.
- **Ask only what the repo cannot answer.** Read first, ask second: a question
whose answer is in the code, the tests, `CLAUDE.md` or the existing UI costs
the user's time and earns you a worse answer than reading would. What belongs
to the user is intent — scope, priorities, trade-offs, what "done" means —
never facts you could have looked up.
- **Ask the user, and wait for the answer.** Put the open questions to them with
`AskUserQuestion` — batched in one round (up to 4 at a time), each with real
options, exactly the way plan mode asks. Questions that change what you build:
scope boundaries, which surfaces are in/out, behavior on the edge cases you
found, what "done" means. You may not answer them on the user's behalf, and
you may not move to `plan` until they are answered. Answering yourself and
proceeding IS the bug this phase exists to prevent.
- The one exception: the user explicitly said to skip the questions and decide
yourself ("cứ làm đi", "no questions", "you decide"). Then state the
assumptions you are proceeding under, in writing, and go on.
- **Leave when:** the user's answers are in hand (or their explicit "decide
yourself" is), and you can state the scope, the non-goals and what "done"
means in your own words without a guess in any of them.
### plan — a written plan, challenged before it is code
### plan — a written plan, challenged, then APPROVED by the user
- `agenthub stage plan`
- **superpowers:writing-plans**: approach, files to change, the test strategy
(which behavior each test pins), and how each success criterion is met.
@@ -59,72 +159,143 @@ you dropped back to.
simpler approach, unhandled edge cases. Give it the requirement and the plan,
not your reasons for believing the plan. Apply what survives
**superpowers:receiving-code-review** scrutiny.
- **STOP. The plan is presented to the user and implementation does not start
until they approve it in this turn.** Show the plan (or its path plus a short
summary: what changes, which files, what is deliberately out of scope, how it
will be verified) and ask with `AskUserQuestion` — approve / revise / change
approach. "Revise" means edit the plan and ask again; the loop is the point.
Writing a plan and immediately implementing it is exactly the behavior this
phase forbids, however obvious the plan looks to you.
- Approval covers the plan as presented. Work that turns out to need a
materially different approach comes BACK here (`agenthub stage plan` again)
for a fresh approval, rather than drifting under the old one.
- **Leave when:** a written plan exists, it survived the challenge, and the user
approved THIS version of it in this turn.
### implement — smallest coherent diff
- `agenthub stage implementing`
- Entered only from an **approved** plan (or from a gate NO-GO sending work
back here). No approval in this turn → you are still in `plan`.
- **superpowers:test-driven-development**: failing test → minimal code → green.
- Smallest diff that satisfies the requirement. Every changed line traces to it.
- **file-headers** applies to every source file you create or edit.
- Work the approved plan task by task, in its order. Before leaving this phase,
re-read the plan's acceptance criteria and check each one against what you
actually built — that list, not your memory of it, is what `gate` will ask
about.
- **The plan turning out wrong is a result, not a detour.** If implementing
shows the approach cannot work, or the requirement means something else, stop
and go back to `plan` (re-declare it, present the new plan, get it approved
again). Quietly building a different thing than the one approved is the
failure this phase has to avoid.
- Long implementation? Re-declare `agenthub stage implementing` as you go — it
doubles as the heartbeat, so the dashboard doesn't read a working lane as
stalled.
- **Leave when:** every plan task is done, every acceptance criterion has
something concrete behind it, and the diff contains nothing that traces to
neither.
### tests — this repo's verification, not a claim
- `agenthub stage tests --status running`
- **Dispatch, don't self-grade.** Both halves below run in a **fresh-context
agent** (Agent tool, `subagent_type: general-purpose` — FOREGROUND, and
**never `fork`**: a fork inherits your context, which is the bias you are
trying to escape). Hand it only what it can verify itself — repo path, the
spec/requirement, `git diff` range, the commands — never your own conclusions.
- **Independent unit tests.** It reads the requirement and the diff cold and
writes the tests the implementation is *missing* — happy, negative and
boundary, in the repo's existing style. It may not touch non-test files nor
weaken existing tests. Final line: `UNIT-TESTS: PASS — <n> added` or
`UNIT-TESTS: FAIL — <test> :: <defect>`.
- **Suite run.** Backend changed → `npm run test:server`. Frontend →
`npm run test:client`.
A UI snapshot diff is reported for review, **never blindly regenerated**
(`cd client && npx vitest run -u` is yours to run, only after you read the
diff).
- **Dispatch the judgement, run the commands yourself.** Only work that needs an
unbiased pair of eyes is worth an agent; a deterministic command is not.
- **Independent unit tests → a fresh-context agent** (`general-purpose`; see
*Dispatching a judgement*). It reads the requirement
and the diff cold and writes the tests the implementation is *missing* —
happy, negative and boundary, in the repo's existing style. It may not touch
non-test files nor weaken existing tests. Final line:
`UNIT-TESTS: PASS — <n> added` or `UNIT-TESTS: FAIL — <test> :: <defect>`.
- **Suite run → plain Bash, here.** Run the suite(s) the diff actually needs:
backend changed → `npm run test:server`; frontend changed →
`npm run test:client`; both → both. A docs-only change needs neither, and
running the other layer's suite proves nothing about this diff. Nothing is judged, so nothing is
delegated: an agent for this buys a second model's tokens to read output you
can read for free. A UI snapshot diff is reported for review, **never
blindly regenerated** (`cd client && npx vitest run -u` is yours to run,
only after you read the diff).
- `FAIL` → fix the **source** (root cause), not the agent's test — then re-run
this phase. Rewriting a failing independent test to pass is the one move that
makes this whole phase worthless.
- Record the outcome: `agenthub stage tests --evidence "<what passed>"`, or
`--result fail` with what failed. A step you could not run is reported as not
run, never as passed.
- **Leave when:** the suites the diff needs are green in output you actually
read, and the independent-test agent (when the size called for one) returned
`PASS`.
### review — a real review pass, not a re-read
### review — the native review, plus the domains this diff actually touched
- `agenthub stage review --status running`
- Hand the review to a **fresh-context agent** (`subagent_type: Code Reviewer`,
FOREGROUND, never `fork`) — a reviewer that watched you write the code is not
a reviewer. It runs the **code-review** skill on the working diff
(see also **superpowers:requesting-code-review** for what to hand over).
- **Read the diff first, then decide who reviews it.** `git diff --name-only`
against the base this task started from is what picks the reviewers. Spawning
a fixed set regardless of what changed wastes a pass on files nobody touched,
and worse, reads as coverage that never happened.
- **Scale the pass to the diff.** A handful of lines in one layer gets the
`code-review` pass alone — three agents re-reading a 20-line change costs
three times the tokens for one opinion. Add the domain reviewer(s) once the
change is big enough to have seams: multiple files in a layer, a route or
schema or hook touched, or anything you would want a second reader on.
- **Always: Claude Code's own `code-review` skill** (`/code-review`, on the
working diff — it is also what the `review` node declares). Not optional, and
not replaced by an agent reviewing "in its own words": run it in a
fresh-context `Code Reviewer` agent whose job is to invoke that skill and
report what it found (a reviewer that watched you write the code is not a
reviewer). See **superpowers:requesting-code-review** for what to hand over.
Final line: `REVIEW: CLEAN` or `REVIEW: FINDINGS — <n>`.
- **Domain reviewers, in parallel with the above (same message, separate
Agent calls).** Check `git diff --name-only` (against the base this task
started from) for touched layers and launch every matching one —
FOREGROUND, fresh context, never `fork`, addressed as `agenthub:backend-reviewer`
/ `agenthub:frontend-reviewer`:
- Any path under `server/` → `agenthub:backend-reviewer`.
- Any path under `client/` → `agenthub:frontend-reviewer`.
- Neither touched → skip both; the generic Code Reviewer
above is the only gate.
- **Domain reviewers, conditional on the touched layers**, in the same message
as the above so they run concurrently:
- Any path under `server/` (routes, lib, hooks, db, CLI-facing code) →
`agenthub:backend-reviewer`. No backend path changed → do not launch it.
- Any path under `client/` (components, pages, i18n, styles) →
`agenthub:frontend-reviewer`. No frontend path changed → do not launch it.
- Both touched → both, in the same message so they run concurrently.
- Neither touched (docs-only, scripts-only, config-only) → neither; the
`code-review` pass above is the whole review.
- Hand each ONLY the repo path and the working diff — each scopes itself to
its own domain's files. Each ends with its own parseable line:
`BACKEND-REVIEW:` / `FRONTEND-REVIEW:` — `CLEAN` or
`FINDINGS — <n>`.
- Apply what is worth applying — the generic review plus every domain
reviewer that ran — with **superpowers:receiving-code-review** judgment:
verify each point, neither blind agreement nor blind rejection.
- Changed code in response? Re-run the `tests` phase.
`BACKEND-REVIEW:` / `FRONTEND-REVIEW:` — `CLEAN` or `FINDINGS — <n>`.
- Apply what is worth applying — `code-review` plus every domain reviewer that
ran — with **superpowers:receiving-code-review** judgment: verify each point,
neither blind agreement nor blind rejection. A point you reject is answered
with a reason, not silence.
- **Changed code in response? Re-run `tests`, then re-run THIS phase.** Review
is a loop, not a checkpoint: it ends when a pass over the current diff comes
back clean (or with only findings you have explicitly and defensibly
rejected), not when one pass has been performed.
- Record it: `agenthub stage review --evidence "<n> findings across <who ran>,
resolved"`.
- **Leave when:** the latest pass over the CURRENT diff is clean or
argued-through — not when a pass has merely happened.
### gate — evidence before the completion claim
### gate — evidence, then the user's acceptance
- **superpowers:verification-before-completion**. Commands actually run, output
actually read. This is the phase that stops "should work" from shipping.
- For a change of any real size, the gate is a **fresh-context agent** too
(`general-purpose`, FOREGROUND, never `fork`): hand it the requirement, the
diff range and the verification commands — not your evidence — and let it
re-run and re-check them. Final line: `GATE: GO` or `GATE: NO-GO — <what is
missing>`. A one-line fix does not need it; anything you would defend in a
review does.
- `agenthub stage gate --evidence "<what was verified>"`
- At medium/large size the gate is a **fresh-context agent** too
(`general-purpose`): hand it the requirement, the diff range and the
verification commands — not your evidence — and let it re-run and re-check
them. Final line: `GATE: GO` or `GATE: NO-GO — <what is missing>`. At small
size skip it; the user's acceptance below is the gate.
- **The gate is not yours to wave through. Ask the user.** Present, in a few
lines: what was built, what was verified and how (the commands and their real
outcome), what review found and what you did about it, and anything you could
not run. Then ask with `AskUserQuestion`: accept / send back for fixes /
rethink the approach. Silence is not acceptance, and neither is `GATE: GO` —
the agent gate checks the evidence, the user decides whether this is what they
wanted.
- **NO-GO, or the user does not accept → go BACK, do not proceed.** Pick the
node by the size of what is wrong, and re-declare it so the dashboard follows
the rework (a declaration on an earlier node clears the nodes ahead of it —
that is what makes a loop visible instead of leaving stale `done` evidence):
- A defect, a missed case, a wrong detail → `agenthub stage implementing`, fix
at the root cause, then forward through `tests` and `review` again.
- Wrong approach, wrong scope, a requirement that turns out to mean something
else → `agenthub stage plan`, replan, and get the plan approved again (the
`plan` phase's approval step applies in full, every time through).
- Either way the phases after it run again. Never carry a NO-GO forward into
`ship` with a note about it.
- Only once the user has accepted: `agenthub stage gate --evidence "<what was
verified, and that the user accepted it>"`
- **Leave when:** the verification really ran, its output was really read, and
the user said yes. Two of three is a NO-GO.
### ship — docs, then the user's call
- `agenthub stage ship`
@@ -133,6 +304,8 @@ you dropped back to.
deferred, not "if asked".
- Commit / push / PR **only when the user asks in this turn**. Finishing an
implementation is not authorization to commit.
- **Leave when:** the docs that the change made wrong are right again, and you
have reported plainly what shipped, what was verified, and what you skipped.
- `agenthub stage done --status passed --evidence "<what shipped>"`
## This repository's own rules
+9 -9
View File
@@ -30,11 +30,11 @@ Update docs **in the same change-set (PR/commit) as the code**, before claiming
| Change type | Docs to update |
|---|---|
| **Env var** | `README.md` (env table), `ARCHITECTURE.md` (inline), `server/README.md`, `.env.example` |
| **Event type** | `README.md`+VN+CN (hook-event table), `ARCHITECTURE.md` (Event types line), `docs/PLUGINS.md`, + i18n, `docs/DATABASE.md` (if it enumerates types) |
| **Hook behavior / state transition** | `docs/HOOKS.md`, state-machine **mermaid** diagrams in `README.md`+VN+CN + `server/README.md` + `docs/DATABASE.md` + , `ARCHITECTURE.md` (hooks.js row) |
| **API route / response** | `docs/API.md`, `server/README.md` (routes), `ARCHITECTURE.md` (routes row), `server/openapi*.js` (code) |
| **Event type** | `README.md` (hook-event table), `ARCHITECTURE.md` (Event types line), `docs/PLUGINS.md`, `docs/DATABASE.md` (if it enumerates types), plus the `en`/`vi` strings under `client/src/i18n/locales/` if the type is rendered |
| **Hook behavior / state transition** | `docs/HOOKS.md`, the state-machine **mermaid** diagrams in `README.md`, `server/README.md` and `docs/DATABASE.md`, `ARCHITECTURE.md` (hooks.js row) |
| **API route / response** | `docs/API.md`, `server/README.md` (routes), `ARCHITECTURE.md` (routes row), `server/openapi.js` + `server/openapi-extra/*.js` (code), then regenerate `openapi.yaml` with `npm run openapi:yaml` |
| **DB schema** | `docs/DATABASE.md`, `ARCHITECTURE.md` (ERD/schema) |
| **WebSocket message** | `client/README.md` (Event Types), `server/README.md`, |
| **WebSocket message** | `client/README.md` (Event Types), `server/README.md` |
| **Feature / page / background service** | `README.md` (feature table + data-flow list), `ARCHITECTURE.md` (module table), `server/README.md` or `client/README.md` |
| **CLI command / script** | `README.md` commands, `CLAUDE.md` / `AGENTS.md`, `INSTALL.md` / `SETUP.md` |
| **New language** | `docs/I18N.md`, `client/src/i18n/locales/<xx>/*`, `client/src/i18n/index.ts` (add to `supportedLngs` AND the `resources` map) |
@@ -42,13 +42,13 @@ Update docs **in the same change-set (PR/commit) as the code**, before claiming
## Procedure
1. **Classify** the change against the table above. A change can hit multiple rows (a new feature with a new env var hits both).
2. **Write the canonical English version first** — usually `README.md` and/or `ARCHITECTURE.md`. Get the wording right there; it anchors everything else.
6. **Area READMEs / docs/**: update `server/README.md`, `client/README.md`, and the relevant `docs/*.md` per the mapping.
7. **Diagrams**: when a state transition changes, edit every mermaid `stateDiagram-v2` block that models it (they are duplicated across README, server/README and docs/DATABASE). Keep transition labels consistent.
2. **Write the canonical version first** — usually `README.md` and/or `ARCHITECTURE.md`. Get the wording right there; it anchors everything else.
3. **Area READMEs / docs/**: update `server/README.md`, `client/README.md`, and the relevant `docs/*.md` per the mapping.
4. **Diagrams**: when a state transition changes, edit every mermaid `stateDiagram-v2` block that models it (they are duplicated across README, server/README and docs/DATABASE). Keep transition labels consistent.
## Verify (do not skip)
- **Coverage**: run `scripts/doc-coverage.sh <new-term> [...]` (e.g. the new env var / event type / identifier) and confirm every doc the mapping flags shows a HIT. The matrix is advisory — not every term belongs in every file — but a flagged doc reading `0` is a miss to fix.
- **Coverage**: grep the new term (env var / event type / identifier) across the docs the mapping flags — `grep -rn "<new-term>" README.md ARCHITECTURE.md server/README.md client/README.md docs/` — and confirm each flagged doc actually shows a hit. Advisory, not mechanical: not every term belongs in every file, but a flagged doc with zero hits is a miss to fix.
- **Tables**: markdown tables stay pipe-balanced (header column count == every row).
- **Mermaid**: each edited block still parses (valid `source --> target: label`).
- **i18n**: every new English string has a `vi` entry in `client/src/i18n/locales/vi/`.
@@ -58,5 +58,5 @@ Update docs **in the same change-set (PR/commit) as the code**, before claiming
## Tips
- The fastest way to find where something already lives: `grep -n "<existing-neighbor-term>" <doc>` (e.g. grep an adjacent env var to find the env table). `references/doc-map.md` lists the stable anchors per file.
- Parallelize translations + HTML across subagents when the change is large, but write the canonical English edit yourself first so the translations have a faithful source.
- Parallelize independent doc edits across subagents when the change is large, but write the canonical `README.md`/`ARCHITECTURE.md` edit yourself first so every other file has a faithful source.
- One language/area per subagent keeps edits reviewable and tables un-corrupted.
@@ -14,10 +14,7 @@ The source of truth most other docs mirror. Key sections:
### Translations
This build ships English only. `README-VN.md`, `README-CN.md` and `README-KO.md` were removed, as were the `zh` and `ko` UI locales — do not recreate them.
Standalone full translations of `README.md`. **Every** README change must be mirrored here at the corresponding section. Conventions:
- Keep in English/code: identifiers, env-var names, event-type names, `awaiting_input_since`, `pendingInterrupt`, "watchdog", `fs.watch`, model IDs, mermaid transition labels.
- Translate prose. "Waiting" → **Đang chờ** (vi) / **等待中** (zh) / **대기 중** (ko). "watchdog" often kept; in zh sometimes 看门狗.
This build ships English only. `README-VN.md`, `README-CN.md` and `README-KO.md` were removed, as were the `zh` and `ko` UI locales — do not recreate them, and do not mirror a README change into a translation file. (The UI itself still ships `en` and `vi` locales under `client/src/i18n/locales/` — those are strings, not docs, and are covered by the frontend section below.)
### `ARCHITECTURE.md`
- **Module responsibility table** — one row per source file (`scripts/import-history.js`, `lib/transcript-cache.js`, `routes/hooks.js`, `server/index.js`, …). Update the row whose file you changed. Grep the file path.