fix: event-loop freezes, plugin-install boot crash, tmux attach/scrollbar, Codex transcripts

- GET /api/workflows: one LEAD() window pass in a read worker (read-pool.js,
  workflow-stats.js); idx_events_session restored, idx_events_tool added
- compat-sqlite gains function/pluck/readonly so a plugin install without a
  better-sqlite3 build boots again; antigravity-usage uses db.js's Database
- install-hooks adds no checkout hooks while the agenthub plugin is enabled
  (events were doubled)
- terminal attach runs the live tmux server's own binary; the pane scrollbar
  gets a visible 2-column style, and claude panes start with
  CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 so tmux keeps history to scroll
- Codex sessions' conversation in GET /api/sessions/:id/transcript
- Codex/Antigravity sessions claimed by their workspace without hooks
- UI-audit follow-ups, docs and OpenAPI
This commit is contained in:
2026-10-05 17:01:05 +07:00
parent 442f133930
commit 198fbb7980
78 changed files with 2716 additions and 873 deletions
+7
View File
@@ -91,6 +91,13 @@
# (default: enabled). # (default: enabled).
# AGENTHUB_PRICING_SYNC=0 # AGENTHUB_PRICING_SYNC=0
# Heavy read-only aggregates (GET /api/workflows) run in a worker thread on
# their own connection so they never stall other requests. 0 runs them inline
# on the event loop instead (default: worker). A job past the timeout answers
# 503 EREADTIMEOUT and the next one gets a fresh worker (default: 60000).
# AGENTHUB_READ_WORKER=0
# AGENTHUB_READ_TIMEOUT_MS=60000
# Where settings saved from the dashboard persist (default: # Where settings saved from the dashboard persist (default:
# $XDG_CONFIG_HOME/agenthub/settings.env, i.e. ~/.config/agenthub/settings.env). # $XDG_CONFIG_HOME/agenthub/settings.env, i.e. ~/.config/agenthub/settings.env).
# Here or in the real environment; the file is located after this one loads. # Here or in the real environment; the file is located after this one loads.
+8 -4
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
File diff suppressed because one or more lines are too long
+17 -3
View File
@@ -31,7 +31,7 @@ dashboard, run it from source, or stay on Node 20.
| npm | 9+ | Comes with Node.js | | npm | 9+ | Comes with Node.js |
| Claude Code | 2.x+ | Required for hook integration | | Claude Code | 2.x+ | Required for hook integration |
| Git | Any | Cloning the repository, and every lane operation (worktrees, branch/status reads) | | Git | Any | Cloning the repository, and every lane operation (worktrees, branch/status reads) |
| **tmux** | 3.x | **Required for the run console** — the browser terminal, `agenthub lanes shell`, and typing `/reload-plugins` into a live pane. See [Step 0](#step-0--system-packages) | | **tmux** | 3.x (3.7+ for pane scrollbars) | **Required for the run console** — the browser terminal, `agenthub lanes shell`, and typing `/reload-plugins` into a live pane. See [Step 0](#step-0--system-packages) |
| Codex CLI | Any | Optional — only for lanes whose tool is `codex` | | Codex CLI | Any | Optional — only for lanes whose tool is `codex` |
| GitHub CLI (`gh`) | Any | Optional — only for `agenthub lanes ci status` | | GitHub CLI (`gh`) | Any | Optional — only for `agenthub lanes ci status` |
| Python | 3.6+ | Optional — only for `scripts/expand-ts-module-docs.py` (doc generation) | | Python | 3.6+ | Optional — only for `scripts/expand-ts-module-docs.py` (doc generation) |
@@ -55,6 +55,19 @@ sudo pacman -S git tmux # Arch
Windows has no native tmux. Run AgentHub under **WSL2** and install it inside Windows has no native tmux. Run AgentHub under **WSL2** and install it inside
the WSL distro — a tmux from Git Bash or MSYS is not what the server executes. the WSL distro — a tmux from Git Bash or MSYS is not what the server executes.
A terminal pane gets a draggable scrollbar only on **tmux 3.7+** — older
versions work, just without it, and `/agenthub-doctor` warns. Homebrew ships the
latest (`brew upgrade tmux`); Ubuntu 24.04's apt has 3.4, so build the release:
```bash
sudo apt install build-essential pkg-config libevent-dev libncurses-dev bison
curl -fsSL https://github.com/tmux/tmux/releases/download/3.7c/tmux-3.7c.tar.gz | tar xz
cd tmux-3.7c && ./configure && make && sudo make install # into /usr/local/bin
```
A tmux server already running keeps its old version until every tmux session
has closed (or the machine reboots) — only then do the scrollbars appear.
Check it: Check it:
```bash ```bash
@@ -72,7 +85,8 @@ curl -s localhost:4820/api/run/tmux # {"available":true}
**No configuration is needed.** AgentHub creates its own session per lane, **No configuration is needed.** AgentHub creates its own session per lane,
named `agenthub-lane-<id>`, and never reads your `~/.tmux.conf` for anything it named `agenthub-lane-<id>`, and never reads your `~/.tmux.conf` for anything it
depends on (mouse mode is set on the session explicitly). You can attach to the depends on (mouse mode, the hidden status bar and the scrollbar are set on the
session explicitly). You can attach to the
exact same pane from a normal terminal: exact same pane from a normal terminal:
```bash ```bash
@@ -191,7 +205,7 @@ This starts two processes concurrently:
Open **http://localhost:5173** in your browser. Open **http://localhost:5173** in your browser.
> [!TIP] > [!TIP]
> When you run the dashboard directly on the host with `npm run dev` or `npm start`, the server automatically writes the Claude Code hook configuration to `~/.claude/settings.json`. If you run the dashboard inside a container, install hooks from the host with `npm run install-hooks` instead — a hook path written from inside a container points at the container filesystem. > When you run the dashboard directly on the host with `npm run dev` or `npm start`, the server automatically writes the Claude Code hook configuration to `~/.claude/settings.json` — unless the `agenthub` plugin is enabled there, whose own hooks already run; then it removes any copies a checkout wrote instead, since two sets post every event twice. If you run the dashboard inside a container, install hooks from the host with `npm run install-hooks` instead — a hook path written from inside a container points at the container filesystem.
--- ---
+1 -1
View File
@@ -12,7 +12,7 @@ Claude Code → hook fires → hook-handler.js → POST /api/hooks/event
Browser ← WebSocket broadcast ← Express server ← SQLite Browser ← WebSocket broadcast ← Express server ← SQLite
``` ```
No extra Claude Code configuration is required in the normal host-run path — when you start the dashboard with `npm run dev` or `npm start`, the server configures the hooks automatically on startup. Container deployments are the exception: after the container is up, run `npm run install-hooks` on the host so Claude Code points at `http://localhost:4820`. No extra Claude Code configuration is required in the normal host-run path — when you start the dashboard with `npm run dev` or `npm start`, the server configures the hooks automatically on startup (or, with the `agenthub` plugin enabled in `~/.claude/settings.json`, removes the checkout's copies — the plugin's own hooks already run, and both would post every event twice). Container deployments are the exception: after the container is up, run `npm run install-hooks` on the host so Claude Code points at `http://localhost:4820`.
--- ---
+10 -6
View File
@@ -167,11 +167,13 @@ async function api(method, pathname, body, options = {}) {
const url = `${baseUrl()}${pathname}`; const url = `${baseUrl()}${pathname}`;
let res; let res;
try { try {
// A FormData body (a file upload) sets its own multipart Content-Type.
const form = body instanceof FormData;
res = await fetch(url, { res = await fetch(url, {
method, method,
headers: body ? { "Content-Type": "application/json" } : undefined, headers: body && !form ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined, body: body ? (form ? body : JSON.stringify(body)) : undefined,
signal: AbortSignal.timeout(30_000), signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
}); });
} catch { } catch {
throw new ServerDownError(); throw new ServerDownError();
@@ -1340,8 +1342,8 @@ async function cmdExport(positional) {
} }
// Restore a bundle produced by `agenthub export` (or the dashboard's Export data). // Restore a bundle produced by `agenthub export` (or the dashboard's Export data).
// The local server reads the file from disk, so we pass an absolute path rather // Uploaded like the dashboard's Restore button does — the server never reads a
// than uploading — idempotent and non-destructive (existing sessions skipped). // path from a request. Idempotent and non-destructive (existing sessions skipped).
async function cmdImportData(positional) { async function cmdImportData(positional) {
const target = positional[0]; const target = positional[0];
if (!target) { if (!target) {
@@ -1353,7 +1355,9 @@ async function cmdImportData(positional) {
console.error(c.red(`✖ File not found: ${abs}`)); console.error(c.red(`✖ File not found: ${abs}`));
process.exit(1); process.exit(1);
} }
const r = await post("/api/settings/import", { path: abs }); const form = new FormData();
form.append("file", new Blob([fs.readFileSync(abs)]), path.basename(abs));
const r = await post("/api/settings/import", form, { timeoutMs: 10 * 60_000 });
console.log( console.log(
`${c.green("✔")} Restored: ${r.sessions_imported ?? 0} sessions added, ` + `${c.green("✔")} Restored: ${r.sessions_imported ?? 0} sessions added, ` +
`${r.sessions_skipped ?? 0} already present, ${r.events ?? 0} events, ` + `${r.sessions_skipped ?? 0} already present, ${r.events ?? 0} events, ` +
+3 -2
View File
@@ -54,7 +54,7 @@
* - `skills/file-headers/` — mandatory `@author` header policy. * - `skills/file-headers/` — mandatory `@author` header policy.
* ============================================================================= */ * ============================================================================= */
import { BrowserRouter, Routes, Route, Navigate, useParams } from "react-router-dom"; import { BrowserRouter, Routes, Route, Navigate, useParams, useLocation } from "react-router-dom";
import { lazy, Suspense, useCallback, useEffect, type ReactNode } from "react"; import { lazy, Suspense, useCallback, useEffect, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Layout } from "./components/Layout"; import { Layout } from "./components/Layout";
@@ -92,8 +92,9 @@ function PageFallback() {
} }
function Page({ children }: { children: ReactNode }) { function Page({ children }: { children: ReactNode }) {
const { pathname } = useLocation();
return ( return (
<RouteErrorBoundary> <RouteErrorBoundary resetKey={pathname}>
<Suspense fallback={<PageFallback />}>{children}</Suspense> <Suspense fallback={<PageFallback />}>{children}</Suspense>
</RouteErrorBoundary> </RouteErrorBoundary>
); );
+9 -1
View File
@@ -5,7 +5,9 @@
* which the SPA fallback answers with index.html, so the dynamic import * which the SPA fallback answers with index.html, so the dynamic import
* rejects: that case reloads the page once (a sessionStorage guard stops a * rejects: that case reloads the page once (a sessionStorage guard stops a
* reload loop). Any other render error — or a second chunk failure — shows a * reload loop). Any other render error — or a second chunk failure — shows a
* themed "Something went wrong — Reload" panel instead of a blank app. * themed "Something went wrong — Reload" panel instead of a blank app, until the
* route changes (`resetKey`): the lazy routes share one boundary instance, so
* an error on one page would otherwise stay on every other.
* `reloadOnce` is also used by App's `vite:preloadError` listener. * `reloadOnce` is also used by App's `vite:preloadError` listener.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -45,6 +47,8 @@ interface Props {
children: ReactNode; children: ReactNode;
/** Injected in tests; defaults to `window.location.reload`. */ /** Injected in tests; defaults to `window.location.reload`. */
reload?: () => void; reload?: () => void;
/** A change (the route's path) clears a caught error. */
resetKey?: string;
} }
export class RouteErrorBoundary extends Component<Props, { error: Error | null }> { export class RouteErrorBoundary extends Component<Props, { error: Error | null }> {
@@ -58,6 +62,10 @@ export class RouteErrorBoundary extends Component<Props, { error: Error | null }
if (isChunkLoadError(error)) reloadOnce(this.props.reload); if (isChunkLoadError(error)) reloadOnce(this.props.reload);
} }
componentDidUpdate(prev: Props) {
if (this.state.error && prev.resetKey !== this.props.resetKey) this.setState({ error: null });
}
render() { render() {
if (!this.state.error) return this.props.children; if (!this.state.error) return this.props.children;
const reload = this.props.reload ?? (() => window.location.reload()); const reload = this.props.reload ?? (() => window.location.reload());
@@ -49,6 +49,28 @@ describe("RouteErrorBoundary", () => {
expect(reload).not.toHaveBeenCalled(); expect(reload).not.toHaveBeenCalled();
}); });
it("clears the error when the route changes, so the next page renders", () => {
const { rerender } = render(
<RouteErrorBoundary reload={reload} resetKey="/workspace">
<Boom error={new Error("boom")} />
</RouteErrorBoundary>
);
expect(screen.getByRole("alert")).toBeInTheDocument();
rerender(
<RouteErrorBoundary reload={reload} resetKey="/workspace">
<p>settings page</p>
</RouteErrorBoundary>
);
expect(screen.getByRole("alert")).toBeInTheDocument();
rerender(
<RouteErrorBoundary reload={reload} resetKey="/settings/general">
<p>settings page</p>
</RouteErrorBoundary>
);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.getByText("settings page")).toBeInTheDocument();
});
it("reloads on a chunk error the first time", () => { it("reloads on a chunk error the first time", () => {
render( render(
<RouteErrorBoundary reload={reload}> <RouteErrorBoundary reload={reload}>
@@ -154,12 +154,32 @@ describe("WorkspaceTree", () => {
expect(within(list).getByText("Live one")).toBeInTheDocument(); expect(within(list).getByText("Live one")).toBeInTheDocument();
// Its OWN sessions only — never the rest of its folder. // Its OWN sessions only — never the rest of its folder.
expect(api.sessions.list).toHaveBeenCalledTimes(1); expect(api.sessions.list).toHaveBeenCalledTimes(1);
expect(api.sessions.list).toHaveBeenCalledWith({ lane: 1, archived: false, limit: 20 }); expect(api.sessions.list).toHaveBeenCalledWith({ lane: 1, archived: false, limit: 6 });
expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).expanded).toEqual({ expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).expanded).toEqual({
"1": true, "1": true,
}); });
}); });
it("shows six sessions and loads the rest on See all", async () => {
const all = Array.from({ length: 9 }, (_, i) => session(`s-${i}`, `Session ${i}`));
vi.mocked(api.sessions.list).mockImplementation(async (p) => ({
sessions: all.slice(0, p?.limit),
total: all.length,
limit: p?.limit ?? 50,
offset: 0,
}));
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-toggle-1"));
const list = await screen.findByTestId("workspace-sessions-1");
expect(within(list).getAllByTestId(/^workspace-session-s-\d+$/)).toHaveLength(6);
expect(screen.getByTestId("workspace-sessions-more-1")).toHaveTextContent("See all (3 more)");
await user.click(screen.getByTestId("workspace-sessions-more-1"));
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 9 });
await screen.findByText("Session 8");
expect(screen.queryByTestId("workspace-sessions-more-1")).toBeNull();
});
it("renames a session on double-click + Enter, and archives one from its row", async () => { it("renames a session on double-click + Enter, and archives one from its row", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderTree(); renderTree();
+3 -2
View File
@@ -751,7 +751,9 @@ export function LaneDiffView({
const [anchor, setAnchor] = useState<{ top: number; left: number } | null>(null); const [anchor, setAnchor] = useState<{ top: number; left: number } | null>(null);
const place = useCallback(() => { const place = useCallback(() => {
const r = anchorEl.current?.isConnected ? anchorEl.current.getBoundingClientRect() : null; const r = anchorEl.current?.isConnected ? anchorEl.current.getBoundingClientRect() : null;
if (r) setAnchor({ top: r.bottom + 2, left: r.left }); // A long diff unmounts rows scrolled out of its window, the clicked number
// with them: the box docks under the diff rather than float over other lines.
setAnchor(r ? { top: r.bottom + 2, left: r.left } : null);
}, []); }, []);
useEffect(() => { useEffect(() => {
if (commentAt == null) return; if (commentAt == null) return;
@@ -764,7 +766,6 @@ export function LaneDiffView({
? { ? {
open: (line: number, el?: HTMLElement) => { open: (line: number, el?: HTMLElement) => {
anchorEl.current = el ?? null; anchorEl.current = el ?? null;
setAnchor(null);
place(); place();
setCommentAt(line); setCommentAt(line);
setUndelivered(false); setUndelivered(false);
@@ -17,7 +17,8 @@
* any other session opens in its workspace too (`?lane=<id>&session=<id>`, * any other session opens in its workspace too (`?lane=<id>&session=<id>`,
* `SessionReplay`): its conversation with a prompt box, resumed only when a * `SessionReplay`): its conversation with a prompt box, resumed only when a
* message is SENT, because a resume re-reads the whole transcript into context. * message is SENT, because a resume re-reads the whole transcript into context.
* The list shows `SESSION_LIMIT` rows and a **Show N more** under them. A * The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that
* loads every one. A
* live tab whose session was archived is not brought back as a New session, * live tab whose session was archived is not brought back as a New session,
* and archiving a live one asks first. F2 renames the focused row too; each * and archiving a live one asks first. F2 renames the focused row too; each
* dot carries its meaning as screen-reader text. * dot carries its meaning as screen-reader text.
@@ -39,7 +40,7 @@ import { useAiTools } from "../../hooks/useAiTools";
import { useContextMenu } from "../ContextMenu"; import { useContextMenu } from "../ContextMenu";
import { SessionMenu } from "./SessionMenu"; import { SessionMenu } from "./SessionMenu";
export const SESSION_LIMIT = 20; export const SESSION_LIMIT = 6;
/** /**
* The live pane holding a session: the run the hooks bound it to * The live pane holding a session: the run the hooks bound it to
@@ -244,7 +245,7 @@ export function WorkspaceSessionList({
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const ctx = useContextMenu<RunHandle>(); const ctx = useContextMenu<RunHandle>();
const sessionCtx = useContextMenu<Session>(); const sessionCtx = useContextMenu<Session>();
// Rows shown; Show more adds a page. // Rows shown; See all raises it to the whole list.
const [limit, setLimit] = useState(SESSION_LIMIT); const [limit, setLimit] = useState(SESSION_LIMIT);
// This workspace's live AI tabs. A tab started bare (no prompt) has its // This workspace's live AI tabs. A tab started bare (no prompt) has its
@@ -525,10 +526,10 @@ export function WorkspaceSessionList({
<button <button
type="button" type="button"
data-testid={`workspace-sessions-more-${lane.id}`} data-testid={`workspace-sessions-more-${lane.id}`}
onClick={() => setLimit((n) => n + SESSION_LIMIT)} onClick={() => own && setLimit(own.total)}
className="w-full py-1 pl-8 pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary" className="w-full py-1 pl-8 pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary"
> >
{t("tree.showMore", { count: Math.min(more, SESSION_LIMIT) })} {t("tree.seeAll", { count: more })}
</button> </button>
)} )}
{failure} {failure}
@@ -528,6 +528,17 @@ describe("LaneDiffView — line comments", () => {
expect(screen.queryByTestId("diff-comment-box")).toBeNull(); expect(screen.queryByTestId("diff-comment-box")).toBeNull();
}); });
it("docks the box under the diff once its line scrolls out of a windowed diff", () => {
render(<LaneDiffView file={FILE} layout="unified" onComment={() => true} />);
const btn = screen.getByTestId("diff-comment-line-1");
fireEvent.click(btn);
expect(screen.getByTestId("diff-comment-box").style.position).toBe("fixed");
// What windowing does to a row scrolled out of range.
btn.remove();
fireEvent.scroll(screen.getByTestId("lane-diff-view"));
expect(screen.getByTestId("diff-comment-box").style.position).toBe("");
});
it("offers no comment button without onComment", () => { it("offers no comment button without onComment", () => {
render(<LaneDiffView file={FILE} layout="unified" />); render(<LaneDiffView file={FILE} layout="unified" />);
expect(screen.queryByTestId("diff-comment-line-1")).toBeNull(); expect(screen.queryByTestId("diff-comment-line-1")).toBeNull();
+7 -2
View File
@@ -108,6 +108,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
const paletteRef = useRef(palette); const paletteRef = useRef(palette);
paletteRef.current = palette; paletteRef.current = palette;
const termRef = useRef<Terminal | null>(null); const termRef = useRef<Terminal | null>(null);
// The current attach: a paste into a socket that is not open would be dropped.
const wsRef = useRef<WebSocket | null>(null);
const { t } = useTranslation("run"); const { t } = useTranslation("run");
const tRef = useRef(t); const tRef = useRef(t);
tRef.current = t; tRef.current = t;
@@ -319,6 +321,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
const size = term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : ""; const size = term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`); const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
ws = sock; ws = sock;
wsRef.current = sock;
// Server sends PTY bytes as binary frames — default binaryType ("blob") // Server sends PTY bytes as binary frames — default binaryType ("blob")
// would hand onmessage a Blob that the string checks below never match, // would hand onmessage a Blob that the string checks below never match,
// silently dropping all terminal output. "arraybuffer" keeps it sync. // silently dropping all terminal output. "arraybuffer" keeps it sync.
@@ -432,8 +435,10 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
termRef.current?.paste(text); termRef.current?.paste(text);
termRef.current?.focus(); termRef.current?.focus();
}, },
// On screen: a console hidden with CSS measures 0×0. // On screen (a console hidden with CSS measures 0×0) AND attached:
() => !!containerRef.current?.offsetWidth // `onData` drops input while the socket is connecting or reconnecting,
// so a caller waits or says the paste failed instead.
() => !!containerRef.current?.offsetWidth && wsRef.current?.readyState === WebSocket.OPEN
), ),
[runId] [runId]
); );
@@ -10,6 +10,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react"; import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react";
import { TerminalView } from "../TerminalView"; import { TerminalView } from "../TerminalView";
import { pasteToRun } from "../../../lib/panePaste";
const writeMock = vi.fn(); const writeMock = vi.fn();
const onDataHandlers: Array<(d: string) => void> = []; const onDataHandlers: Array<(d: string) => void> = [];
@@ -288,6 +289,24 @@ describe("TerminalView", () => {
expect(screen.getByTestId("terminal-reconnecting")).toHaveTextContent("Reconnecting"); expect(screen.getByTestId("terminal-reconnecting")).toHaveTextContent("Reconnecting");
}); });
it("takes no paste while its socket is not open, so the text is not dropped unseen", () => {
const width = vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(800);
try {
render(<TerminalView runId="agenthub-lane-7" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!;
ws.readyState = 0; // CONNECTING
expect(pasteToRun("agenthub-lane-7", "hello")).toBe(false);
ws.readyState = 1;
act(() => ws.onopen?.());
expect(pasteToRun("agenthub-lane-7", "hello")).toBe(true);
ws.readyState = 3; // CLOSED: Reconnecting…
act(() => ws.onclose?.());
expect(pasteToRun("agenthub-lane-7", "hello")).toBe(false);
} finally {
width.mockRestore();
}
});
it("takes the keyboard as soon as it mounts", () => { it("takes the keyboard as soon as it mounts", () => {
// The terminal IS the page here — without this the first thing typed after // The terminal IS the page here — without this the first thing typed after
// opening a lane went nowhere until the user clicked the pane. // opening a lane went nowhere until the user clicked the pane.
+1
View File
@@ -427,6 +427,7 @@
"tree.state.idle": "ended", "tree.state.idle": "ended",
"tree.showMore_one": "Show {{count}} more", "tree.showMore_one": "Show {{count}} more",
"tree.showMore_other": "Show {{count}} more", "tree.showMore_other": "Show {{count}} more",
"tree.seeAll": "See all ({{count}} more)",
"home.contextPanel": "Workspace context", "home.contextPanel": "Workspace context",
"row.state.needsYou": "needs you", "row.state.needsYou": "needs you",
"row.state.error": "error", "row.state.error": "error",
+1
View File
@@ -56,6 +56,7 @@
}, },
"exportCsv": "CSV", "exportCsv": "CSV",
"exportJson": "JSON", "exportJson": "JSON",
"exportCapped": "The file holds {{shown}} of {{total}} sessions (the export limit) — narrow the filters to export the rest.",
"filterDirectory": "Filter by directory", "filterDirectory": "Filter by directory",
"allDirectories": "All directories", "allDirectories": "All directories",
"sortLabel": "Sort sessions", "sortLabel": "Sort sessions",
+1
View File
@@ -427,6 +427,7 @@
"tree.state.idle": "đã kết thúc", "tree.state.idle": "đã kết thúc",
"tree.showMore_one": "Hiện thêm {{count}}", "tree.showMore_one": "Hiện thêm {{count}}",
"tree.showMore_other": "Hiện thêm {{count}}", "tree.showMore_other": "Hiện thêm {{count}}",
"tree.seeAll": "Xem tất cả (thêm {{count}})",
"home.contextPanel": "Ngữ cảnh workspace", "home.contextPanel": "Ngữ cảnh workspace",
"row.state.needsYou": "cần bạn", "row.state.needsYou": "cần bạn",
"row.state.error": "lỗi", "row.state.error": "lỗi",
+1
View File
@@ -55,6 +55,7 @@
}, },
"exportCsv": "CSV", "exportCsv": "CSV",
"exportJson": "JSON", "exportJson": "JSON",
"exportCapped": "Tệp chỉ gồm {{shown}} trên {{total}} phiên (giới hạn xuất) — hãy thu hẹp bộ lọc để xuất phần còn lại.",
"filterDirectory": "Lọc theo thư mục", "filterDirectory": "Lọc theo thư mục",
"allDirectories": "Tất cả thư mục", "allDirectories": "Tất cả thư mục",
"sortLabel": "Sắp xếp phiên", "sortLabel": "Sắp xếp phiên",
@@ -0,0 +1,29 @@
/**
* @file runWorktree.test.ts
* @description The Changes panel's worktree session for a `?run=` pane survives
* a poll that misses the run, and follows every real answer.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { runWorktreeSession } from "../runWorktree";
const wt = { worktree: true, sessionId: "s1" };
describe("runWorktreeSession", () => {
it("follows the listed run's worktree session", () => {
expect(runWorktreeSession(null, "r1", wt)).toEqual({ run: "r1", session: "s1" });
expect(runWorktreeSession(null, "r1", { worktree: false, sessionId: "s1" })).toBeNull();
});
it("keeps the last answer through a poll that misses the run", () => {
const prev = runWorktreeSession(null, "r1", wt);
expect(runWorktreeSession(prev, "r1", undefined)).toEqual({ run: "r1", session: "s1" });
});
it("drops it for another run or no run", () => {
const prev = runWorktreeSession(null, "r1", wt);
expect(runWorktreeSession(prev, "r2", undefined)).toBeNull();
expect(runWorktreeSession(prev, null, undefined)).toBeNull();
});
});
+35
View File
@@ -0,0 +1,35 @@
/**
* @file toast.test.ts
* @description The toast queue: at most four on screen, and an error — which
* stays until dismissed — is never the one pushed out by newer toasts.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, afterEach } from "vitest";
import { toast, subscribeToasts, dismissToast, type ToastItem } from "../toast";
let current: ToastItem[] = [];
const unsubscribe = subscribeToasts((items) => (current = items));
afterEach(() => {
for (const t of current) dismissToast(t.id);
});
describe("toast queue", () => {
it("keeps an unread error when newer toasts overflow the queue", () => {
toast.error("save failed");
for (let i = 0; i < 5; i++) toast.success(`copied ${i}`);
expect(current).toHaveLength(4);
expect(current.map((t) => t.message)).toContain("save failed");
expect(current[current.length - 1]?.message).toBe("copied 4");
});
it("still shows the newest toast when only errors are queued", () => {
for (let i = 0; i < 4; i++) toast.error(`error ${i}`);
toast.info("hello");
expect(current).toHaveLength(4);
expect(current[current.length - 1]?.message).toBe("hello");
});
});
void unsubscribe;
+14 -3
View File
@@ -1255,10 +1255,21 @@ export const api = {
* "all" (which means "no filter"), keeping the default URL param-free. * "all" (which means "no filter"), keeping the default URL param-free.
* *
* @param status Optional lifecycle filter; "all" (or omitted) means no filter. * @param status Optional lifecycle filter; "all" (or omitted) means no filter.
* @returns {@link WorkflowData} — the aggregated workflow-intelligence panel. * @param sections Optional subset of the bundle to compute (`?sections=`);
* the server skips the rest, so a page reading a few cards asks for those.
* @returns {@link WorkflowData} — the aggregated workflow-intelligence panel,
* or just the named sections of it.
*/ */
get: (status?: string) => get: <K extends keyof WorkflowData = keyof WorkflowData>(
request<WorkflowData>(`/workflows${status && status !== "all" ? `?status=${status}` : ""}`), status?: string,
sections?: readonly K[]
) => {
const qs = new URLSearchParams();
if (status && status !== "all") qs.set("status", status);
if (sections?.length) qs.set("sections", sections.join(","));
const q = qs.toString();
return request<Pick<WorkflowData, K>>(`/workflows${q ? `?${q}` : ""}`);
},
/** /**
* GET /api/workflows/session/:id - single-session drill-in (agent tree, * GET /api/workflows/session/:id - single-session drill-in (agent tree,
* tool timeline, swim lanes). * tool timeline, swim lanes).
+29
View File
@@ -0,0 +1,29 @@
/**
* @file runWorktree.ts
* @description Which `claude --worktree` session the Workspace's Changes panel
* follows for the URL's `?run=` pane. A poll that misses the run (one bad read
* lists no runs) keeps the last answer instead of moving the panel to the main
* folder — which, while it holds an unsaved edit, asked to discard it out of
* nowhere.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export interface RunWorktree {
run: string;
session: string;
}
/**
* @param prev The previous answer.
* @param runParam The URL's `?run=`.
* @param opened That run in the latest list, if the list had it.
*/
export function runWorktreeSession(
prev: RunWorktree | null,
runParam: string | null,
opened: { worktree?: boolean; sessionId: string | null } | undefined
): RunWorktree | null {
if (!runParam) return null;
if (!opened) return prev?.run === runParam ? prev : null;
return opened.worktree && opened.sessionId ? { run: runParam, session: opened.sessionId } : null;
}
+9 -1
View File
@@ -16,6 +16,7 @@ export interface ToastItem {
} }
const SUCCESS_MS = 4000; const SUCCESS_MS = 4000;
const MAX_TOASTS = 4;
let items: ToastItem[] = []; let items: ToastItem[] = [];
let nextId = 1; let nextId = 1;
const listeners = new Set<(items: ToastItem[]) => void>(); const listeners = new Set<(items: ToastItem[]) => void>();
@@ -33,7 +34,14 @@ export function dismissToast(id: number): void {
function push(kind: ToastKind, message: string): number { function push(kind: ToastKind, message: string): number {
const id = nextId++; const id = nextId++;
// Same text twice in a row (a double click, a retry) shows once. // Same text twice in a row (a double click, a retry) shows once.
items = [...items.filter((t) => t.message !== message), { id, kind, message }].slice(-4); const next = [...items.filter((t) => t.message !== message), { id, kind, message }];
// Over four: the oldest non-error goes first, so an unread error is not pushed
// out by a few copies or saves; the newest toast always stays.
while (next.length > MAX_TOASTS) {
const older = next.slice(0, -1).findIndex((t) => t.kind !== "error");
next.splice(older === -1 ? 0 : older, 1);
}
items = next;
emit(); emit();
if (kind !== "error") window.setTimeout(() => dismissToast(id), SUCCESS_MS); if (kind !== "error") window.setTimeout(() => dismissToast(id), SUCCESS_MS);
return id; return id;
+18 -7
View File
@@ -113,6 +113,18 @@ const PAGE_SIZE = 50;
// stream still refreshes, which a trailing debounce never did. // stream still refreshes, which a trailing debounce never did.
const REFRESH_THROTTLE_MS = 1000; const REFRESH_THROTTLE_MS = 1000;
/** The agents on screen not asked about yet, and the sessions to ask for them. */
export function agentsToFetch(
events: { session_id: string; agent_id?: string | null }[],
asked: ReadonlySet<string>
): { agentIds: string[]; sessionIds: string[] } {
const fresh = events.filter((e) => e.agent_id && !asked.has(e.agent_id));
return {
agentIds: [...new Set(fresh.map((e) => e.agent_id as string))],
sessionIds: [...new Set(fresh.map((e) => e.session_id))],
};
}
export function ActivityFeed({ embedded = false }: { embedded?: boolean } = {}) { export function ActivityFeed({ embedded = false }: { embedded?: boolean } = {}) {
const eventCtx = useContextMenu<DashboardEvent>(); const eventCtx = useContextMenu<DashboardEvent>();
const { t } = useTranslation("activity"); const { t } = useTranslation("activity");
@@ -158,7 +170,7 @@ export function ActivityFeed({ embedded = false }: { embedded?: boolean } = {})
// agent_id → subagent-facing info for the agents of the sessions on screen, // agent_id → subagent-facing info for the agents of the sessions on screen,
// fetched in one call per new set of sessions (never every agent). // fetched in one call per new set of sessions (never every agent).
const [agentInfoById, setAgentInfoById] = useState<Map<string, AgentInfo>>(() => new Map()); const [agentInfoById, setAgentInfoById] = useState<Map<string, AgentInfo>>(() => new Map());
const knownAgentSessions = useRef(new Set<string>()); const askedAgents = useRef(new Set<string>());
const pausedRef = useRef(paused); const pausedRef = useRef(paused);
pausedRef.current = paused; pausedRef.current = paused;
@@ -223,13 +235,12 @@ export function ActivityFeed({ embedded = false }: { embedded?: boolean } = {})
load(); load();
}, [load]); }, [load]);
// Agent labels for the sessions on screen that we have not seen yet. // Agent labels for agents on screen not asked about yet — keyed by agent, so
// a subagent spawned later in a live session gets its label too.
useEffect(() => { useEffect(() => {
const missing = [...new Set(events.map((e) => e.session_id))].filter( const { agentIds, sessionIds: missing } = agentsToFetch(events, askedAgents.current);
(sid) => !knownAgentSessions.current.has(sid)
);
if (missing.length === 0) return; if (missing.length === 0) return;
for (const sid of missing) knownAgentSessions.current.add(sid); for (const id of agentIds) askedAgents.current.add(id);
api.agents api.agents
.list({ session_ids: missing, limit: 5000 }) .list({ session_ids: missing, limit: 5000 })
.then(({ agents }) => .then(({ agents }) =>
@@ -247,7 +258,7 @@ export function ActivityFeed({ embedded = false }: { embedded?: boolean } = {})
) )
.catch(() => { .catch(() => {
// Non-fatal: subagent pills fall back to the short-id label. // Non-fatal: subagent pills fall back to the short-id label.
for (const sid of missing) knownAgentSessions.current.delete(sid); for (const id of agentIds) askedAgents.current.delete(id);
}); });
}, [events]); }, [events]);
+26 -9
View File
@@ -388,7 +388,7 @@ export function CcConfig() {
// and Back land on it. // and Back land on it.
const [params, setParams] = useSearchParams(); const [params, setParams] = useSearchParams();
const rawTab = params.get("tab"); const rawTab = params.get("tab");
const tab: TabKey = TABS.some((d) => d.key === rawTab) ? (rawTab as TabKey) : "overview"; const urlTab: TabKey = TABS.some((d) => d.key === rawTab) ? (rawTab as TabKey) : "overview";
// Editors inside a tab report unsaved edits here (by id), so leaving the tab // Editors inside a tab report unsaved edits here (by id), so leaving the tab
// asks first instead of silently dropping the draft. // asks first instead of silently dropping the draft.
const dirtyIds = useRef(new Set<string>()); const dirtyIds = useRef(new Set<string>());
@@ -396,11 +396,8 @@ export function CcConfig() {
if (d) dirtyIds.current.add(id); if (d) dirtyIds.current.add(id);
else dirtyIds.current.delete(id); else dirtyIds.current.delete(id);
}, []); }, []);
const setTab = useCallback( const writeTab = useCallback(
async (k: TabKey) => { (k: TabKey) =>
if (k === tab) return;
if (dirtyIds.current.size > 0 && !(await confirmDiscard())) return;
dirtyIds.current.clear();
setParams( setParams(
(prev) => { (prev) => {
const next = new URLSearchParams(prev); const next = new URLSearchParams(prev);
@@ -409,10 +406,30 @@ export function CcConfig() {
return next; return next;
}, },
{ replace: true } { replace: true }
); ),
}, [setParams]
[tab, setParams]
); );
// The tab shown follows the URL, but only once an unsaved edit is let go:
// the URL also changes from outside the tab bar (a settings search result,
// Back), and those must ask too. Kept, the URL goes back to the shown tab.
const [tab, setShownTab] = useState<TabKey>(urlTab);
useEffect(() => {
if (urlTab === tab) return;
let live = true;
void (async () => {
if (dirtyIds.current.size > 0 && !(await confirmDiscard())) {
if (live) writeTab(tab);
return;
}
if (!live) return;
dirtyIds.current.clear();
setShownTab(urlTab);
})();
return () => {
live = false;
};
}, [urlTab, tab, writeTab]);
const setTab = useCallback((k: TabKey) => writeTab(k), [writeTab]);
// Bumped by Refresh: the AgentHub cards load on mount, so a new key reloads them. // Bumped by Refresh: the AgentHub cards load on mount, so a new key reloads them.
const [agenthubKey, setAgenthubKey] = useState(0); const [agenthubKey, setAgenthubKey] = useState(0);
const [scope, setScope] = useState<CcScope>("all"); const [scope, setScope] = useState<CcScope>("all");
+17 -2
View File
@@ -1583,15 +1583,30 @@ function AnalyticsTab() {
); );
} }
// The workflow sections the health cards read; the server computes only these.
const HEALTH_SECTIONS = [
"stats",
"toolFlow",
"effectiveness",
"modelDelegation",
"errorPropagation",
"concurrency",
"compaction",
] as const satisfies readonly (keyof WorkflowData)[];
type HealthWorkflow = Pick<WorkflowData, (typeof HEALTH_SECTIONS)[number]>;
function SystemHealthTab() { function SystemHealthTab() {
const { t } = useTranslation("dashboard"); const { t } = useTranslation("dashboard");
const [info, setInfo] = useState<SystemInfo | null>(null); const [info, setInfo] = useState<SystemInfo | null>(null);
const [workflow, setWorkflow] = useState<WorkflowData | null>(null); const [workflow, setWorkflow] = useState<HealthWorkflow | null>(null);
const [loadError, setLoadError] = useState<string | null>(null); const [loadError, setLoadError] = useState<string | null>(null);
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
try { try {
const [infoRes, workflowRes] = await Promise.all([api.settings.info(), api.workflows.get()]); const [infoRes, workflowRes] = await Promise.all([
api.settings.info(),
api.workflows.get(undefined, HEALTH_SECTIONS),
]);
setInfo(infoRes as any); setInfo(infoRes as any);
setWorkflow(workflowRes); setWorkflow(workflowRes);
setLoadError(null); setLoadError(null);
+3
View File
@@ -304,6 +304,9 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
]), ]),
"text/csv" "text/csv"
); );
// The server answers at most 10 000 rows: say the file is not all of them.
if (res.total > res.sessions.length)
toast.info(t("exportCapped", { shown: res.sessions.length, total: res.total }));
} catch (err) { } catch (err) {
toast.error(err); toast.error(err);
} finally { } finally {
+15 -2
View File
@@ -96,6 +96,14 @@ const historyIdx = (): number => {
return typeof idx === "number" ? idx : 0; return typeof idx === "number" ? idx : 0;
}; };
/**
* How far Back moves: to the entry before the one Settings was entered at, and
* always at least one step — after a browser Back below that entry, the plain
* difference would be 0 (`history.go(0)`, a reload) or a step forward.
*/
export const backDelta = (current: number, entry: number): number =>
-Math.max(1, current - entry + 1);
/** An unknown section: say so, then show General. */ /** An unknown section: say so, then show General. */
function UnknownSection({ name }: { name: string }) { function UnknownSection({ name }: { name: string }) {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
@@ -142,7 +150,12 @@ export function SettingsHub() {
// Deep link to a row: scroll to the anchor once the page has rendered it // Deep link to a row: scroll to the anchor once the page has rendered it
// (content loads async, so poll briefly) and highlight it for a moment. // (content loads async, so poll briefly) and highlight it for a moment.
useEffect(() => { useEffect(() => {
const id = decodeURIComponent(location.hash.slice(1)); let id = location.hash.slice(1);
try {
id = decodeURIComponent(id);
} catch {
// A malformed escape (`#%`): look the raw text up instead of crashing.
}
if (!id) return; if (!id) return;
let timer: number | undefined; let timer: number | undefined;
let lit: Element | null = null; let lit: Element | null = null;
@@ -363,7 +376,7 @@ export function SettingsHub() {
const back = (e: MouseEvent) => { const back = (e: MouseEvent) => {
if (!canGoBack) return; if (!canGoBack) return;
e.preventDefault(); e.preventDefault();
navigate(-(historyIdx() - (entryIdx.current ?? 0) + 1)); navigate(backDelta(historyIdx(), entryIdx.current ?? 0));
}; };
return ( return (
+8 -6
View File
@@ -49,6 +49,7 @@
* *
* ----------------------------------------------------------------------------- */ * ----------------------------------------------------------------------------- */
import { runWorktreeSession, type RunWorktree } from "../lib/runWorktree";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -687,12 +688,13 @@ export function Workspace() {
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps -- the lane by id // eslint-disable-next-line react-hooks/exhaustive-deps -- the lane by id
}, [sessionParam, replayLane?.id]); }, [sessionParam, replayLane?.id]);
const gitSession = const runWorktree = useRef<RunWorktree | null>(null);
openedRun?.worktree && openedRun.sessionId runWorktree.current = runWorktreeSession(runWorktree.current, runParam, openedRun);
? openedRun.sessionId const gitSession = runWorktree.current
: sessionParam && replayed?.id === sessionParam && replayed.worktree ? runWorktree.current.session
? sessionParam : sessionParam && replayed?.id === sessionParam && replayed.worktree
: undefined; ? sessionParam
: undefined;
// An unsaved whole-file edit in the Changes panel. While it is open the // An unsaved whole-file edit in the Changes panel. While it is open the
// panel stays on the tree it was opened in — moving it (another workspace, // panel stays on the tree it was opened in — moving it (another workspace,
@@ -0,0 +1,32 @@
/**
* @file ActivityFeed.agents.test.ts
* @description The Activity feed asks for agent labels by agent, not by
* session: a subagent spawned later in a session already on screen is fetched
* too, and an agent already asked about is not asked for again.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { agentsToFetch } from "../ActivityFeed";
describe("agentsToFetch", () => {
it("asks again for a session once a new agent of it appears", () => {
const asked = new Set(["s1-main"]);
const events = [
{ session_id: "s1", agent_id: "s1-main" },
{ session_id: "s1", agent_id: "s1-sub-late" },
{ session_id: "s2", agent_id: null },
];
expect(agentsToFetch(events, asked)).toEqual({
agentIds: ["s1-sub-late"],
sessionIds: ["s1"],
});
});
it("asks nothing when every agent on screen is known", () => {
expect(agentsToFetch([{ session_id: "s1", agent_id: "a" }], new Set(["a"]))).toEqual({
agentIds: [],
sessionIds: [],
});
});
});
@@ -10,7 +10,7 @@
import { describe, it, expect, vi, afterEach } from "vitest"; import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, act, within } from "@testing-library/react"; import { render, screen, act, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter, useLocation, useNavigate } from "react-router-dom";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm"; import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
import { eventBus } from "../../lib/eventBus"; import { eventBus } from "../../lib/eventBus";
@@ -119,6 +119,40 @@ describe("CcConfig — settings editor", () => {
expect(ccConfigMock.patchSettings).toHaveBeenCalledWith({ scope: "user", unset: ["a"] }); expect(ccConfigMock.patchSettings).toHaveBeenCalledWith({ scope: "user", unset: ["a"] });
}); });
it("a tab change from the URL (a search result) asks before dropping an unsaved edit", async () => {
const user = userEvent.setup();
function Jump() {
const navigate = useNavigate();
const loc = useLocation();
return (
<>
<button onClick={() => navigate("/settings/claude?tab=skills")}>jump</button>
<span data-testid="url">{loc.search}</span>
</>
);
}
render(
<MemoryRouter initialEntries={["/settings/claude?tab=settings"]}>
<CcConfig />
<Jump />
</MemoryRouter>
);
await settle();
await user.click(screen.getByRole("button", { name: /^edit$/i }));
await user.click(screen.getAllByRole("button", { name: /remove key/i })[0]!);
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
await user.click(screen.getByRole("button", { name: "jump" }));
await settle();
expect(pending).not.toBeNull();
// Keep editing: the draft stays and the URL goes back to its tab.
await act(async () => settleConfirm(false));
await settle();
off();
expect(screen.getByRole("button", { name: /^save$/i })).toBeInTheDocument();
expect(screen.getByTestId("url")).toHaveTextContent("?tab=settings");
});
it("a renamed key is sent under its new name, not dropped", async () => { it("a renamed key is sent under its new name, not dropped", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render( render(
@@ -58,7 +58,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
}; };
}); });
import { SettingsHub } from "../SettingsHub"; import { SettingsHub, backDelta } from "../SettingsHub";
import { __resetAiTools } from "../../hooks/useAiTools"; import { __resetAiTools } from "../../hooks/useAiTools";
import { subscribeToasts, type ToastItem } from "../../lib/toast"; import { subscribeToasts, type ToastItem } from "../../lib/toast";
@@ -243,6 +243,21 @@ describe("SettingsHub", () => {
await vi.waitFor(() => expect(row.className).toContain("ring-2")); await vi.waitFor(() => expect(row.className).toContain("ring-2"));
}); });
it("a malformed #anchor is ignored instead of crashing the page", async () => {
renderAt("/settings/data#%E0%A4");
expect(await screen.findByTestId("settings-data")).toBeInTheDocument();
});
it("Back always goes back: one step past the entry, at least one step", () => {
// Entered at index 2, now two pages further on: back to index 1.
expect(backDelta(4, 2)).toBe(-3);
expect(backDelta(2, 2)).toBe(-1);
// Browser Back moved below the entry with the hub still mounted: one step,
// never history.go(0) (a reload) or a step forward.
expect(backDelta(1, 2)).toBe(-1);
expect(backDelta(0, 2)).toBe(-1);
});
it("toasts when an unknown section falls back to General", async () => { it("toasts when an unknown section falls back to General", async () => {
let seen: ToastItem[] = []; let seen: ToastItem[] = [];
const off = subscribeToasts((i) => (seen = i)); const off = subscribeToasts((i) => (seen = i));
+27 -1
View File
@@ -1071,6 +1071,31 @@ records anything — a pipeline going unused has to be visible as such.
### Workflows ### Workflows
#### Workflow aggregate bundle
```http
GET /api/workflows?status=active|completed&sections=stats,toolFlow
```
The Workflow analytics page's eleven sections in one payload: `stats`,
`orchestration`, `toolFlow`, `effectiveness`, `patterns`, `modelDelegation`,
`errorPropagation`, `concurrency`, `complexity`, `compaction`, `cooccurrence`.
`status` scopes every section to sessions of one lifecycle status; omit it (or
pass `all`) for no filter. `sections` computes and returns only the named ones —
Dashboard → Health asks for the seven its cards read. `stats.topFlow` is the
head of `toolFlow.transitions` (one window pass over the events for both).
The bundle scans every event, so it is computed in a worker thread on its own
read-only connection (`server/lib/read-pool.js`) and never stalls another
request, hook or websocket meanwhile. Identical requests in flight share one
run. `AGENTHUB_READ_WORKER=0` computes it inline instead.
**Status codes:**
- **200** — the bundle, or the requested sections of it
- **400** `{ "error": { "code": "INVALID_SECTIONS", "message": "sections must name some of: …" } }` — `sections` names no known section (unknown names beside known ones are ignored)
- **500** `{ "error": { "message": "…" } }`
- **503** `{ "error": { "code": "EREADTIMEOUT", "message": "read query took over 60000 ms" } }` — the run passed `AGENTHUB_READ_TIMEOUT_MS` (default 60000); its worker is replaced for the next request
#### Skill and plugin usage #### Skill and plugin usage
```http ```http
@@ -1319,7 +1344,8 @@ Body: any of `name`, `status`, `ended_at`, `metadata` and `archived`.
the session leaves its workspace's lists and nothing is deleted. Ownership the session leaves its workspace's lists and nothing is deleted. Ownership
(`lane_id`) is not patchable: `bindRunSession` (`server/routes/hooks.js`) (`lane_id`) is not patchable: `bindRunSession` (`server/routes/hooks.js`)
stamps the session a dashboard pane holds with that pane's lane, first writer stamps the session a dashboard pane holds with that pane's lane, first writer
wins. wins (`claimToolSessions` in `server/lib/dashboard-runs.js` does it for a
Codex/Antigravity session from the run that was live in its folder).
Returns `{ session }`. Returns `{ session }`.
--- ---
+1 -1
View File
@@ -211,7 +211,7 @@ When the server is down, **read-only commands automatically fall back to reading
| ------- | ----------- | | ------- | ----------- |
| `agenthub import rescan` | Re-scan the default `~/.claude/projects` tree (idempotent; prints imported / backfilled / skipped / errors) | | `agenthub import rescan` | Re-scan the default `~/.claude/projects` tree (idempotent; prints imported / backfilled / skipped / errors) |
| `agenthub import path <dir>` | Recursively import every `.jsonl` under an absolute directory (`~` is expanded server-side) | | `agenthub import path <dir>` | Recursively import every `.jsonl` under an absolute directory (`~` is expanded server-side) |
| `agenthub import-data <file.json>` | Restore a full dashboard export produced by `agenthub export` (or **Settings → Export data**). Idempotent and non-destructive — sessions already present are skipped whole, so it safely **consolidates several machines** into one dashboard. The file path is resolved to absolute and read server-side | | `agenthub import-data <file.json>` | Restore a full dashboard export produced by `agenthub export` (or **Settings → Export data**). Idempotent and non-destructive — sessions already present are skipped whole, so it safely **consolidates several machines** into one dashboard. The file is uploaded to the server, like the dashboard's Restore button |
### Remote Sources ### Remote Sources
+12 -1
View File
@@ -212,7 +212,7 @@ CREATE TABLE sessions (
| `transcript_path` | TEXT | YES | Absolute path to the session's JSONL transcript. Written by `routes/hooks.js` on the first event that carries it (subsequent events no-op via a SQL guard) and read by the periodic compaction sweep — so the sweep touches only active session rows instead of scanning the entire `events` table for `json_extract(data,'$.transcript_path')`. Backfilled once from `events` by the `db.js` migration | | `transcript_path` | TEXT | YES | Absolute path to the session's JSONL transcript. Written by `routes/hooks.js` on the first event that carries it (subsequent events no-op via a SQL guard) and read by the periodic compaction sweep — so the sweep touches only active session rows instead of scanning the entire `events` table for `json_extract(data,'$.transcript_path')`. Backfilled once from `events` by the `db.js` migration |
| `source` | TEXT | NO | Data source this session was captured from. `'local'` for this machine's own Claude Code history (the default); otherwise the `remote_sources.id` of the remote SSH machine it was pulled from. Powers the `sources` query filter on `/api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, and `/api/analytics`, and the `sources` facet on `/api/sessions/facets`. Indexed by `idx_sessions_source` | | `source` | TEXT | NO | Data source this session was captured from. `'local'` for this machine's own Claude Code history (the default); otherwise the `remote_sources.id` of the remote SSH machine it was pulled from. Powers the `sources` query filter on `/api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, and `/api/analytics`, and the `sources` facet on `/api/sessions/facets`. Indexed by `idx_sessions_source` |
| `tool` | TEXT | NO | The AI coding CLI that produced the session. `'claude'` for every hook-created and imported row (the default); `'codex'` for a session ingested from Codex's rollout journals by `server/lib/codex-usage.js` (see `codex_rollouts`) or created by a Codex hook (`tool: "codex"`, `server/lib/codex-hooks.js`) — a Codex session keeps the status its hooks give it while they are current (its newest event at or after its newest journal write minus two minutes); once they stop (removed, untrusted) the journal decides again. A codex row has no `transcript_path`, and, going by the journal, its `status` is `active` only while one of its journals was written in the last two minutes. `'antigravity'` for an agy CLI conversation, ingested by `server/lib/antigravity-usage.js` (see `antigravity_conversations`) or created by an Antigravity hook (`server/lib/antigravity-hooks.js`); no `transcript_path`, and `active` until it has been quiet — no write to its conversation file (or a non-empty `-wal`) or transcript, no hook event — for ten minutes when it has hook events, two minutes when it has none; it then settles to `completed`, its agents too. The startup cleanup of stale `active` sessions and a workspace purge touch only the matching tool's rows (a purge takes the sessions of the workspace's own `tool`). The Workspace's attach/resume list offers `'claude'` rows only | | `tool` | TEXT | NO | The AI coding CLI that produced the session. `'claude'` for every hook-created and imported row (the default); `'codex'` for a session ingested from Codex's rollout journals by `server/lib/codex-usage.js` (see `codex_rollouts`) or created by a Codex hook (`tool: "codex"`, `server/lib/codex-hooks.js`) — a Codex session keeps the status its hooks give it while they are current (its newest event at or after its newest journal write minus two minutes); once they stop (removed, untrusted) the journal decides again. A codex row has no `transcript_path`, and, going by the journal, its `status` is `active` only while one of its journals was written in the last two minutes. `'antigravity'` for an agy CLI conversation, ingested by `server/lib/antigravity-usage.js` (see `antigravity_conversations`) or created by an Antigravity hook (`server/lib/antigravity-hooks.js`); no `transcript_path`, and `active` until it has been quiet — no write to its conversation file (or a non-empty `-wal`) or transcript, no hook event — for ten minutes when it has hook events, two minutes when it has none; it then settles to `completed`, its agents too. The startup cleanup of stale `active` sessions and a workspace purge touch only the matching tool's rows (a purge takes the sessions of the workspace's own `tool`). The Workspace's attach/resume list offers `'claude'` rows only |
| `lane_id` | INTEGER | YES | The workspace that OWNS the session — the only sessions a workspace lists: the lane whose dashboard pane held it, stamped by `bindRunSession` (`server/routes/hooks.js`) from the hook's `run_id` — first writer wins, a nested `claude -p` excluded. NULL for a session no workspace started (a terminal, an import, a tool with no hooks). No FK: removing a workspace leaves its sessions. Backfilled once from `dashboard_runs`. Indexed by `idx_sessions_lane` | | `lane_id` | INTEGER | YES | The workspace that OWNS the session — the only sessions a workspace lists: the lane whose dashboard pane held it, stamped by `bindRunSession` (`server/routes/hooks.js`) from the hook's `run_id` — first writer wins, a nested `claude -p` excluded — or, for a Codex/Antigravity session, by `claimToolSessions` (`server/lib/dashboard-runs.js`) from the same-tool run that was live in its folder when it started. NULL for a session no workspace started (a terminal, an import, a tool with no hooks). No FK: removing a workspace leaves its sessions. Backfilled once from `dashboard_runs`. Indexed by `idx_sessions_lane` |
| `archived_at` | TEXT | YES | When the user archived the session out of its workspace's lists (`PATCH /api/sessions/:id {archived}`). Hides only — nothing is deleted | | `archived_at` | TEXT | YES | When the user archived the session out of its workspace's lists (`PATCH /api/sessions/:id {archived}`). Hides only — nothing is deleted |
**Constraints:** **Constraints:**
@@ -607,6 +607,17 @@ CREATE INDEX idx_agents_status ON agents(status);
-- before inserting; on a subagent-heavy re-import this drops a large sweep from -- before inserting; on a subagent-heavy re-import this drops a large sweep from
-- tens of seconds to sub-second. -- tens of seconds to sub-second.
CREATE INDEX idx_events_agent_type ON events(agent_id, event_type); CREATE INDEX idx_events_agent_type ON events(agent_id, event_type);
-- A session's events in insert order. NOT a prefix of idx_events_session_type
-- (not id-ordered): every hook's duplicate guard reads a session's latest event
-- through it. Dropping it (ad6e801) made that read sort the whole session per
-- hook and made a correlated "next tool event" query quadratic — minutes on a
-- 29k-event session, the event loop frozen for every page.
CREATE INDEX idx_events_session ON events(session_id, id);
-- Per-tool counts (analytics, workflows) and the events filter's tool list
-- read this alone instead of every event row (~300 ms -> ~20 ms at 233k rows).
CREATE INDEX idx_events_tool ON events(tool_name);
``` ```
### tool_executions Indexes ### tool_executions Indexes
+2
View File
@@ -260,6 +260,8 @@ AGENTHUB_GEMINI_DIR= # where agy keeps its data (default ~/.g
# Pricing and saved settings # Pricing and saved settings
AGENTHUB_PRICING_SYNC= # 0|false|off disables automatic pricing from LiteLLM (default on) AGENTHUB_PRICING_SYNC= # 0|false|off disables automatic pricing from LiteLLM (default on)
AGENTHUB_READ_WORKER= # 0 runs heavy aggregates (GET /api/workflows) inline instead of in the read worker (default worker)
AGENTHUB_READ_TIMEOUT_MS=60000 # a read-worker job past this answers 503 EREADTIMEOUT and gets a fresh worker
AGENTHUB_SETTINGS_FILE= # where dashboard-saved settings persist (default ~/.config/agenthub/settings.env); AGENTHUB_SETTINGS_FILE= # where dashboard-saved settings persist (default ~/.config/agenthub/settings.env);
# load order: real env > the checkout's .env > this file # load order: real env > the checkout's .env > this file
+6 -2
View File
@@ -168,8 +168,12 @@ npm run install-hooks
> `~/.claude/settings.json.agenthub-bak`) — and since v1.6.17 a settings file that > `~/.claude/settings.json.agenthub-bak`) — and since v1.6.17 a settings file that
> still carries them also blocks the bootstrap's fast path (`hasLegacyHooks`), > still carries them also blocks the bootstrap's fast path (`hasLegacyHooks`),
> because the strip runs only in the full pass and a healthy install skipped it > because the strip runs only in the full pass and a healthy install skipped it
> every session, keeping the duplicates alive indefinitely. `install-hooks.js` warns when it detects > every session, keeping the duplicates alive indefinitely. And `install-hooks.js` itself —
> a plugin install, and `/agenthub-doctor` reports any duplicates that remain. See > run by hand or by every checkout server start (`npm run dev`, `npm start`) — adds nothing
> while settings.json enables `agenthub@…` and removes every `hook-handler.js` entry it finds
> instead (same backup); it used to put them straight back after each strip, so a dev server
> beside the plugin kept ~40% of events doubled. Settings reports such hooks as installed
> (`source: "plugin"`). `/agenthub-doctor` reports any duplicates that remain. See
> [PLUGINS.md](PLUGINS.md). > [PLUGINS.md](PLUGINS.md).
> [!IMPORTANT] > [!IMPORTANT]
+1
View File
@@ -406,6 +406,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first. A pane has **no run form of its own**: a session with a prompt starts from the workspace home's prompt box. There is no **New run** button: when a pane's process ends (a double Ctrl+C out of `claude`, `exit`, a crash) its pane closes; the last one shows the next open tab, or the home when none is left. A pane with nothing to show offers **New session**, which opens the home too. - **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first. A pane has **no run form of its own**: a session with a prompt starts from the workspace home's prompt box. There is no **New run** button: when a pane's process ends (a double Ctrl+C out of `claude`, `exit`, a crash) its pane closes; the last one shows the next open tab, or the home when none is left. A pane with nothing to show offers **New session**, which opens the home too.
Keyboard: the terminal keeps Tab and Shift+Tab (`claude` cycles its permission mode on Shift+Tab), so **Ctrl+M** toggles *Tab moves focus* — VS Code's way out of its terminal — and Tab then leaves the pane; **Cmd+F** (macOS) / **Ctrl+Shift+F** opens a find bar. URLs open in a new tab; a selection tmux copies (OSC 52) lands in the browser clipboard, and that path is write-only — nothing in the pane can read your clipboard. `agenthub.terminal.screenReader` = `"1"` in `localStorage` turns on xterm's screen-reader mode. While the socket is down a **Reconnecting…** overlay says keystrokes are not reaching the pane; it re-attaches by itself. In the session view, outside a terminal or a text field, `Alt+1`–`Alt+4` focuses pane 1–4, `Alt+\` splits the focused pane right with a new terminal, and `Alt+W` closes the focused pane (its session stays a tab). Keyboard: the terminal keeps Tab and Shift+Tab (`claude` cycles its permission mode on Shift+Tab), so **Ctrl+M** toggles *Tab moves focus* — VS Code's way out of its terminal — and Tab then leaves the pane; **Cmd+F** (macOS) / **Ctrl+Shift+F** opens a find bar. URLs open in a new tab; a selection tmux copies (OSC 52) lands in the browser clipboard, and that path is write-only — nothing in the pane can read your clipboard. `agenthub.terminal.screenReader` = `"1"` in `localStorage` turns on xterm's screen-reader mode. While the socket is down a **Reconnecting…** overlay says keystrokes are not reaching the pane; it re-attaches by itself. In the session view, outside a terminal or a text field, `Alt+1`–`Alt+4` focuses pane 1–4, `Alt+\` splits the focused pane right with a new terminal, and `Alt+W` closes the focused pane (its session stays a tab).
- **Clipboard in a pane** — the workspace's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one. - **Clipboard in a pane** — the workspace's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
- **Scrollbar in a pane** — every pane gets tmux's `mouse on` and `pane-scrollbars on` with a visible style (`pane-scrollbars-style fg=brightwhite,bg=black,width=2`, `enableMouse` in `server/lib/tmux.js`; tmux 3.6+). tmux keeps no history and draws no scrollbar on the alternate screen, so a `claude` pane is started with `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1` (`spawnRun` in `server/lib/pty-run.js`) and renders inline even when `~/.claude/settings.json` says `"tui": "fullscreen"`. That setting still applies to `claude` in your own terminals; a pane started before this change keeps fullscreen until it is restarted.
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`), one tab per live tmux session of the workspace — at most four, its slots 0–3: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–3 are `agenthub-lane-<id>-<slot>`. A tab shows its AI tool's icon and name (or **Terminal**); the active tab is the focused pane's, which is the URL's `?run=`, so the Changes panel and Analyst follow it, and a tab another pane shows carries a grey underline. A click shows a tab in the focused pane (or focuses the pane already showing it), **Ctrl/Cmd+click** — on a tab or on a `+` item — opens it in a split, and a tab drags onto the panes. Every tab with a live run stays mounted and those no pane shows are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops its session (`DELETE /api/run/<runId>`) and closes its pane; with no pane left it moves to the next tab, or the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With four tabs open **+** is disabled. EVERY tab is bound to the sidebar's selection. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.) - **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`), one tab per live tmux session of the workspace — at most four, its slots 0–3: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–3 are `agenthub-lane-<id>-<slot>`. A tab shows its AI tool's icon and name (or **Terminal**); the active tab is the focused pane's, which is the URL's `?run=`, so the Changes panel and Analyst follow it, and a tab another pane shows carries a grey underline. A click shows a tab in the focused pane (or focuses the pane already showing it), **Ctrl/Cmd+click** — on a tab or on a `+` item — opens it in a split, and a tab drags onto the panes. Every tab with a live run stays mounted and those no pane shows are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops its session (`DELETE /api/run/<runId>`) and closes its pane; with no pane left it moves to the next tab, or the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With four tabs open **+** is disabled. EVERY tab is bound to the sidebar's selection. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.)
- **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): a binary tree of splits, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes, one per live session, a session never shown twice. **Ctrl/Cmd+click** a tab, a `+` item or a sidebar session row opens it to the right of the focused pane (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two —, its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, and **×** closes the pane only (its session stays a tab). Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The layout is a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip. - **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): a binary tree of splits, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes, one per live session, a session never shown twice. **Ctrl/Cmd+click** a tab, a `+` item or a sidebar session row opens it to the right of the focused pane (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two —, its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, and **×** closes the pane only (its session stays a tab). Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The layout is a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip.
+35 -15
View File
@@ -1753,7 +1753,7 @@ components:
type: boolean type: boolean
source: source:
type: string type: string
description: Uploaded filename or server-side path read. description: The uploaded file's name.
format: format:
type: string type: string
nullable: true nullable: true
@@ -6159,10 +6159,17 @@ paths:
tags: tags:
- Workflows - Workflows
summary: Get workflow intelligence aggregates summary: Get workflow intelligence aggregates
description: 'Returns the full workflow-intelligence aggregate powering the Workflows analytics page — 11 sections in one payload: `stats` (headline counters: sessions, agents, subagents, success rate, avg depth/duration, compactions, top tool flow), `orchestration` (subagent-type breakdown + delegation edges + outcomes), `toolFlow` (tool-to-tool transitions + tool counts), `effectiveness` (per-subagent-type success rate, avg duration, weekly trend), `patterns` (frequent subagent sequences + solo-session share), `modelDelegation` (model usage for main/sub agents + tokens by model), `errorPropagation` (errors by depth/type + error rate), `concurrency` (averaged agent swim-lane start/end), `complexity` (per-session agent/token/duration rows), `compaction` (compaction counts + tokens recovered), and `cooccurrence` (directed subagent-after-subagent pairs). The optional `status` query filter scopes every section to sessions of one status. Errors use the SHORT `{ error: { message } }` shape.' description: 'Returns the full workflow-intelligence aggregate powering the Workflows analytics page — 11 sections in one payload: `stats` (headline counters: sessions, agents, subagents, success rate, avg depth/duration, compactions, top tool flow), `orchestration` (subagent-type breakdown + delegation edges + outcomes), `toolFlow` (tool-to-tool transitions + tool counts), `effectiveness` (per-subagent-type success rate, avg duration, weekly trend), `patterns` (frequent subagent sequences + solo-session share), `modelDelegation` (model usage for main/sub agents + tokens by model), `errorPropagation` (errors by depth/type + error rate), `concurrency` (averaged agent swim-lane start/end), `complexity` (per-session agent/token/duration rows), `compaction` (compaction counts + tokens recovered), and `cooccurrence` (directed subagent-after-subagent pairs). The optional `status` query filter scopes every section to sessions of one status, and `sections` computes and returns only the named ones (`stats.topFlow` is the head of `toolFlow.transitions`, one pass for both). The bundle is computed in a worker thread on its own read-only connection, so it never stalls other requests; identical requests in flight share one run, and one over `AGENTHUB_READ_TIMEOUT_MS` (default 60 s) answers 503 `EREADTIMEOUT`. Errors use the SHORT `{ error: { message } }` shape.'
operationId: getWorkflowIntelligence operationId: getWorkflowIntelligence
parameters: parameters:
- $ref: '#/components/parameters/WorkflowStatusQuery' - $ref: '#/components/parameters/WorkflowStatusQuery'
- name: sections
in: query
required: false
description: 'Comma-separated subset of the bundle to compute: stats, orchestration, toolFlow, effectiveness, patterns, modelDelegation, errorPropagation, concurrency, complexity, compaction, cooccurrence. Unknown names are ignored; none known is a 400.'
schema:
type: string
example: stats,toolFlow
responses: responses:
'200': '200':
description: Workflow aggregate data description: Workflow aggregate data
@@ -6306,6 +6313,16 @@ paths:
- source: Explore - source: Explore
target: general-purpose target: general-purpose
weight: 142 weight: 142
'400':
description: '`sections` names no known section'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
error:
code: INVALID_SECTIONS
message: 'sections must name some of: stats, …'
'500': '500':
description: Internal server error description: Internal server error
content: content:
@@ -6315,6 +6332,16 @@ paths:
example: example:
error: error:
message: 'no such table: agents' message: 'no such table: agents'
'503':
description: The aggregate ran past `AGENTHUB_READ_TIMEOUT_MS`
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
error:
code: EREADTIMEOUT
message: read query took over 60000 ms
/api/workflows/session/{id}: /api/workflows/session/{id}:
get: get:
tags: tags:
@@ -13075,7 +13102,7 @@ paths:
tags: tags:
- Settings - Settings
summary: Restore (import) a previously exported data bundle summary: Restore (import) a previously exported data bundle
description: 'Restores a bundle produced by GET /api/settings/export. Supply it either as `multipart/form-data` with a single `file` field (browser upload) or as a JSON body `{ "path": "<absolute path>" }` (the server reads the file from disk — used by the `agenthub import-data` CLI, and it also sidesteps the global 1 MB JSON body cap for large bundles). The restore is idempotent and NON-DESTRUCTIVE: it is session-atomic, so a session already present (matched by its UUID) is skipped whole together with its agents/events/token_usage/workflows, and independent config rows (dashboard_runs, alert_rules, model_pricing) are inserted only when absent. Nothing existing is overwritten — ideal for consolidating several machines into one dashboard. The response reports per-table counts.' description: 'Restores a bundle produced by GET /api/settings/export, uploaded as `multipart/form-data` with a single `file` field (the dashboard''s Restore button and the `agenthub import-data` CLI both upload it; the server never reads a path named by a request). Same-origin only. At most 512 MB (`413 FILE_TOO_LARGE`); a file that is not JSON or not an export answers `400 INVALID_FORMAT` without echoing its text, no file `400 NO_FILE`. The restore is idempotent and NON-DESTRUCTIVE: it is session-atomic, so a session already present (matched by its UUID) is skipped whole together with its agents/events/token_usage/workflows, and independent config rows (dashboard_runs, alert_rules, model_pricing) are inserted only when absent. Restored event payloads are trimmed like every event insert (`trim_event_data`). Nothing existing is overwritten — ideal for consolidating several machines into one dashboard. The response reports per-table counts.'
operationId: importData operationId: importData
requestBody: requestBody:
required: true required: true
@@ -13088,17 +13115,6 @@ paths:
type: string type: string
format: binary format: binary
description: The export .json file. description: The export .json file.
application/json:
schema:
type: object
required:
- path
properties:
path:
type: string
description: Absolute path (server-side) to an export .json file. `~` is expanded.
example:
path: /Users/you/agenthub-export-2026-06-26.json
responses: responses:
'200': '200':
description: Per-table restore counts description: Per-table restore counts
@@ -13121,5 +13137,9 @@ paths:
model_pricing: 0 model_pricing: 0
errors: 0 errors: 0
'400': '400':
description: Missing file, invalid JSON, or unrecognized export format description: Missing file (NO_FILE), invalid JSON or unrecognized export format (INVALID_FORMAT)
'403':
description: Cross-origin request (EBADORIGIN)
'413':
description: The file is larger than 512 MB (FILE_TOO_LARGE)
x-issues-url: https://git.smartgift.io.vn/Smartgift-AI/AgentHub/issues x-issues-url: https://git.smartgift.io.vn/Smartgift-AI/AgentHub/issues
+54 -13
View File
@@ -2,7 +2,9 @@
/** /**
* Installs Claude Code hooks that forward events to the Agent Dashboard. * Installs Claude Code hooks that forward events to the Agent Dashboard.
* Modifies ~/.claude/settings.json to add hook entries. * Modifies ~/.claude/settings.json to add hook entries — or, while the
* `agenthub` plugin is enabled (it ships the same hooks inline), removes ours
* instead, since two handlers post every event twice.
* *
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -31,6 +33,22 @@ function pluginBootstrapRan() {
} }
} }
/**
* True when settings.json enables the `agenthub` plugin, whose `plugin.json`
* carries these same eight hooks inline — Claude Code then runs them itself,
* and any hook-handler.js entry in settings.json is a second copy of each.
*
* @param {object} settings parsed settings.json
* @returns {boolean}
*/
function pluginProvidesHooks(settings) {
const ep = settings && settings.enabledPlugins;
if (!ep || typeof ep !== "object") return false;
return Object.entries(ep).some(
([k, v]) => v === true && (k === "agenthub" || k.startsWith("agenthub@"))
);
}
function envFlag(name) { function envFlag(name) {
return ["1", "true", "yes", "on"].includes(String(process.env[name] || "").toLowerCase()); return ["1", "true", "yes", "on"].includes(String(process.env[name] || "").toLowerCase());
} }
@@ -143,17 +161,6 @@ function installHooks(silent = false) {
return false; return false;
} }
// The `agenthub` plugin installs the same eight hooks itself. Running both means
// every event is POSTed twice and token/cost figures double — warn loudly
// rather than silently double-count. (`/agenthub-doctor` reports the same state.)
if (!silent && pluginBootstrapRan()) {
console.warn(
"WARNING: the agenthub plugin is installed and already provides these hooks.\n" +
" Installing them again double-counts every event. Uninstall the\n" +
" plugin first, or skip this step. Run /agenthub-doctor to check."
);
}
let settings = {}; let settings = {};
if (fs.existsSync(getSettingsPath())) { if (fs.existsSync(getSettingsPath())) {
try { try {
@@ -165,6 +172,40 @@ function installHooks(silent = false) {
} }
} }
// The enabled plugin already runs these hooks: take our copies out instead
// of adding them (every server start used to re-add them and double every
// event). Other hooks stay; the file is backed up before it is written.
if (pluginProvidesHooks(settings)) {
let removed = 0;
for (const [type, entries] of Object.entries(settings.hooks || {})) {
if (!Array.isArray(entries)) continue;
const kept = entries.filter((e) => !isOurEntry(e));
removed += entries.length - kept.length;
if (kept.length) settings.hooks[type] = kept;
else delete settings.hooks[type];
}
if (removed) {
fs.copyFileSync(getSettingsPath(), `${getSettingsPath()}.agenthub-bak`);
fs.writeFileSync(getSettingsPath(), JSON.stringify(settings, null, 2) + "\n", "utf8");
}
if (!silent) {
console.log(
"The agenthub plugin is enabled and provides these hooks — nothing to install." +
(removed ? ` Removed ${removed} duplicate entr${removed === 1 ? "y" : "ies"}.` : "")
);
}
return true;
}
// Bootstrapped once but not enabled now (disabled or uninstalled): the
// plugin's hooks are off, so ours are the only ones — install them.
if (!silent && pluginBootstrapRan()) {
console.warn(
"Note: the agenthub plugin was installed here but is not enabled — installing\n" +
" the checkout's hooks. Re-enabling the plugin removes them again."
);
}
if (!settings.hooks) settings.hooks = {}; if (!settings.hooks) settings.hooks = {};
let installed = 0; let installed = 0;
@@ -206,4 +247,4 @@ 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 = { installHooks, isInsideContainer, isOurEntry }; module.exports = { installHooks, isInsideContainer, isOurEntry, pluginProvidesHooks };
+39 -8
View File
@@ -247,17 +247,48 @@ function systemdStatus() {
* This checks the doctor's own PATH. A server started as a systemd `--user` * This checks the doctor's own PATH. A server started as a systemd `--user`
* service can still see a different one — `GET /api/run/tmux` is the server's * service can still see a different one — `GET /api/run/tmux` is the server's
* own answer, and `agenthub doctor` reports that. * own answer, and `agenthub doctor` reports that.
*
* Also WARN below tmux 3.7, the first with pane scrollbars (`tmux.js` turns
* them on), and when a new binary still talks to an older running server — a
* server keeps its version until every tmux session has closed. The doctor
* only says so: it never installs or restarts tmux.
*/ */
const hasScrollbars = (version) => {
const m = /(\d+)\.(\d+)/.exec(version || "");
return !!m && (Number(m[1]) > 3 || (Number(m[1]) === 3 && Number(m[2]) >= 7));
};
function tmuxStatus() { function tmuxStatus() {
if (require("../server/lib/tmux").isTmuxAvailable()) { const tmux = require("../server/lib/tmux");
return ["ok", "tmux", "on PATH — the run console can start sessions"]; const client = tmux.clientVersion();
if (!client) {
return [
"warn",
"tmux",
"not found — Start Run and `agenthub lanes shell` will fail (everything else is unaffected). " +
"Install it: sudo apt install tmux / brew install tmux (Windows: run AgentHub under WSL2)",
];
} }
return [ if (!hasScrollbars(client)) {
"warn", return [
"tmux", "warn",
"not found — Start Run and `agenthub lanes shell` will fail (everything else is unaffected). " + "tmux",
"Install it: sudo apt install tmux / brew install tmux (Windows: run AgentHub under WSL2)", `${client} works, but terminal panes get a scrollbar only from tmux 3.7. ` +
]; "Upgrade: brew upgrade tmux (macOS); on Linux, when the distro ships an older one " +
"(Ubuntu 24.04 has 3.4), build the release from https://github.com/tmux/tmux/releases " +
"(needs libevent-dev libncurses-dev bison: ./configure && make && sudo make install)",
];
}
const server = tmux.serverVersion();
if (server && !hasScrollbars(server)) {
return [
"warn",
"tmux",
`${client} installed, but the running tmux server is ${server} — panes get their ` +
"scrollbar once every tmux session has closed (or after a reboot)",
];
}
return ["ok", "tmux", `${client} on PATH — the run console can start sessions, with scrollbars`];
} }
function countLegacyHookEntries() { function countLegacyHookEntries() {
+3 -3
View File
@@ -469,7 +469,7 @@ The OpenAPI spec is generated from `server/openapi.js` (`createOpenApiSpec()`),
**Session names** are kept in sync with the transcript title: on every hook event (and in the 15 s watchdog) the ingestor reads the latest `custom-title` (`/rename`, `claude -n`, picker `Ctrl+R`) or `ai-title` (auto) from the JSONL and updates `sessions.name` — `custom-title` always wins, `ai-title` only fills a placeholder/auto name — broadcasting `session_updated` so the UI reflects renames in real time. When neither title exists, the session's first user prompt (tool-result / meta / slash-command plumbing entries skipped, 60-char label) fills the placeholder session name plus the main agent's placeholder name and empty task; a later `ai-title` can still replace a descriptor-filled name, and the agent fill passes the in-flight `current_tool` through so it is never wiped mid-turn. **Session names** are kept in sync with the transcript title: on every hook event (and in the 15 s watchdog) the ingestor reads the latest `custom-title` (`/rename`, `claude -n`, picker `Ctrl+R`) or `ai-title` (auto) from the JSONL and updates `sessions.name` — `custom-title` always wins, `ai-title` only fills a placeholder/auto name — broadcasting `session_updated` so the UI reflects renames in real time. When neither title exists, the session's first user prompt (tool-result / meta / slash-command plumbing entries skipped, 60-char label) fills the placeholder session name plus the main agent's placeholder name and empty task; a later `ai-title` can still replace a descriptor-filled name, and the agent fill passes the in-flight `current_tool` through so it is never wiped mid-turn.
**Transcript stream** (`GET /api/sessions/:id/transcript`) returns `user` / `assistant` messages plus: synthetic `session_event` rename markers (from `custom-title`), local slash-command I/O surfaced from `system`/`local_command` lines (the `<command-name>` pill + `<local-command-stdout>`/`stderr` output, e.g. `/color`, `/rename`, custom commands), and **mid-turn queued user messages** surfaced from `attachment`/`queued_command` lines — a message typed while Claude was still working is journaled as `queue-operation` bookkeeping plus a `queued_command` attachment (never as a `user` line), so the attachment is rendered as a user message at the point the model actually received it. The queue is shared with harness injections, so queued lines are only attributed to the human when they aren't harness traffic: `<task-notification>`/`[SYSTEM NOTIFICATION` payloads and any non-`human` `origin.kind` render as `system` (harness notification attachments carry no `origin` field at all; typed messages carry `origin.kind = "human"`). Content-less `local_command` lines, other `system` subtypes, `queue-operation` lines, and every other attachment subtype are dropped. **Transcript stream** (`GET /api/sessions/:id/transcript`) returns `user` / `assistant` messages plus: synthetic `session_event` rename markers (from `custom-title`), local slash-command I/O surfaced from `system`/`local_command` lines (the `<command-name>` pill + `<local-command-stdout>`/`stderr` output, e.g. `/color`, `/rename`, custom commands), and **mid-turn queued user messages** surfaced from `attachment`/`queued_command` lines — a message typed while Claude was still working is journaled as `queue-operation` bookkeeping plus a `queued_command` attachment (never as a `user` line), so the attachment is rendered as a user message at the point the model actually received it. The queue is shared with harness injections, so queued lines are only attributed to the human when they aren't harness traffic: `<task-notification>`/`[SYSTEM NOTIFICATION` payloads and any non-`human` `origin.kind` render as `system` (harness notification attachments carry no `origin` field at all; typed messages carry `origin.kind = "human"`). Content-less `local_command` lines, other `system` subtypes, `queue-operation` lines, and every other attachment subtype are dropped. A **Codex** session (`tool = 'codex'`) streams its root thread's rollout journal instead (the path `codex_rollouts` records): `message` items as user/assistant text — developer messages and the injected AGENTS.md / `<environment_context>`-style blocks left out —, `reasoning` summaries as `thinking` (encrypted-only ones skipped), `function_call`/`custom_tool_call` as `tool_use` with their `*_output` as a `tool`-sender `tool_result`, and a sub-agent's `agent_message` as a `system` line; same paging, same shape (`codexMessage` in `server/routes/sessions.js`).
### Hook Ingestion ### Hook Ingestion
@@ -505,7 +505,7 @@ Request body shape:
| Method | Path | Description | | Method | Path | Description |
| ------ | ----------------------------- | ------------------------------------------------------------------- | | ------ | ----------------------------- | ------------------------------------------------------------------- |
| `GET` | `/api/workflows` | Aggregate workflow intelligence (`?status=active\|completed\|...`) | | `GET` | `/api/workflows` | Aggregate workflow intelligence (`?status=active\|completed\|...`, `?sections=a,b`); computed in the read worker, 503 `EREADTIMEOUT` past `AGENTHUB_READ_TIMEOUT_MS` |
| `GET` | `/api/workflows/session/:id` | Per-session drill-in (tree, timeline, swim lanes, events) | | `GET` | `/api/workflows/session/:id` | Per-session drill-in (tree, timeline, swim lanes, events) |
### Workspaces ### Workspaces
@@ -692,7 +692,7 @@ Because sync runs non-interactively (`ssh -o BatchMode=yes`), the connection mus
| `POST` | `/api/settings/antigravity-hooks` | Install or reinstall it — PreInvocation, PostToolUse, Stop, never PreToolUse (`sameOriginGuard`; same 400 codes) | | `POST` | `/api/settings/antigravity-hooks` | Install or reinstall it — PreInvocation, PostToolUse, Stop, never PreToolUse (`sameOriginGuard`; same 400 codes) |
| `DELETE` | `/api/settings/antigravity-hooks` | Remove the `agenthub` hook only (`sameOriginGuard`) | | `DELETE` | `/api/settings/antigravity-hooks` | Remove the `agenthub` hook only (`sameOriginGuard`) |
| `GET` | `/api/settings/export` | Export all data (sessions, agents, events, token_usage, workflows, dashboard_runs, alert_rules, model_pricing) as one versioned JSON attachment | | `GET` | `/api/settings/export` | Export all data (sessions, agents, events, token_usage, workflows, dashboard_runs, alert_rules, model_pricing) as one versioned JSON attachment |
| `POST` | `/api/settings/import` | Restore a bundle from `/export`. Multipart `file`, or JSON `{ path }` (server reads it). Idempotent + non-destructive: sessions already present are skipped whole | | `POST` | `/api/settings/import` | Restore a bundle from `/export`. Multipart `file` only, same-origin, ≤ 512 MB. Idempotent + non-destructive: sessions already present are skipped whole; restored payloads trimmed like every event insert |
| `POST` | `/api/settings/cleanup` | Abandon stale sessions and purge old data (`purge_days` whole ≥ 1, else `400 INVALID_PURGE_DAYS`; `dry_run: true` counts only) | | `POST` | `/api/settings/cleanup` | Abandon stale sessions and purge old data (`purge_days` whole ≥ 1, else `400 INVALID_PURGE_DAYS`; `dry_run: true` counts only) |
### Claude Config Explorer (`/api/cc-config`) ### Claude Config Explorer (`/api/cc-config`)
@@ -148,6 +148,33 @@ describe("antigravity usage", () => {
assert.equal(agy.firstFileUri(msg(pb(1, msg(pb(2, "file:///a%20b/c"))))), "/a b/c"); assert.equal(agy.firstFileUri(msg(pb(1, msg(pb(2, "file:///a%20b/c"))))), "/a b/c");
}); });
it("decodes a blob given as a plain Uint8Array, as node:sqlite returns one", () => {
const u8 = (b) => new Uint8Array(b);
const u = agy.generationUsage(
u8(generation({ model: "gemini-3.8-flash", input: 5, output: 1 }))
);
assert.equal(u.model, "gemini-3.8-flash");
assert.equal(agy.firstFileUri(u8(msg(pb(1, msg(pb(2, "file:///a%20b/c")))))), "/a b/c");
});
it("opens agy's files with the driver db.js chose, read-only (node:sqlite when better-sqlite3 has no build)", () => {
const { Database: Chosen } = require("../db");
assert.equal(typeof Chosen, "function");
const Compat = require("../compat-sqlite");
const file = conversation("33333333-3333-4333-8333-333333333333", {
gens: [{ model: "gemini-3.1-pro", input: 1, output: 2 }],
});
const conn = new Compat(file, { readonly: true, fileMustExist: true });
assert.equal(conn.prepare("SELECT count(*) AS n FROM gen_metadata").get().n, 1);
assert.throws(() => conn.exec("DELETE FROM gen_metadata"), /readonly|read-only/i);
conn.close();
assert.throws(
() => new Compat(path.join(CONV, "missing.db"), { readonly: true, fileMustExist: true }),
/does not exist|unable to open/i
);
assert.equal(fs.existsSync(path.join(CONV, "missing.db")), false);
});
it("creates the session with its name, cwd, model and per-model tokens", async () => { it("creates the session with its name, cwd, model and per-model tokens", async () => {
conversation(ID, { conversation(ID, {
cwd: "/work/my app", cwd: "/work/my app",
+3 -3
View File
@@ -1813,10 +1813,10 @@ describe("Database Integrity", () => {
assert.ok(indexes.includes("idx_agents_session")); assert.ok(indexes.includes("idx_agents_session"));
assert.ok(indexes.includes("idx_agents_status")); assert.ok(indexes.includes("idx_agents_status"));
// (session_id, created_at) replaces the bare session_id index, a prefix of // (session_id, id) is NOT a prefix of idx_events_session_type: the hook
// idx_events_session_type that only cost disk. // duplicate guard reads a session's latest event by id through it.
assert.ok(indexes.includes("idx_events_session_created")); assert.ok(indexes.includes("idx_events_session_created"));
assert.ok(!indexes.includes("idx_events_session")); assert.ok(indexes.includes("idx_events_session"));
assert.ok(indexes.includes("idx_events_type")); assert.ok(indexes.includes("idx_events_type"));
assert.ok(indexes.includes("idx_events_created")); assert.ok(indexes.includes("idx_events_created"));
assert.ok(indexes.includes("idx_sessions_status")); assert.ok(indexes.includes("idx_sessions_status"));
+159
View File
@@ -0,0 +1,159 @@
/**
* @file GET /api/sessions/:id/transcript for a Codex session: the conversation
* comes from the session's own rollout journal (`codex_rollouts`, root thread),
* mapped to the Claude transcript shape — user/assistant text, reasoning
* summaries as thinking, function and custom tool calls with their outputs,
* sub-agent messages as system — with the harness's developer messages and
* injected context (AGENTS.md, environment_context) left out.
* @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 fs = require("fs");
const os = require("os");
const path = require("path");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-codex-transcript-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.CLAUDE_HOME = path.join(TMP, "claude");
process.env.CODEX_HOME = path.join(TMP, "codex");
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const ID = "01a10b66-ced6-7f30-af72-18e8a77e11f9";
let server;
let BASE;
const item = (ts, payload) => ({ timestamp: ts, type: "response_item", payload });
const text = (t, kind = "input_text") => ({ type: kind, text: t });
before(async () => {
const file = path.join(TMP, "codex", "sessions", "2026", "10", "05", `rollout-x-${ID}.jsonl`);
fs.mkdirSync(path.dirname(file), { recursive: true });
const lines = [
{ timestamp: "2026-10-05T09:30:49Z", type: "session_meta", payload: { id: ID, cwd: "/w" } },
item("2026-10-05T09:30:52Z", {
type: "message",
role: "developer",
content: [text("<permissions instructions>…")],
}),
item("2026-10-05T09:30:52Z", {
type: "message",
role: "user",
content: [
text("# AGENTS.md instructions for /w\n…"),
text("<environment_context>\n <cwd>/w</cwd>"),
],
}),
item("2026-10-05T09:30:53Z", { type: "message", role: "user", content: [text("Hi")] }),
item("2026-10-05T09:30:54Z", {
type: "reasoning",
summary: [{ type: "summary_text", text: "Greet back" }],
}),
item("2026-10-05T09:30:54Z", { type: "reasoning", summary: [], encrypted_content: "gAAA" }),
item("2026-10-05T09:30:54Z", {
type: "function_call",
name: "exec_command",
arguments: '{"cmd":"ls"}',
call_id: "call_1",
}),
item("2026-10-05T09:30:55Z", {
type: "function_call_output",
call_id: "call_1",
output: [text("Script completed\n"), text("a.txt\n")],
}),
item("2026-10-05T09:30:55Z", {
type: "custom_tool_call",
name: "apply_patch",
input: "*** Begin Patch",
call_id: "call_2",
}),
item("2026-10-05T09:30:55Z", {
type: "custom_tool_call_output",
call_id: "call_2",
output: "Done",
}),
item("2026-10-05T09:30:55Z", {
type: "message",
role: "assistant",
content: [text("Hi! What can I help you with?", "output_text")],
}),
item("2026-10-05T09:30:56Z", {
type: "agent_message",
author: "/root/task1",
content: [text("Message Type: FINAL_ANSWER\nPayload: ok")],
}),
];
fs.writeFileSync(file, lines.map((l) => JSON.stringify(l)).join("\n") + "\n");
db.prepare("INSERT INTO sessions (id, tool, status) VALUES (?, 'codex', 'completed')").run(ID);
db.prepare("INSERT INTO codex_rollouts (path, session_id, thread_id) VALUES (?, ?, ?)").run(
file,
ID,
ID
);
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server?.close();
try {
db.close();
} catch {}
fs.rmSync(TMP, { recursive: true, force: true });
});
const get = async (q = "") => (await fetch(`${BASE}/api/sessions/${ID}/transcript${q}`)).json();
describe("Codex session transcript", () => {
it("maps the rollout to the transcript shape, without the harness's injections", async () => {
const body = await get();
const shape = body.messages.map((m) => [
m.type,
m.sender,
m.content.map((c) => c.type).join("+"),
]);
assert.deepEqual(shape, [
["user", "user", "text"],
["assistant", "assistant", "thinking"],
["assistant", "assistant", "tool_use"],
["user", "tool", "tool_result"],
["assistant", "assistant", "tool_use"],
["user", "tool", "tool_result"],
["assistant", "assistant", "text"],
["user", "system", "text"],
]);
const [hi, think, call, out, , patchOut, reply] = body.messages;
assert.equal(hi.content[0].text, "Hi");
assert.equal(hi.timestamp, "2026-10-05T09:30:53Z");
assert.equal(think.content[0].text, "Greet back");
assert.deepEqual(call.content[0], {
type: "tool_use",
name: "exec_command",
id: "call_1",
input: { cmd: "ls" },
});
assert.deepEqual(out.content[0], {
type: "tool_result",
id: "call_1",
output: "Script completed\na.txt\n",
is_error: false,
});
assert.equal(patchOut.content[0].output, "Done");
assert.equal(reply.content[0].text, "Hi! What can I help you with?");
assert.equal(body.total, 8);
});
it("pages like a Claude transcript (limit, after, before)", async () => {
const latest = await get("?limit=2");
assert.equal(latest.messages.length, 2);
assert.equal(latest.has_more, true);
assert.equal(latest.messages[1].content[0].type, "text");
const after = await get(`?after=${latest.first_line}`);
assert.equal(after.messages.length, 1);
const before = await get(`?before=${latest.first_line}&limit=50`);
assert.equal(before.messages.length, 6);
});
});
+76
View File
@@ -0,0 +1,76 @@
/**
* @file Tests server/compat-sqlite.js, the node:sqlite stand-in a plugin
* install runs on (better-sqlite3 has no native build there): every
* better-sqlite3 API the server uses — `function`, `pluck`, `readonly` /
* `fileMustExist` — and a real `server/db.js` boot in a child process with
* better-sqlite3 blocked, since the rest of the suite only ever runs on
* better-sqlite3 and a missing method there crashes the dashboard at start.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { spawnSync } = require("child_process");
const Database = require("../compat-sqlite");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-compat-"));
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
describe("compat-sqlite", () => {
it("registers a SQL function like better-sqlite3's db.function", () => {
const db = new Database(":memory:");
db.function("twice", { deterministic: true }, (s) => `${s}${s}`);
db.function("one", () => 1);
assert.equal(db.prepare("SELECT twice('ab') AS v, one() AS o").get().v, "abab");
db.close();
});
it("pluck() returns the first column from get, all and iterate", () => {
const db = new Database(":memory:");
db.exec("CREATE TABLE t (a, b); INSERT INTO t VALUES (1, 'x'), (2, 'y');");
const s = db.prepare("SELECT a, b FROM t ORDER BY a").pluck();
assert.equal(s.get(), 1);
assert.deepEqual(s.all(), [1, 2]);
assert.deepEqual([...s.iterate()], [1, 2]);
assert.equal(db.prepare("SELECT a FROM t WHERE a = 9").pluck().get(), undefined);
db.close();
});
it("boots server/db.js and its trim/sweep paths with better-sqlite3 unavailable", () => {
const block = path.join(TMP, "no-bs3.js");
fs.writeFileSync(
block,
`const M = require("module"); const load = M._load;
M._load = function (r, ...a) { if (r === "better-sqlite3") throw new Error("blocked"); return load.call(this, r, ...a); };`
);
const script = `
const { db, Database } = require("./server/db");
if (Database !== require("./server/compat-sqlite")) throw new Error("not on compat");
const big = JSON.stringify({ tool_input: { content: "x".repeat(20000) } });
db.prepare("INSERT INTO sessions (id) VALUES ('s1')").run();
db.prepare("INSERT INTO events (session_id, event_type, tool_name, data) VALUES ('s1', 'PreToolUse', 'Write', ?)").run(big);
require("./server/lib/event-payload").sweepEventPayloads(db).then((r) => {
const len = db.prepare("SELECT length(trim_event_data(?)) AS n").get(big).n;
console.log(JSON.stringify({ swept: r.rows >= 0, trimmedShorter: len < big.length }));
process.exit(0);
});`;
const r = spawnSync(process.execPath, ["-r", block, "-e", script], {
cwd: path.join(__dirname, "..", ".."),
env: {
...process.env,
DASHBOARD_DB_PATH: path.join(TMP, "boot.db"),
AGENTHUB_PRICING_SYNC: "0",
},
encoding: "utf8",
timeout: 60_000,
});
assert.equal(r.status, 0, r.stderr);
assert.deepEqual(JSON.parse(r.stdout.trim().split("\n").pop()), {
swept: true,
trimmedShorter: true,
});
});
});
+34
View File
@@ -49,4 +49,38 @@ describe("dashboard-runs", () => {
assert.equal(runs.getRun("agenthub-lane-5").status, "running"); assert.equal(runs.getRun("agenthub-lane-5").status, "running");
assert.equal(runs.getRun("agenthub-lane-6").status, "abandoned"); assert.equal(runs.getRun("agenthub-lane-6").status, "abandoned");
}); });
it("gives a hookless tool's session the lane of the run it started in", () => {
const { db } = require("../db");
start("agenthub-lane-7-1", {
laneId: 7,
cwd: "/w",
tool: "antigravity",
startedAt: Date.parse("2026-10-05T08:49:57.145Z"),
});
runs.patchRun({
id: "agenthub-lane-7-1",
status: "killed",
endedAt: Date.parse("2026-10-05T08:50:10.740Z"),
});
const add = db.prepare(
"INSERT INTO sessions (id, name, status, cwd, started_at, tool) VALUES (?, 'x', 'completed', ?, ?, ?)"
);
add.run("in", "/w", "2026-10-05T08:50:01Z", "antigravity");
add.run("before", "/w", "2026-10-05T08:40:00Z", "antigravity");
add.run("after", "/w", "2026-10-05T09:00:00Z", "antigravity");
add.run("elsewhere", "/x", "2026-10-05T08:50:01Z", "antigravity");
add.run("other-tool", "/w", "2026-10-05T08:50:01Z", "codex");
add.run("claude", "/w", "2026-10-05T08:50:01Z", "claude");
runs.claimToolSessions();
const lane = (id) => db.prepare("SELECT lane_id FROM sessions WHERE id = ?").get(id).lane_id;
assert.equal(lane("in"), 7);
for (const id of ["before", "after", "elsewhere", "other-tool", "claude"]) {
assert.equal(lane(id), null, id);
}
// One session at a time also becomes its run's session.
assert.equal(runs.getRun("agenthub-lane-7-1").session_id, null);
runs.claimToolSessions("in");
assert.equal(runs.getRun("agenthub-lane-7-1").session_id, "in");
});
}); });
+31
View File
@@ -179,6 +179,37 @@ describe("data-transfer export/import round-trip", () => {
assert.equal(db.prepare("SELECT COUNT(*) c FROM events WHERE session_id='S3'").get().c, 4); assert.equal(db.prepare("SELECT COUNT(*) c FROM events WHERE session_id='S3'").get().c, 4);
}); });
it("stores a restored event's payload trimmed, like every other event insert", () => {
const data = JSON.stringify({
tool_name: "Edit",
tool_response: { originalFile: "x".repeat(40_000) },
});
const c = importExportBundle(db, {
sessions: [
{
id: "S-trim",
status: "completed",
started_at: "2026-06-01T00:00:00.000Z",
updated_at: "2026-06-01T00:00:00.000Z",
source: "local",
tool: "claude",
},
],
events: [
{
session_id: "S-trim",
event_type: "PostToolUse",
data,
created_at: "2026-06-01T00:00:01.000Z",
},
],
});
assert.equal(c.events, 1);
const row = db.prepare("SELECT data FROM events WHERE session_id = 'S-trim'").get();
assert.ok(row.data.length < 1000, `stored ${row.data.length} chars`);
assert.deepEqual(JSON.parse(row.data)._trimmed, ["tool_response.originalFile"]);
});
it("rejects a non-export object", () => { it("rejects a non-export object", () => {
assert.throws(() => importExportBundle(db, { foo: "bar" }), /recognizable dashboard export/); assert.throws(() => importExportBundle(db, { foo: "bar" }), /recognizable dashboard export/);
assert.throws(() => importExportBundle(db, { format: "something-else" }), /Unrecognized/); assert.throws(() => importExportBundle(db, { format: "something-else" }), /Unrecognized/);
+28
View File
@@ -98,6 +98,14 @@ describe("trimEventData", () => {
const junk = big(STR_CAP + 10, "{"); const junk = big(STR_CAP + 10, "{");
assert.equal(trimEventData(junk), junk); assert.equal(trimEventData(junk), junk);
}); });
it("returns a long payload it has nothing to trim byte for byte (no number rounding)", () => {
// 12345678901234567890 does not survive a JSON.parse/stringify round trip.
const json = `{"tool_name":"Bash","id":12345678901234567890,"tool_input":{"a":"${big(
STR_CAP - 10
)}","b":"${big(STR_CAP - 10)}"}}`;
assert.equal(trimEventData(json), json);
});
}); });
describe("event inserts", () => { describe("event inserts", () => {
@@ -125,4 +133,24 @@ describe("event inserts", () => {
const again = await sweepEventPayloads(db); const again = await sweepEventPayloads(db);
assert.equal(again.rows, 0); assert.equal(again.rows, 0);
}); });
it("the sweep commits once a chunk's bytes pass the budget, and still trims every row", async () => {
db.prepare("INSERT OR IGNORE INTO sessions (id) VALUES ('s-sweep')").run();
const ins = db.prepare(
"INSERT INTO events (session_id, event_type, data, created_at) VALUES ('s-sweep', 'PostToolUse', ?, '2026-01-01T00:00:00Z')"
);
for (let i = 0; i < 5; i++) ins.run(payload);
let txns = 0;
const counting = {
prepare: (sql) => db.prepare(sql),
transaction: (fn) => {
const t = db.transaction(fn);
return (...a) => (txns++, t(...a));
},
};
// Each payload is ~32 KB; a 1-byte budget closes the transaction after every row.
const r = await sweepEventPayloads(counting, { chunk: 200, maxBytes: 1 });
assert.equal(r.rows, 5);
assert.ok(txns >= 5, `expected a transaction per row, got ${txns}`);
});
}); });
+29
View File
@@ -104,6 +104,35 @@ describe("install-hooks host-only guard (#193)", () => {
assert.equal(ours.length, 1, "must not duplicate our hook entry on re-run"); assert.equal(ours.length, 1, "must not duplicate our hook entry on re-run");
}); });
it("with the agenthub plugin enabled, adds nothing and removes our duplicate entries", () => {
process.env.AGENTHUB_FORCE_HOST = "1";
const foreign = { hooks: [{ type: "command", command: "my-own-hook.sh" }], matcher: "*" };
const ours = {
hooks: [{ type: "command", command: 'node "/x/scripts/hook-handler.js" Stop' }],
};
fs.writeFileSync(
SETTINGS,
JSON.stringify({
enabledPlugins: { "agenthub@sg": true },
hooks: { PreToolUse: [foreign], Stop: [ours] },
})
);
assert.equal(installHooks(true), true);
const settings = JSON.parse(fs.readFileSync(SETTINGS, "utf8"));
assert.doesNotMatch(JSON.stringify(settings.hooks), /hook-handler\.js/);
assert.deepEqual(settings.hooks.PreToolUse, [foreign]);
assert.equal(settings.hooks.Stop, undefined);
assert.ok(fs.existsSync(`${SETTINGS}.agenthub-bak`), "backs the file up before writing");
});
it("installs as before when the plugin is disabled", () => {
process.env.AGENTHUB_FORCE_HOST = "1";
fs.writeFileSync(SETTINGS, JSON.stringify({ enabledPlugins: { "agenthub@sg": false } }));
assert.equal(installHooks(true), true);
const settings = JSON.parse(fs.readFileSync(SETTINGS, "utf8"));
assert.match(JSON.stringify(settings.hooks.PreToolUse), /hook-handler\.js/);
});
it("isInsideContainer honors the force flags", () => { it("isInsideContainer honors the force flags", () => {
process.env.AGENTHUB_FORCE_CONTAINER = "1"; process.env.AGENTHUB_FORCE_CONTAINER = "1";
assert.equal(isInsideContainer(), true); assert.equal(isInsideContainer(), true);
+21
View File
@@ -1988,4 +1988,25 @@ describe("start: slot auto and a session already live", () => {
}); });
assert.equal(fork.status, 200); assert.equal(fork.status, 200);
}); });
it("an explicit slot 0 never takes over a terminal tab that holds pane 0", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lane-slot0-"));
try {
const own = (await request("POST", "/api/lanes", { cwd: dir })).body.lane.id;
// A terminal tab started on a stopped workspace lands in pane 0 and, being
// no run, leaves `run_id` empty.
const shell = await request("POST", `/api/lanes/${own}/start`, {
slot: "auto",
shell: true,
});
assert.equal(shell.body.run_id, `agenthub-lane-${own}`);
for (const body of [{}, { slot: 0 }]) {
const r = await request("POST", `/api/lanes/${own}/start`, body);
assert.equal(r.status, 409, JSON.stringify(body));
assert.equal(r.body.error.code, "ERUNLIVE");
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
}); });
+30 -11
View File
@@ -3,8 +3,10 @@
* an install its browser run console cannot work, because every run the * an install its browser run console cannot work, because every run the
* dashboard starts is a tmux session (server/lib/tmux.js). The row must WARN * dashboard starts is a tmux session (server/lib/tmux.js). The row must WARN
* and never FAIL: sessions, analytics, lanes and the CLI all work without the * and never FAIL: sessions, analytics, lanes and the CLI all work without the
* binary, so failing a doctor run over it would cry wolf. tmux is faked through * binary, so failing a doctor run over it would cry wolf. A tmux older than 3.7
* the module's own exec seam, so the test never depends on the host having it. * (no pane scrollbars) also warns, as does a new binary still talking to an
* older running server. tmux is faked through the module's own exec seam, so
* the test never depends on the host having it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -26,11 +28,14 @@ after(() => {
delete process.env.CLAUDE_HOME; delete process.env.CLAUDE_HOME;
}); });
/** Fake the tmux binary as present or absent, then read back the row. */ /** Fake the tmux binary (null = absent) and its running server (null = none),
function rowWithTmux(present) { * then read back the row. */
tmux.__setExecImpl(() => { function rowWithTmux(client, server = null) {
if (!present) throw new Error("spawnSync tmux ENOENT"); tmux.__setExecImpl((args) => {
return "tmux 3.4\n"; if (!client) throw new Error("spawnSync tmux ENOENT");
if (args[0] === "-V") return `tmux ${client}\n`;
if (!server) throw new Error("no server running");
return `${server}\n`;
}); });
const row = diagnose().find((r) => r.label === "tmux"); const row = diagnose().find((r) => r.label === "tmux");
assert.ok(row, "the doctor must always emit a tmux row"); assert.ok(row, "the doctor must always emit a tmux row");
@@ -39,16 +44,30 @@ function rowWithTmux(present) {
describe("plugin-doctor: tmux row", () => { describe("plugin-doctor: tmux row", () => {
it("warns, never fails, when tmux is not on PATH", () => { it("warns, never fails, when tmux is not on PATH", () => {
const row = rowWithTmux(false); const row = rowWithTmux(null);
assert.equal(row.level, "warn", "a missing tmux must not fail the doctor run"); assert.equal(row.level, "warn", "a missing tmux must not fail the doctor run");
assert.match(row.detail, /lanes shell/, "the reader needs to know WHAT breaks"); assert.match(row.detail, /lanes shell/, "the reader needs to know WHAT breaks");
assert.match(row.detail, /install/i, "and how to fix it"); assert.match(row.detail, /install/i, "and how to fix it");
}); });
it("passes when tmux is available", () => { it("passes when tmux has pane scrollbars (3.7+)", () => {
const row = rowWithTmux(true); assert.equal(rowWithTmux("3.7c").level, "ok");
assert.equal(rowWithTmux("next-3.8", "3.8-rc3").level, "ok");
});
assert.equal(row.level, "ok", row.detail); it("warns, with the upgrade path, when tmux is older than 3.7", () => {
const row = rowWithTmux("3.4", "3.4");
assert.equal(row.level, "warn");
assert.match(row.detail, /3\.7/);
assert.match(row.detail, /brew upgrade tmux/);
});
it("warns when a new tmux is still talking to an older running server", () => {
const row = rowWithTmux("3.7c", "3.4");
assert.equal(row.level, "warn");
assert.match(row.detail, /server is 3\.4/);
}); });
}); });
+27
View File
@@ -69,6 +69,33 @@ describe("pty-attach", () => {
assert.deepEqual(calls[0], ["set-option", "-t", "=agenthub-lane-1:", "mouse", "on"]); assert.deepEqual(calls[0], ["set-option", "-t", "=agenthub-lane-1:", "mouse", "on"]);
}); });
it("attaches with the RUNNING tmux server's own binary, not the one on PATH", () => {
// A tmux upgraded while its server runs leaves a newer client on PATH that
// the old server answers with "open terminal failed: not a terminal".
tmux.__setExecImpl((args) => (args[0] === "display-message" ? `${process.pid}\n` : ""));
let file;
ptyAttach.__setSpawnImpl((f) => {
file = f;
return fakePty;
});
ptyAttach.attach(makeFakeWs(), "agenthub-lane-1", { cols: 80, rows: 24 });
assert.equal(file, process.platform === "linux" ? `/proc/${process.pid}/exe` : "tmux");
});
it("falls back to tmux on PATH when the server's pid cannot be read", () => {
tmux.__setExecImpl((args) => {
if (args[0] === "display-message") throw new Error("no server running");
return "";
});
let file;
ptyAttach.__setSpawnImpl((f) => {
file = f;
return fakePty;
});
ptyAttach.attach(makeFakeWs(), "agenthub-lane-1", { cols: 80, rows: 24 });
assert.equal(file, "tmux");
});
it("still attaches even if the mouse-mode set-option call fails", () => { it("still attaches even if the mouse-mode set-option call fails", () => {
tmux.__setExecImpl(() => { tmux.__setExecImpl(() => {
throw new Error("no such session: agenthub-lane-1"); throw new Error("no such session: agenthub-lane-1");
+11
View File
@@ -539,6 +539,17 @@ describe("pty-run", () => {
assert.ok(!envFlags(claude).some((a) => a.startsWith("CODEX_HOME="))); assert.ok(!envFlags(claude).some((a) => a.startsWith("CODEX_HOME=")));
}); });
it("keeps a claude pane off the alternate screen, so tmux scrolls (and draws a scrollbar for) it", () => {
const envFlags = (call) => call.filter((a, n) => call[n - 1] === "-e");
const claude = captureNewSession(() => pty.spawnRun({ laneId: 42, cwd: "/tmp/repo" }));
assert.ok(envFlags(claude).includes("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1"));
tmux.__reset();
const codex = captureNewSession(() =>
pty.spawnRun({ laneId: 43, cwd: "/tmp/repo", tool: "codex" })
);
assert.ok(!envFlags(codex).some((a) => a.startsWith("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=")));
});
describe("catalog tools", () => { describe("catalog tools", () => {
const aiTools = require("../lib/ai-tools"); const aiTools = require("../lib/ai-tools");
const BIN = path.join(TMP, "bin"); const BIN = path.join(TMP, "bin");
+72
View File
@@ -0,0 +1,72 @@
/**
* @file Tests server/lib/read-pool.js: a read job runs in the worker on its own
* connection, identical calls in flight share one run, a job over the timeout
* is refused with EREADTIMEOUT and the next one gets a fresh worker, and
* AGENTHUB_READ_WORKER=0 runs the job inline.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const os = require("os");
const path = require("path");
const TEST_DB = path.join(os.tmpdir(), `dashboard-readpool-test-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const { db } = require("../db");
const { runRead } = require("../lib/read-pool");
before(() => {
db.prepare("INSERT INTO sessions (id, status) VALUES ('rp-1', 'completed')").run();
const ins = db.prepare(
"INSERT INTO events (session_id, event_type, tool_name) VALUES ('rp-1', 'PreToolUse', ?)"
);
for (const t of ["Read", "Edit", "Read", "Edit"]) ins.run(t);
});
afterEach(() => {
delete process.env.AGENTHUB_READ_TIMEOUT_MS;
delete process.env.AGENTHUB_READ_WORKER;
});
after(() => {
try {
db.close();
} catch {}
for (const s of ["", "-wal", "-shm"]) fs.rmSync(TEST_DB + s, { force: true });
});
describe("read-pool", () => {
it("runs a job in the worker and sees the main connection's committed rows", async () => {
const data = await runRead("workflows", { sections: ["toolFlow"] });
assert.deepEqual(data.toolFlow.transitions.map((t) => [t.source, t.target, t.value]).sort(), [
["Edit", "Read", 1],
["Read", "Edit", 2],
]);
});
it("shares one run between identical calls in flight", async () => {
const a = runRead("workflows", { sections: ["stats"] });
const b = runRead("workflows", { sections: ["stats"] });
assert.equal(a, b);
assert.notEqual(a, runRead("workflows", { sections: ["toolFlow"] }));
await Promise.all([a, b]);
});
it("refuses a job over the timeout, then recovers with a fresh worker", async () => {
process.env.AGENTHUB_READ_TIMEOUT_MS = "1";
await assert.rejects(runRead("workflows", { tag: "slow" }), { code: "EREADTIMEOUT" });
delete process.env.AGENTHUB_READ_TIMEOUT_MS;
const data = await runRead("workflows", { sections: ["stats"] });
assert.equal(data.stats.totalSessions, 1);
});
it("runs inline with AGENTHUB_READ_WORKER=0, and rejects an unknown job", async () => {
process.env.AGENTHUB_READ_WORKER = "0";
const data = await runRead("workflows", { sections: ["stats"], tag: "inline" });
assert.equal(data.stats.topFlow.source, "Read");
await assert.rejects(runRead("nope"), { code: "EBADJOB" });
});
});
+123
View File
@@ -0,0 +1,123 @@
/**
* @file settings-import.test.js
* @description Restoring a backup: `GET /api/settings/export` writes every table
* the restore reads (with the format stamp), and `POST /api/settings/import`
* takes that file as a multipart upload, restores it without overwriting
* anything, refuses a file that is not an export without echoing its text, and
* refuses a cross-origin caller.
* @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 TEST_DB = path.join(os.tmpdir(), `settings-import-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
let server;
let BASE;
function upload(text, headers = {}) {
const form = new FormData();
form.append("file", new Blob([text], { type: "application/json" }), "backup.json");
return fetch(`${BASE}/api/settings/import`, { method: "POST", body: form, headers });
}
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
db.prepare("INSERT INTO sessions (id, name, status, started_at) VALUES (?, ?, ?, ?)").run(
"imp-1",
"Backed up",
"completed",
"2026-01-10T10:00:00.000Z"
);
db.prepare("INSERT INTO events (session_id, event_type, data) VALUES (?, ?, ?)").run(
"imp-1",
"Stop",
"{}"
);
});
after(() => {
if (server) server.close();
if (db) db.close();
for (const f of [TEST_DB, TEST_DB + "-wal", TEST_DB + "-shm"]) {
try {
fs.unlinkSync(f);
} catch {
// ignore
}
}
});
describe("backup and restore", () => {
let exported;
it("the export carries the format stamp and every table the restore reads", async () => {
const res = await fetch(`${BASE}/api/settings/export`);
assert.equal(res.status, 200);
exported = await res.text();
const bundle = JSON.parse(exported);
assert.equal(bundle.format, "agenthub-export");
assert.equal(bundle.version, 1);
for (const t of [
"sessions",
"agents",
"events",
"token_usage",
"workflows",
"dashboard_runs",
"alert_rules",
"model_pricing",
]) {
assert.ok(Array.isArray(bundle[t]), `missing ${t}`);
}
});
it("restores an uploaded export, skipping what is already there", async () => {
let res = await upload(exported);
let body = await res.json();
assert.equal(res.status, 200, JSON.stringify(body));
assert.equal(body.sessions_imported, 0);
assert.equal(body.sessions_skipped, 1);
db.prepare("DELETE FROM events WHERE session_id = 'imp-1'").run();
db.prepare("DELETE FROM sessions WHERE id = 'imp-1'").run();
res = await upload(exported);
body = await res.json();
assert.equal(res.status, 200, JSON.stringify(body));
assert.equal(body.sessions_imported, 1);
assert.equal(body.events, 1);
assert.ok(db.prepare("SELECT 1 FROM sessions WHERE id = 'imp-1'").get());
});
it("refuses a file that is not an export, without echoing its text", async () => {
const res = await upload("root:x:0:0:secret-line");
const body = await res.json();
assert.equal(res.status, 400);
assert.equal(body.error.code, "INVALID_FORMAT");
assert.doesNotMatch(JSON.stringify(body), /secret-line/);
const notBundle = await upload(JSON.stringify({ foo: 1 }));
assert.equal(notBundle.status, 400);
});
it("asks for a file", async () => {
const res = await fetch(`${BASE}/api/settings/import`, { method: "POST" });
assert.equal(res.status, 400);
assert.equal((await res.json()).error.code, "NO_FILE");
});
it("refuses a cross-origin caller", async () => {
const res = await upload(exported, { Origin: "https://evil.example" });
assert.equal(res.status, 403);
});
});
+19
View File
@@ -52,6 +52,25 @@ describe("tmux wrapper", () => {
"opus", "opus",
]); ]);
assert.deepEqual(calls[1], ["set-option", "-t", "=agenthub-lane-1:", "mouse", "on"]); assert.deepEqual(calls[1], ["set-option", "-t", "=agenthub-lane-1:", "mouse", "on"]);
assert.deepEqual(calls[2], ["set-option", "-t", "=agenthub-lane-1:", "status", "off"]);
assert.deepEqual(calls[3], ["set-option", "-t", "=agenthub-lane-1:", "pane-scrollbars", "on"]);
assert.deepEqual(calls[4], [
"set-option",
"-t",
"=agenthub-lane-1:",
"pane-scrollbars-style",
"fg=brightwhite,bg=black,width=2,pad=0",
]);
});
it("newSession still succeeds on a tmux older than 3.7 (no pane-scrollbars)", () => {
tmux.__setExecImpl((args) => {
if (args.includes("pane-scrollbars")) throw new Error("invalid option: pane-scrollbars");
return "";
});
assert.doesNotThrow(() =>
tmux.newSession({ name: "agenthub-lane-1", cwd: "/tmp/repo", argv: ["claude"] })
);
}); });
it("sendText types the literal string and presses Enter, with no quoting", () => { it("sendText types the literal string and presses Enter, with no quoting", () => {
@@ -0,0 +1,81 @@
/**
* @file GET /api/workflows tool-to-tool transitions (`stats.topFlow`,
* `toolFlow.transitions`): each tool event pairs with the NEXT tool event of the
* same session, and the query stays linear in the session's event count — the
* old correlated `MIN(id)` subquery went quadratic once `idx_events_session`
* was dropped and froze the whole server on a large session.
* @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 fs = require("fs");
const os = require("os");
const path = require("path");
const TEST_DB = path.join(os.tmpdir(), `dashboard-toolflow-test-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const { createApp, startServer } = require("../index");
const { db } = require("../db");
let server;
let BASE;
function addSession(id, tools) {
db.prepare("INSERT INTO sessions (id, status) VALUES (?, 'completed')").run(id);
const ins = db.prepare("INSERT INTO events (session_id, event_type, tool_name) VALUES (?, ?, ?)");
db.transaction(() => {
for (const t of tools) ins.run(id, t ? "PreToolUse" : "Stop", t);
})();
}
before(async () => {
// A: Read → (Stop, no tool) → Edit → Read ; B: Read → Edit
addSession("flow-a", ["Read", null, "Edit", "Read"]);
addSession("flow-b", ["Read", "Edit"]);
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server?.close();
try {
db.close();
} catch {}
for (const s of ["", "-wal", "-shm"]) fs.rmSync(TEST_DB + s, { force: true });
});
describe("GET /api/workflows tool transitions", () => {
it("pairs each tool event with the next tool event of its own session", async () => {
const body = await (await fetch(`${BASE}/api/workflows`)).json();
const flow = Object.fromEntries(
body.toolFlow.transitions.map((t) => [`${t.source}>${t.target}`, t.value])
);
// Read>Edit twice (A skips its Stop, B), Edit>Read once; nothing crosses sessions.
assert.deepEqual(flow, { "Read>Edit": 2, "Edit>Read": 1 });
assert.deepEqual(body.stats.topFlow, { source: "Read", target: "Edit", count: 2 });
});
it("?sections= computes only the named parts, and refuses names it does not know", async () => {
const body = await (await fetch(`${BASE}/api/workflows?sections=stats,toolFlow,bogus`)).json();
assert.deepEqual(Object.keys(body), ["stats", "toolFlow"]);
assert.equal(body.stats.topFlow.source, "Read");
const bad = await fetch(`${BASE}/api/workflows?sections=bogus`);
assert.equal(bad.status, 400);
assert.equal((await bad.json()).error.code, "INVALID_SECTIONS");
});
it("stays fast on one large session", async () => {
const tools = ["Read", "Edit", "Bash", "Grep"];
addSession(
"flow-big",
Array.from({ length: 20000 }, (_, i) => tools[i % 4])
);
const t0 = Date.now();
const res = await fetch(`${BASE}/api/workflows`);
assert.equal(res.status, 200);
await res.json();
assert.ok(Date.now() - t0 < 3000, `took ${Date.now() - t0} ms`);
});
});
+28 -4
View File
@@ -7,15 +7,20 @@
* On older Node versions, require() will throw and the caller should * On older Node versions, require() will throw and the caller should
* handle the error (e.g. show an informative message). * handle the error (e.g. show an informative message).
* *
* @file This module exports a Database class that wraps node:sqlite's DatabaseSync to provide a better-sqlite3-like API. * @file This module exports a Database class that wraps node:sqlite's DatabaseSync to provide a better-sqlite3-like API: `pragma`, `transaction`, `function`, `stmt.pluck()` and the `readonly`/`fileMustExist` options. A plugin install has no better-sqlite3 build, so there this IS the driver.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
const fs = require("fs");
const { DatabaseSync } = require("node:sqlite"); const { DatabaseSync } = require("node:sqlite");
class Database { class Database {
constructor(filePath) { /** better-sqlite3's `readonly` and `fileMustExist` options, mapped. */
this._db = new DatabaseSync(filePath); constructor(filePath, { readonly = false, fileMustExist = false } = {}) {
if (fileMustExist && !fs.existsSync(filePath)) {
throw new Error(`unable to open database file: ${filePath} does not exist`);
}
this._db = new DatabaseSync(filePath, { readOnly: readonly });
} }
exec(sql) { exec(sql) {
@@ -36,7 +41,26 @@ class Database {
} }
prepare(sql) { prepare(sql) {
return this._db.prepare(sql); const stmt = this._db.prepare(sql);
// better-sqlite3's pluck(): get/all/iterate yield the first column only.
stmt.pluck = () => {
const first = (row) => (row === undefined ? undefined : Object.values(row)[0]);
const { get, all, iterate } = stmt;
stmt.get = (...a) => first(get.apply(stmt, a));
stmt.all = (...a) => all.apply(stmt, a).map(first);
stmt.iterate = function* (...a) {
for (const row of iterate.apply(stmt, a)) yield first(row);
};
return stmt;
};
return stmt;
}
/** better-sqlite3's db.function(name, [options], fn): a SQL function. */
function(name, options, fn) {
if (typeof options === "function") [fn, options] = [options, {}];
this._db.function(name, options, fn);
return this;
} }
transaction(fn) { transaction(fn) {
+9 -2
View File
@@ -317,8 +317,14 @@ db.exec(`
-- read by the watchdog every 15 s and by every lane payload. Without it each -- read by the watchdog every 15 s and by every lane payload. Without it each
-- read walked the rows' overflow chains (created_at sits after data). -- read walked the rows' overflow chains (created_at sits after data).
CREATE INDEX IF NOT EXISTS idx_events_session_created ON events(session_id, created_at); CREATE INDEX IF NOT EXISTS idx_events_session_created ON events(session_id, created_at);
-- A prefix of idx_events_session_type: never needed, ~1% of the file. -- (session_id, id): a session's events in insert order, NOT a prefix of
DROP INDEX IF EXISTS idx_events_session; -- idx_events_session_type (that one is not id-ordered). Every hook's
-- duplicate guard reads a session's latest event through it; dropped, that
-- read sorted the whole session per hook.
CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id, id);
-- tool_name: the per-tool counts (analytics, workflows) and the events
-- filter's tool list read it alone instead of scanning every event row.
CREATE INDEX IF NOT EXISTS idx_events_tool ON events(tool_name);
CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type); CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type);
CREATE INDEX IF NOT EXISTS idx_events_created ON events(created_at DESC); CREATE INDEX IF NOT EXISTS idx_events_created ON events(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status); CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
@@ -2181,6 +2187,7 @@ module.exports = {
db, db,
stmts, stmts,
DB_PATH, DB_PATH,
Database, // the driver class picked above, for other SQLite files (agy's)
DEFAULT_PRICING, DEFAULT_PRICING,
applyIntroPricing, applyIntroPricing,
topUpDefaultPricing, topUpDefaultPricing,
+4
View File
@@ -557,6 +557,9 @@ function startBackgroundServices() {
} catch (err) { } catch (err) {
console.warn("codex poll failed to start:", err.message); console.warn("codex poll failed to start:", err.message);
} }
// A Codex/Antigravity session imported before it could be tied to the
// workspace pane it ran in gets that workspace now (dashboard-runs.js).
require("./lib/dashboard-runs").claimToolSessions();
// Codex sessions and token usage, from the same journals (codex-usage.js): // Codex sessions and token usage, from the same journals (codex-usage.js):
// a first pass over every rollout in the background, then the same beat. // a first pass over every rollout in the background, then the same beat.
try { try {
@@ -1073,6 +1076,7 @@ if (require.main === module) {
try { try {
const { installHooks, isInsideContainer } = require("../scripts/install-hooks"); const { installHooks, isInsideContainer } = require("../scripts/install-hooks");
if (installHooks(true)) { if (installHooks(true)) {
// With the agenthub plugin enabled this only removed duplicate entries.
console.log("Claude Code hooks auto-configured."); console.log("Claude Code hooks auto-configured.");
} else if (isInsideContainer()) { } else if (isInsideContainer()) {
console.log( console.log(
+8 -2
View File
@@ -39,8 +39,10 @@
const fs = require("node:fs"); const fs = require("node:fs");
const path = require("node:path"); const path = require("node:path");
const Database = require("better-sqlite3"); // The driver db.js chose: better-sqlite3 has no build in a plugin install
const { db, stmts } = require("../db"); // (installed without scripts), and only db.js falls back to node:sqlite.
const { db, stmts, Database } = require("../db");
const { claimToolSessions } = require("./dashboard-runs");
const { getAgyCliDir } = require("./antigravity-home"); const { getAgyCliDir } = require("./antigravity-home");
const pricingSync = require("./pricing-sync"); const pricingSync = require("./pricing-sync");
@@ -56,6 +58,9 @@ const NOT_AGYS = /SQLITE_NOTADB|SQLITE_CORRUPT|no such table/;
* a Buffer. Throws on a malformed buffer. * a Buffer. Throws on a malformed buffer.
*/ */
function pbFields(buf) { function pbFields(buf) {
// node:sqlite hands a BLOB over as a plain Uint8Array, whose toString()
// ignores its encoding — model names and file URIs would decode as "1,2,3".
if (!Buffer.isBuffer(buf)) buf = Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength);
const out = []; const out = [];
let i = 0; let i = 0;
const varint = () => { const varint = () => {
@@ -359,6 +364,7 @@ function writeSession(f, conv, now) {
lastWrite, lastWrite,
JSON.stringify({ source: "antigravity-conversation" }) JSON.stringify({ source: "antigravity-conversation" })
); );
claimToolSessions(id); // its workspace, before the broadcast
// Every usage row of an Antigravity session is this module's: rewritten whole. // Every usage row of an Antigravity session is this module's: rewritten whole.
clearUsage.run(id); clearUsage.run(id);
for (const [m, t] of Object.entries(conv.usage)) { for (const [m, t] of Object.entries(conv.usage)) {
+2
View File
@@ -36,6 +36,7 @@ const fs = require("node:fs");
const path = require("node:path"); const path = require("node:path");
const readline = require("node:readline"); const readline = require("node:readline");
const { db, stmts } = require("../db"); const { db, stmts } = require("../db");
const { claimToolSessions } = require("./dashboard-runs");
const { getCodexHome, codexHomes } = require("./codex-home"); const { getCodexHome, codexHomes } = require("./codex-home");
const { allRolloutFiles, FUTURE_SLACK_MS } = require("./codex-ingest"); const { allRolloutFiles, FUTURE_SLACK_MS } = require("./codex-ingest");
const pricingSync = require("./pricing-sync"); const pricingSync = require("./pricing-sync");
@@ -320,6 +321,7 @@ function writeSession(sessionId, names, now) {
lastWrite, lastWrite,
JSON.stringify({ source: "codex-rollout" }) JSON.stringify({ source: "codex-rollout" })
); );
claimToolSessions(sessionId); // its workspace, before the broadcast
// Every usage row of a Codex session is this module's, so it is rewritten // Every usage row of a Codex session is this module's, so it is rewritten
// whole: a model gone from a re-read journal leaves no stale row behind. // whole: a model gone from a re-read journal leaves no stale row behind.
clearUsage.run(sessionId); clearUsage.run(sessionId);
+40
View File
@@ -196,6 +196,45 @@ function laneForSession(sessionId) {
} }
} }
// The newest same-tool run in session `s`'s folder that was live when it
// started (2 s of slack: agy stamps whole seconds).
const runOf = (col, s) => `
SELECT r.${col} FROM dashboard_runs r
WHERE r.lane_id IS NOT NULL AND r.tool = ${s}.tool AND r.cwd = ${s}.cwd
AND julianday(r.started_at) <= julianday(${s}.started_at) + 2.0 / 86400
AND julianday(${s}.started_at) <= julianday(COALESCE(r.ended_at, 'now'))
ORDER BY r.started_at DESC LIMIT 1`;
const claimToolStmt = db.prepare(`
UPDATE sessions SET lane_id = (${runOf("lane_id", "sessions")})
WHERE lane_id IS NULL AND tool != 'claude' AND (@id IS NULL OR id = @id)
AND (${runOf("lane_id", "sessions")}) IS NOT NULL
`);
const claimRunStmt = db.prepare(`
UPDATE dashboard_runs SET session_id = @id
WHERE session_id IS NULL
AND id = (SELECT (${runOf("id", "s")}) FROM sessions s WHERE s.id = @id AND s.tool != 'claude')
`);
/**
* Stamp the workspace (`sessions.lane_id`) of a Codex / Antigravity session
* that nothing bound: their panes learn their session only from opt-in hooks
* (routes/hooks.js → bindRunSession), so without them a session the poll
* imported belonged to no workspace and vanished from it once its tab closed.
* Claude is left to `--session-id` + its hooks. `sessionId` null = every one;
* a single session also becomes its run's `session_id` while that is empty,
* so its live tab shows the session instead of a second "New session".
* ponytail: matched by (tool, cwd, time), so an agy started by hand in the
* same folder while a pane of that tool is live is counted as the pane's.
*/
function claimToolSessions(sessionId = null) {
try {
if (sessionId) claimRunStmt.run({ id: sessionId });
return claimToolStmt.run({ id: sessionId }).changes;
} catch {
return 0;
}
}
const runningStmt = db.prepare( const runningStmt = db.prepare(
"SELECT id FROM dashboard_runs WHERE status IN ('running', 'spawning')" "SELECT id FROM dashboard_runs WHERE status IN ('running', 'spawning')"
); );
@@ -235,4 +274,5 @@ module.exports = {
reconcileOrphans, reconcileOrphans,
routerForSession, routerForSession,
laneForSession, laneForSession,
claimToolSessions,
}; };
+6 -4
View File
@@ -85,12 +85,14 @@ function bindable(v) {
/** /**
* Make a prepared INSERT OR IGNORE that only writes the columns a table * Make a prepared INSERT OR IGNORE that only writes the columns a table
* actually has AND the row actually provides. `omit` drops columns even if * actually has AND the row actually provides. `omit` drops columns even if
* present (used to strip the non-portable events.id). * present (used to strip the non-portable events.id); `wrap` names a column
* whose value goes through a SQL function (events.data through
* `trim_event_data`, as every event insert must).
*/ */
function makeInserter(db, table, { omit = [] } = {}) { function makeInserter(db, table, { omit = [], wrap = {} } = {}) {
const cols = tableColumns(db, table).filter((c) => !omit.includes(c)); const cols = tableColumns(db, table).filter((c) => !omit.includes(c));
const quoted = cols.map((c) => `"${c}"`).join(", "); const quoted = cols.map((c) => `"${c}"`).join(", ");
const placeholders = cols.map(() => "?").join(", "); const placeholders = cols.map((c) => (wrap[c] ? `${wrap[c]}(?)` : "?")).join(", ");
const stmt = db.prepare(`INSERT OR IGNORE INTO ${table} (${quoted}) VALUES (${placeholders})`); const stmt = db.prepare(`INSERT OR IGNORE INTO ${table} (${quoted}) VALUES (${placeholders})`);
return (row) => stmt.run(cols.map((c) => bindable(row[c]))); return (row) => stmt.run(cols.map((c) => bindable(row[c])));
} }
@@ -175,7 +177,7 @@ function importExportBundle(db, bundle) {
const insert = { const insert = {
sessions: makeInserter(db, "sessions"), sessions: makeInserter(db, "sessions"),
agents: makeInserter(db, "agents"), agents: makeInserter(db, "agents"),
events: makeInserter(db, "events", { omit: ["id"] }), events: makeInserter(db, "events", { omit: ["id"], wrap: { data: "trim_event_data" } }),
token_usage: makeInserter(db, "token_usage"), token_usage: makeInserter(db, "token_usage"),
workflows: makeInserter(db, "workflows"), workflows: makeInserter(db, "workflows"),
dashboard_runs: makeInserter(db, "dashboard_runs"), dashboard_runs: makeInserter(db, "dashboard_runs"),
+52 -29
View File
@@ -30,26 +30,29 @@ const DROP_RESPONSE = {
}; };
const DROP_RESPONSE_FILE = { Read: ["content", "base64"] }; const DROP_RESPONSE_FILE = { Read: ["content", "base64"] };
function capString(s) { function capString(s, hit) {
if (s.length <= STR_CAP) return s; if (s.length <= STR_CAP) return s;
hit.n++;
return `${s.slice(0, HEAD)}\n…[${s.length - HEAD - TAIL} chars trimmed]…\n${s.slice(-TAIL)}`; return `${s.slice(0, HEAD)}\n…[${s.length - HEAD - TAIL} chars trimmed]…\n${s.slice(-TAIL)}`;
} }
// Caps every string at any depth; `structuredPatch` is left whole. // Caps every string at any depth; `structuredPatch` is left whole.
function capDeep(v, key) { function capDeep(v, key, hit) {
if (typeof v === "string") return capString(v); if (typeof v === "string") return capString(v, hit);
if (key === "structuredPatch" || v === null || typeof v !== "object") return v; if (key === "structuredPatch" || v === null || typeof v !== "object") return v;
if (Array.isArray(v)) return v.map((x) => capDeep(x)); if (Array.isArray(v)) return v.map((x) => capDeep(x, undefined, hit));
const out = {}; const out = {};
for (const [k, x] of Object.entries(v)) out[k] = capDeep(x, k); for (const [k, x] of Object.entries(v)) out[k] = capDeep(x, k, hit);
return out; return out;
} }
/** /**
* @param {object} data A parsed hook payload. * @param {object} data A parsed hook payload.
* @param {{n: number}} [hit] Counts every drop and cap made, so a caller can tell
* "nothing to trim" apart.
* @returns {object} A trimmed copy (the input is not mutated). * @returns {object} A trimmed copy (the input is not mutated).
*/ */
function trimHookPayload(data) { function trimHookPayload(data, hit = { n: 0 }) {
if (!data || typeof data !== "object" || Array.isArray(data)) return data; if (!data || typeof data !== "object" || Array.isArray(data)) return data;
const out = { ...data }; const out = { ...data };
const dropped = new Set(Array.isArray(data._trimmed) ? data._trimmed : []); const dropped = new Set(Array.isArray(data._trimmed) ? data._trimmed : []);
@@ -59,6 +62,7 @@ function trimHookPayload(data) {
for (const k of DROP_RESPONSE[data.tool_name] || []) { for (const k of DROP_RESPONSE[data.tool_name] || []) {
if (typeof r[k] === "string" && r[k].length > 0) { if (typeof r[k] === "string" && r[k].length > 0) {
delete r[k]; delete r[k];
hit.n++;
dropped.add(`tool_response.${k}`); dropped.add(`tool_response.${k}`);
} }
} }
@@ -68,6 +72,7 @@ function trimHookPayload(data) {
for (const k of fileDrop) { for (const k of fileDrop) {
if (typeof f[k] === "string" && f[k].length > 0) { if (typeof f[k] === "string" && f[k].length > 0) {
delete f[k]; delete f[k];
hit.n++;
dropped.add(`tool_response.file.${k}`); dropped.add(`tool_response.file.${k}`);
} }
} }
@@ -75,8 +80,8 @@ function trimHookPayload(data) {
} }
out.tool_response = r; out.tool_response = r;
} }
if ("tool_response" in out) out.tool_response = capDeep(out.tool_response); if ("tool_response" in out) out.tool_response = capDeep(out.tool_response, undefined, hit);
if ("tool_input" in out) out.tool_input = capDeep(out.tool_input); if ("tool_input" in out) out.tool_input = capDeep(out.tool_input, undefined, hit);
if (dropped.size > 0) out._trimmed = [...dropped]; if (dropped.size > 0) out._trimmed = [...dropped];
return out; return out;
} }
@@ -84,7 +89,8 @@ function trimHookPayload(data) {
/** /**
* The SQL-side entry point (`trim_event_data(?)`, registered in `server/db.js`): * The SQL-side entry point (`trim_event_data(?)`, registered in `server/db.js`):
* a JSON string in, a JSON string out. Short, null or unparseable input is * a JSON string in, a JSON string out. Short, null or unparseable input is
* returned unchanged, and trimming is idempotent. * returned unchanged, and so is a payload with nothing to trim — re-serializing
* it would round integers past 2^53. Trimming is idempotent.
* @param {string|null} json * @param {string|null} json
* @returns {string|null} * @returns {string|null}
*/ */
@@ -97,40 +103,57 @@ function trimEventData(json) {
return json; return json;
} }
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return json; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return json;
return JSON.stringify(trimHookPayload(parsed)); const hit = { n: 0 };
const out = trimHookPayload(parsed, hit);
// ponytail: a payload that IS trimmed is re-serialized whole, so a huge integer
// elsewhere in it still rounds; splice the JSON text instead if one ever matters.
return hit.n ? JSON.stringify(out) : json;
} }
/** /**
* Trims the rows stored before trimming existed, a chunk per write transaction * Trims the rows stored before trimming existed, a few rows per write
* so a hook never waits long for the lock. Idempotent: a crash just resumes from * transaction so a hook never waits long for the lock: ids are picked first and
* the start on the next run. * each row's payload is read alone, and a transaction ends after `chunk` rows or
* once the payloads it read pass `maxBytes` — a Read of an image can hold MBs of
* base64, and 200 of those in one go stalled the event loop. Idempotent: a crash
* just resumes from the start on the next run.
* @param {import("better-sqlite3").Database} db * @param {import("better-sqlite3").Database} db
* @param {{chunk?: number}} [opts] * @param {{chunk?: number, maxBytes?: number}} [opts]
* @returns {Promise<{rows: number, bytesSaved: number}>} * @returns {Promise<{rows: number, bytesSaved: number}>}
*/ */
function sweepEventPayloads(db, { chunk = 200 } = {}) { function sweepEventPayloads(db, { chunk = 200, maxBytes = 8 * 1024 * 1024 } = {}) {
const select = db.prepare( const pick = db
"SELECT id, data FROM events WHERE id > ? AND length(data) > ? ORDER BY id LIMIT ?" .prepare("SELECT id FROM events WHERE id > ? AND length(data) > ? ORDER BY id LIMIT ?")
); .pluck();
const read = db.prepare("SELECT data FROM events WHERE id = ?").pluck();
const update = db.prepare("UPDATE events SET data = ? WHERE id = ?"); const update = db.prepare("UPDATE events SET data = ? WHERE id = ?");
const out = { rows: 0, bytesSaved: 0 }; const out = { rows: 0, bytesSaved: 0 };
const trimChunk = db.transaction((rows) => { // Returns the last id it handled; the next pick starts after it.
for (const r of rows) { const trimChunk = db.transaction((ids) => {
const t = trimEventData(r.data); let bytes = 0;
if (t === r.data) continue; let last = ids[0];
update.run(t, r.id); for (const id of ids) {
out.rows++; last = id;
out.bytesSaved += r.data.length - t.length; const data = read.get(id);
if (typeof data !== "string") continue;
bytes += data.length;
const t = trimEventData(data);
if (t !== data) {
update.run(t, id);
out.rows++;
out.bytesSaved += data.length - t.length;
}
if (bytes >= maxBytes) break;
} }
return last;
}); });
let lastId = 0; let lastId = 0;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const step = () => { const step = () => {
try { try {
const rows = select.all(lastId, SKIP_UNDER, chunk); const ids = pick.all(lastId, SKIP_UNDER, chunk);
if (rows.length === 0) return resolve(out); if (ids.length === 0) return resolve(out);
trimChunk(rows); lastId = trimChunk(ids);
lastId = rows[rows.length - 1].id;
setImmediate(step); setImmediate(step);
} catch (err) { } catch (err) {
reject(err); reject(err);
+24 -2
View File
@@ -1,7 +1,8 @@
/** /**
* @file pty-attach.js * @file pty-attach.js
* @description Bridges one WebSocket connection to an `@lydell/node-pty`-backed * @description Bridges one WebSocket connection to an `@lydell/node-pty`-backed
* `tmux attach-session` process. Binary WS frames carry raw PTY bytes in * `tmux attach-session` process, run with the live tmux server's own binary
* (`attachBinary`). Binary WS frames carry raw PTY bytes in
* both directions; text WS frames carry small JSON control messages * both directions; text WS frames carry small JSON control messages
* (`resize`, and an outbound `exit` sent once when the pane process/tmux * (`resize`, and an outbound `exit` sent once when the pane process/tmux
* session ends). Multiple browser tabs each get their own PTY attach * session ends). Multiple browser tabs each get their own PTY attach
@@ -10,6 +11,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
const fs = require("fs");
const tmux = require("./tmux"); const tmux = require("./tmux");
const RUN_ID_RE = /^agenthub-lane-\d+(?:-[1-3])?$/; const RUN_ID_RE = /^agenthub-lane-\d+(?:-[1-3])?$/;
@@ -34,6 +36,26 @@ function __setSpawnImpl(fn) {
spawnImpl = fn; spawnImpl = fn;
} }
/**
* The binary to run `tmux attach` with: the RUNNING server's own executable
* (`/proc/<pid>/exe`, which still works after a package upgrade replaced the
* file), else `tmux` on PATH. A client must match its server — tmux installed
* in a newer version while the server runs makes PATH's client fail every
* attach with "open terminal failed: not a terminal", though every other tmux
* command still works. Linux only; elsewhere PATH's tmux as before.
*/
function attachBinary() {
const pid = tmux.serverPid();
if (!pid) return "tmux";
const exe = `/proc/${pid}/exe`;
try {
fs.accessSync(exe, fs.constants.X_OK);
return exe;
} catch {
return "tmux";
}
}
/** /**
* Attach `ws` to the tmux session `runId`. Spawns one PTY-backed * Attach `ws` to the tmux session `runId`. Spawns one PTY-backed
* `tmux attach-session -t <runId>` per call. * `tmux attach-session -t <runId>` per call.
@@ -48,7 +70,7 @@ function attach(ws, runId, { cols, rows }) {
} catch { } catch {
/* session gone or tmux unavailable — attach below will surface that */ /* session gone or tmux unavailable — attach below will surface that */
} }
const pty = spawnImpl("tmux", ["attach-session", "-t", `=${runId}`], { const pty = spawnImpl(attachBinary(), ["attach-session", "-t", `=${runId}`], {
name: "xterm-256color", name: "xterm-256color",
cols: cols || 80, cols: cols || 80,
rows: rows || 24, rows: rows || 24,
+5
View File
@@ -451,6 +451,11 @@ function spawnRun(args) {
// journals and the router layer `-p <name>` loads live there — and the // journals and the router layer `-p <name>` loads live there — and the
// tmux server's own env may predate, or never have had, CODEX_HOME. // tmux server's own env may predate, or never have had, CODEX_HOME.
...(tool === "codex" ? { CODEX_HOME: getCodexHome() } : {}), ...(tool === "codex" ? { CODEX_HOME: getCodexHome() } : {}),
// Claude Code inline even under `"tui": "fullscreen"`: on the alternate
// screen tmux keeps no history and draws no pane scrollbar, so the
// dashboard terminal had nothing to drag. The user's own terminals keep
// their setting — this reaches only panes AgentHub starts.
...(tool === "claude" ? { CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: "1" } : {}),
...routerSpawn.env, ...routerSpawn.env,
...(bypass === true && entry.bypassEnv ? entry.bypassEnv : {}), ...(bypass === true && entry.bypassEnv ? entry.bypassEnv : {}),
}; };
+129
View File
@@ -0,0 +1,129 @@
/**
* @file Runs the dashboard's heavy read-only aggregations in a worker thread
* on its own SQLite connection, so a slow one never blocks the event loop that
* serves every other page, hook and websocket. Identical requests in flight are
* shared, a job over `AGENTHUB_READ_TIMEOUT_MS` (default 60 s) is answered with
* `EREADTIMEOUT` and its worker replaced, and `AGENTHUB_READ_WORKER=0` — or a
* worker that cannot start — runs the job inline as before. This file is also
* the worker's entry point.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Worker, isMainThread, workerData, parentPort } = require("node:worker_threads");
/** Read-only jobs by name: (connection, args) → plain data. */
const JOBS = {
workflows: (conn, a) =>
require("./workflow-stats").computeWorkflowData(conn, a.status || null, a.sections || null),
};
function timeoutMs() {
return Number(process.env.AGENTHUB_READ_TIMEOUT_MS) || 60_000;
}
function codedError(message, code) {
return Object.assign(new Error(message), { code });
}
// ponytail: one worker, so heavy reads queue behind each other (the event loop
// stays free either way); a small pool if concurrent heavy reads ever pile up.
let worker = null;
let seq = 0;
const pending = new Map(); // id → { resolve, reject, timer, w }
const inflight = new Map(); // job+args → Promise
function spawn(dbPath) {
const w = new Worker(__filename, { workerData: { agenthubReadWorker: true, dbPath } });
w.on("message", ({ id, result, error }) => {
const p = pending.get(id);
if (!p) return;
pending.delete(id);
clearTimeout(p.timer);
if (error) p.reject(codedError(error.message, error.code));
else p.resolve(result);
});
const fail = (err) => {
if (worker === w) worker = null;
for (const [id, p] of pending) {
if (p.w !== w) continue;
pending.delete(id);
clearTimeout(p.timer);
p.reject(err);
}
};
w.on("error", fail);
w.on("exit", (code) => fail(codedError(`read worker exited (${code})`, "EREADWORKER")));
w.unref(); // after the listeners: adding a "message" listener refs it again
return w;
}
function runInline(job, args) {
const { db } = require("../db");
return Promise.resolve().then(() => JOBS[job](db, args));
}
function dispatch(job, args) {
if (process.env.AGENTHUB_READ_WORKER === "0") return runInline(job, args);
try {
if (!worker) worker = spawn(require("../db").DB_PATH);
} catch {
return runInline(job, args);
}
const w = worker;
return new Promise((resolve, reject) => {
const id = ++seq;
const ms = timeoutMs();
const timer = setTimeout(() => {
// The stuck query keeps its thread until SQLite returns; the next job
// gets a fresh worker. Jobs queued behind it are rejected by `exit`.
pending.delete(id);
if (worker === w) worker = null;
w.terminate();
reject(codedError(`read query took over ${ms} ms`, "EREADTIMEOUT"));
}, ms);
timer.unref?.();
pending.set(id, { resolve, reject, timer, w });
w.postMessage({ id, job, args });
});
}
/**
* Run a named read job off the event loop. Concurrent calls with the same job
* and args share one run.
*
* @param {keyof JOBS} job
* @param {object} [args] structured-cloneable
* @returns {Promise<any>}
*/
function runRead(job, args = {}) {
if (!JOBS[job]) return Promise.reject(codedError(`unknown read job ${job}`, "EBADJOB"));
const key = `${job}:${JSON.stringify(args)}`;
const hit = inflight.get(key);
if (hit) return hit;
const p = dispatch(job, args).finally(() => inflight.delete(key));
inflight.set(key, p);
return p;
}
if (!isMainThread && workerData?.agenthubReadWorker) {
// Same driver order as server/db.js: better-sqlite3, else node:sqlite.
let conn;
try {
const Database = require("better-sqlite3");
conn = new Database(workerData.dbPath);
} catch {
const Database = require("../compat-sqlite");
conn = new Database(workerData.dbPath);
}
conn.pragma("busy_timeout = 5000");
conn.pragma("query_only = ON"); // this connection can never write
parentPort.on("message", ({ id, job, args }) => {
try {
parentPort.postMessage({ id, result: JOBS[job](conn, args) });
} catch (err) {
parentPort.postMessage({ id, error: { message: err.message, code: err.code } });
}
});
}
module.exports = { runRead };
+53 -3
View File
@@ -49,6 +49,16 @@ function hasSession(name) {
} }
} }
/** The running tmux server's pid, or null when none answers. */
function serverPid() {
try {
const pid = String(execImpl(["display-message", "-p", "#{pid}"])).trim();
return /^\d+$/.test(pid) ? pid : null;
} catch {
return null;
}
}
/** `-e NAME=value` pairs for tmux, in a stable order. */ /** `-e NAME=value` pairs for tmux, in a stable order. */
function envFlags(env) { function envFlags(env) {
const flags = []; const flags = [];
@@ -77,8 +87,29 @@ function newSession({ name, cwd, argv, env }) {
// Called on every attach (not just at creation) so a session started before // Called on every attach (not just at creation) so a session started before
// this option existed — or before a server restart picked up the change — // this option existed — or before a server restart picked up the change —
// still gets it; `set-option` is idempotent, so re-running it is harmless. // still gets it; `set-option` is idempotent, so re-running it is harmless.
// The status bar goes off here too: the dashboard's tab bar already names the
// pane, and tmux's green line only eats a row of the terminal.
function enableMouse(name) { function enableMouse(name) {
execImpl(["set-option", "-t", pane(name), "mouse", "on"]); execImpl(["set-option", "-t", pane(name), "mouse", "on"]);
execImpl(["set-option", "-t", pane(name), "status", "off"]);
// A draggable scrollbar beside the pane (mouse mode makes it drag). Only a
// tmux SERVER of 3.7+ knows the option; an older one refuses it, which is
// fine — the pane just has none (`/agenthub-doctor` says how to upgrade).
try {
execImpl(["set-option", "-t", pane(name), "pane-scrollbars", "on"]);
// tmux's default (`fg=white,bg=black,width=1`) is a one-column track in
// ANSI black — the terminal theme's border colour, almost the background —
// so it reads as no scrollbar at all. Two columns, bright slider.
execImpl([
"set-option",
"-t",
pane(name),
"pane-scrollbars-style",
"fg=brightwhite,bg=black,width=2,pad=0",
]);
} catch {
/* tmux < 3.7 */
}
} }
/** /**
@@ -174,11 +205,27 @@ function listSessions(prefix) {
} }
function isTmuxAvailable() { function isTmuxAvailable() {
return clientVersion() !== null;
}
/** The tmux binary's version (`3.7c`, `next-3.8`), null when it is absent. */
function clientVersion() {
try { try {
execImpl(["-V"]); return execImpl(["-V"])
return true; .trim()
.replace(/^tmux\s+/, "");
} catch { } catch {
return false; return null;
}
}
/** The RUNNING server's version, null when none runs. A server started before
* an upgrade keeps its old version until every tmux session has closed. */
function serverVersion() {
try {
return execImpl(["display-message", "-p", "#{version}"]).trim() || null;
} catch {
return null;
} }
} }
@@ -193,6 +240,9 @@ module.exports = {
killSession, killSession,
listSessions, listSessions,
isTmuxAvailable, isTmuxAvailable,
clientVersion,
serverVersion,
serverPid,
__setExecImpl, __setExecImpl,
__reset, __reset,
}; };
+721
View File
@@ -0,0 +1,721 @@
/**
* @file The aggregations behind GET /api/workflows, as pure reads over a given
* SQLite connection — so they can run in the read worker
* (`server/lib/read-worker.js`) on its own connection instead of blocking the
* server's event loop, or inline on the main one as a fallback.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
// ponytail: one module-level connection, set at the top of each
// computeWorkflowData call; every query here is synchronous, so two calls never
// interleave. Pass `db` through each function if that ever changes.
let db;
// ── Helper: compute session duration in seconds ──
function durationSec(s) {
if (!s.started_at) return 0;
const end = s.ended_at || new Date().toISOString();
return Math.max(0, (new Date(end) - new Date(s.started_at)) / 1000);
}
// ═══════════════════════════════════════════════════
// Data-fetching functions
// ═══════════════════════════════════════════════════
/**
* Build a SQL WHERE clause for session status filtering.
* Returns { clause, params } where clause is either empty or " AND s.status = ?".
* Use `sessionAlias` to match the table alias used in your query (default "s").
*/
function statusClause(statusFilter, alias = "s") {
if (!statusFilter || statusFilter === "all") return { clause: "", params: [] };
return { clause: ` AND ${alias}.status = ?`, params: [statusFilter] };
}
/** Same but for agents table joins where we need to filter via session_id */
function sessionIdFilter(statusFilter) {
if (!statusFilter || statusFilter === "all") return { clause: "", params: [] };
return {
clause: " AND session_id IN (SELECT id FROM sessions WHERE status = ?)",
params: [statusFilter],
};
}
/**
* Each tool event paired with the next tool event of the same session, as
* `(source, target)` rows. One window pass: a correlated `MIN(id) … id > ?`
* subquery per event is quadratic in a session's size unless an index orders
* a session's events by id, and it froze the server for minutes on a big one.
*/
function toolPairsSql(sessionClause) {
return `SELECT source, target FROM (
SELECT tool_name AS source,
LEAD(tool_name) OVER (PARTITION BY session_id ORDER BY id) AS target
FROM events WHERE tool_name IS NOT NULL${sessionClause}
) WHERE target IS NOT NULL`;
}
function getWorkflowStats(statusFilter, transitions) {
const sf = sessionIdFilter(statusFilter);
const ss = statusClause(statusFilter);
const totalSessions = db
.prepare(`SELECT COUNT(*) as c FROM sessions s WHERE 1=1${ss.clause}`)
.get(...ss.params).c;
const totalAgents = db
.prepare(`SELECT COUNT(*) as c FROM agents WHERE 1=1${sf.clause}`)
.get(...sf.params).c;
const totalSubagents = db
.prepare(`SELECT COUNT(*) as c FROM agents WHERE type = 'subagent'${sf.clause}`)
.get(...sf.params).c;
// Average subagents per session
const avgSubagents = totalSessions > 0 ? +(totalSubagents / totalSessions).toFixed(1) : 0;
// Agent success rate
const completedAgents = db
.prepare(`SELECT COUNT(*) as c FROM agents WHERE status = 'completed'${sf.clause}`)
.get(...sf.params).c;
const errorAgents = db
.prepare(`SELECT COUNT(*) as c FROM agents WHERE status = 'error'${sf.clause}`)
.get(...sf.params).c;
const finishedAgents = completedAgents + errorAgents;
const successRate =
finishedAgents > 0 ? +((completedAgents / finishedAgents) * 100).toFixed(1) : 100;
// Average max depth per session
const depthRows = db
.prepare(
`WITH RECURSIVE agent_depth AS (
SELECT id, session_id, parent_agent_id, 0 as depth FROM agents WHERE parent_agent_id IS NULL
UNION ALL
SELECT a.id, a.session_id, a.parent_agent_id, ad.depth + 1
FROM agents a JOIN agent_depth ad ON a.parent_agent_id = ad.id
)
SELECT session_id, MAX(depth) as max_depth FROM agent_depth
WHERE 1=1${sf.clause}
GROUP BY session_id`
)
.all(...sf.params);
const avgDepth =
depthRows.length > 0
? +(depthRows.reduce((s, r) => s + r.max_depth, 0) / depthRows.length).toFixed(1)
: 0;
// Average session duration
const sessions = db
.prepare(`SELECT started_at, ended_at FROM sessions s WHERE ended_at IS NOT NULL${ss.clause}`)
.all(...ss.params);
const totalDuration = sessions.reduce((s, sess) => s + durationSec(sess), 0);
const avgDurationSec = sessions.length > 0 ? Math.round(totalDuration / sessions.length) : 0;
// Total compactions
const totalCompactions = db
.prepare(`SELECT COUNT(*) as c FROM agents WHERE subagent_type = 'compaction'${sf.clause}`)
.get(...sf.params).c;
const avgCompactions = totalSessions > 0 ? +(totalCompactions / totalSessions).toFixed(1) : 0;
// Most common tool flow (top 2-tool sequence): the head of the ranked list
const topFlow = transitions[0];
return {
totalSessions,
totalAgents,
totalSubagents,
avgSubagents,
successRate,
avgDepth,
avgDurationSec,
totalCompactions,
avgCompactions,
topFlow: topFlow
? { source: topFlow.source, target: topFlow.target, count: topFlow.value }
: null,
};
}
function getOrchestrationData(statusFilter) {
const sf = sessionIdFilter(statusFilter);
const ss = statusClause(statusFilter);
// Count sessions
const sessionCount = db
.prepare(`SELECT COUNT(*) as c FROM sessions s WHERE 1=1${ss.clause}`)
.get(...ss.params).c;
// Main agents count
const mainCount = db
.prepare(`SELECT COUNT(*) as c FROM agents WHERE type = 'main'${sf.clause}`)
.get(...sf.params).c;
// Subagent types with counts and parent info
const subagentTypes = db
.prepare(
`SELECT subagent_type, COUNT(*) as count,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as errors
FROM agents WHERE type = 'subagent' AND subagent_type IS NOT NULL${sf.clause}
GROUP BY subagent_type ORDER BY count DESC`
)
.all(...sf.params);
// Edges: parent_subagent_type -> child_subagent_type with frequency
const edges = db
.prepare(
`SELECT
COALESCE(p.subagent_type, 'main') as source,
a.subagent_type as target,
COUNT(*) as weight
FROM agents a
LEFT JOIN agents p ON a.parent_agent_id = p.id
WHERE a.type = 'subagent' AND a.subagent_type IS NOT NULL${sf.clause.replace("session_id", "a.session_id")}
GROUP BY source, target
ORDER BY weight DESC`
)
.all(...sf.params);
// Outcome counts
const outcomes = db
.prepare(
`SELECT status, COUNT(*) as count FROM agents
WHERE status IN ('completed', 'error')${sf.clause}
GROUP BY status`
)
.all(...sf.params);
// Compaction agents (context compressions per session)
const compactions = db
.prepare(
`SELECT session_id, COUNT(*) as count
FROM agents WHERE subagent_type = 'compaction'${sf.clause}
GROUP BY session_id`
)
.all(...sf.params);
const totalCompactions = compactions.reduce((s, r) => s + r.count, 0);
const sessionsWithCompactions = compactions.length;
return {
sessionCount,
mainCount,
subagentTypes,
edges,
outcomes,
compactions: { total: totalCompactions, sessions: sessionsWithCompactions },
};
}
/** Tool-to-tool transitions (next tool in same session), most frequent first. */
function getToolTransitions(statusFilter) {
const sf = sessionIdFilter(statusFilter);
return db
.prepare(
`SELECT source, target, COUNT(*) as value
FROM (${toolPairsSql(sf.clause)})
GROUP BY source, target
ORDER BY value DESC
LIMIT 50`
)
.all(...sf.params);
}
function getToolFlowData(statusFilter, transitions) {
const sf = sessionIdFilter(statusFilter);
// Tool counts for sizing nodes
const toolCounts = db
.prepare(
`SELECT tool_name, COUNT(*) as count FROM events
WHERE tool_name IS NOT NULL${sf.clause}
GROUP BY tool_name ORDER BY count DESC LIMIT 15`
)
.all(...sf.params);
return { transitions, toolCounts };
}
function getSubagentEffectiveness(statusFilter) {
const sf = sessionIdFilter(statusFilter);
const types = db
.prepare(
`SELECT
a.subagent_type,
COUNT(*) as total,
SUM(CASE WHEN a.status = 'completed' THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN a.status = 'error' THEN 1 ELSE 0 END) as errors,
COUNT(DISTINCT a.session_id) as sessions
FROM agents a
WHERE a.type = 'subagent' AND a.subagent_type IS NOT NULL${sf.clause.replace("session_id", "a.session_id")}
GROUP BY a.subagent_type
ORDER BY total DESC
LIMIT 12`
)
.all(...sf.params);
// Get token usage per subagent type (approximate via session token totals)
// Also get average duration per type
const withMetrics = types.map((t) => {
const durRow = db
.prepare(
// Clamp each row's duration to >= 0 before averaging. The #156 startup
// repair only heals compaction rows; any other subagent type whose
// ended_at < started_at (clock skew, replayed/synced transcripts) would
// otherwise drag this average negative. MAX(0, …) mirrors the Math.max(0)
// guard durationSec() already applies to every other duration here.
`SELECT AVG(
CASE WHEN ended_at IS NOT NULL THEN
MAX(0, (julianday(ended_at) - julianday(started_at)) * 86400)
ELSE NULL END
) as avg_duration
FROM agents WHERE subagent_type = ? AND type = 'subagent'${sf.clause}`
)
.get(t.subagent_type, ...sf.params);
// Weekly trend: count per day-of-week (Mon–Sun) over last 8 weeks.
// SQLite strftime('%w') → 0=Sun, 1=Mon, ..., 6=Sat.
// Frontend expects index 0=Mon → 6=Sun, so remap with (dow + 6) % 7.
const trendRows = db
.prepare(
`SELECT CAST(strftime('%w', started_at) AS INTEGER) as dow, COUNT(*) as count
FROM agents WHERE subagent_type = ? AND type = 'subagent'
AND started_at >= date('now', '-56 days')${sf.clause}
GROUP BY dow ORDER BY dow ASC`
)
.all(t.subagent_type, ...sf.params);
// Build 7-slot array: [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
const trendByDay = [0, 0, 0, 0, 0, 0, 0];
for (const row of trendRows) {
const idx = (row.dow + 6) % 7; // Sun(0)→6, Mon(1)→0, Tue(2)→1, ...
trendByDay[idx] = row.count;
}
return {
...t,
successRate:
t.completed + t.errors > 0
? +((t.completed / (t.completed + t.errors)) * 100).toFixed(1)
: 100,
avgDuration: durRow?.avg_duration ? Math.round(durRow.avg_duration) : null,
trend: trendByDay,
};
});
return withMetrics;
}
function getWorkflowPatterns(statusFilter) {
const sf = sessionIdFilter(statusFilter);
const ss = statusClause(statusFilter);
// Get ordered subagent sequences per session
const sessions = db
.prepare(
`SELECT session_id, GROUP_CONCAT(subagent_type, '→') as sequence
FROM (
SELECT session_id, subagent_type
FROM agents
WHERE type = 'subagent' AND subagent_type IS NOT NULL${sf.clause}
ORDER BY session_id, started_at ASC
)
GROUP BY session_id
HAVING COUNT(*) >= 2`
)
.all(...sf.params);
// Count pattern frequencies
const patternCounts = {};
const totalSessions = db
.prepare(`SELECT COUNT(*) as c FROM sessions s WHERE 1=1${ss.clause}`)
.get(...ss.params).c;
for (const row of sessions) {
const seq = row.sequence;
patternCounts[seq] = (patternCounts[seq] || 0) + 1;
}
// Also count 2-step and 3-step sub-patterns
for (const row of sessions) {
const steps = row.sequence.split("→");
// 2-step windows
for (let i = 0; i < steps.length - 1; i++) {
const sub = steps.slice(i, i + 2).join("→");
patternCounts[sub] = (patternCounts[sub] || 0) + 1;
}
// 3-step windows
for (let i = 0; i < steps.length - 2; i++) {
const sub = steps.slice(i, i + 3).join("→");
patternCounts[sub] = (patternCounts[sub] || 0) + 1;
}
}
// Deduplicate: keep only patterns where the full sequence count >= 2-step count
// Sort by frequency, take top 10
const sorted = Object.entries(patternCounts)
.filter(([, count]) => count >= 2)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([pattern, count]) => ({
steps: pattern.split("→"),
count,
percentage: totalSessions > 0 ? +((count / totalSessions) * 100).toFixed(1) : 0,
}));
// Also track solo sessions (no subagents)
const soloCount = db
.prepare(
`SELECT COUNT(*) as c FROM sessions s
WHERE NOT EXISTS (SELECT 1 FROM agents a WHERE a.session_id = s.id AND a.type = 'subagent')${ss.clause}`
)
.get(...ss.params).c;
return {
patterns: sorted,
soloSessionCount: soloCount,
soloPercentage: totalSessions > 0 ? +((soloCount / totalSessions) * 100).toFixed(1) : 0,
};
}
function getModelDelegation(statusFilter) {
const ss = statusClause(statusFilter);
// Model usage for main agents
const mainModels = db
.prepare(
`SELECT s.model, COUNT(DISTINCT a.id) as agent_count, COUNT(DISTINCT s.id) as session_count
FROM agents a JOIN sessions s ON a.session_id = s.id
WHERE a.type = 'main' AND s.model IS NOT NULL${ss.clause}
GROUP BY s.model ORDER BY agent_count DESC`
)
.all(...ss.params);
// Model usage for subagents (via session model — best approximation)
const subagentModels = db
.prepare(
`SELECT s.model, COUNT(a.id) as agent_count
FROM agents a JOIN sessions s ON a.session_id = s.id
WHERE a.type = 'subagent' AND s.model IS NOT NULL${ss.clause}
GROUP BY s.model ORDER BY agent_count DESC`
)
.all(...ss.params);
// Token cost per model — filter via session_id on token_usage table
const sfToken = sessionIdFilter(statusFilter);
const tokensByModel = db
.prepare(
`SELECT model,
SUM(input_tokens + baseline_input) as input_tokens,
SUM(output_tokens + baseline_output) as output_tokens,
SUM(cache_read_tokens + baseline_cache_read) as cache_read_tokens,
SUM(cache_write_tokens + baseline_cache_write) as cache_write_tokens
FROM token_usage WHERE 1=1${sfToken.clause}
GROUP BY model ORDER BY (input_tokens + output_tokens) DESC`
)
.all(...sfToken.params);
return { mainModels, subagentModels, tokensByModel };
}
function getErrorPropagation(statusFilter) {
const sf = sessionIdFilter(statusFilter);
const ss = statusClause(statusFilter);
// Error count by depth — include both agent-level errors (status = 'error')
// AND session-level errors (session status = 'error' mapped to depth 0 for main agent).
const errorsByDepth = db
.prepare(
`WITH RECURSIVE agent_depth AS (
SELECT id, session_id, subagent_type, status, 0 as depth
FROM agents WHERE parent_agent_id IS NULL
UNION ALL
SELECT a.id, a.session_id, a.subagent_type, a.status, ad.depth + 1
FROM agents a JOIN agent_depth ad ON a.parent_agent_id = ad.id
)
SELECT depth, COUNT(*) as count FROM agent_depth
WHERE status = 'error'${sf.clause}
GROUP BY depth ORDER BY depth ASC`
)
.all(...sf.params);
// Also count sessions that ended in error but whose main agent wasn't marked error.
// Map these to depth 0 (session-level errors: quota limits, crashes, etc.)
const sessionErrorsNotInAgents = db
.prepare(
`SELECT COUNT(*) as c FROM sessions s
WHERE s.status = 'error'${ss.clause}
AND NOT EXISTS (
SELECT 1 FROM agents a WHERE a.session_id = s.id AND a.status = 'error'
)`
)
.get(...ss.params).c;
if (sessionErrorsNotInAgents > 0) {
const existing = errorsByDepth.find((d) => d.depth === 0);
if (existing) {
existing.count += sessionErrorsNotInAgents;
} else {
errorsByDepth.unshift({ depth: 0, count: sessionErrorsNotInAgents });
}
}
// Error-prone subagent types — from agent errors + from error events on subagents
const errorTypes = db
.prepare(
`SELECT subagent_type, COUNT(*) as count
FROM agents WHERE status = 'error' AND subagent_type IS NOT NULL${sf.clause}
GROUP BY subagent_type ORDER BY count DESC LIMIT 5`
)
.all(...sf.params);
// Also capture error events (Stop with error summary, API errors from transcripts)
const eventErrors = db
.prepare(
`SELECT e.summary, COUNT(*) as count
FROM events e
WHERE ((e.event_type = 'Stop' AND e.summary LIKE 'Error in%')
OR e.event_type = 'APIError')${sf.clause.replace("session_id", "e.session_id")}
GROUP BY e.summary ORDER BY count DESC LIMIT 10`
)
.all(...sf.params);
// Error rate per session (sessions with error status OR sessions with error events)
const sessionsWithErrors = db
.prepare(
`SELECT COUNT(DISTINCT id) as c FROM (
SELECT id FROM sessions s WHERE s.status = 'error'${ss.clause}
UNION
SELECT DISTINCT session_id as id FROM agents WHERE status = 'error'${sf.clause}
UNION
SELECT DISTINCT session_id as id FROM events
WHERE ((event_type = 'Stop' AND summary LIKE 'Error in%')
OR event_type = 'APIError')${sf.clause}
)`
)
.get(...ss.params, ...sf.params, ...sf.params).c;
const totalSessions = db
.prepare(`SELECT COUNT(*) as c FROM sessions s WHERE 1=1${ss.clause}`)
.get(...ss.params).c;
return {
byDepth: errorsByDepth,
byType: errorTypes,
eventErrors,
sessionsWithErrors,
totalSessions,
errorRate: totalSessions > 0 ? +((sessionsWithErrors / totalSessions) * 100).toFixed(1) : 0,
};
}
function getConcurrencyData(statusFilter) {
const ss = statusClause(statusFilter);
// For aggregate: average agent types per position in session timeline
// Get agent start/end as fraction of session duration per session
const lanes = db
.prepare(
`SELECT
a.id, a.name, a.type, a.subagent_type, a.status,
a.started_at, a.ended_at, a.session_id,
s.started_at as session_start, s.ended_at as session_end
FROM agents a
JOIN sessions s ON a.session_id = s.id
WHERE s.ended_at IS NOT NULL${ss.clause}
ORDER BY a.started_at ASC
LIMIT 2000`
)
.all(...ss.params);
// Build aggregate: for each subagent_type, average start% and end%
const typeAgg = {};
for (const lane of lanes) {
const sessStart = new Date(lane.session_start).getTime();
const sessEnd = new Date(lane.session_end).getTime();
const sessDur = sessEnd - sessStart;
if (sessDur <= 0) continue;
const agStart = new Date(lane.started_at).getTime();
const agEnd = lane.ended_at ? new Date(lane.ended_at).getTime() : sessEnd;
const startPct = Math.max(0, Math.min(1, (agStart - sessStart) / sessDur));
const endPct = Math.max(0, Math.min(1, (agEnd - sessStart) / sessDur));
const key = lane.type === "main" ? "Main Agent" : lane.subagent_type || "unknown";
if (!typeAgg[key]) typeAgg[key] = { starts: [], ends: [], status: lane.status };
typeAgg[key].starts.push(startPct);
typeAgg[key].ends.push(endPct);
}
// Average start/end per type
const aggregateLanes = Object.entries(typeAgg)
.map(([name, data]) => ({
name,
avgStart: +(data.starts.reduce((s, v) => s + v, 0) / data.starts.length).toFixed(3),
avgEnd: +(data.ends.reduce((s, v) => s + v, 0) / data.ends.length).toFixed(3),
count: data.starts.length,
}))
.sort((a, b) => a.avgStart - b.avgStart);
return { aggregateLanes };
}
function getSessionComplexity(statusFilter) {
const ss = statusClause(statusFilter);
const rows = db
.prepare(
`SELECT
s.id, s.name, s.status, s.started_at, s.ended_at, s.model,
COUNT(a.id) as agent_count,
SUM(CASE WHEN a.type = 'subagent' THEN 1 ELSE 0 END) as subagent_count
FROM sessions s
LEFT JOIN agents a ON a.session_id = s.id
WHERE 1=1${ss.clause}
GROUP BY s.id
ORDER BY s.started_at DESC
LIMIT 200`
)
.all(...ss.params);
const sessions = rows.map((r) => {
const dur = durationSec(r);
// Get token count for this session
const tokens = db
.prepare(
`SELECT SUM(input_tokens + baseline_input + output_tokens + baseline_output +
cache_read_tokens + baseline_cache_read + cache_write_tokens + baseline_cache_write) as total
FROM token_usage WHERE session_id = ?`
)
.get(r.id);
return {
id: r.id,
name: r.name,
status: r.status,
duration: Math.round(dur),
agentCount: r.agent_count,
subagentCount: r.subagent_count,
totalTokens: tokens?.total || 0,
model: r.model,
};
});
return sessions;
}
function getCompactionImpact(statusFilter) {
const sf = sessionIdFilter(statusFilter);
const ss = statusClause(statusFilter);
// Total compactions
const totalCompactions = db
.prepare(`SELECT COUNT(*) as c FROM agents WHERE subagent_type = 'compaction'${sf.clause}`)
.get(...sf.params).c;
// Total baseline tokens (tokens "recovered" through compaction)
const recovered = db
.prepare(
`SELECT
SUM(baseline_input + baseline_output + baseline_cache_read + baseline_cache_write) as total
FROM token_usage WHERE 1=1${sf.clause}`
)
.get(...sf.params);
// Compactions per session distribution
const perSession = db
.prepare(
`SELECT session_id, COUNT(*) as compactions
FROM agents WHERE subagent_type = 'compaction'${sf.clause}
GROUP BY session_id ORDER BY compactions DESC LIMIT 50`
)
.all(...sf.params);
// Sessions with compactions vs without
const sessionsWithCompactions = db
.prepare(
`SELECT COUNT(DISTINCT session_id) as c FROM agents WHERE subagent_type = 'compaction'${sf.clause}`
)
.get(...sf.params).c;
const totalSessions = db
.prepare(`SELECT COUNT(*) as c FROM sessions s WHERE 1=1${ss.clause}`)
.get(...ss.params).c;
return {
totalCompactions,
tokensRecovered: recovered?.total || 0,
perSession,
sessionsWithCompactions,
totalSessions,
};
}
function getAgentCooccurrence(statusFilter) {
const sf = sessionIdFilter(statusFilter);
// Directed: which agent type runs AFTER which other type in the same session
// a1 started before a2 → edge a1 → a2 with count
const pairs = db
.prepare(
`SELECT a1.subagent_type as source, a2.subagent_type as target,
COUNT(*) as weight
FROM agents a1
JOIN agents a2 ON a1.session_id = a2.session_id
AND a1.started_at < a2.started_at
AND a1.id != a2.id
WHERE a1.type = 'subagent' AND a2.type = 'subagent'
AND a1.subagent_type IS NOT NULL AND a2.subagent_type IS NOT NULL
AND a1.subagent_type != 'compaction' AND a2.subagent_type != 'compaction'${sf.clause.replace("session_id", "a1.session_id")}
GROUP BY a1.subagent_type, a2.subagent_type
HAVING weight >= 2
ORDER BY weight DESC
LIMIT 40`
)
.all(...sf.params);
return pairs;
}
/** The bundle's sections, in response order. */
const WORKFLOW_SECTIONS = [
"stats",
"orchestration",
"toolFlow",
"effectiveness",
"patterns",
"modelDelegation",
"errorPropagation",
"concurrency",
"complexity",
"compaction",
"cooccurrence",
];
/**
* The GET /api/workflows bundle, or only the named sections of it.
*
* @param {object} conn SQLite connection (compat-sqlite Database)
* @param {string|null} statusFilter "active"/"completed"/…, or null/"all"
* @param {string[]|null} sections subset of WORKFLOW_SECTIONS; null = all
* @returns {object}
*/
function computeWorkflowData(conn, statusFilter, sections = null) {
db = conn;
const want = (k) => !sections || sections.includes(k);
// stats.topFlow is the head of toolFlow.transitions: one window pass for both.
const transitions = want("stats") || want("toolFlow") ? getToolTransitions(statusFilter) : null;
const build = {
stats: () => getWorkflowStats(statusFilter, transitions),
orchestration: () => getOrchestrationData(statusFilter),
toolFlow: () => getToolFlowData(statusFilter, transitions),
effectiveness: () => getSubagentEffectiveness(statusFilter),
patterns: () => getWorkflowPatterns(statusFilter),
modelDelegation: () => getModelDelegation(statusFilter),
errorPropagation: () => getErrorPropagation(statusFilter),
concurrency: () => getConcurrencyData(statusFilter),
complexity: () => getSessionComplexity(statusFilter),
compaction: () => getCompactionImpact(statusFilter),
cooccurrence: () => getAgentCooccurrence(statusFilter),
};
const data = {};
for (const k of WORKFLOW_SECTIONS) if (want(k)) data[k] = build[k]();
return data;
}
module.exports = { computeWorkflowData, WORKFLOW_SECTIONS, sessionIdFilter, statusClause };
+42 -18
View File
@@ -1140,7 +1140,7 @@ const paths = {
tags: ["Settings"], tags: ["Settings"],
summary: "Restore (import) a previously exported data bundle", summary: "Restore (import) a previously exported data bundle",
description: description:
'Restores a bundle produced by GET /api/settings/export. Supply it either as `multipart/form-data` with a single `file` field (browser upload) or as a JSON body `{ "path": "<absolute path>" }` (the server reads the file from disk — used by the `agenthub import-data` CLI, and it also sidesteps the global 1 MB JSON body cap for large bundles). The restore is idempotent and NON-DESTRUCTIVE: it is session-atomic, so a session already present (matched by its UUID) is skipped whole together with its agents/events/token_usage/workflows, and independent config rows (dashboard_runs, alert_rules, model_pricing) are inserted only when absent. Nothing existing is overwritten — ideal for consolidating several machines into one dashboard. The response reports per-table counts.', "Restores a bundle produced by GET /api/settings/export, uploaded as `multipart/form-data` with a single `file` field (the dashboard's Restore button and the `agenthub import-data` CLI both upload it; the server never reads a path named by a request). Same-origin only. At most 512 MB (`413 FILE_TOO_LARGE`); a file that is not JSON or not an export answers `400 INVALID_FORMAT` without echoing its text, no file `400 NO_FILE`. The restore is idempotent and NON-DESTRUCTIVE: it is session-atomic, so a session already present (matched by its UUID) is skipped whole together with its agents/events/token_usage/workflows, and independent config rows (dashboard_runs, alert_rules, model_pricing) are inserted only when absent. Restored event payloads are trimmed like every event insert (`trim_event_data`). Nothing existing is overwritten — ideal for consolidating several machines into one dashboard. The response reports per-table counts.",
operationId: "importData", operationId: "importData",
requestBody: { requestBody: {
required: true, required: true,
@@ -1153,20 +1153,6 @@ const paths = {
}, },
}, },
}, },
"application/json": {
schema: {
type: "object",
required: ["path"],
properties: {
path: {
type: "string",
description:
"Absolute path (server-side) to an export .json file. `~` is expanded.",
},
},
},
example: { path: "/Users/you/agenthub-export-2026-06-26.json" },
},
}, },
}, },
responses: { responses: {
@@ -1194,8 +1180,11 @@ const paths = {
}, },
}, },
400: { 400: {
description: "Missing file, invalid JSON, or unrecognized export format", description:
"Missing file (NO_FILE), invalid JSON or unrecognized export format (INVALID_FORMAT)",
}, },
403: { description: "Cross-origin request (EBADORIGIN)" },
413: { description: "The file is larger than 512 MB (FILE_TOO_LARGE)" },
}, },
}, },
}, },
@@ -1620,9 +1609,19 @@ const paths = {
tags: ["Workflows"], tags: ["Workflows"],
summary: "Get workflow intelligence aggregates", summary: "Get workflow intelligence aggregates",
description: description:
"Returns the full workflow-intelligence aggregate powering the Workflows analytics page — 11 sections in one payload: `stats` (headline counters: sessions, agents, subagents, success rate, avg depth/duration, compactions, top tool flow), `orchestration` (subagent-type breakdown + delegation edges + outcomes), `toolFlow` (tool-to-tool transitions + tool counts), `effectiveness` (per-subagent-type success rate, avg duration, weekly trend), `patterns` (frequent subagent sequences + solo-session share), `modelDelegation` (model usage for main/sub agents + tokens by model), `errorPropagation` (errors by depth/type + error rate), `concurrency` (averaged agent swim-lane start/end), `complexity` (per-session agent/token/duration rows), `compaction` (compaction counts + tokens recovered), and `cooccurrence` (directed subagent-after-subagent pairs). The optional `status` query filter scopes every section to sessions of one status. Errors use the SHORT `{ error: { message } }` shape.", "Returns the full workflow-intelligence aggregate powering the Workflows analytics page — 11 sections in one payload: `stats` (headline counters: sessions, agents, subagents, success rate, avg depth/duration, compactions, top tool flow), `orchestration` (subagent-type breakdown + delegation edges + outcomes), `toolFlow` (tool-to-tool transitions + tool counts), `effectiveness` (per-subagent-type success rate, avg duration, weekly trend), `patterns` (frequent subagent sequences + solo-session share), `modelDelegation` (model usage for main/sub agents + tokens by model), `errorPropagation` (errors by depth/type + error rate), `concurrency` (averaged agent swim-lane start/end), `complexity` (per-session agent/token/duration rows), `compaction` (compaction counts + tokens recovered), and `cooccurrence` (directed subagent-after-subagent pairs). The optional `status` query filter scopes every section to sessions of one status, and `sections` computes and returns only the named ones (`stats.topFlow` is the head of `toolFlow.transitions`, one pass for both). The bundle is computed in a worker thread on its own read-only connection, so it never stalls other requests; identical requests in flight share one run, and one over `AGENTHUB_READ_TIMEOUT_MS` (default 60 s) answers 503 `EREADTIMEOUT`. Errors use the SHORT `{ error: { message } }` shape.",
operationId: "getWorkflowIntelligence", operationId: "getWorkflowIntelligence",
parameters: [{ $ref: "#/components/parameters/WorkflowStatusQuery" }], parameters: [
{ $ref: "#/components/parameters/WorkflowStatusQuery" },
{
name: "sections",
in: "query",
required: false,
description:
"Comma-separated subset of the bundle to compute: stats, orchestration, toolFlow, effectiveness, patterns, modelDelegation, errorPropagation, concurrency, complexity, compaction, cooccurrence. Unknown names are ignored; none known is a 400.",
schema: { type: "string", example: "stats,toolFlow" },
},
],
responses: { responses: {
200: { 200: {
description: "Workflow aggregate data", description: "Workflow aggregate data",
@@ -1633,6 +1632,20 @@ const paths = {
}, },
}, },
}, },
400: {
description: "`sections` names no known section",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
example: {
error: {
code: "INVALID_SECTIONS",
message: "sections must name some of: stats, …",
},
},
},
},
},
500: { 500: {
description: "Internal server error", description: "Internal server error",
content: { content: {
@@ -1642,6 +1655,17 @@ const paths = {
}, },
}, },
}, },
503: {
description: "The aggregate ran past `AGENTHUB_READ_TIMEOUT_MS`",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
example: {
error: { code: "EREADTIMEOUT", message: "read query took over 60000 ms" },
},
},
},
},
}, },
}, },
}, },
+1 -1
View File
@@ -1347,7 +1347,7 @@ function createOpenApiSpec() {
"Per-table counts from restoring an export bundle. Session-scoped counts are rows newly inserted; sessions_skipped counts sessions already present (skipped whole to stay idempotent).", "Per-table counts from restoring an export bundle. Session-scoped counts are rows newly inserted; sessions_skipped counts sessions already present (skipped whole to stay idempotent).",
properties: { properties: {
ok: { type: "boolean" }, ok: { type: "boolean" },
source: { type: "string", description: "Uploaded filename or server-side path read." }, source: { type: "string", description: "The uploaded file's name." },
format: { format: {
type: "string", type: "string",
nullable: true, nullable: true,
+3 -2
View File
@@ -1895,8 +1895,9 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
const picked = pickSlot(current.id); const picked = pickSlot(current.id);
if (picked === null) return { allBusy: true }; if (picked === null) return { allBusy: true };
slot = picked; slot = picked;
const liveId = slot ? runs.runIdForLane(current.id, slot) : current.run_id; // By the pane's own name, slot 0 too: a terminal tab in pane 0 is no
const live = liveId ? runs.getRun(liveId) : null; // run, so `run_id` does not name it, and a start there would take it over.
const live = runs.getRun(runs.runIdForLane(current.id, slot || 0));
if (live && live.status === "running") { if (live && live.status === "running") {
return { conflict: true }; return { conflict: true };
} }
+94 -9
View File
@@ -677,7 +677,14 @@ router.get("/:id/transcript", async (req, res) => {
// the live file is gone once Claude Code prunes it under cleanupPeriodDays // the live file is gone once Claude Code prunes it under cleanupPeriodDays
// (default 30 days), but the snapshot taken at import time survives. // (default 30 days), but the snapshot taken at import time survives.
let jsonlPath; let jsonlPath;
if (agentId && agentId !== "main") { // A Codex session's conversation is its root thread's rollout journal
// (`codex_rollouts`, recorded by codex-usage.js), never a Claude transcript.
const isCodex = session.tool === "codex";
if (isCodex) {
jsonlPath = db
.prepare("SELECT path FROM codex_rollouts WHERE session_id = ? AND thread_id = ?")
.get(req.params.id, req.params.id)?.path;
} else if (agentId && agentId !== "main") {
jsonlPath = jsonlPath =
getSubagentTranscriptPath(req.params.id, session.cwd, agentId, runId) || getSubagentTranscriptPath(req.params.id, session.cwd, agentId, runId) ||
findSubagentTranscriptPath(req.params.id, agentId, runId) || findSubagentTranscriptPath(req.params.id, agentId, runId) ||
@@ -707,6 +714,10 @@ router.get("/:id/transcript", async (req, res) => {
crlfDelay: Infinity, crlfDelay: Infinity,
}); });
const renders = isCodex
? (e) => e.type === "response_item"
: (e) => TRANSCRIPT_RENDER_TYPES.has(e.type);
// Dedupe state for synthetic rename markers: custom-title lines can repeat // Dedupe state for synthetic rename markers: custom-title lines can repeat
// with the same value across a transcript, so only emit when the title // with the same value across a transcript, so only emit when the title
// actually changes from the last one we surfaced. // actually changes from the last one we surfaced.
@@ -870,14 +881,14 @@ router.get("/:id/transcript", async (req, res) => {
} catch { } catch {
continue; continue;
} }
if (!TRANSCRIPT_RENDER_TYPES.has(entry.type)) continue; if (!renders(entry)) continue;
if (!foundStart) { if (!foundStart) {
if (lineNum <= afterLine) continue; if (lineNum <= afterLine) continue;
foundStart = true; foundStart = true;
} }
const message = parseMessage(entry, lineNum); const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
if (!message) continue; if (!message) continue;
total++; total++;
messages.push(message); messages.push(message);
@@ -904,7 +915,7 @@ router.get("/:id/transcript", async (req, res) => {
} catch { } catch {
continue; continue;
} }
if (!TRANSCRIPT_RENDER_TYPES.has(entry.type)) continue; if (!renders(entry)) continue;
if (lineNum >= beforeLine) { if (lineNum >= beforeLine) {
// Reached the boundary — stop reading // Reached the boundary — stop reading
rl.close(); rl.close();
@@ -912,7 +923,7 @@ router.get("/:id/transcript", async (req, res) => {
break; break;
} }
const message = parseMessage(entry, lineNum); const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
if (!message) continue; if (!message) continue;
total++; total++;
messages.push(message); messages.push(message);
@@ -934,9 +945,9 @@ router.get("/:id/transcript", async (req, res) => {
} catch { } catch {
continue; continue;
} }
if (!TRANSCRIPT_RENDER_TYPES.has(entry.type)) continue; if (!renders(entry)) continue;
const message = parseMessage(entry, lineNum); const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
if (!message) continue; if (!message) continue;
total++; total++;
@@ -963,9 +974,9 @@ router.get("/:id/transcript", async (req, res) => {
} catch { } catch {
continue; continue;
} }
if (!TRANSCRIPT_RENDER_TYPES.has(entry.type)) continue; if (!renders(entry)) continue;
const message = parseMessage(entry, lineNum); const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
if (!message) continue; if (!message) continue;
total++; total++;
messages.push(message); messages.push(message);
@@ -998,6 +1009,80 @@ router.get("/:id/transcript", async (req, res) => {
} }
}); });
// Context Codex injects as a user message: AGENTS.md and the `<snake_case>`
// blocks (environment_context, user_instructions, …).
const CODEX_INJECTED = /^(# AGENTS\.md instructions for |<[a-z]+(?:_[a-z]+)+[\s>])/;
const codexText = (parts) =>
Array.isArray(parts)
? parts.map((p) => (p && typeof p.text === "string" ? p.text : "")).join("")
: typeof parts === "string"
? parts
: "";
/**
* One Codex rollout `response_item` → a transcript message in the Claude shape
* the client renders, or null. Developer messages, injected context and
* encrypted-only reasoning are left out.
*/
function codexMessage(entry, num) {
const p = entry.payload || {};
const at = entry.timestamp || null;
const msg = (type, sender, content) => ({ type, sender, timestamp: at, content, line: num });
const out = (o) => truncate(codexText(o), 10240);
switch (p.type) {
case "message": {
if (p.role !== "user" && p.role !== "assistant") return null;
const content = (p.content || [])
.map((c) => (c && typeof c.text === "string" ? c.text : ""))
.filter((t) => t.trim() && !(p.role === "user" && CODEX_INJECTED.test(t.trimStart())))
.map((t) => ({ type: "text", text: truncate(t, 10240) }));
return content.length ? msg(p.role, p.role, content) : null;
}
case "reasoning": {
const t = codexText(p.summary);
return t.trim()
? msg("assistant", "assistant", [{ type: "thinking", text: truncate(t, 10240) }])
: null;
}
case "function_call":
case "custom_tool_call": {
let input = p.input;
if (p.type === "function_call") {
try {
input = JSON.parse(p.arguments);
} catch {
input = { arguments: p.arguments };
}
} else input = { input };
return msg("assistant", "assistant", [
{
type: "tool_use",
name: p.name || "unknown",
id: p.call_id || null,
input: truncateObj(input, 10240),
},
]);
}
case "function_call_output":
case "custom_tool_call_output": {
const o =
p.output && !Array.isArray(p.output) && typeof p.output === "object"
? p.output.content
: p.output;
return msg("user", "tool", [
{ type: "tool_result", id: p.call_id || null, output: out(o), is_error: false },
]);
}
case "agent_message": {
const t = codexText(p.content);
return t.trim() ? msg("user", "system", [{ type: "text", text: truncate(t, 10240) }]) : null;
}
default:
return null;
}
}
function truncate(str, maxLen) { function truncate(str, maxLen) {
if (!str || str.length <= maxLen) return str; if (!str || str.length <= maxLen) return str;
return str.slice(0, maxLen) + "[truncated]"; return str.slice(0, maxLen) + "[truncated]";
+82 -1
View File
@@ -26,6 +26,7 @@ const { getSettingsPath, getClaudeHome, setClaudeHome } = require("../lib/claude
const { getCodexHome, setCodexHome } = require("../lib/codex-home"); const { getCodexHome, setCodexHome } = require("../lib/codex-home");
const { envOwned } = require("../lib/settings-env"); const { envOwned } = require("../lib/settings-env");
const pricingSync = require("../lib/pricing-sync"); const pricingSync = require("../lib/pricing-sync");
const { EXPORT_FORMAT, EXPORT_VERSION, importExportBundle } = require("../lib/data-transfer");
function getDbSize() { function getDbSize() {
try { try {
@@ -64,6 +65,17 @@ function getHookStatus() {
"SessionStart", "SessionStart",
"SessionEnd", "SessionEnd",
]; ];
// The enabled plugin runs the hooks from its own plugin.json, so
// settings.json rightly holds none of ours (installHooks removes them).
const { pluginProvidesHooks } = require("../../scripts/install-hooks");
if (pluginProvidesHooks(settings)) {
return {
installed: true,
source: "plugin",
path: getSettingsPath(),
hooks: Object.fromEntries(hookTypes.map((ht) => [ht, true])),
};
}
const hooks = {}; const hooks = {};
for (const ht of hookTypes) { for (const ht of hookTypes) {
const entries = settings.hooks?.[ht] || []; const entries = settings.hooks?.[ht] || [];
@@ -288,7 +300,10 @@ router.get("/export", async (req, res) => {
}; };
const field = (name, value) => `,${JSON.stringify(name)}:${JSON.stringify(value)}`; const field = (name, value) => `,${JSON.stringify(name)}:${JSON.stringify(value)}`;
try { try {
await write(`{"exported_at":${JSON.stringify(new Date().toISOString())}`); await write(
`{"format":${JSON.stringify(EXPORT_FORMAT)},"version":${EXPORT_VERSION}` +
field("exported_at", new Date().toISOString())
);
await write( await write(
field("sessions", db.prepare("SELECT * FROM sessions ORDER BY started_at DESC").all()) field("sessions", db.prepare("SELECT * FROM sessions ORDER BY started_at DESC").all())
); );
@@ -311,6 +326,16 @@ router.get("/export", async (req, res) => {
} }
await write("]"); await write("]");
await write(field("token_usage", db.prepare("SELECT * FROM token_usage").all())); await write(field("token_usage", db.prepare("SELECT * FROM token_usage").all()));
await write(field("workflows", db.prepare("SELECT * FROM workflows").all()));
await write(
field(
"dashboard_runs",
db.prepare("SELECT * FROM dashboard_runs ORDER BY started_at DESC").all()
)
);
await write(
field("alert_rules", db.prepare("SELECT * FROM alert_rules ORDER BY created_at ASC").all())
);
await write(field("model_pricing", stmts.listPricing.all()) + "}"); await write(field("model_pricing", stmts.listPricing.all()) + "}");
res.end(); res.end();
} catch (err) { } catch (err) {
@@ -319,6 +344,62 @@ router.get("/export", async (req, res) => {
} }
}); });
// POST /api/settings/import — restore a file written by GET /export, uploaded
// as multipart `file`. Non-destructive (data-transfer.js: a session already
// here is skipped whole, nothing is overwritten). The whole file is parsed in
// memory, hence the size cap; a bad file is answered without echoing its text.
// ponytail: one JSON.parse of the whole bundle; stream-parse if backups outgrow 512 MB.
const IMPORT_MAX_BYTES = 512 * 1024 * 1024;
router.post("/import", sameOriginGuard, (req, res) => {
const multer = require("multer");
const upload = multer({
dest: os.tmpdir(),
limits: { files: 1, fileSize: IMPORT_MAX_BYTES, fields: 8 },
}).single("file");
upload(req, res, (uploadErr) => {
const file = req.file;
const done = (status, body) => {
if (file) fs.rm(file.path, { force: true }, () => {});
res.status(status).json(body);
};
if (uploadErr) {
const tooBig = uploadErr.code === "LIMIT_FILE_SIZE";
return done(tooBig ? 413 : 400, {
error: {
code: tooBig ? "FILE_TOO_LARGE" : "BAD_UPLOAD",
message: tooBig ? "the backup is larger than 512 MB" : uploadErr.message,
},
});
}
if (!file) {
return done(400, { error: { code: "NO_FILE", message: "attach the export as `file`" } });
}
let bundle;
try {
bundle = JSON.parse(fs.readFileSync(file.path, "utf8"));
} catch {
return done(400, {
error: { code: "INVALID_FORMAT", message: "Not a valid export file (not JSON)." },
});
}
try {
const counts = importExportBundle(db, bundle);
done(200, {
ok: true,
source: file.originalname,
format: bundle.format ?? null,
...counts,
});
} catch (err) {
if (err.code === "INVALID_FORMAT") {
return done(400, { error: { code: err.code, message: err.message } });
}
console.warn("import failed:", err.message);
done(500, { error: { code: "IMPORT_FAILED", message: err.message } });
}
});
});
// The two editable home directories share one shape: GET answers the value and // The two editable home directories share one shape: GET answers the value and
// whether the real environment set it (`env_owned` — then a saved value is // whether the real environment set it (`env_owned` — then a saved value is
// overridden again at the next start), PUT validates, applies and persists // overridden again at the next start), PUT validates, applies and persists
+25 -677
View File
@@ -1,40 +1,40 @@
/** /**
* @file Express router for workflow intelligence endpoints, providing aggregated insights into workflow orchestration, tool usage patterns, subagent effectiveness, error propagation, concurrency, and session complexity. It queries the database for various metrics and patterns related to agents, sessions, and events, and returns a comprehensive JSON response for frontend visualization on the dashboard. * @file Express router for workflow intelligence endpoints, providing aggregated insights into workflow orchestration, tool usage patterns, subagent effectiveness, error propagation, concurrency, and session complexity. It queries the database for various metrics and patterns related to agents, sessions, and events, and returns a comprehensive JSON response for frontend visualization on the dashboard. The GET / bundle is computed by `server/lib/workflow-stats.js` in the read worker (`server/lib/read-pool.js`), off the event loop.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
const { Router } = require("express"); const { Router } = require("express");
const { db, stmts } = require("../db"); const { db, stmts } = require("../db");
const { runRead } = require("../lib/read-pool");
const { WORKFLOW_SECTIONS, sessionIdFilter } = require("../lib/workflow-stats");
const router = Router(); const router = Router();
// ── Helper: compute session duration in seconds ──
function durationSec(s) {
if (!s.started_at) return 0;
const end = s.ended_at || new Date().toISOString();
return Math.max(0, (new Date(end) - new Date(s.started_at)) / 1000);
}
// ── GET / — Aggregate workflow intelligence ── // ── GET / — Aggregate workflow intelligence ──
router.get("/", (req, res) => { // Runs in the read worker: the bundle scans every event and takes seconds on a
// large database, which inline would stall every other request meanwhile.
// `?sections=a,b` computes only those parts of it (System Health needs seven).
router.get("/", async (req, res) => {
// Optional status filter: "active", "completed", or omit for all
const statusFilter = req.query.status || null;
let sections = null;
if (typeof req.query.sections === "string") {
sections = req.query.sections.split(",").filter((k) => WORKFLOW_SECTIONS.includes(k));
if (sections.length === 0) {
return res.status(400).json({
error: {
code: "INVALID_SECTIONS",
message: `sections must name some of: ${WORKFLOW_SECTIONS.join(", ")}`,
},
});
}
}
try { try {
// Optional status filter: "active", "completed", or omit for all res.json(await runRead("workflows", { status: statusFilter, sections }));
const statusFilter = req.query.status || null;
const data = {
stats: getWorkflowStats(statusFilter),
orchestration: getOrchestrationData(statusFilter),
toolFlow: getToolFlowData(statusFilter),
effectiveness: getSubagentEffectiveness(statusFilter),
patterns: getWorkflowPatterns(statusFilter),
modelDelegation: getModelDelegation(statusFilter),
errorPropagation: getErrorPropagation(statusFilter),
concurrency: getConcurrencyData(statusFilter),
complexity: getSessionComplexity(statusFilter),
compaction: getCompactionImpact(statusFilter),
cooccurrence: getAgentCooccurrence(statusFilter),
};
res.json(data);
} catch (err) { } catch (err) {
if (err.code === "EREADTIMEOUT") {
return res.status(503).json({ error: { code: err.code, message: err.message } });
}
res.status(500).json({ error: { message: err.message } }); res.status(500).json({ error: { message: err.message } });
} }
}); });
@@ -160,658 +160,6 @@ router.get("/session/:id", (req, res) => {
} }
}); });
// ═══════════════════════════════════════════════════
// Data-fetching functions
// ═══════════════════════════════════════════════════
/**
* Build a SQL WHERE clause for session status filtering.
* Returns { clause, params } where clause is either empty or " AND s.status = ?".
* Use `sessionAlias` to match the table alias used in your query (default "s").
*/
function statusClause(statusFilter, alias = "s") {
if (!statusFilter || statusFilter === "all") return { clause: "", params: [] };
return { clause: ` AND ${alias}.status = ?`, params: [statusFilter] };
}
/** Same but for agents table joins where we need to filter via session_id */
function sessionIdFilter(statusFilter) {
if (!statusFilter || statusFilter === "all") return { clause: "", params: [] };
return {
clause: " AND session_id IN (SELECT id FROM sessions WHERE status = ?)",
params: [statusFilter],
};
}
function getWorkflowStats(statusFilter) {
const sf = sessionIdFilter(statusFilter);
const ss = statusClause(statusFilter);
const totalSessions = db
.prepare(`SELECT COUNT(*) as c FROM sessions s WHERE 1=1${ss.clause}`)
.get(...ss.params).c;
const totalAgents = db
.prepare(`SELECT COUNT(*) as c FROM agents WHERE 1=1${sf.clause}`)
.get(...sf.params).c;
const totalSubagents = db
.prepare(`SELECT COUNT(*) as c FROM agents WHERE type = 'subagent'${sf.clause}`)
.get(...sf.params).c;
// Average subagents per session
const avgSubagents = totalSessions > 0 ? +(totalSubagents / totalSessions).toFixed(1) : 0;
// Agent success rate
const completedAgents = db
.prepare(`SELECT COUNT(*) as c FROM agents WHERE status = 'completed'${sf.clause}`)
.get(...sf.params).c;
const errorAgents = db
.prepare(`SELECT COUNT(*) as c FROM agents WHERE status = 'error'${sf.clause}`)
.get(...sf.params).c;
const finishedAgents = completedAgents + errorAgents;
const successRate =
finishedAgents > 0 ? +((completedAgents / finishedAgents) * 100).toFixed(1) : 100;
// Average max depth per session
const depthRows = db
.prepare(
`WITH RECURSIVE agent_depth AS (
SELECT id, session_id, parent_agent_id, 0 as depth FROM agents WHERE parent_agent_id IS NULL
UNION ALL
SELECT a.id, a.session_id, a.parent_agent_id, ad.depth + 1
FROM agents a JOIN agent_depth ad ON a.parent_agent_id = ad.id
)
SELECT session_id, MAX(depth) as max_depth FROM agent_depth
WHERE 1=1${sf.clause}
GROUP BY session_id`
)
.all(...sf.params);
const avgDepth =
depthRows.length > 0
? +(depthRows.reduce((s, r) => s + r.max_depth, 0) / depthRows.length).toFixed(1)
: 0;
// Average session duration
const sessions = db
.prepare(`SELECT started_at, ended_at FROM sessions s WHERE ended_at IS NOT NULL${ss.clause}`)
.all(...ss.params);
const totalDuration = sessions.reduce((s, sess) => s + durationSec(sess), 0);
const avgDurationSec = sessions.length > 0 ? Math.round(totalDuration / sessions.length) : 0;
// Total compactions
const totalCompactions = db
.prepare(`SELECT COUNT(*) as c FROM agents WHERE subagent_type = 'compaction'${sf.clause}`)
.get(...sf.params).c;
const avgCompactions = totalSessions > 0 ? +(totalCompactions / totalSessions).toFixed(1) : 0;
// Most common tool flow (top 2-tool sequence)
const topFlow = db
.prepare(
`SELECT e1.tool_name as source, e2.tool_name as target, COUNT(*) as c
FROM events e1
JOIN events e2 ON e2.session_id = e1.session_id AND e2.id = (
SELECT MIN(e3.id) FROM events e3
WHERE e3.session_id = e1.session_id AND e3.id > e1.id AND e3.tool_name IS NOT NULL
)
WHERE e1.tool_name IS NOT NULL AND e2.tool_name IS NOT NULL${sf.clause.replace("session_id", "e1.session_id")}
GROUP BY e1.tool_name, e2.tool_name
ORDER BY c DESC LIMIT 1`
)
.get(...sf.params);
return {
totalSessions,
totalAgents,
totalSubagents,
avgSubagents,
successRate,
avgDepth,
avgDurationSec,
totalCompactions,
avgCompactions,
topFlow: topFlow ? { source: topFlow.source, target: topFlow.target, count: topFlow.c } : null,
};
}
function getOrchestrationData(statusFilter) {
const sf = sessionIdFilter(statusFilter);
const ss = statusClause(statusFilter);
// Count sessions
const sessionCount = db
.prepare(`SELECT COUNT(*) as c FROM sessions s WHERE 1=1${ss.clause}`)
.get(...ss.params).c;
// Main agents count
const mainCount = db
.prepare(`SELECT COUNT(*) as c FROM agents WHERE type = 'main'${sf.clause}`)
.get(...sf.params).c;
// Subagent types with counts and parent info
const subagentTypes = db
.prepare(
`SELECT subagent_type, COUNT(*) as count,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as errors
FROM agents WHERE type = 'subagent' AND subagent_type IS NOT NULL${sf.clause}
GROUP BY subagent_type ORDER BY count DESC`
)
.all(...sf.params);
// Edges: parent_subagent_type -> child_subagent_type with frequency
const edges = db
.prepare(
`SELECT
COALESCE(p.subagent_type, 'main') as source,
a.subagent_type as target,
COUNT(*) as weight
FROM agents a
LEFT JOIN agents p ON a.parent_agent_id = p.id
WHERE a.type = 'subagent' AND a.subagent_type IS NOT NULL${sf.clause.replace("session_id", "a.session_id")}
GROUP BY source, target
ORDER BY weight DESC`
)
.all(...sf.params);
// Outcome counts
const outcomes = db
.prepare(
`SELECT status, COUNT(*) as count FROM agents
WHERE status IN ('completed', 'error')${sf.clause}
GROUP BY status`
)
.all(...sf.params);
// Compaction agents (context compressions per session)
const compactions = db
.prepare(
`SELECT session_id, COUNT(*) as count
FROM agents WHERE subagent_type = 'compaction'${sf.clause}
GROUP BY session_id`
)
.all(...sf.params);
const totalCompactions = compactions.reduce((s, r) => s + r.count, 0);
const sessionsWithCompactions = compactions.length;
return {
sessionCount,
mainCount,
subagentTypes,
edges,
outcomes,
compactions: { total: totalCompactions, sessions: sessionsWithCompactions },
};
}
function getToolFlowData(statusFilter) {
const sf = sessionIdFilter(statusFilter);
// Tool-to-tool transitions (next tool in same session)
const transitions = db
.prepare(
`SELECT e1.tool_name as source, e2.tool_name as target, COUNT(*) as value
FROM events e1
JOIN events e2 ON e2.session_id = e1.session_id AND e2.id = (
SELECT MIN(e3.id) FROM events e3
WHERE e3.session_id = e1.session_id AND e3.id > e1.id AND e3.tool_name IS NOT NULL
)
WHERE e1.tool_name IS NOT NULL AND e2.tool_name IS NOT NULL${sf.clause.replace("session_id", "e1.session_id")}
GROUP BY e1.tool_name, e2.tool_name
ORDER BY value DESC
LIMIT 50`
)
.all(...sf.params);
// Tool counts for sizing nodes
const toolCounts = db
.prepare(
`SELECT tool_name, COUNT(*) as count FROM events
WHERE tool_name IS NOT NULL${sf.clause}
GROUP BY tool_name ORDER BY count DESC LIMIT 15`
)
.all(...sf.params);
return { transitions, toolCounts };
}
function getSubagentEffectiveness(statusFilter) {
const sf = sessionIdFilter(statusFilter);
const types = db
.prepare(
`SELECT
a.subagent_type,
COUNT(*) as total,
SUM(CASE WHEN a.status = 'completed' THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN a.status = 'error' THEN 1 ELSE 0 END) as errors,
COUNT(DISTINCT a.session_id) as sessions
FROM agents a
WHERE a.type = 'subagent' AND a.subagent_type IS NOT NULL${sf.clause.replace("session_id", "a.session_id")}
GROUP BY a.subagent_type
ORDER BY total DESC
LIMIT 12`
)
.all(...sf.params);
// Get token usage per subagent type (approximate via session token totals)
// Also get average duration per type
const withMetrics = types.map((t) => {
const durRow = db
.prepare(
// Clamp each row's duration to >= 0 before averaging. The #156 startup
// repair only heals compaction rows; any other subagent type whose
// ended_at < started_at (clock skew, replayed/synced transcripts) would
// otherwise drag this average negative. MAX(0, …) mirrors the Math.max(0)
// guard durationSec() already applies to every other duration here.
`SELECT AVG(
CASE WHEN ended_at IS NOT NULL THEN
MAX(0, (julianday(ended_at) - julianday(started_at)) * 86400)
ELSE NULL END
) as avg_duration
FROM agents WHERE subagent_type = ? AND type = 'subagent'${sf.clause}`
)
.get(t.subagent_type, ...sf.params);
// Weekly trend: count per day-of-week (Mon–Sun) over last 8 weeks.
// SQLite strftime('%w') → 0=Sun, 1=Mon, ..., 6=Sat.
// Frontend expects index 0=Mon → 6=Sun, so remap with (dow + 6) % 7.
const trendRows = db
.prepare(
`SELECT CAST(strftime('%w', started_at) AS INTEGER) as dow, COUNT(*) as count
FROM agents WHERE subagent_type = ? AND type = 'subagent'
AND started_at >= date('now', '-56 days')${sf.clause}
GROUP BY dow ORDER BY dow ASC`
)
.all(t.subagent_type, ...sf.params);
// Build 7-slot array: [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
const trendByDay = [0, 0, 0, 0, 0, 0, 0];
for (const row of trendRows) {
const idx = (row.dow + 6) % 7; // Sun(0)→6, Mon(1)→0, Tue(2)→1, ...
trendByDay[idx] = row.count;
}
return {
...t,
successRate:
t.completed + t.errors > 0
? +((t.completed / (t.completed + t.errors)) * 100).toFixed(1)
: 100,
avgDuration: durRow?.avg_duration ? Math.round(durRow.avg_duration) : null,
trend: trendByDay,
};
});
return withMetrics;
}
function getWorkflowPatterns(statusFilter) {
const sf = sessionIdFilter(statusFilter);
const ss = statusClause(statusFilter);
// Get ordered subagent sequences per session
const sessions = db
.prepare(
`SELECT session_id, GROUP_CONCAT(subagent_type, '→') as sequence
FROM (
SELECT session_id, subagent_type
FROM agents
WHERE type = 'subagent' AND subagent_type IS NOT NULL${sf.clause}
ORDER BY session_id, started_at ASC
)
GROUP BY session_id
HAVING COUNT(*) >= 2`
)
.all(...sf.params);
// Count pattern frequencies
const patternCounts = {};
const totalSessions = db
.prepare(`SELECT COUNT(*) as c FROM sessions s WHERE 1=1${ss.clause}`)
.get(...ss.params).c;
for (const row of sessions) {
const seq = row.sequence;
patternCounts[seq] = (patternCounts[seq] || 0) + 1;
}
// Also count 2-step and 3-step sub-patterns
for (const row of sessions) {
const steps = row.sequence.split("→");
// 2-step windows
for (let i = 0; i < steps.length - 1; i++) {
const sub = steps.slice(i, i + 2).join("→");
patternCounts[sub] = (patternCounts[sub] || 0) + 1;
}
// 3-step windows
for (let i = 0; i < steps.length - 2; i++) {
const sub = steps.slice(i, i + 3).join("→");
patternCounts[sub] = (patternCounts[sub] || 0) + 1;
}
}
// Deduplicate: keep only patterns where the full sequence count >= 2-step count
// Sort by frequency, take top 10
const sorted = Object.entries(patternCounts)
.filter(([, count]) => count >= 2)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([pattern, count]) => ({
steps: pattern.split("→"),
count,
percentage: totalSessions > 0 ? +((count / totalSessions) * 100).toFixed(1) : 0,
}));
// Also track solo sessions (no subagents)
const soloCount = db
.prepare(
`SELECT COUNT(*) as c FROM sessions s
WHERE NOT EXISTS (SELECT 1 FROM agents a WHERE a.session_id = s.id AND a.type = 'subagent')${ss.clause}`
)
.get(...ss.params).c;
return {
patterns: sorted,
soloSessionCount: soloCount,
soloPercentage: totalSessions > 0 ? +((soloCount / totalSessions) * 100).toFixed(1) : 0,
};
}
function getModelDelegation(statusFilter) {
const ss = statusClause(statusFilter);
// Model usage for main agents
const mainModels = db
.prepare(
`SELECT s.model, COUNT(DISTINCT a.id) as agent_count, COUNT(DISTINCT s.id) as session_count
FROM agents a JOIN sessions s ON a.session_id = s.id
WHERE a.type = 'main' AND s.model IS NOT NULL${ss.clause}
GROUP BY s.model ORDER BY agent_count DESC`
)
.all(...ss.params);
// Model usage for subagents (via session model — best approximation)
const subagentModels = db
.prepare(
`SELECT s.model, COUNT(a.id) as agent_count
FROM agents a JOIN sessions s ON a.session_id = s.id
WHERE a.type = 'subagent' AND s.model IS NOT NULL${ss.clause}
GROUP BY s.model ORDER BY agent_count DESC`
)
.all(...ss.params);
// Token cost per model — filter via session_id on token_usage table
const sfToken = sessionIdFilter(statusFilter);
const tokensByModel = db
.prepare(
`SELECT model,
SUM(input_tokens + baseline_input) as input_tokens,
SUM(output_tokens + baseline_output) as output_tokens,
SUM(cache_read_tokens + baseline_cache_read) as cache_read_tokens,
SUM(cache_write_tokens + baseline_cache_write) as cache_write_tokens
FROM token_usage WHERE 1=1${sfToken.clause}
GROUP BY model ORDER BY (input_tokens + output_tokens) DESC`
)
.all(...sfToken.params);
return { mainModels, subagentModels, tokensByModel };
}
function getErrorPropagation(statusFilter) {
const sf = sessionIdFilter(statusFilter);
const ss = statusClause(statusFilter);
// Error count by depth — include both agent-level errors (status = 'error')
// AND session-level errors (session status = 'error' mapped to depth 0 for main agent).
const errorsByDepth = db
.prepare(
`WITH RECURSIVE agent_depth AS (
SELECT id, session_id, subagent_type, status, 0 as depth
FROM agents WHERE parent_agent_id IS NULL
UNION ALL
SELECT a.id, a.session_id, a.subagent_type, a.status, ad.depth + 1
FROM agents a JOIN agent_depth ad ON a.parent_agent_id = ad.id
)
SELECT depth, COUNT(*) as count FROM agent_depth
WHERE status = 'error'${sf.clause}
GROUP BY depth ORDER BY depth ASC`
)
.all(...sf.params);
// Also count sessions that ended in error but whose main agent wasn't marked error.
// Map these to depth 0 (session-level errors: quota limits, crashes, etc.)
const sessionErrorsNotInAgents = db
.prepare(
`SELECT COUNT(*) as c FROM sessions s
WHERE s.status = 'error'${ss.clause}
AND NOT EXISTS (
SELECT 1 FROM agents a WHERE a.session_id = s.id AND a.status = 'error'
)`
)
.get(...ss.params).c;
if (sessionErrorsNotInAgents > 0) {
const existing = errorsByDepth.find((d) => d.depth === 0);
if (existing) {
existing.count += sessionErrorsNotInAgents;
} else {
errorsByDepth.unshift({ depth: 0, count: sessionErrorsNotInAgents });
}
}
// Error-prone subagent types — from agent errors + from error events on subagents
const errorTypes = db
.prepare(
`SELECT subagent_type, COUNT(*) as count
FROM agents WHERE status = 'error' AND subagent_type IS NOT NULL${sf.clause}
GROUP BY subagent_type ORDER BY count DESC LIMIT 5`
)
.all(...sf.params);
// Also capture error events (Stop with error summary, API errors from transcripts)
const eventErrors = db
.prepare(
`SELECT e.summary, COUNT(*) as count
FROM events e
WHERE ((e.event_type = 'Stop' AND e.summary LIKE 'Error in%')
OR e.event_type = 'APIError')${sf.clause.replace("session_id", "e.session_id")}
GROUP BY e.summary ORDER BY count DESC LIMIT 10`
)
.all(...sf.params);
// Error rate per session (sessions with error status OR sessions with error events)
const sessionsWithErrors = db
.prepare(
`SELECT COUNT(DISTINCT id) as c FROM (
SELECT id FROM sessions s WHERE s.status = 'error'${ss.clause}
UNION
SELECT DISTINCT session_id as id FROM agents WHERE status = 'error'${sf.clause}
UNION
SELECT DISTINCT session_id as id FROM events
WHERE ((event_type = 'Stop' AND summary LIKE 'Error in%')
OR event_type = 'APIError')${sf.clause}
)`
)
.get(...ss.params, ...sf.params, ...sf.params).c;
const totalSessions = db
.prepare(`SELECT COUNT(*) as c FROM sessions s WHERE 1=1${ss.clause}`)
.get(...ss.params).c;
return {
byDepth: errorsByDepth,
byType: errorTypes,
eventErrors,
sessionsWithErrors,
totalSessions,
errorRate: totalSessions > 0 ? +((sessionsWithErrors / totalSessions) * 100).toFixed(1) : 0,
};
}
function getConcurrencyData(statusFilter) {
const ss = statusClause(statusFilter);
// For aggregate: average agent types per position in session timeline
// Get agent start/end as fraction of session duration per session
const lanes = db
.prepare(
`SELECT
a.id, a.name, a.type, a.subagent_type, a.status,
a.started_at, a.ended_at, a.session_id,
s.started_at as session_start, s.ended_at as session_end
FROM agents a
JOIN sessions s ON a.session_id = s.id
WHERE s.ended_at IS NOT NULL${ss.clause}
ORDER BY a.started_at ASC
LIMIT 2000`
)
.all(...ss.params);
// Build aggregate: for each subagent_type, average start% and end%
const typeAgg = {};
for (const lane of lanes) {
const sessStart = new Date(lane.session_start).getTime();
const sessEnd = new Date(lane.session_end).getTime();
const sessDur = sessEnd - sessStart;
if (sessDur <= 0) continue;
const agStart = new Date(lane.started_at).getTime();
const agEnd = lane.ended_at ? new Date(lane.ended_at).getTime() : sessEnd;
const startPct = Math.max(0, Math.min(1, (agStart - sessStart) / sessDur));
const endPct = Math.max(0, Math.min(1, (agEnd - sessStart) / sessDur));
const key = lane.type === "main" ? "Main Agent" : lane.subagent_type || "unknown";
if (!typeAgg[key]) typeAgg[key] = { starts: [], ends: [], status: lane.status };
typeAgg[key].starts.push(startPct);
typeAgg[key].ends.push(endPct);
}
// Average start/end per type
const aggregateLanes = Object.entries(typeAgg)
.map(([name, data]) => ({
name,
avgStart: +(data.starts.reduce((s, v) => s + v, 0) / data.starts.length).toFixed(3),
avgEnd: +(data.ends.reduce((s, v) => s + v, 0) / data.ends.length).toFixed(3),
count: data.starts.length,
}))
.sort((a, b) => a.avgStart - b.avgStart);
return { aggregateLanes };
}
function getSessionComplexity(statusFilter) {
const ss = statusClause(statusFilter);
const rows = db
.prepare(
`SELECT
s.id, s.name, s.status, s.started_at, s.ended_at, s.model,
COUNT(a.id) as agent_count,
SUM(CASE WHEN a.type = 'subagent' THEN 1 ELSE 0 END) as subagent_count
FROM sessions s
LEFT JOIN agents a ON a.session_id = s.id
WHERE 1=1${ss.clause}
GROUP BY s.id
ORDER BY s.started_at DESC
LIMIT 200`
)
.all(...ss.params);
const sessions = rows.map((r) => {
const dur = durationSec(r);
// Get token count for this session
const tokens = db
.prepare(
`SELECT SUM(input_tokens + baseline_input + output_tokens + baseline_output +
cache_read_tokens + baseline_cache_read + cache_write_tokens + baseline_cache_write) as total
FROM token_usage WHERE session_id = ?`
)
.get(r.id);
return {
id: r.id,
name: r.name,
status: r.status,
duration: Math.round(dur),
agentCount: r.agent_count,
subagentCount: r.subagent_count,
totalTokens: tokens?.total || 0,
model: r.model,
};
});
return sessions;
}
function getCompactionImpact(statusFilter) {
const sf = sessionIdFilter(statusFilter);
const ss = statusClause(statusFilter);
// Total compactions
const totalCompactions = db
.prepare(`SELECT COUNT(*) as c FROM agents WHERE subagent_type = 'compaction'${sf.clause}`)
.get(...sf.params).c;
// Total baseline tokens (tokens "recovered" through compaction)
const recovered = db
.prepare(
`SELECT
SUM(baseline_input + baseline_output + baseline_cache_read + baseline_cache_write) as total
FROM token_usage WHERE 1=1${sf.clause}`
)
.get(...sf.params);
// Compactions per session distribution
const perSession = db
.prepare(
`SELECT session_id, COUNT(*) as compactions
FROM agents WHERE subagent_type = 'compaction'${sf.clause}
GROUP BY session_id ORDER BY compactions DESC LIMIT 50`
)
.all(...sf.params);
// Sessions with compactions vs without
const sessionsWithCompactions = db
.prepare(
`SELECT COUNT(DISTINCT session_id) as c FROM agents WHERE subagent_type = 'compaction'${sf.clause}`
)
.get(...sf.params).c;
const totalSessions = db
.prepare(`SELECT COUNT(*) as c FROM sessions s WHERE 1=1${ss.clause}`)
.get(...ss.params).c;
return {
totalCompactions,
tokensRecovered: recovered?.total || 0,
perSession,
sessionsWithCompactions,
totalSessions,
};
}
function getAgentCooccurrence(statusFilter) {
const sf = sessionIdFilter(statusFilter);
// Directed: which agent type runs AFTER which other type in the same session
// a1 started before a2 → edge a1 → a2 with count
const pairs = db
.prepare(
`SELECT a1.subagent_type as source, a2.subagent_type as target,
COUNT(*) as weight
FROM agents a1
JOIN agents a2 ON a1.session_id = a2.session_id
AND a1.started_at < a2.started_at
AND a1.id != a2.id
WHERE a1.type = 'subagent' AND a2.type = 'subagent'
AND a1.subagent_type IS NOT NULL AND a2.subagent_type IS NOT NULL
AND a1.subagent_type != 'compaction' AND a2.subagent_type != 'compaction'${sf.clause.replace("session_id", "a1.session_id")}
GROUP BY a1.subagent_type, a2.subagent_type
HAVING weight >= 2
ORDER BY weight DESC
LIMIT 40`
)
.all(...sf.params);
return pairs;
}
// ── Build agent tree from flat list ── // ── Build agent tree from flat list ──
function buildAgentTree(agents) { function buildAgentTree(agents) {
const map = {}; const map = {};