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:
2026-09-18 16:52:05 +07:00
parent 59b3ba9e39
commit 8d48a038c7
36 changed files with 901 additions and 251 deletions
+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;
}