perf: rest animations, cheaper chat typing and server polls, async per-tool hooks
UI
- No animation runs forever: Sagi moods, the custom pet sprite, status dots
(pulse-dot, pulse-slow, ping) and the chat shimmer play a few cycles and
rest. An infinite animation kept the browser drawing 60 frames a second.
- useSagiBrain: frames that only move lastActivityAt by <1 s keep the old
state; the 1 s tick re-renders only when the mood changes.
- ConversationView and MessageList are memoized and PaneChat passes stable
callbacks: typing in the chat box no longer re-renders the conversation.
- The chat's 700 ms menu poll reads plain text first and scans the terminal
cell by cell (reusing one cell) only when a row could be a menu cursor row.
Server
- GET /sessions/:id/transcript?after=N skips lines <= N before JSON.parse.
- GET /sessions/:id/outline is cached until the session's events change.
- broadcastLane uses the cached tmux.liveSessions(); tmux.hasSession answers
"yes" from the fresh shared list instead of a synchronous spawn (50-150 ms
per psmux call on Windows, blocking terminal echo).
- pane-mode polls every 150 ms; writeHourly writes only changed hour buckets.
- /api/metrics: agenthub_event_loop_delay_seconds{quantile}.
Hooks
- PreToolUse/PostToolUse run with "async": true (settings installer,
plugin.json, S3 managed settings): Claude Code no longer waits a Node start
before and after every tool call.
Committed with --no-verify at the owner's request: the pre-commit full server
suite fails on 9 tests that fail identically on 0c2f498 (lane-sync merge: no
git identity on this host; read-pool; resolveHost; codex-usage 1 ms flake).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -234,6 +234,46 @@ describe("TranscriptCache tokensByHour", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeHourly skips unchanged hours", () => {
|
||||
const { writeHourly, forgetHourly } = require("../lib/hourly-usage");
|
||||
const bucket = (input) => ({
|
||||
hour: "2026-10-05T08",
|
||||
model: "claude-opus-4-1",
|
||||
speed: "standard",
|
||||
geo: "",
|
||||
tier: "standard",
|
||||
input,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cacheWrite1h: 0,
|
||||
webSearch: 0,
|
||||
webFetch: 0,
|
||||
codeExec: 0,
|
||||
});
|
||||
const rows = () =>
|
||||
db
|
||||
.prepare("SELECT input_tokens FROM token_usage_hourly WHERE session_id = 'skip-hourly'")
|
||||
.all()
|
||||
.map((r) => r.input_tokens);
|
||||
|
||||
it("writes a changed hour, skips an unchanged one, and writes again once forgotten", () => {
|
||||
session("skip-hourly", "2026-10-05T08:00:00.000Z");
|
||||
writeHourly("skip-hourly", { a: bucket(5) });
|
||||
assert.deepEqual(rows(), [5]);
|
||||
writeHourly("skip-hourly", { a: bucket(9) });
|
||||
assert.deepEqual(rows(), [9]);
|
||||
// Deleted behind the writer's back: an unchanged bucket is not re-sent…
|
||||
db.prepare("DELETE FROM token_usage_hourly WHERE session_id = 'skip-hourly'").run();
|
||||
writeHourly("skip-hourly", { a: bucket(9) });
|
||||
assert.deepEqual(rows(), []);
|
||||
// …which is why every path that deletes hourly rows forgets them.
|
||||
forgetHourly(["skip-hourly"]);
|
||||
writeHourly("skip-hourly", { a: bucket(9) });
|
||||
assert.deepEqual(rows(), [9]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session list cost", () => {
|
||||
it("equals the session's own cost, speed and server-tool fees included", async () => {
|
||||
session("list-fast", "2026-10-01T00:00:00.000Z");
|
||||
|
||||
@@ -142,6 +142,19 @@ describe("install-hooks host-only guard (#193)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("async hooks", () => {
|
||||
it("runs only the per-tool-call hooks in the background, in settings and the plugin alike", () => {
|
||||
const { makeHookEntry, ASYNC_HOOKS } = require("../../scripts/install-hooks");
|
||||
const plugin = require("../../.claude-plugin/plugin.json");
|
||||
assert.deepEqual([...ASYNC_HOOKS].sort(), ["PostToolUse", "PreToolUse"]);
|
||||
for (const type of HOOK_TYPES) {
|
||||
const want = ASYNC_HOOKS.has(type) ? true : undefined;
|
||||
assert.equal(makeHookEntry(type).hooks[0].async, want, `settings ${type}`);
|
||||
assert.equal(plugin.hooks[type][0].hooks[0].async, want, `plugin ${type}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("HOOK_TYPES", () => {
|
||||
it("is the event list the plugin ships, so the hook status checks every one", () => {
|
||||
const exported = require("../../scripts/install-hooks").HOOK_TYPES;
|
||||
|
||||
@@ -93,6 +93,7 @@ describe("GET /api/metrics", () => {
|
||||
"agenthub_build_info",
|
||||
"agenthub_process_uptime_seconds",
|
||||
"agenthub_process_resident_memory_bytes",
|
||||
"agenthub_event_loop_delay_seconds",
|
||||
"agenthub_sessions",
|
||||
"agenthub_agents",
|
||||
"agenthub_events_total",
|
||||
|
||||
@@ -19,6 +19,21 @@ describe("tmux wrapper", () => {
|
||||
assert.equal(tmux.hasSession("agenthub-lane-1"), true);
|
||||
});
|
||||
|
||||
it("hasSession answers yes from a fresh list, and asks tmux for anything else", () => {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl((args) => {
|
||||
calls.push(args[0]);
|
||||
if (args[0] === "list-sessions") return "agenthub-lane-1\nagenthub-lane-1-2\n";
|
||||
throw Object.assign(new Error("no session"), { status: 1 });
|
||||
});
|
||||
tmux.listSessions("agenthub-lane-");
|
||||
assert.equal(tmux.hasSession("agenthub-lane-1-2"), true);
|
||||
assert.deepEqual(calls, ["list-sessions"]); // no has-session spawn
|
||||
// Not listed: asked for real (a pane that just died is never trusted).
|
||||
assert.equal(tmux.hasSession("agenthub-lane-1-3"), false);
|
||||
assert.deepEqual(calls, ["list-sessions", "has-session"]);
|
||||
});
|
||||
|
||||
it("hasSession returns false when execFileSync throws", () => {
|
||||
tmux.__setExecImpl(() => {
|
||||
const e = new Error("no such session");
|
||||
|
||||
@@ -11,11 +11,25 @@ const { db, stmts } = require("../db");
|
||||
const TranscriptCache = require("./transcript-cache");
|
||||
const { longArgs } = require("./token-usage");
|
||||
|
||||
/** Write every hour of one transcript parse (MAX per hour, see the statement). */
|
||||
// What this process last wrote per session and hour bucket: a long session has
|
||||
// one bucket per active hour per model, and every hook re-sent all of them.
|
||||
// The upsert keeps the MAX, so skipping a bucket whose numbers did not move
|
||||
// writes exactly the same rows. Bounded (oldest session dropped first); a miss
|
||||
// only means one full write, never a lost one.
|
||||
const WRITTEN_MAX = 500;
|
||||
const written = new Map();
|
||||
|
||||
/** Write the hours of one transcript parse that changed (MAX per hour, see the statement). */
|
||||
function writeHourly(sessionId, tokensByHour) {
|
||||
// Runs every hour of the session on each hook — ~1 row per active hour per
|
||||
// model; the statement leaves a row that did not grow untouched (no write).
|
||||
for (const t of Object.values(tokensByHour || {})) {
|
||||
let seen = written.get(sessionId);
|
||||
if (seen) written.delete(sessionId);
|
||||
else seen = new Map();
|
||||
written.set(sessionId, seen);
|
||||
if (written.size > WRITTEN_MAX) written.delete(written.keys().next().value);
|
||||
for (const [key, t] of Object.entries(tokensByHour || {})) {
|
||||
const sig = JSON.stringify(t);
|
||||
if (seen.get(key) === sig) continue;
|
||||
seen.set(key, sig);
|
||||
stmts.upsertHourlyUsage.run(
|
||||
sessionId,
|
||||
t.hour,
|
||||
@@ -73,4 +87,11 @@ function backfillHourly() {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { writeHourly, backfillHourly };
|
||||
/** Forget what was written — for `ids`, or every session. Whatever deletes
|
||||
* hourly rows calls it, or the next hooks would skip re-writing them. */
|
||||
function forgetHourly(ids = null) {
|
||||
if (!ids) return written.clear();
|
||||
for (const id of ids) written.delete(id);
|
||||
}
|
||||
|
||||
module.exports = { writeHourly, backfillHourly, forgetHourly };
|
||||
|
||||
@@ -57,13 +57,18 @@ function readMode(runId) {
|
||||
const fail = (code, message) => Object.assign(new Error(message), { code });
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// Between two reads of the pane. Each read is a SYNCHRONOUS capture-pane
|
||||
// (through sudo for a member's pane) that holds up every other request and
|
||||
// socket while it runs, so a mode change polls gently rather than 20×/s.
|
||||
const POLL_MS = 150;
|
||||
|
||||
// The mode once it differs from `from` — or what the pane shows at the deadline.
|
||||
async function nextMode(runId, from, waitMs) {
|
||||
const until = Date.now() + waitMs;
|
||||
for (;;) {
|
||||
const now = readMode(runId);
|
||||
if ((now && now !== from) || Date.now() >= until) return now;
|
||||
await sleep(50);
|
||||
await sleep(POLL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -352,7 +352,21 @@ const session = (name) => (exact() ? `=${name}` : name);
|
||||
const pane = (name) => (exact() ? `=${name}:` : name);
|
||||
|
||||
function hasSession(name) {
|
||||
// A fresh shared list that names it answers "yes" without a spawn: each
|
||||
// synchronous one blocks every socket (terminal echo included) — 50-150 ms
|
||||
// per psmux call on Windows — and a pane-token request, an AI-proxy call or
|
||||
// a lane broadcast asks this every time. "No" is always asked for real, so a
|
||||
// pane that just died is never trusted past the list's 1 s TTL.
|
||||
const hit = listed.get("");
|
||||
try {
|
||||
if (
|
||||
hit &&
|
||||
!hit.err &&
|
||||
Date.now() - hit.at < LIST_TTL_MS &&
|
||||
hit.out.split("\n").includes(name) &&
|
||||
!accountOf(name)
|
||||
)
|
||||
return true;
|
||||
onServerOf(name, ["has-session", "-t", session(name)]);
|
||||
return true;
|
||||
} catch {
|
||||
|
||||
@@ -209,7 +209,11 @@ function featurePayload(feature) {
|
||||
/** Push the current state of one lane to every connected client. */
|
||||
function broadcastLane(id) {
|
||||
const lane = lanesLib.getLane(id);
|
||||
if (lane) broadcast("lane_update", { lane: payload(lane) });
|
||||
if (!lane) return;
|
||||
// The cached list-sessions (as GET / uses), not a synchronous has-session —
|
||||
// through sudo for a member's pane — on every hook that moves a lane.
|
||||
const live = lane.run_id ? tmux.liveSessions() : null;
|
||||
broadcast("lane_update", { lane: payload(lane, live) });
|
||||
}
|
||||
|
||||
router.get("/", (req, res) => {
|
||||
|
||||
@@ -29,6 +29,22 @@ const { calculateCost, dailyTokenRows } = require("./pricing");
|
||||
|
||||
const router = Router();
|
||||
|
||||
// How long the event loop was held up — what an operator sees as a laggy
|
||||
// terminal: every keystroke's echo waits for it. Sampled every 20 ms and reset
|
||||
// each minute, so the gauges describe the last minute or so.
|
||||
const LOOP_WINDOW_MS = 60_000;
|
||||
const loopDelay = require("perf_hooks").monitorEventLoopDelay({ resolution: 20 });
|
||||
loopDelay.enable();
|
||||
let loopWindow = null; // the last full minute, reported until the next one ends
|
||||
setInterval(() => {
|
||||
loopWindow = {
|
||||
p50: loopDelay.percentile(50),
|
||||
p99: loopDelay.percentile(99),
|
||||
max: loopDelay.max,
|
||||
};
|
||||
loopDelay.reset();
|
||||
}, LOOP_WINDOW_MS).unref();
|
||||
|
||||
// Resolved once at load — the dashboard version, surfaced as a build-info label
|
||||
// (the canonical Prometheus idiom for exposing a version string as a metric).
|
||||
const APP_VERSION = (() => {
|
||||
@@ -100,6 +116,23 @@ router.get("/", (_req, res) => {
|
||||
[{ value: process.memoryUsage().rss }]
|
||||
);
|
||||
|
||||
const loop = loopWindow || {
|
||||
p50: loopDelay.percentile(50),
|
||||
p99: loopDelay.percentile(99),
|
||||
max: loopDelay.max,
|
||||
};
|
||||
appendMetric(
|
||||
out,
|
||||
"agenthub_event_loop_delay_seconds",
|
||||
"Event-loop delay over the last minute (p50, p99, max): how long requests, hooks and terminal echo waited.",
|
||||
"gauge",
|
||||
[
|
||||
{ labels: { quantile: "0.5" }, value: loop.p50 / 1e9 },
|
||||
{ labels: { quantile: "0.99" }, value: loop.p99 / 1e9 },
|
||||
{ labels: { quantile: "1" }, value: loop.max / 1e9 },
|
||||
]
|
||||
);
|
||||
|
||||
// Sessions by status.
|
||||
const sessionCounts = new Map(stmts.sessionStatusCounts.all().map((r) => [r.status, r.count]));
|
||||
appendMetric(
|
||||
|
||||
@@ -469,11 +469,31 @@ function sessionOutline(sessionId) {
|
||||
};
|
||||
}
|
||||
|
||||
// Each open chat polls its outline every few seconds, and building it parses
|
||||
// every tool event of the session: keep the last answer per session until its
|
||||
// events change (a new id, or fewer rows after a cleanup).
|
||||
const outlineStamp = db.prepare(
|
||||
"SELECT MAX(id) AS max_id, COUNT(*) AS n FROM events WHERE session_id = ?"
|
||||
);
|
||||
const OUTLINE_CACHE_MAX = 100;
|
||||
const outlineCache = new Map();
|
||||
function cachedOutline(sessionId) {
|
||||
const st = outlineStamp.get(sessionId);
|
||||
const stamp = `${st.max_id}:${st.n}`;
|
||||
const hit = outlineCache.get(sessionId);
|
||||
if (hit && hit.stamp === stamp) return hit.body;
|
||||
const body = sessionOutline(sessionId);
|
||||
outlineCache.delete(sessionId);
|
||||
outlineCache.set(sessionId, { stamp, body });
|
||||
if (outlineCache.size > OUTLINE_CACHE_MAX) outlineCache.delete(outlineCache.keys().next().value);
|
||||
return body;
|
||||
}
|
||||
|
||||
router.get("/:id/outline", (req, res) => {
|
||||
if (!stmts.getSession.get(req.params.id)) {
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } });
|
||||
}
|
||||
res.json(sessionOutline(req.params.id));
|
||||
res.json(cachedOutline(req.params.id));
|
||||
});
|
||||
|
||||
// An image the session's own Read tool returned (`?path=`, exact), for the Chat
|
||||
@@ -1165,10 +1185,13 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
}
|
||||
|
||||
if (afterLine !== null) {
|
||||
// Incremental mode: skip lines until after afterLine, collect up to limit, then stop
|
||||
let foundStart = false;
|
||||
// Incremental mode: skip lines until after afterLine, collect up to limit, then stop.
|
||||
// A line the client already has is skipped BEFORE it is parsed: every open
|
||||
// chat tails its transcript every few seconds, and parsing the whole JSONL
|
||||
// (MBs for a long session) each time was most of the server's steady CPU.
|
||||
for await (const line of rl) {
|
||||
lineNum++;
|
||||
if (lineNum <= afterLine) continue;
|
||||
if (!line.trim()) continue;
|
||||
let entry;
|
||||
try {
|
||||
@@ -1178,11 +1201,6 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
}
|
||||
if (!renders(entry)) continue;
|
||||
|
||||
if (!foundStart) {
|
||||
if (lineNum <= afterLine) continue;
|
||||
foundStart = true;
|
||||
}
|
||||
|
||||
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
|
||||
if (!message) continue;
|
||||
total++;
|
||||
|
||||
@@ -11,6 +11,7 @@ const v8 = require("v8");
|
||||
const { db, stmts, DB_PATH, DEFAULT_PRICING, applyIntroPricing } = require("../db");
|
||||
const { getConnectionCount } = require("../websocket");
|
||||
const { transcriptCache } = require("./hooks");
|
||||
const { forgetHourly } = require("../lib/hourly-usage");
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -179,6 +180,7 @@ router.post("/clear-data", (_req, res) => {
|
||||
function clearAll() {
|
||||
db.prepare("DELETE FROM token_usage").run();
|
||||
db.prepare("DELETE FROM token_usage_hourly").run();
|
||||
forgetHourly();
|
||||
db.prepare("DELETE FROM events").run();
|
||||
db.prepare("DELETE FROM agents").run();
|
||||
db.prepare("DELETE FROM sessions").run();
|
||||
@@ -782,6 +784,7 @@ router.post("/cleanup", (req, res) => {
|
||||
db.prepare(`DELETE FROM token_usage_hourly WHERE session_id IN (${placeholders})`).run(
|
||||
...chunk
|
||||
);
|
||||
forgetHourly(chunk);
|
||||
db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...chunk);
|
||||
}
|
||||
result.purged_sessions = ids.length;
|
||||
|
||||
Reference in New Issue
Block a user