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
+4 -2
View File
@@ -22,7 +22,8 @@
{ {
"type": "command", "type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PreToolUse", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PreToolUse",
"timeout": 5 "timeout": 5,
"async": true
} }
] ]
} }
@@ -34,7 +35,8 @@
{ {
"type": "command", "type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PostToolUse", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PostToolUse",
"timeout": 5 "timeout": 5,
"async": true
} }
] ]
} }
+7 -1
View File
@@ -58,6 +58,8 @@ const ROW: Record<Mood, number> = {
/** What the user is doing to the pet, which outranks its mood. */ /** What the user is doing to the pet, which outranks its mood. */
export type PetAction = "run-left" | "run-right" | "wave"; export type PetAction = "run-left" | "run-right" | "wave";
const ACTION_ROW: Record<PetAction, number> = { "run-right": 1, "run-left": 2, wave: 3 }; const ACTION_ROW: Record<PetAction, number> = { "run-right": 1, "run-left": 2, wave: 3 };
/** Loops of a row before the sprite rests on its first frame. */
const SPRITE_LOOPS = 4;
export function PetSprite({ export function PetSprite({
id, id,
@@ -95,13 +97,17 @@ export function PetSprite({
const own = play(row, sleeping ? SLEEP_SLOWDOWN : 1); const own = play(row, sleeping ? SLEEP_SLOWDOWN : 1);
let list = MOMENTS.has(row) ? [...own, ...own, ...own] : own; let list = MOMENTS.has(row) ? [...own, ...own, ...own] : own;
let i = 0; let i = 0;
let loops = 0;
let timer = 0; let timer = 0;
// `i` always indexes `list`: it wraps at its end. // `i` always indexes `list`: it wraps at its end. A few loops, then the
// first frame rests — a sprite that never stops re-paints the page several
// times a second for as long as it is open; a new mood or action plays again.
const tick = () => { const tick = () => {
timer = window.setTimeout(() => { timer = window.setTimeout(() => {
if (++i === list.length) { if (++i === list.length) {
i = 0; i = 0;
if (MOMENTS.has(row)) list = play(0); if (MOMENTS.has(row)) list = play(0);
if (++loops >= SPRITE_LOOPS) return setCell(list[0]!);
} }
setCell(list[i]!); setCell(list[i]!);
tick(); tick();
+15 -11
View File
@@ -1,8 +1,12 @@
/** /**
* sagi.css - animations + mood expressions for the Sagi companion. * sagi.css - animations + mood expressions for the Sagi companion.
* Colors mirror the app's theme tokens (accent #6366f1/#818cf8, surface scale) * Colors mirror the app's theme tokens (accent #6366f1/#818cf8, surface scale)
* with warm pink accents (ears, cheeks, nose) for cuteness. All continuous * with warm pink accents (ears, cheeks, nose) for cuteness. No mood animates
* motion is disabled under [data-reduced="1"] and prefers-reduced-motion. * forever: each plays a few cycles when the mood begins and then rests — an
* infinite animation makes the browser draw a new frame 60 times a second for
* as long as the page is open (the drop-shadow filter re-rendered each time),
* which made the whole desktop stutter. All motion is disabled under
* [data-reduced="1"] and prefers-reduced-motion.
* Transform origins are the delicate part - see the comments on .sagi-avatar * Transform origins are the delicate part - see the comments on .sagi-avatar
* and .sagi-eye-blink for which reference box each one resolves against. * and .sagi-eye-blink for which reference box each one resolves against.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -206,20 +210,20 @@
/* idle: gentle breathing + occasional blink */ /* idle: gentle breathing + occasional blink */
.sagi-avatar[data-mood="idle"] { .sagi-avatar[data-mood="idle"] {
animation: sagi-breathe 4s ease-in-out infinite; animation: sagi-breathe 4s ease-in-out 0s 3;
} }
/* Blink lives on the inner group so it never overrides the outer group's /* Blink lives on the inner group so it never overrides the outer group's
eye-tracking translate (which would freeze the eyes - see the CSS-blink eye-tracking translate (which would freeze the eyes - see the CSS-blink
note in SagiAvatar.tsx). */ note in SagiAvatar.tsx). */
.sagi-avatar[data-mood="idle"] .sagi-eye-blink { .sagi-avatar[data-mood="idle"] .sagi-eye-blink {
animation: sagi-blink 5s ease-in-out infinite; animation: sagi-blink 5s ease-in-out 0s 3;
} }
/* happy: whole-mascot bob + twinkling sparkle */ /* happy: whole-mascot bob + twinkling sparkle */
.sagi-avatar[data-mood="happy"] { .sagi-avatar[data-mood="happy"] {
animation: sagi-bob 0.5s ease-in-out 0s 4; animation: sagi-bob 0.5s ease-in-out 0s 4;
} }
.sagi-avatar[data-mood="happy"] .sagi-sparkle { .sagi-avatar[data-mood="happy"] .sagi-sparkle {
animation: sagi-sparkle-twinkle 0.9s ease-in-out infinite; animation: sagi-sparkle-twinkle 0.9s ease-in-out 0s 4;
transform-origin: 797px 274px; /* sparkle centre, mascot 1050,380 */ transform-origin: 797px 274px; /* sparkle centre, mascot 1050,380 */
} }
/* worried: alarmed shake */ /* worried: alarmed shake */
@@ -228,19 +232,19 @@
} }
/* stuck: slow, drooping breathe (fatigued) */ /* stuck: slow, drooping breathe (fatigued) */
.sagi-avatar[data-mood="stuck"] { .sagi-avatar[data-mood="stuck"] {
animation: sagi-droop 2.4s ease-in-out infinite; animation: sagi-droop 2.4s ease-in-out 0s 3;
} }
/* thinking: rocking head tilt */ /* thinking: rocking head tilt */
.sagi-avatar[data-mood="thinking"] { .sagi-avatar[data-mood="thinking"] {
animation: sagi-rock 1.8s ease-in-out infinite; animation: sagi-rock 1.8s ease-in-out 0s 4;
} }
/* watching: attentive sway while a session is live */ /* watching: attentive sway while a session is live */
.sagi-avatar[data-mood="watching"] { .sagi-avatar[data-mood="watching"] {
animation: sagi-sway 3.2s ease-in-out infinite; animation: sagi-sway 3.2s ease-in-out 0s 3;
} }
/* sleeping: slow breathe, droop */ /* sleeping: slow breathe, droop */
.sagi-avatar[data-mood="sleeping"] { .sagi-avatar[data-mood="sleeping"] {
animation: sagi-breathe 5s ease-in-out infinite; animation: sagi-breathe 5s ease-in-out 0s 3;
} }
/* ── reduced motion: kill all continuous animation ── */ /* ── reduced motion: kill all continuous animation ── */
@@ -339,7 +343,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 2px solid #0c0c14; border: 2px solid #0c0c14;
animation: sagi-error-pulse 1.2s ease-in-out infinite; animation: sagi-error-pulse 1.2s ease-in-out 0s 5;
} }
.sagi-bubble { .sagi-bubble {
max-width: 16rem; max-width: 16rem;
@@ -375,7 +379,7 @@
/* An uploaded still image has no frames of its own: it bobs, faster while an /* An uploaded still image has no frames of its own: it bobs, faster while an
agent works and slower asleep. A Codex spritesheet animates by its frames. */ agent works and slower asleep. A Codex spritesheet animates by its frames. */
.sagi-pet-image { .sagi-pet-image {
animation: sagi-pet-bob 2.6s ease-in-out infinite; animation: sagi-pet-bob 2.6s ease-in-out 0s 3;
-webkit-user-drag: none; -webkit-user-drag: none;
} }
.sagi-pet-image[data-mood="thinking"] { .sagi-pet-image[data-mood="thinking"] {
+27 -3
View File
@@ -77,6 +77,17 @@ import {
import { pickQuip } from "./quips"; import { pickQuip } from "./quips";
import { sagiPrefs } from "./prefs"; import { sagiPrefs } from "./prefs";
/** `next` differs from `prev` only by a lastActivityAt less than a second
* newer — nothing the mascot shows, at the moods' 1 s resolution. */
function onlyActivityMoved(prev: SagiState, next: SagiState): boolean {
if (next === prev) return true;
if (next.lastActivityAt - prev.lastActivityAt >= 1000) return false;
for (const k of Object.keys(next) as (keyof SagiState)[]) {
if (k !== "lastActivityAt" && next[k] !== prev[k]) return false;
}
return true;
}
const BUBBLE_MS = 4500; const BUBBLE_MS = 4500;
// Minimum gap between non-error bubbles, so a burst of activity doesn't spam. // Minimum gap between non-error bubbles, so a burst of activity doesn't spam.
const BUBBLE_THROTTLE_MS = 3000; const BUBBLE_THROTTLE_MS = 3000;
@@ -153,7 +164,10 @@ export function useSagiBrain(): SagiBrain {
setState((prev) => { setState((prev) => {
const { state: next, pulse } = reduceSagi(prev, msg, t); const { state: next, pulse } = reduceSagi(prev, msg, t);
if (pulse) showBubble(pickQuip(pulse), pulse === "error"); if (pulse) showBubble(pickQuip(pulse), pulse === "error");
return next; // Most frames only move lastActivityAt, which the moods read at a 1 s
// resolution: keep the old state then, so a hook burst (several
// frames a second) does not re-render the mascot on every frame.
return onlyActivityMoved(prev, next) ? prev : next;
}); });
}); });
const unsubConn = eventBus.onConnection((connected) => { const unsubConn = eventBus.onConnection((connected) => {
@@ -169,15 +183,25 @@ export function useSagiBrain(): SagiBrain {
// happy/worried) re-evaluate without needing a new event. // happy/worried) re-evaluate without needing a new event.
useEffect(() => { useEffect(() => {
// Hidden tab: no re-render; the first visible tick catches the mood up. // Hidden tab: no re-render; the first visible tick catches the mood up.
// Only a tick that changes the mood re-renders: an idle mascot is not
// drawn again every second for nothing.
const id = setInterval(() => { const id = setInterval(() => {
if (document.visibilityState === "visible") setTick(Date.now()); if (document.visibilityState !== "visible") return;
const now = Date.now();
if (deriveMood(stateRef.current, now) !== moodRef.current) setTick(now);
}, 1000); }, 1000);
return () => clearInterval(id); return () => clearInterval(id);
}, []); }, []);
useEffect(() => () => clearTimeout(bubbleTimer.current), []); useEffect(() => () => clearTimeout(bubbleTimer.current), []);
const mood = useMemo(() => deriveMood(state, tick), [state, tick]); // Judged at render time: `tick` only moves when the mood would change, so
// it may be old when a frame re-renders.
const mood = useMemo(() => deriveMood(state, Math.max(tick, Date.now())), [state, tick]);
const stateRef = useRef(state);
stateRef.current = state;
const moodRef = useRef(mood);
moodRef.current = mood;
const status = useMemo(() => statusOf(state), [state]); const status = useMemo(() => statusOf(state), [state]);
const dismissBubble = useCallback(() => { const dismissBubble = useCallback(() => {
@@ -61,7 +61,7 @@
* *
* ----------------------------------------------------------------------------- */ * ----------------------------------------------------------------------------- */
import { useEffect, useState, useCallback, useMemo, useRef } from "react"; import { memo, useEffect, useState, useCallback, useMemo, useRef } from "react";
import { Loader2, ArrowDown, MessagesSquare, RefreshCw } from "lucide-react"; import { Loader2, ArrowDown, MessagesSquare, RefreshCw } from "lucide-react";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus"; import { eventBus } from "../../lib/eventBus";
@@ -112,7 +112,7 @@ interface ConversationViewProps {
onNotStarted?: (notStarted: boolean) => void; onNotStarted?: (notStarted: boolean) => void;
} }
export function ConversationView({ function ConversationViewPane({
sessionId, sessionId,
initialTranscriptId, initialTranscriptId,
fill = false, fill = false,
@@ -646,3 +646,7 @@ export function ConversationView({
</div> </div>
); );
} }
/** Memoized: a chat's prompt box lives in its parent (PaneChat), and every
* keystroke there re-rendered the whole conversation. */
export const ConversationView = memo(ConversationViewPane);
@@ -633,7 +633,7 @@ const EditedFiles = memo(
sameResults(n.blocks, p.toolResultMap, n.toolResultMap) sameResults(n.blocks, p.toolResultMap, n.toolResultMap)
); );
export function MessageList({ function MessageListView({
messages, messages,
loading, loading,
onOpenFile, onOpenFile,
@@ -1550,3 +1550,6 @@ const MessageRow = memo(
MessageRowView, MessageRowView,
(p, n) => p.msg === n.msg && sameResults(n.msg.content, p.toolResultMap, n.toolResultMap) (p, n) => p.msg === n.msg && sameResults(n.msg.content, p.toolResultMap, n.toolResultMap)
); );
/** Memoized: its owner re-renders on scroll state and its parent's typing. */
export const MessageList = memo(MessageListView);
+15 -7
View File
@@ -467,6 +467,19 @@ export const PaneChat = memo(function PaneChat({
[] []
); );
// Stable, so typing in the prompt box does not re-render the conversation
// (ConversationView is memoized).
const openTerminal = useCallback(() => showTerminal(runId, sessionId), [runId, sessionId]);
const openFile = useCallback(
(path: string) => {
// The workspace's Changes panel when the file is in its folder;
// else this view's own preview.
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
setPreview(path);
},
[laneId, cwd]
);
// Before the session starts the CLI may be asking in the terminal (trust // Before the session starts the CLI may be asking in the terminal (trust
// this folder? with "No, exit" preselected): an Enter from here answers it. // this folder? with "No, exit" preselected): an Enter from here answers it.
// Asked, not refused: whether the session row appears right after the // Asked, not refused: whether the session row appears right after the
@@ -515,15 +528,10 @@ export const PaneChat = memo(function PaneChat({
follow={follow} follow={follow}
waiting={asking} waiting={asking}
stopped={stops} stopped={stops}
onOpenTerminal={() => showTerminal(runId, sessionId)} onOpenTerminal={openTerminal}
onContext={onContext} onContext={onContext}
onNotStarted={setNotStarted} onNotStarted={setNotStarted}
onOpenFile={(path) => { onOpenFile={openFile}
// The workspace's Changes panel when the file is in its folder;
// else this view's own preview.
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
setPreview(path);
}}
/> />
{!outlineOpen && !preview && ( {!outlineOpen && !preview && (
<button <button
+14 -3
View File
@@ -62,7 +62,7 @@ import { useTheme } from "../../hooks/useTheme";
import { terminalThemeFor } from "../../lib/terminalTheme"; import { terminalThemeFor } from "../../lib/terminalTheme";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { writeClipboard } from "../../lib/clipboard"; import { writeClipboard } from "../../lib/clipboard";
import { notePaneFocus, registerPane } from "../../lib/panePaste"; import { MENU_CURSOR_ROW, notePaneFocus, registerPane } from "../../lib/panePaste";
import { attachImeInput } from "../../lib/imeInput"; import { attachImeInput } from "../../lib/imeInput";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont"; import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
@@ -649,16 +649,27 @@ export const TerminalView = memo(function TerminalView({
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(data); if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(data);
}, },
// The rows on screen, dim cells (a TUI's placeholder) left out. // The rows on screen, dim cells (a TUI's placeholder) left out.
() => { // `menuOnly` (the chat's 700 ms menu poll): plain text first, and the
// cell-by-cell read only when a row could be a menu's cursor row.
(menuOnly = false) => {
const term = termRef.current; const term = termRef.current;
if (!term) return null; if (!term) return null;
const buf = term.buffer.active; const buf = term.buffer.active;
if (menuOnly) {
let maybe = false;
for (let y = 0; y < term.rows && !maybe; y++)
maybe = MENU_CURSOR_ROW.test(
buf.getLine(buf.baseY + y)?.translateToString(true) ?? ""
);
if (!maybe) return null;
}
const rows: string[] = []; const rows: string[] = [];
const reuse = buf.getNullCell();
for (let y = 0; y < term.rows; y++) { for (let y = 0; y < term.rows; y++) {
const line = buf.getLine(buf.baseY + y); const line = buf.getLine(buf.baseY + y);
let row = ""; let row = "";
for (let x = 0; line && x < line.length; x++) { for (let x = 0; line && x < line.length; x++) {
const cell = line.getCell(x); const cell = line.getCell(x, reuse);
// Width 0: the right half of a wide character. // Width 0: the right half of a wide character.
if (cell && cell.getWidth() && !cell.isDim()) row += cell.getChars() || " "; if (cell && cell.getWidth() && !cell.isDim()) row += cell.getChars() || " ";
} }
+10 -4
View File
@@ -530,12 +530,18 @@
} }
} }
/* A few pulses when the dot appears, then it rests lit. Status dots sit in
always-visible chrome; an infinite animation there makes the browser draw a
frame 60 times a second for as long as the page is open, which made the
whole desktop stutter. A state change re-adds the class and pulses again. */
.animate-pulse-dot { .animate-pulse-dot {
animation: pulse-dot 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; animation: pulse-dot 2s cubic-bezier(0.4, 0, 0.6, 1) 3;
} }
/* The chat's live step, like Claude Code's: a light sweeping over the words. /* The chat's live step, like Claude Code's: a light sweeping over the words —
Reduced motion stops it on the muted colour. */ a few sweeps, then it rests on the muted colour (a background-position
animation repaints the text every frame; the spinner beside it keeps saying
the step runs). Reduced motion stops it at once. */
.text-shimmer { .text-shimmer {
background: linear-gradient( background: linear-gradient(
90deg, 90deg,
@@ -547,7 +553,7 @@
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
color: transparent; color: transparent;
animation: text-shimmer 2.5s linear infinite; animation: text-shimmer 2.5s linear 3;
} }
@keyframes text-shimmer { @keyframes text-shimmer {
@@ -24,6 +24,7 @@ import {
readRunMenu, readRunMenu,
registerPane, registerPane,
takeRunInput, takeRunInput,
MENU_CURSOR_ROW,
} from "../panePaste"; } from "../panePaste";
const RULE = "─".repeat(60); const RULE = "─".repeat(60);
@@ -311,3 +312,17 @@ const CODEX_APPROVAL = [
// "Press enter to confirm or esc to cancel", its dim cells left out. // "Press enter to confirm or esc to cancel", its dim cells left out.
" enteresc", " enteresc",
]; ];
describe("MENU_CURSOR_ROW", () => {
// The chat's 700 ms poll reads the screen cell by cell only when a row
// matches it, so every menu parsePaneMenu finds must have such a row.
it("matches a row of every menu, and no row of an idle prompt", () => {
const has = (rows: string[]) => rows.some((r) => MENU_CURSOR_ROW.test(r));
for (const menu of [ASK, MULTI, PERMISSION, CODEX_APPROVAL]) {
expect(parsePaneMenu(menu)).not.toBeNull();
expect(has(menu)).toBe(true);
}
expect(has(CLAUDE)).toBe(false);
expect(has(CODEX)).toBe(false);
});
});
+8 -3
View File
@@ -27,7 +27,8 @@ interface Pane {
paste: (text: string, submit?: boolean) => void; paste: (text: string, submit?: boolean) => void;
visible: () => boolean; visible: () => boolean;
key?: (data: string) => void; key?: (data: string) => void;
screen?: () => PaneScreen | null; /** `menuOnly`: null at once unless a row could be a menu's cursor row. */
screen?: (menuOnly?: boolean) => PaneScreen | null;
} }
const panes = new Map<string, Pane>(); const panes = new Map<string, Pane>();
let lastFocused: string | null = null; let lastFocused: string | null = null;
@@ -39,7 +40,7 @@ export function registerPane(
paste: (text: string, submit?: boolean) => void, paste: (text: string, submit?: boolean) => void,
visible: () => boolean = () => true, visible: () => boolean = () => true,
key?: (data: string) => void, key?: (data: string) => void,
screen?: () => PaneScreen | null screen?: (menuOnly?: boolean) => PaneScreen | null
): () => void { ): () => void {
const entry = { paste, visible, key, screen }; const entry = { paste, visible, key, screen };
panes.set(runId, entry); panes.set(runId, entry);
@@ -175,6 +176,8 @@ export type PaneMenu = {
}; };
const OPTION = /^ {0,4}([❯›][ \u00a0]?)? *(\d{1,2})\. +(.*)$/; const OPTION = /^ {0,4}([❯›][ \u00a0]?)? *(\d{1,2})\. +(.*)$/;
/** A row a menu's cursor sits on (a numbered option or Submit): a menu has one. */
export const MENU_CURSOR_ROW = /^ *[❯›][ \u00a0]* *(?:\d{1,2}\. |Submit\s*$)/;
const SUBMIT = /^ *([❯›])?[ \u00a0]*Submit$/; const SUBMIT = /^ *([❯›])?[ \u00a0]*Submit$/;
const RULE_ROW = /^ *[─╌]{3,} *$/; const RULE_ROW = /^ *[─╌]{3,} *$/;
const CHECK = /^\[([ ✔])\] *(.*)$/; const CHECK = /^\[([ ✔])\] *(.*)$/;
@@ -251,7 +254,9 @@ function typeRow(options: PaneMenuOption[]): number {
/** The menu `runId`'s visible pane shows now, or null. */ /** The menu `runId`'s visible pane shows now, or null. */
export function readRunMenu(runId: string): PaneMenu | null { export function readRunMenu(runId: string): PaneMenu | null {
const p = panes.get(runId); const p = panes.get(runId);
const screen = p?.visible() && p.key ? p.screen?.() : null; // Polled every 700 ms while a chat shows: a screen with no cursor row is
// answered from plain text, without reading it cell by cell.
const screen = p?.visible() && p.key ? p.screen?.(true) : null;
return screen ? parsePaneMenu(screen.rows) : null; return screen ? parsePaneMenu(screen.rows) : null;
} }
+1 -1
View File
@@ -2664,7 +2664,7 @@ function SystemHealthTab() {
<Tip block raw={t("health.wsTip", { count: info.server.ws_connections })}> <Tip block raw={t("health.wsTip", { count: info.server.ws_connections })}>
<div className="flex items-center gap-3 bg-status-success/5 px-3 py-2.5 rounded-lg border border-status-success/10 cursor-default"> <div className="flex items-center gap-3 bg-status-success/5 px-3 py-2.5 rounded-lg border border-status-success/10 cursor-default">
<Activity className="w-3.5 h-3.5 text-status-success animate-pulse" /> <Activity className="w-3.5 h-3.5 text-status-success animate-pulse-dot" />
<div> <div>
<p className="text-[10px] text-status-success font-medium"> <p className="text-[10px] text-status-success font-medium">
{t("health.wsActive")} {t("health.wsActive")}
+4 -1
View File
@@ -122,7 +122,10 @@ export default {
serif: ["ui-serif", "Cambria", "Times New Roman", "Times", "serif"], serif: ["ui-serif", "Cambria", "Times New Roman", "Times", "serif"],
}, },
animation: { animation: {
"pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite", // Status indicators pulse a few times and rest (never `infinite`: an
// always-on animation keeps the browser drawing 60 frames a second).
"pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) 3",
ping: "ping 1s cubic-bezier(0, 0, 0.2, 1) 4",
"fade-in": "fadeIn 0.3s ease-out", "fade-in": "fadeIn 0.3s ease-out",
"slide-up": "slideUp 0.3s ease-out", "slide-up": "slideUp 0.3s ease-out",
}, },
+1
View File
@@ -2015,6 +2015,7 @@ Response `Content-Type: text/plain; version=0.0.4; charset=utf-8`.
| `agenthub_build_info` | gauge | `version` | Always `1`; dashboard version rides on the label | | `agenthub_build_info` | gauge | `version` | Always `1`; dashboard version rides on the label |
| `agenthub_process_uptime_seconds` | gauge | — | Server process uptime | | `agenthub_process_uptime_seconds` | gauge | — | Server process uptime |
| `agenthub_process_resident_memory_bytes` | gauge | — | Server process RSS | | `agenthub_process_resident_memory_bytes` | gauge | — | Server process RSS |
| `agenthub_event_loop_delay_seconds` | gauge | `quantile` (`0.5`, `0.99`, `1` = max) | Event-loop delay over the last minute — how long requests, hooks and terminal echo waited (a laggy terminal shows here) |
| `agenthub_sessions` | gauge | `status` (`active`/`completed`/`error`/`abandoned`) | Sessions by status | | `agenthub_sessions` | gauge | `status` (`active`/`completed`/`error`/`abandoned`) | Sessions by status |
| `agenthub_agents` | gauge | `status` (`working`/`waiting`/`completed`/`error`) | Agents by status | | `agenthub_agents` | gauge | `status` (`working`/`waiting`/`completed`/`error`) | Agents by status |
| `agenthub_events_total` | counter | — | Total events recorded | | `agenthub_events_total` | counter | — | Total events recorded |
+2 -2
View File
@@ -182,7 +182,7 @@ npm run install-hooks
> (Escape hatch for running Claude Code *inside* the same container: > (Escape hatch for running Claude Code *inside* the same container:
> `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.) > `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.)
For each of the eight hook types it adds one entry to `~/.claude/settings.json` that runs the handler with the type as its argument; the five tool/turn hooks (`PreToolUse`, `PostToolUse`, `Stop`, `SubagentStop`, `Notification`) carry `"matcher": "*"`. Entries that are not AgentHub's are left alone. There is nothing else to install — Claude Code runs `scripts/hook-handler.js` directly. For each of the eight hook types it adds one entry to `~/.claude/settings.json` that runs the handler with the type as its argument; the five tool/turn hooks (`PreToolUse`, `PostToolUse`, `Stop`, `SubagentStop`, `Notification`) carry `"matcher": "*"`. `PreToolUse` and `PostToolUse` also carry `"async": true` (`ASYNC_HOOKS` in `scripts/install-hooks.js`, and the same in `.claude-plugin/plugin.json`): they fire around every tool call, and Claude Code waits for a synchronous hook before going on — each a Node start of 0.1–0.2 s, so ~0.4 s added to every tool call. The handler only reports (it prints nothing and decides nothing), so nothing is lost by not waiting; the rarer hooks stay synchronous so a turn's start and end reach the dashboard in order. Entries that are not AgentHub's are left alone. There is nothing else to install — Claude Code runs `scripts/hook-handler.js` directly.
```json ```json
{ {
@@ -191,7 +191,7 @@ For each of the eight hook types it adds one entry to `~/.claude/settings.json`
{ {
"matcher": "*", "matcher": "*",
"hooks": [ "hooks": [
{ "type": "command", "command": "node \"/path/to/agenthub/scripts/hook-handler.js\" PreToolUse" } { "type": "command", "command": "node \"/path/to/agenthub/scripts/hook-handler.js\" PreToolUse", "async": true }
] ]
} }
], ],
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -24,7 +24,8 @@ as they are into an existing Grafana (Dashboards → New → Import).
## Metrics used ## Metrics used
`agenthub_up`, `agenthub_build_info{version}`, `agenthub_process_uptime_seconds`, `agenthub_up`, `agenthub_build_info{version}`, `agenthub_process_uptime_seconds`,
`agenthub_process_resident_memory_bytes`, `agenthub_sessions{status}`, `agenthub_process_resident_memory_bytes`, `agenthub_event_loop_delay_seconds{quantile}`,
`agenthub_sessions{status}`,
`agenthub_agents{status}`, `agenthub_events_total`, `agenthub_websocket_clients`, `agenthub_agents{status}`, `agenthub_events_total`, `agenthub_websocket_clients`,
`agenthub_remote_sources{enabled}`, `agenthub_tokens_total{kind}`. `agenthub_remote_sources{enabled}`, `agenthub_tokens_total{kind}`.
+3 -1
View File
@@ -796,7 +796,9 @@ What a new machine still needs by hand after it:
`PostToolUse` (both with `"matcher": "*"`), `Stop`, `SubagentStop`, `PostToolUse` (both with `"matcher": "*"`), `Stop`, `SubagentStop`,
`Notification`, `SessionStart`, `SessionEnd` and `UserPromptSubmit`, a `Notification`, `SessionStart`, `SessionEnd` and `UserPromptSubmit`, a
`command` hook `/opt/agenthub/libexec/member-hook <Event>` with `command` hook `/opt/agenthub/libexec/member-hook <Event>` with
`"timeout": 5`. `"timeout": 5` — and `"async": true` on `PreToolUse` and `PostToolUse`, so
Claude Code does not wait a Node start before and after every tool call.
Re-running `install-system` rewrites a managed file it wrote itself.
5. Any other daemon that must see every process (monitoring agents, for 5. Any other daemon that must see every process (monitoring agents, for
example) needs group `agenthub-proc`: `sudo usermod -aG agenthub-proc <it>`. example) needs group `agenthub-proc`: `sudo usermod -aG agenthub-proc <it>`.
6. A tool `agenthub server tools` reports as `ESHAREDLAYOUT` (a script in a 6. A tool `agenthub server tools` reports as `ESHAREDLAYOUT` (a script in a
+16 -1
View File
@@ -127,6 +127,12 @@ const HOOKS_WITH_MATCHER = ["PreToolUse", "PostToolUse", "Stop", "SubagentStop",
// tool-name matchers, hence the separate list. // tool-name matchers, hence the separate list.
const HOOKS_WITHOUT_MATCHER = ["SessionStart", "SessionEnd", "UserPromptSubmit"]; const HOOKS_WITHOUT_MATCHER = ["SessionStart", "SessionEnd", "UserPromptSubmit"];
const HOOK_TYPES = [...HOOKS_WITH_MATCHER, ...HOOKS_WITHOUT_MATCHER]; const HOOK_TYPES = [...HOOKS_WITH_MATCHER, ...HOOKS_WITHOUT_MATCHER];
// Fired around EVERY tool call, and Claude Code waits for a synchronous hook
// before it goes on — each one a Node start (0.1–0.2 s), so ~0.4 s added to
// every tool call. The handler only reports (it prints nothing, decides
// nothing), so these run in the background (`async`). The rarer events stay
// synchronous so a turn's start and end still reach the dashboard in order.
const ASYNC_HOOKS = new Set(["PreToolUse", "PostToolUse"]);
function makeHookEntry(hookType) { function makeHookEntry(hookType) {
const entry = { const entry = {
@@ -134,6 +140,7 @@ function makeHookEntry(hookType) {
{ {
type: "command", type: "command",
command: `node "${HOOK_HANDLER}" ${hookType}`, command: `node "${HOOK_HANDLER}" ${hookType}`,
...(ASYNC_HOOKS.has(hookType) && { async: true }),
}, },
], ],
}; };
@@ -247,4 +254,12 @@ if (require.main === module) {
// isOurEntry is also used by scripts/plugin-bootstrap.js to strip hook entries // isOurEntry is also used by scripts/plugin-bootstrap.js to strip hook entries
// a previous checkout install left behind (they would double-count events). // a previous checkout install left behind (they would double-count events).
module.exports = { HOOK_TYPES, installHooks, isInsideContainer, isOurEntry, pluginProvidesHooks }; module.exports = {
ASYNC_HOOKS,
HOOK_TYPES,
installHooks,
isInsideContainer,
isOurEntry,
makeHookEntry,
pluginProvidesHooks,
};
+4 -1
View File
@@ -156,8 +156,11 @@ if [ -f "$MANAGED" ] && ! { [ -f "$MANAGED_MARK" ] && sha256sum -c --status "$MA
else else
"$NODE" -e ' "$NODE" -e '
const ev = ["PreToolUse","PostToolUse","Stop","SubagentStop","Notification","SessionStart","SessionEnd","UserPromptSubmit"]; const ev = ["PreToolUse","PostToolUse","Stop","SubagentStop","Notification","SessionStart","SessionEnd","UserPromptSubmit"];
// Per-tool-call hooks run in the background (async): a synchronous one
// makes Claude Code wait a Node start (0.1-0.2 s) before and after every call.
const hook = (e) => [{ ...(["PreToolUse","PostToolUse"].includes(e) ? { matcher: "*" } : {}), const hook = (e) => [{ ...(["PreToolUse","PostToolUse"].includes(e) ? { matcher: "*" } : {}),
hooks: [{ type: "command", command: `/opt/agenthub/libexec/member-hook ${e}`, timeout: 5 }] }]; hooks: [{ type: "command", command: `/opt/agenthub/libexec/member-hook ${e}`, timeout: 5,
...(["PreToolUse","PostToolUse"].includes(e) ? { async: true } : {}) }] }];
process.stdout.write(JSON.stringify({ hooks: Object.fromEntries(ev.map((e) => [e, hook(e)])) }, null, 2) + "\n"); process.stdout.write(JSON.stringify({ hooks: Object.fromEntries(ev.map((e) => [e, hook(e)])) }, null, 2) + "\n");
' >"$MANAGED" ' >"$MANAGED"
chmod 0644 "$MANAGED" chmod 0644 "$MANAGED"
+40
View File
@@ -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", () => { describe("session list cost", () => {
it("equals the session's own cost, speed and server-tool fees included", async () => { it("equals the session's own cost, speed and server-tool fees included", async () => {
session("list-fast", "2026-10-01T00:00:00.000Z"); session("list-fast", "2026-10-01T00:00:00.000Z");
+13
View File
@@ -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", () => { describe("HOOK_TYPES", () => {
it("is the event list the plugin ships, so the hook status checks every one", () => { it("is the event list the plugin ships, so the hook status checks every one", () => {
const exported = require("../../scripts/install-hooks").HOOK_TYPES; const exported = require("../../scripts/install-hooks").HOOK_TYPES;
+1
View File
@@ -93,6 +93,7 @@ describe("GET /api/metrics", () => {
"agenthub_build_info", "agenthub_build_info",
"agenthub_process_uptime_seconds", "agenthub_process_uptime_seconds",
"agenthub_process_resident_memory_bytes", "agenthub_process_resident_memory_bytes",
"agenthub_event_loop_delay_seconds",
"agenthub_sessions", "agenthub_sessions",
"agenthub_agents", "agenthub_agents",
"agenthub_events_total", "agenthub_events_total",
+15
View File
@@ -19,6 +19,21 @@ describe("tmux wrapper", () => {
assert.equal(tmux.hasSession("agenthub-lane-1"), true); 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", () => { it("hasSession returns false when execFileSync throws", () => {
tmux.__setExecImpl(() => { tmux.__setExecImpl(() => {
const e = new Error("no such session"); const e = new Error("no such session");
+26 -5
View File
@@ -11,11 +11,25 @@ const { db, stmts } = require("../db");
const TranscriptCache = require("./transcript-cache"); const TranscriptCache = require("./transcript-cache");
const { longArgs } = require("./token-usage"); 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) { function writeHourly(sessionId, tokensByHour) {
// Runs every hour of the session on each hook — ~1 row per active hour per let seen = written.get(sessionId);
// model; the statement leaves a row that did not grow untouched (no write). if (seen) written.delete(sessionId);
for (const t of Object.values(tokensByHour || {})) { 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( stmts.upsertHourlyUsage.run(
sessionId, sessionId,
t.hour, 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 };
+6 -1
View File
@@ -57,13 +57,18 @@ function readMode(runId) {
const fail = (code, message) => Object.assign(new Error(message), { code }); const fail = (code, message) => Object.assign(new Error(message), { code });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); 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. // The mode once it differs from `from` — or what the pane shows at the deadline.
async function nextMode(runId, from, waitMs) { async function nextMode(runId, from, waitMs) {
const until = Date.now() + waitMs; const until = Date.now() + waitMs;
for (;;) { for (;;) {
const now = readMode(runId); const now = readMode(runId);
if ((now && now !== from) || Date.now() >= until) return now; if ((now && now !== from) || Date.now() >= until) return now;
await sleep(50); await sleep(POLL_MS);
} }
} }
+14
View File
@@ -352,7 +352,21 @@ const session = (name) => (exact() ? `=${name}` : name);
const pane = (name) => (exact() ? `=${name}:` : name); const pane = (name) => (exact() ? `=${name}:` : name);
function hasSession(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 { 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)]); onServerOf(name, ["has-session", "-t", session(name)]);
return true; return true;
} catch { } catch {
+5 -1
View File
@@ -209,7 +209,11 @@ function featurePayload(feature) {
/** Push the current state of one lane to every connected client. */ /** Push the current state of one lane to every connected client. */
function broadcastLane(id) { function broadcastLane(id) {
const lane = lanesLib.getLane(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) => { router.get("/", (req, res) => {
+33
View File
@@ -29,6 +29,22 @@ const { calculateCost, dailyTokenRows } = require("./pricing");
const router = Router(); 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 // 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). // (the canonical Prometheus idiom for exposing a version string as a metric).
const APP_VERSION = (() => { const APP_VERSION = (() => {
@@ -100,6 +116,23 @@ router.get("/", (_req, res) => {
[{ value: process.memoryUsage().rss }] [{ 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. // Sessions by status.
const sessionCounts = new Map(stmts.sessionStatusCounts.all().map((r) => [r.status, r.count])); const sessionCounts = new Map(stmts.sessionStatusCounts.all().map((r) => [r.status, r.count]));
appendMetric( 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) => { router.get("/:id/outline", (req, res) => {
if (!stmts.getSession.get(req.params.id)) { if (!stmts.getSession.get(req.params.id)) {
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } }); 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 // 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) { if (afterLine !== null) {
// Incremental mode: skip lines until after afterLine, collect up to limit, then stop // Incremental mode: skip lines until after afterLine, collect up to limit, then stop.
let foundStart = false; // 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) { for await (const line of rl) {
lineNum++; lineNum++;
if (lineNum <= afterLine) continue;
if (!line.trim()) continue; if (!line.trim()) continue;
let entry; let entry;
try { try {
@@ -1178,11 +1201,6 @@ router.get("/:id/transcript", async (req, res) => {
} }
if (!renders(entry)) continue; if (!renders(entry)) continue;
if (!foundStart) {
if (lineNum <= afterLine) continue;
foundStart = true;
}
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum); const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
if (!message) continue; if (!message) continue;
total++; total++;
+3
View File
@@ -11,6 +11,7 @@ const v8 = require("v8");
const { db, stmts, DB_PATH, DEFAULT_PRICING, applyIntroPricing } = require("../db"); const { db, stmts, DB_PATH, DEFAULT_PRICING, applyIntroPricing } = require("../db");
const { getConnectionCount } = require("../websocket"); const { getConnectionCount } = require("../websocket");
const { transcriptCache } = require("./hooks"); const { transcriptCache } = require("./hooks");
const { forgetHourly } = require("../lib/hourly-usage");
const router = Router(); const router = Router();
@@ -179,6 +180,7 @@ router.post("/clear-data", (_req, res) => {
function clearAll() { function clearAll() {
db.prepare("DELETE FROM token_usage").run(); db.prepare("DELETE FROM token_usage").run();
db.prepare("DELETE FROM token_usage_hourly").run(); db.prepare("DELETE FROM token_usage_hourly").run();
forgetHourly();
db.prepare("DELETE FROM events").run(); db.prepare("DELETE FROM events").run();
db.prepare("DELETE FROM agents").run(); db.prepare("DELETE FROM agents").run();
db.prepare("DELETE FROM sessions").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( db.prepare(`DELETE FROM token_usage_hourly WHERE session_id IN (${placeholders})`).run(
...chunk ...chunk
); );
forgetHourly(chunk);
db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...chunk); db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...chunk);
} }
result.purged_sessions = ids.length; result.purged_sessions = ids.length;