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
+15
View File
@@ -713,6 +713,21 @@ calculations match real-time captured sessions exactly. Re-imports are
idempotent (dedupe by session ID; compaction `baseline_*` columns
prevent token double-counting).
Both parsers count each assistant message's `usage` **once**: Claude Code
writes one JSONL line per content block (thinking / text / each `tool_use`)
and every one repeats the same message-level totals, so summing lines
inflated a tool-heavy session's tokens — and its cost — by ~1.5–2×. The
guard is the previous message id (`lastUsageMsgId`), carried across
incremental reads so a message split over a chunk boundary still counts once.
`reconcileTokens` (`node scripts/import-history.js --reconcile-tokens`)
re-parses every session that still has a transcript on disk and writes the
result with `stmts.setTokenUsage`, which overwrites the row and clears the
baselines. That is deliberate: the hook writer's `replaceTokenUsage` keeps a
high-water mark, so without an exact write a previously over-counted total
could never be corrected downwards. The server runs this reconcile once, in
the background, at the first start after upgrading (`user_version` 2).
Imported and live-scanned subagents also get their **nested hierarchy**
rebuilt: rows are inserted flat under the main agent, then
`reconcileSubagentParents` recovers each spawner from the subagent
+39
View File
@@ -268,6 +268,45 @@ describe("legacy hook cleanup", () => {
fs.rmSync(`${settings}.agenthub-bak`, { force: true });
});
it("reports a settings file that still carries checkout hooks", () => {
// What fastPathOk consults on every SessionStart. The strip itself only
// runs in the worker, so without this the one defect that DOUBLES every
// event survives on an otherwise-healthy install forever.
fs.writeFileSync(settings, JSON.stringify({ hooks: {} }));
assert.equal(boot.hasLegacyHooks(settings), false);
fs.writeFileSync(
settings,
JSON.stringify({
hooks: {
PreToolUse: [
{ matcher: "*", hooks: [{ type: "command", command: "node /other/tool.js" }] },
],
},
})
);
assert.equal(boot.hasLegacyHooks(settings), false, "an unrelated hook is not ours");
fs.writeFileSync(
settings,
JSON.stringify({
hooks: {
PreToolUse: [
{
matcher: "*",
hooks: [
{ type: "command", command: "node /repo/scripts/hook-handler.js PreToolUse" },
],
},
],
},
})
);
assert.equal(boot.hasLegacyHooks(settings), true);
// Pure read: the caller is a hot path, and the file is the user's.
assert.equal(fs.existsSync(`${settings}.agenthub-bak`), false);
});
it("removes checkout-installed hook entries and backs the file up first", () => {
fs.writeFileSync(
settings,
@@ -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");
});
});
+36
View File
@@ -1479,6 +1479,15 @@ const stmts = {
insertEvent: db.prepare(
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))"
),
// Same insert with an EXPLICIT created_at, for events recovered from a
// transcript: their real time is in the file, not the moment we happened to
// re-read it. `TurnDuration` needs it twice over — its own dedup guard
// matches on `created_at`, so stamping NOW made every guard miss and every
// re-parse re-insert the whole turn history (587k rows across this DB, 75%
// of the events table, one session holding 68,747 copies of the same turns).
insertEventAt: db.prepare(
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
),
listEvents: db.prepare("SELECT * FROM events ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?"),
listEventsBySession: db.prepare(
"SELECT * FROM events WHERE session_id = ? ORDER BY created_at DESC, id DESC"
@@ -1562,6 +1571,33 @@ const stmts = {
web_fetch_requests = excluded.web_fetch_requests,
code_execution_requests = excluded.code_execution_requests
`),
// EXACT (non-monotonic) write, used only by the deliberate re-parse/repair
// path (`reconcileTokens`). Same args as replaceTokenUsage. The high-water
// mark above is right for the incremental hook writer, but it makes a
// CORRECTION impossible: when a parser bug inflated the stored total, every
// re-parse of the (lower) truth just rolls the inflation into baseline_*.
// This statement overwrites the row and clears the baselines, so a full
// re-parse of an append-only transcript can restore the real numbers.
setTokenUsage: db.prepare(`
INSERT INTO token_usage (session_id, model, speed, inference_geo, service_tier,
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, cache_write_1h_tokens,
web_search_requests, web_fetch_requests, code_execution_requests,
baseline_input, baseline_output, baseline_cache_read, baseline_cache_write, baseline_cache_write_1h,
baseline_web_search, baseline_web_fetch, baseline_code_execution)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, 0, 0, 0, 0, 0)
ON CONFLICT(session_id, model, speed, inference_geo, service_tier) DO UPDATE SET
input_tokens = excluded.input_tokens,
output_tokens = excluded.output_tokens,
cache_read_tokens = excluded.cache_read_tokens,
cache_write_tokens = excluded.cache_write_tokens,
cache_write_1h_tokens = excluded.cache_write_1h_tokens,
web_search_requests = excluded.web_search_requests,
web_fetch_requests = excluded.web_fetch_requests,
code_execution_requests = excluded.code_execution_requests,
baseline_input = 0, baseline_output = 0, baseline_cache_read = 0,
baseline_cache_write = 0, baseline_cache_write_1h = 0,
baseline_web_search = 0, baseline_web_fetch = 0, baseline_code_execution = 0
`),
getTokenTotals: db.prepare(`
SELECT
COALESCE(SUM(input_tokens + baseline_input), 0) as total_input,
+51
View File
@@ -391,6 +391,57 @@ function startBackgroundServices() {
if (t.unref) t.unref();
}
// One-time repair (user_version 3) of two parser bugs fixed in v1.6.16:
//
// 1. The transcript parser summed the `usage` of EVERY JSONL line of an
// assistant message, and Claude Code writes one line per content block —
// each repeating the same message-level totals. Stored tokens (and cost)
// ran ~1.5-2x high, and `replaceTokenUsage`'s high-water baselines mean
// the fixed parser alone can never bring them down: only the exact-write
// reconcile can.
// 2. Every `TurnDuration` row was stamped NOW while its dedup guard matched
// on the transcript timestamp, so each hook re-inserted the session's
// whole turn history — 75% of a typical events table.
//
// Marker 3 and not 2: an install that ran the v1.6.15 pass had its corrected
// rows immediately re-inflated by whatever old build was still ingesting
// hooks, so that marker cannot be trusted to mean "already correct".
// Deferred and non-blocking; the marker is written only on success, so a
// crash mid-run just retries on the next start.
{
const dbModule = require("./db");
if (dbModule.db.pragma("user_version", { simple: true }) < 3) {
const { reconcileTokens } = require("../scripts/import-history");
const t = setTimeout(() => {
Promise.resolve()
.then(() => {
// Duplicates differ only by their insertion time, so the earliest
// row of each (session, payload) group is the one real turn.
const del = dbModule.db
.prepare(
`DELETE FROM events WHERE event_type = 'TurnDuration' AND id NOT IN (
SELECT MIN(id) FROM events WHERE event_type = 'TurnDuration'
GROUP BY session_id, data
)`
)
.run();
if (del.changes > 0) {
console.log(`Removed ${del.changes} duplicated TurnDuration event(s)`);
}
})
.then(() => reconcileTokens(dbModule))
.then((c) => {
console.log(
`Token re-parse: corrected ${c.sessionsTouched} session(s), ${c.modelsWritten} model row(s)`
);
dbModule.db.pragma("user_version = 3");
})
.catch((err) => console.warn("token re-parse failed:", err?.message || err));
}, 1_000);
if (t.unref) t.unref();
}
}
const { startUpdateScheduler } = require("./update-scheduler");
const { broadcast } = require("./websocket");
startUpdateScheduler({ broadcast });
@@ -666,4 +666,58 @@ describe("TranscriptCache", () => {
assert.strictEqual(result.lastInterruptTs, null);
assert.strictEqual(result.pendingInterrupt, false);
});
// Claude Code writes ONE line per content block of an assistant message, each
// repeating the whole message's usage. Counting them all inflated cost.
const M1 = ["m1", "standard", "global", "standard"].join(String.fromCharCode(1));
const blockLine = (id, block) => ({
type: "assistant",
message: {
id,
model: "m1",
content: [block],
usage: { input_tokens: 10, output_tokens: 40, cache_read_input_tokens: 1000 },
},
});
it("counts one assistant message once, however many block lines repeat its usage", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [
blockLine("msg_a", { type: "thinking" }),
blockLine("msg_a", { type: "text", text: "hi" }),
blockLine("msg_a", { type: "tool_use", name: "Bash" }),
blockLine("msg_b", { type: "text", text: "next turn" }),
]);
const result = new TranscriptCache().extract(file);
assert.deepStrictEqual(result.tokensByModel[M1], {
model: "m1",
speed: "standard",
geo: "global",
tier: "standard",
input: 20,
output: 80,
cacheRead: 2000,
cacheWrite: 0,
cacheWrite1h: 0,
webSearch: 0,
webFetch: 0,
codeExec: 0,
});
assert.strictEqual(result.thinkingBlockCount, 1, "thinking blocks still counted per line");
});
it("does not re-count a message whose block lines straddle an incremental read", () => {
const file = path.join(tmpDir, "session.jsonl");
writeJsonl(file, [blockLine("msg_a", { type: "thinking" })]);
const cache = new TranscriptCache();
assert.strictEqual(cache.extract(file).tokensByModel[M1].output, 40);
fs.appendFileSync(file, JSON.stringify(blockLine("msg_a", { type: "text", text: "x" })) + "\n");
assert.strictEqual(cache.extract(file).tokensByModel[M1].output, 40, "same message, one count");
fs.appendFileSync(file, JSON.stringify(blockLine("msg_b", { type: "text", text: "y" })) + "\n");
assert.strictEqual(cache.extract(file).tokensByModel[M1].output, 80, "new message counts");
});
});
+43 -12
View File
@@ -151,7 +151,12 @@ class TranscriptCache {
// File grew → incremental read from last position
if (stat.size > cached.bytesRead) {
const incremental = this._streamRange(transcriptPath, cached.bytesRead, stat.size);
const incremental = this._streamRange(
transcriptPath,
cached.bytesRead,
stat.size,
cached.result?.lastUsageMsgId || null
);
if (incremental) {
const merged = this._merge(cached, incremental);
const hasTokens = Object.keys(merged.tokensByModel).length > 0;
@@ -174,6 +179,7 @@ class TranscriptCache {
firstUserMessage: merged.firstUserMessage || null,
lastInterruptTs: merged.lastInterruptTs || null,
lastTurnTs: merged.lastTurnTs || null,
lastUsageMsgId: merged.lastUsageMsgId || null,
pendingInterrupt: computePendingInterrupt(merged.lastInterruptTs, merged.lastTurnTs),
};
if (
@@ -256,8 +262,11 @@ class TranscriptCache {
* straddling a chunk boundary are not corrupted. Never builds a string
* larger than a single line, so V8 string-length limits cannot be hit.
*/
_streamRange(filePath, startOffset, endOffset) {
_streamRange(filePath, startOffset, endOffset, seedUsageMsgId = null) {
const state = this._initParseState();
// Carry the last counted message id across the chunk boundary: an
// incremental read can start in the middle of one message's block lines.
state.lastUsageMsgId = seedUsageMsgId;
if (endOffset <= startOffset) return this._finalizeState(state);
const CHUNK = 4 * 1024 * 1024; // 4 MiB
@@ -391,6 +400,15 @@ class TranscriptCache {
// comparison cannot, since the UserPromptSubmit event is stamped later.
lastInterruptTs: null,
lastTurnTs: null,
// Id of the assistant message whose usage was last counted. Claude Code
// writes ONE JSONL line PER CONTENT BLOCK of a single assistant message
// (thinking, text, each tool_use), and every one of those lines repeats
// the SAME `message.usage` totals for the whole message. Summing them
// inflates a session's tokens (and therefore its cost) by the average
// number of blocks per message — ~1.6x on a tool-heavy session. The
// repeated lines are always consecutive among usage-bearing lines, so
// remembering just the previous id is enough to count each message once.
lastUsageMsgId: null,
};
}
@@ -500,17 +518,23 @@ class TranscriptCache {
const model = msg.model;
if (!model || model === "<synthetic>" || !msg.usage) return;
state.latestModel = model;
// Bucket tokens by the pricing dimensions (speed / geo / tier) so cost can
// apply fast-mode, data-residency, and Batch modifiers per bucket. The value
// carries those dimensions so the DB writer can key the row correctly.
const speed = normalizeSpeed(msg.usage);
const geo = normalizeGeo(msg.usage);
const tier = normalizeTier(msg.usage);
const key = bucketKey(model, speed, geo, tier);
if (!state.tokensByModel[key]) {
state.tokensByModel[key] = emptyBucket(model, speed, geo, tier);
// One message, many lines (see lastUsageMsgId): count its usage once.
const usageMsgId = typeof msg.id === "string" ? msg.id : null;
const repeatedLine = usageMsgId !== null && usageMsgId === state.lastUsageMsgId;
if (usageMsgId !== null) state.lastUsageMsgId = usageMsgId;
if (!repeatedLine) {
// Bucket tokens by the pricing dimensions (speed / geo / tier) so cost can
// apply fast-mode, data-residency, and Batch modifiers per bucket. The value
// carries those dimensions so the DB writer can key the row correctly.
const speed = normalizeSpeed(msg.usage);
const geo = normalizeGeo(msg.usage);
const tier = normalizeTier(msg.usage);
const key = bucketKey(model, speed, geo, tier);
if (!state.tokensByModel[key]) {
state.tokensByModel[key] = emptyBucket(model, speed, geo, tier);
}
accumulateBucket(state.tokensByModel[key], extractUsageFields(msg.usage));
}
accumulateBucket(state.tokensByModel[key], extractUsageFields(msg.usage));
if (msg.usage.service_tier) state.usageExtras.service_tiers.add(msg.usage.service_tier);
if (msg.usage.speed) state.usageExtras.speeds.add(msg.usage.speed);
@@ -578,6 +602,7 @@ class TranscriptCache {
firstUserMessage: state.firstUserMessage,
lastInterruptTs: state.lastInterruptTs,
lastTurnTs: state.lastTurnTs,
lastUsageMsgId: state.lastUsageMsgId,
pendingInterrupt: computePendingInterrupt(state.lastInterruptTs, state.lastTurnTs),
};
}
@@ -696,6 +721,11 @@ class TranscriptCache {
(incremental && incremental.lastInterruptTs) || cached.result?.lastInterruptTs || null;
const lastTurnTs = (incremental && incremental.lastTurnTs) || cached.result?.lastTurnTs || null;
// Newest chunk wins when it counted any usage; else keep the cached id so
// the next chunk still recognizes a message split across the boundary.
const lastUsageMsgId =
(incremental && incremental.lastUsageMsgId) || cached.result?.lastUsageMsgId || null;
return {
tokensByModel,
compaction,
@@ -709,6 +739,7 @@ class TranscriptCache {
firstUserMessage,
lastInterruptTs,
lastTurnTs,
lastUsageMsgId,
};
}
+17 -6
View File
@@ -1053,13 +1053,17 @@ const processEvent = db.transaction((hookType, data) => {
if (existing) continue;
const tdSummary = `Turn completed in ${(td.durationMs / 1000).toFixed(1)}s`;
stmts.insertEvent.run(
// Stamped with the TRANSCRIPT's timestamp, not NOW — that is what the
// dedup query above matches on, and a turn that happened at 10:00
// belongs at 10:00 in the feed regardless of when we re-read the file.
stmts.insertEventAt.run(
sessionId,
mainAgentId,
"TurnDuration",
null,
tdSummary,
JSON.stringify({ durationMs: td.durationMs })
JSON.stringify({ durationMs: td.durationMs }),
tdTs
);
broadcast("new_event", {
session_id: sessionId,
@@ -1080,13 +1084,20 @@ const processEvent = db.transaction((hookType, data) => {
if (result.usageExtras) {
meta.usage_extras = result.usageExtras;
}
// ASSIGNED, never accumulated. `extract()` returns the totals for the
// WHOLE transcript every time (the cache merges each incremental
// chunk into the cached total), so `+=` re-added the entire history
// on every hook — a 4-hour session reported 24,746 thinking blocks
// and a turn count in the thousands.
if (result.thinkingBlockCount > 0) {
meta.thinking_blocks = (meta.thinking_blocks || 0) + result.thinkingBlockCount;
meta.thinking_blocks = result.thinkingBlockCount;
}
if (result.turnDurations) {
meta.turn_count = (meta.turn_count || 0) + result.turnDurations.length;
const totalMs = result.turnDurations.reduce((s, t) => s + t.durationMs, 0);
meta.total_turn_duration_ms = (meta.total_turn_duration_ms || 0) + totalMs;
meta.turn_count = result.turnDurations.length;
meta.total_turn_duration_ms = result.turnDurations.reduce(
(s, t) => s + t.durationMs,
0
);
}
stmts.updateSession.run(null, null, null, JSON.stringify(meta), sessionId);
}
+11 -1
View File
@@ -647,11 +647,21 @@ router.post("/:id/router/apply", sameOriginGuard, (req, res) => {
try {
const result = applyRouterToLane(lane, profile);
lanesLib.setRouterProfile(lane.id, profile);
// A pane that is already running does NOT fully adopt the switch. Claude
// Code re-applies an `env` block to a live session when the settings file
// changes, but the session's model was resolved at startup — so the OLD
// router's model name keeps being sent to the NEW endpoint, which answers
// "model not found". (Switching to `native` is the same story: the router's
// model name reaches api.anthropic.com.) Nothing here can fix that from the
// outside; the pane has to be restarted. Report it instead of letting the
// switch look clean and then fail on the user's next prompt.
const runId = runs.runIdForLane(lane.id);
const live = !!runs.getRun(runId);
// Every other mutating lane route broadcasts; without it the client's
// cached row keeps the OLD router_profile, and the next lane switch
// re-reads that stale row and flips the picker back.
broadcastLane(lane.id);
res.json(result);
res.json({ ...result, restart_required: live, run_id: live ? runId : null });
} catch (err) {
const status = { ENOTFOUND: 404, EBADJSON: 409, EBADNAME: 400 }[err.code] || 500;
res.status(status).json({ error: { code: err.code || "ERUNTIME", message: err.message } });