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.
This commit is contained in:
+9
-9
@@ -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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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 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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ử",
|
||||
|
||||
@@ -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 }),
|
||||
}),
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
+28
-11
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
@@ -875,6 +902,7 @@ module.exports = {
|
||||
releaseLock,
|
||||
isCheckoutHookEntry,
|
||||
stripLegacyHooks,
|
||||
hasLegacyHooks,
|
||||
linkCli,
|
||||
installDeps,
|
||||
serverIsLive,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,6 +518,11 @@ class TranscriptCache {
|
||||
const model = msg.model;
|
||||
if (!model || model === "<synthetic>" || !msg.usage) return;
|
||||
state.latestModel = model;
|
||||
// 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.
|
||||
@@ -511,6 +534,7 @@ class TranscriptCache {
|
||||
state.tokensByModel[key] = emptyBucket(model, speed, geo, tier);
|
||||
}
|
||||
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
@@ -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
@@ -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 } });
|
||||
|
||||
Reference in New Issue
Block a user