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:
2026-10-14 11:55:53 +07:00
parent c3f83d3922
commit 1c5521cf2e
31 changed files with 341 additions and 61 deletions
+5 -1
View File
@@ -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) => {
+33
View File
@@ -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(
+26 -8
View File
@@ -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++;
+3
View File
@@ -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;