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:
+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,
|
||||
|
||||
Reference in New Issue
Block a user