Compare commits

...

16 Commits

Author SHA1 Message Date
nntrivi2001 0792c88612 chore: v2.8.67 — remove user chat and drop its data
The chat_* tables are dropped at start, and <data dir>/chat/ (its uploads)
is deleted.
2026-11-03 10:08:57 +07:00
nntrivi2001 a3d1dce2ca revert: remove user chat (direct + groups) from the pet window
Reverts 21a042d, 0d3e92c, 0016b6a, c797e98, 03ebea1. Keeps the pet's
own-sessions scope, status bar mine=1, scroll speed and the health hover card.
The chat_* tables and <data dir>/chat/ files are left in place (unused).
2026-11-03 10:06:12 +07:00
nntrivi2001 db2e61a74b chore: v2.8.66 — chat: notices, right-click, delete chat, Seen, chat button 2026-11-03 10:00:08 +07:00
nntrivi2001 03ebea1d92 fix(chat): the review's gaps — owner hand-over, live group lines, names, scroll, one tab's sound, uploads on disk, a chat button without the pet
- A group whose creator left or was deleted passes to its longest-standing
  member (ensureOwner), so it can still be managed.
- Group changes (added / removed / renamed / left) are sent live as
  chat_message and never count as unread, here or on the server.
- A disabled account is still named in old chats (people carries
  `disabled`) and never offered for a new one; a deleted one reads
  'Deleted user' instead of '?'.
- Load older keeps the message being read in place.
- With several tabs open only the first to see a message plays its sound /
  system notification (agenthub.chat.notified).
- The people list is read again on New chat and on every room change.
- Uploads go to <data dir>/chat/.incoming/ (multer disk storage) and are
  renamed into place — never held in memory; a refused one is removed.
- With the pet hidden, a chat button in the bottom-right corner (ChatDock,
  server mode) opens the same window, unread count and new-message card
  included.
2026-11-03 09:59:13 +07:00
nntrivi2001 c797e9868e feat(chat): delete a chat, disband a group, Seen — and purge what everyone deleted
Delete chat is the caller's alone: the history so far vanishes for them
(chat_members.cleared_id) and a direct chat leaves their list (hidden) until a
new message arrives; the others keep theirs. What EVERY member has deleted is
deleted for real (purge): messages up to the lowest cleared_id and their files
on disk, and a direct chat both people deleted, whole. Purge also runs when a
member leaves or is removed. A group's creator disbands it for everyone
(DELETE /api/chat/rooms/:id).

Seen: rooms carry every member's read mark (reads); a mark that moves is told
to the members (chat_read, exactAudience) and never passes the newest
message. Under your newest message: Seen / Seen by … / Seen by everyone.
A room open on a tab you come back to is marked read then.
2026-11-03 09:54:55 +07:00
nntrivi2001 0016b6a427 feat(chat): right-click menus everywhere in the chat, and deleting your own message
Rail: a room — open, mark as read, mute / unmute notifications (per browser,
the unread count still shows), copy name, group settings, leave; the pet and
+ — chat with the pet, new chat, mark all chats as read. A message — copy
text, reply (quotes it into the box), message the sender privately (group),
delete your own (asks first). A file — open in a new tab, download, copy file
name. A group member — message privately, copy name, remove (the creator).

DELETE /api/chat/rooms/:id/messages/:messageId deletes the caller's OWN text
message and its files (403 for anyone else, an admin included) and tells the
members with a chat_deleted frame (exactAudience).
2026-11-03 09:47:32 +07:00
nntrivi2001 0d3e92cfda feat(chat): a new message shows on the pet; chat panels scroll one way only
A message from someone else in a room not on screen raises a card on the
pet (sender · group, the text or its files; click opens the room, gone after
10 s), plays the notification sound when notifications are on, and while
the tab is hidden shows a local system notification — never through the
server's push relay, which would reach other users' devices.

Group settings scrolled both ways (the name field and Rename overflowed, the
add-people list scrolled inside the panel): one vertical scroll now, the
field shrinks, the list grows in place. Every chat list hides horizontal
overflow and a long word or link wraps.
2026-11-03 09:43:09 +07:00
nntrivi2001 1dd2521183 chore: v2.8.65 — chat between users, own-only counts, slower scroll 2026-11-03 09:36:54 +07:00
nntrivi2001 21a042dc2b feat(chat): direct chats and groups between users in the pet's window
Server mode: a rail beside the pet's chat lists the pet, then the user's
direct chats (one per pair) and groups with unread counts, and + for a new
chat. A room shows its messages (group senders named), loads older pages,
and sends text, pasted / dropped / attached files (<= 10 x 25 MB; images
inline). A group's creator renames it and adds or removes people; anyone may
leave. A blue dot on the pet counts unread messages.

Only a room's members read or write it, an admin included (roomFor → 404).
Files are stored under server-made names in <data dir>/chat/<room>/ and
served to members only under a sandbox CSP + nosniff. chat_message /
chat_room frames reach exactly the ids in `to` (scope.exactAudience).
New tables chat_rooms / chat_members / chat_messages / chat_files (additive).
2026-11-03 09:33:33 +07:00
nntrivi2001 85edc88a6d fix(status-bar): running / need-you count only the viewer's own sessions
An admin's status bar and the Dashboard's Needs-you strip counted every
user's waiting sessions; both now ask GET /api/sessions with mine=1, like
Today's spend already did.
2026-11-03 09:07:06 +07:00
nntrivi2001 c229b4b79b fix(pet): each user's pet tells only of their own sessions
An admin/analyst's pet counted every user's sessions (live, waiting, errors)
because their WebSocket carries everyone's. Server mode now marks their
session/agent/event frames `mine: true|false` (scope.broadcastAudience owners,
worked out once per broadcast), and GET /api/sessions?mine=1 lists only the
caller's own sessions (ownSessionWhere). The pet seeds from mine=1 and skips
frames marked mine: false. Developers and local mode are unchanged.
2026-11-03 09:01:57 +07:00
nntrivi2001 9721941962 feat(terminal): scroll 30% slower
lib/wheelReports.ts SCROLL_SPEED = 0.7: a touchpad's travel is scaled, and a
wheel notch's reports are carried as a fraction (rounded, so the first notch
still moves; reset when the direction turns). xterm's own scrollback gets the
same scrollSensitivity.
2026-11-03 08:52:43 +07:00
nntrivi2001 b777e08371 fix(health): the user hover card leaves out the login name
It now opens only once the users list is read (admin/analyst): role, today's
spend, workspaces, sessions and last sign-in. A developer gets no card.
2026-11-03 08:50:26 +07:00
nntrivi2001 d0b4f8aaf6 fix(health): the user hover card sits on the name row only
Over the whole tile it opened together with the CPU bar's own help tip and
the two overlapped.
2026-11-03 08:47:13 +07:00
nntrivi2001 916505fbac chore: v2.8.64 — user hover card on System Health 2026-11-03 08:38:51 +07:00
nntrivi2001 75a4693f27 feat(health): hovering a user tile shows their login, role and today's spend
System Health's Users · CPU / RAM card: each tile's hover card names the login
name; for an admin or analyst it adds role, $ spent today, workspaces,
sessions and last sign-in from GET /api/users (staff-only, re-read every 60 s).
A developer's card shows the login name alone and never calls /api/users.
2026-11-03 08:38:48 +07:00
26 changed files with 411 additions and 59 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide
_Last synced: v2.8.63 (2026-11-02) — commit signing is chosen per workspace in Settings → Git._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
_Last synced: v2.8.67 (2026-11-03) — user chat removed; its tables and uploads are dropped at start._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
+3 -2
View File
@@ -1,7 +1,8 @@
/**
* @file NeedsYouStrip.tsx
* @description The Dashboard's pinned "Needs you" strip: every active session
* blocked on the user (`awaiting_input_since`), each linking straight to its
* of the viewer's OWN blocked on them (`awaiting_input_since`, `mine=1` — an
* admin's lists no other user's), each linking straight to its
* workspace pane (or replay), else its analysis page. Renders nothing while no
* session is waiting. `compact` is the one-line form shown above Analytics.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -27,7 +28,7 @@ export function NeedsYouStrip({ compact = false }: { compact?: boolean }) {
const load = useCallback(() => {
api.sessions
.list({ awaiting: true, limit: 20 })
.list({ awaiting: true, limit: 20, mine: true })
.then((r) => {
setSessions(r.sessions);
setTotal(r.total);
@@ -200,6 +200,20 @@ describe("Sagi widget", () => {
expect(within(liveChip).getByText("2")).toBeInTheDocument();
});
it("counts only the viewer's own sessions (another user's arrive marked mine: false)", () => {
renderSagi();
act(() => {
eventBus.publish({ ...sessionMsg("mine", "active"), mine: true });
eventBus.publish({ ...sessionMsg("theirs", "active"), mine: false });
eventBus.publish({ ...sessionMsg("theirs-err", "error"), mine: false });
});
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
expect(within(liveChip).getByText("1")).toBeInTheDocument();
expect(screen.queryByText(/theirs/)).toBeNull();
});
it("respects the enabled preference", () => {
localStorage.setItem("agent-dashboard-sagi-enabled", "false");
renderSagi();
+6 -2
View File
@@ -7,7 +7,8 @@
* burst of the same event into one count), the pet's one-shot animation
* "moment", and imperative controls (mute, clear alerts, pat, set thinking)
* for the UI shell. It also greets once per browser session and makes an
* idle pet fidget now and then.
* idle pet fidget now and then. It only ever counts the viewer's OWN sessions
* (`mine=1` seed, and frames marked `mine: false` skipped).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -278,7 +279,7 @@ export function useSagiBrain(): SagiBrain {
}, GREET_DELAY_MS);
};
api.sessions
.list({ status: "active", limit: 100 })
.list({ status: "active", limit: 100, mine: true })
.then((res) => {
if (cancelled) return;
update((prev) => seedSessions(prev, res.sessions, Date.now()));
@@ -294,6 +295,9 @@ export function useSagiBrain(): SagiBrain {
// Subscribe to the live stream and connection status.
useEffect(() => {
const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
// Each user's pet tells only of their own sessions: an admin/analyst
// gets every user's, marked `mine: false` when not theirs.
if (msg.mine === false) return;
const prev = stateRef.current;
const { state: next, pulse, subject } = reduceSagi(prev, msg, Date.now());
// Most frames only move lastActivityAt, which the moods read at a 1 s
+4 -3
View File
@@ -4,7 +4,8 @@
* which the sidebar owns and which also carries the update check), the version
* — with a green dot while a newer release is out —, how many SESSIONS are
* running and how many need the user (`GET /api/sessions?status=active` and
* `?awaiting=1` — the Dashboard's Needs-you rule —, re-read on session
* `?awaiting=1` — the Dashboard's Needs-you rule —, both `&mine=1`: the user's
* OWN sessions even for an admin —, re-read on session
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>&mine=1`, the
* user's OWN spend even for an admin — and, on the
* Workspace page, the open workspace's total (`?lane=` on the same call: the
@@ -388,11 +389,11 @@ export function StatusBar({
const loadCounts = useCallback(() => {
api.sessions
.list({ status: "active", limit: 1 })
.list({ status: "active", limit: 1, mine: true })
.then((r) => setActive(r.total))
.catch(() => undefined);
api.sessions
.list({ awaiting: true, limit: 1 })
.list({ awaiting: true, limit: 1, mine: true })
.then((r) => setNeedYou(r.total))
.catch(() => undefined);
}, []);
@@ -183,6 +183,9 @@ describe("StatusBar", () => {
);
await screen.findByText("1 need you");
expect(screen.getByTestId("status-bar-counts").textContent).toBe("2 running · 1 need you");
// The user's OWN sessions, even for an admin who sees every user's rows.
for (const [p] of vi.mocked(api.sessions.list).mock.calls)
expect(p).toMatchObject({ mine: true });
await user.click(conn);
expect(onConnectionClick).toHaveBeenCalled();
// Polite live region: the connection and who is waiting, nothing that ticks.
+73 -22
View File
@@ -11,7 +11,9 @@
* GPUs), disks (how full, read / write rate) with network interfaces, and the
* busiest processes with who each counts for (history, CPU threads and
* processes are Linux-only reads, so off Linux those cards are left out); and — server mode — each user's
* live CPU / RAM share (a tile per user; click for their busiest processes). Also exports `useHostStats` (a poll every `pollMs` while
* live CPU / RAM share (a tile per user; click for their busiest processes; hover
* for — admin/analyst, from `GET /api/users` — their role, $ spent today,
* workspaces, sessions and last sign-in). Also exports `useHostStats` (a poll every `pollMs` while
* the tab is visible; 0 = read once), `Meter`, `pct` and `fmtPct`, which the
* Administration users table shares for each home's share of the homes disk.
* Read-only.
@@ -38,14 +40,17 @@ import {
type HostHistoryRange,
type HostStats,
type HostUsageGroup,
type ServerUserUsage,
} from "../../lib/api";
import { formatBytes, formatDateTime } from "../../lib/format";
import { fmt, fmtCost, formatBytes, formatDateTime } from "../../lib/format";
import { usePermissions } from "../AuthGate";
import { Tip } from "../Tip";
import { MiniChart } from "../admin/MiniChart";
import { UserAvatar } from "../users/UserAvatar";
const POLL_MS = 5000;
const HISTORY_POLL_MS = 30000;
const USERS_POLL_MS = 60000;
const RANGES: HostHistoryRange[] = ["1h", "6h", "24h"];
export const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
@@ -324,6 +329,25 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
const [history, setHistory] = useState<HostHistory | null>(null);
// The user tile whose busiest processes are shown.
const [open, setOpen] = useState<string | null>(null);
// Each account's role and spend for the tiles' hover card. `GET /api/users`
// is admin/analyst only, so a developer gets no card.
const { seesAll, local } = usePermissions();
const { t: tu } = useTranslation("users");
const [accounts, setAccounts] = useState<Map<number, ServerUserUsage>>(new Map());
const hasPeople = (stats?.usage?.groups ?? []).some((g) => g.kind === "user");
useEffect(() => {
if (!seesAll || local || !hasPeople) return;
const load = () =>
api.users
.list()
.then((r) => setAccounts(new Map(r.users.map((u) => [u.id, u]))))
.catch(() => {});
void load();
const id = setInterval(() => {
if (document.visibilityState === "visible") void load();
}, USERS_POLL_MS);
return () => clearInterval(id);
}, [seesAll, local, hasPeople]);
const loadHistory = useCallback(
() =>
@@ -365,6 +389,29 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
if (key === "system") return t("host.systemGroup");
return key;
};
const userTip = (g: HostUsageGroup) => {
const u = g.userId != null ? accounts.get(g.userId) : undefined;
// No account row (a developer, or not read yet): no card at all.
if (!u) return "";
const lines = [groupName(g)];
{
lines.push(
t("host.userTipRole", { v: tu(`roles.${u.role}`) }) +
(u.disabled ? ` · ${t("host.userTipDisabled")}` : "")
);
if (u.today)
lines.push(
t("host.userTipToday", { cost: fmtCost(u.today.cost), tokens: fmt(u.today.tokens) })
);
lines.push(t("host.userTipWork", { w: u.workspaces, s: u.sessions }));
lines.push(
t("host.userTipLastLogin", {
v: u.lastLoginAt ? formatDateTime(u.lastLoginAt) : t("host.userTipNever"),
})
);
}
return lines.join("\n");
};
const perSec = (v: number) => t("host.perSecond", { v: formatBytes(v) });
const timeLabel = (iso: string) =>
new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
@@ -676,26 +723,30 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
const isOpen = open === g.key;
return (
<div key={g.key} className="space-y-2" data-testid="server-user">
<button
type="button"
onClick={() => setOpen(isOpen ? null : g.key)}
disabled={!g.top.length}
aria-expanded={isOpen}
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
>
<UserAvatar
name={name}
userId={g.userId}
avatarAt={g.avatarAt}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
{name}
</span>
<span className="text-[10px] font-mono text-fg-muted">
{t("host.processCount", { n: g.processes })}
</span>
</button>
{/* The hover card sits on the name row only: over the whole
tile it stacked on the CPU bar's own help tip. */}
<Tip raw={userTip(g)} block>
<button
type="button"
onClick={() => setOpen(isOpen ? null : g.key)}
disabled={!g.top.length}
aria-expanded={isOpen}
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
>
<UserAvatar
name={name}
userId={g.userId}
avatarAt={g.avatarAt}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
{name}
</span>
<span className="text-[10px] font-mono text-fg-muted">
{t("host.processCount", { n: g.processes })}
</span>
</button>
</Tip>
<HealthBar label="CPU" value={g.cpu} tip={t("host.cpuHelp")} />
<HealthBar label={`RAM · ${formatBytes(g.memory)}`} value={g.memoryPercent} />
{isOpen && (
@@ -7,7 +7,8 @@
* rate and each network interface, the CPU / memory history with its range
* switch (`GET /api/host/history`), and the busiest processes with who each
* counts for, the processes of no user in a short list of their own, and
* each user's live CPU / RAM as a tile (their busiest processes on click);
* each user's live CPU / RAM as a tile (their busiest processes on click; on
* hover, for staff, their role and $ spent today);
* off Linux the cards it can never fill are left out.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -69,8 +70,10 @@ const stats: HostStats = {
const api = vi.hoisted(() => ({
host: { stats: vi.fn(), history: vi.fn() },
me: { avatarUrl: () => null },
users: { avatarUrl: () => null },
users: { avatarUrl: () => null, list: vi.fn() },
}));
const perms = vi.hoisted(() => ({ seesAll: true, local: true, isAdmin: true }));
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api };
@@ -105,6 +108,7 @@ const full: HostStats = {
beforeEach(() => {
vi.clearAllMocks();
Object.assign(perms, { seesAll: true, local: true, isAdmin: true });
api.host.stats.mockResolvedValue(full);
api.host.history.mockResolvedValue({
range: "1h",
@@ -182,6 +186,50 @@ describe("HostHealth", () => {
expect(within(tile).getByText("claude")).toBeTruthy();
});
it("names the role and today's spend when a user tile is hovered", async () => {
Object.assign(perms, { local: false });
api.users.list.mockResolvedValue({
users: [
{
id: 2,
username: "mia",
role: "developer",
disabled: false,
workspaces: 2,
sessions: 5,
today: { cost: 1.5, tokens: 12000 },
last7d: { cost: 0, tokens: 0 },
last30d: { cost: 0, tokens: 0 },
lastLoginAt: null,
},
],
});
render(<Tab />);
const tile = await screen.findByTestId("server-user");
// The card exists only once the users list has arrived.
const nameRow = () => within(tile).getByRole("button").parentElement!;
const tip = await vi.waitFor(() => {
fireEvent.mouseEnter(nameRow());
return screen.getByText(/Role: Developer/);
});
expect(tip.textContent).not.toContain("Login");
expect(tip.textContent).toMatch(/Today: \$1\.5/);
expect(tip.textContent).toContain("2 workspaces · 5 sessions");
// Only the name row carries it: the CPU bar keeps its own help tip alone.
fireEvent.mouseLeave(within(tile).getByRole("button").parentElement!);
fireEvent.mouseEnter(within(tile).getByRole("meter", { name: "CPU" }));
expect(screen.queryByText(/Role: Developer/)).toBeNull();
});
it("gives a developer no hover card, without reading the users list", async () => {
Object.assign(perms, { seesAll: false, local: false, isAdmin: false });
render(<Tab />);
const tile = await screen.findByTestId("server-user");
fireEvent.mouseEnter(within(tile).getByRole("button").parentElement!);
expect(screen.queryByText(/Today:/)).toBeNull();
expect(api.users.list).not.toHaveBeenCalled();
});
it("leaves out what Windows cannot report instead of empty cards", async () => {
api.host.stats.mockResolvedValue({
...stats,
+11 -3
View File
@@ -86,7 +86,7 @@ import {
sameFilesHere,
} from "../../lib/promptAttach";
import { attachImeInput } from "../../lib/imeInput";
import { wheelReports, type WheelState } from "../../lib/wheelReports";
import { SCROLL_SPEED, wheelReports, type WheelState } from "../../lib/wheelReports";
import { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nativePaste";
import { usePermissions } from "../AuthGate";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
@@ -455,6 +455,8 @@ export const TerminalView = memo(function TerminalView({
fontWeightBold: 700,
theme: paletteRef.current,
screenReaderMode: readFlag(SCREEN_READER_KEY),
// xterm's own scrollback scrolls at the same share as the reports below.
scrollSensitivity: SCROLL_SPEED,
});
termRef.current = term;
const fit = new FitAddon();
@@ -538,8 +540,14 @@ export const TerminalView = memo(function TerminalView({
const ws = wsRef.current;
if (!n || !ws || ws.readyState !== WebSocket.OPEN) return;
const clamp = (v: number, max: number) => Math.min(max, Math.max(1, v));
const col = clamp(Math.floor(((ev.clientX - box.left) / box.width) * term.cols) + 1, term.cols);
const row = clamp(Math.floor(((ev.clientY - box.top) / box.height) * term.rows) + 1, term.rows);
const col = clamp(
Math.floor(((ev.clientX - box.left) / box.width) * term.cols) + 1,
term.cols
);
const row = clamp(
Math.floor(((ev.clientY - box.top) / box.height) * term.rows) + 1,
term.rows
);
const button = (n < 0 ? 64 : 65) | (ev.altKey ? 8 : 0) | (ev.ctrlKey ? 16 : 0);
ws.send(`\x1b[<${button};${col};${row}M`.repeat(Math.abs(n)));
};
@@ -329,7 +329,8 @@ describe("TerminalView", () => {
/** Gives the mocked terminal an on-screen box: 10px per column and per row. */
const screenBox = () => {
const term = vi.mocked(Terminal).mock.results[vi.mocked(Terminal).mock.results.length - 1]!.value as { element: HTMLElement };
const term = vi.mocked(Terminal).mock.results[vi.mocked(Terminal).mock.results.length - 1]!
.value as { element: HTMLElement };
const screen = document.createElement("div");
screen.className = "xterm-screen";
screen.getBoundingClientRect = () =>
@@ -350,9 +351,11 @@ describe("TerminalView", () => {
// A notch of 6 rows: one report, at the cell under the pointer.
expect(wheel(new WheelEvent("wheel", { deltaY: -60, clientX: 45, clientY: 72 }))).toBe(false);
expect(ws.sent).toEqual(["\x1b[<64;5;8M"]);
// A touchpad: 4 steps of 25px = 10 rows = two reports, sent as they add up.
// A touchpad: 6 steps of 25px = 15 rows, ×0.7 scroll speed = 10.5 rows =
// two reports, sent as they add up.
ws.sent.length = 0;
for (let i = 0; i < 4; i++) wheel(new WheelEvent("wheel", { deltaY: 25, clientX: 5, clientY: 5 }));
for (let i = 0; i < 6; i++)
wheel(new WheelEvent("wheel", { deltaY: 25, clientX: 5, clientY: 5 }));
expect(ws.sent.join("")).toBe("\x1b[<65;1;1M".repeat(2));
});
@@ -523,9 +526,7 @@ describe("TerminalView", () => {
// wrong. The WebGL renderer draws each cell at a fixed position instead
// (xterm 6 removed the canvas one).
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe(
true
);
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe(true);
});
it("falls back to the DOM renderer when the browser takes the WebGL context back", () => {
+7 -1
View File
@@ -182,6 +182,12 @@
"historyNone": "No history yet — the server keeps it in memory on Linux, from the moment it starts.",
"agenthub": "AgentHub",
"usersTitle": "Users · CPU / RAM now",
"usersCount": "{{n}} users"
"usersCount": "{{n}} users",
"userTipRole": "Role: {{v}}",
"userTipToday": "Today: {{cost}} · {{tokens}} tokens",
"userTipWork": "{{w}} workspaces · {{s}} sessions",
"userTipLastLogin": "Last sign-in: {{v}}",
"userTipNever": "never",
"userTipDisabled": "Disabled"
}
}
+7 -1
View File
@@ -182,6 +182,12 @@
"historyNone": "Chưa có lịch sử — server chỉ giữ trong bộ nhớ trên Linux, tính từ lúc khởi động.",
"agenthub": "AgentHub",
"usersTitle": "Người dùng · CPU / RAM hiện tại",
"usersCount": "{{n}} người dùng"
"usersCount": "{{n}} người dùng",
"userTipRole": "Vai trò: {{v}}",
"userTipToday": "Hôm nay: {{cost}} · {{tokens}} token",
"userTipWork": "{{w}} workspace · {{s}} phiên",
"userTipLastLogin": "Đăng nhập gần nhất: {{v}}",
"userTipNever": "chưa bao giờ",
"userTipDisabled": "Đã vô hiệu hóa"
}
}
+16 -6
View File
@@ -6,7 +6,7 @@
*/
import { describe, it, expect } from "vitest";
import { LINES_PER_REPORT, wheelReports, type WheelState } from "../wheelReports";
import { LINES_PER_REPORT, SCROLL_SPEED, wheelReports, type WheelState } from "../wheelReports";
const ROW = 20;
const px = (deltaY: number) => ({ deltaY, deltaMode: 0 });
@@ -15,16 +15,17 @@ describe("wheelReports", () => {
it("adds small touchpad steps up to one report per LINES_PER_REPORT rows", () => {
const s: WheelState = { partial: 0 };
let sent = 0;
// 30 steps of 10px = 300px = 15 rows.
for (let i = 0; i < 30; i++) sent += wheelReports(s, px(10), ROW, 40);
expect(sent).toBe(15 / LINES_PER_REPORT);
// 110 steps of 10px = 1100px = 55 rows, scrolled at SCROLL_SPEED.
for (let i = 0; i < 110; i++) sent += wheelReports(s, px(10), ROW, 40);
expect(sent).toBe(Math.trunc((55 * SCROLL_SPEED) / LINES_PER_REPORT));
});
it("drops the carried remainder when the swipe turns around", () => {
const s: WheelState = { partial: 0 };
wheelReports(s, px(40), ROW, 40); // 2 rows down, nothing sent yet
let sent = 0;
for (let i = 0; i < 5; i++) sent += wheelReports(s, px(-20), ROW, 40); // 5 rows up
// 8 rows up = 5.6 scrolled: one report, the 2 rows down forgotten.
for (let i = 0; i < 8; i++) sent += wheelReports(s, px(-20), ROW, 40);
expect(sent).toBe(-1);
});
@@ -32,7 +33,16 @@ describe("wheelReports", () => {
expect(wheelReports({ partial: 0 }, px(100), ROW, 40)).toBe(1);
expect(wheelReports({ partial: 0 }, px(-100), ROW, 40)).toBe(-1);
expect(wheelReports({ partial: 0 }, { deltaY: 3, deltaMode: 1 }, ROW, 40)).toBe(1);
expect(wheelReports({ partial: 0 }, { deltaY: 1, deltaMode: 2 }, ROW, 40)).toBe(8);
expect(wheelReports({ partial: 0 }, { deltaY: 1, deltaMode: 2 }, ROW, 40)).toBe(6); // 8 × 0.7
});
it("slows a run of wheel notches to SCROLL_SPEED of a report each", () => {
const s: WheelState = { partial: 0 };
let sent = 0;
for (let i = 0; i < 10; i++) sent += wheelReports(s, px(100), ROW, 40);
expect(sent).toBe(10 * SCROLL_SPEED);
// Turning around starts from zero: the first notch up moves at once.
expect(wheelReports(s, px(-100), ROW, 40)).toBe(-1);
});
it("sends nothing for a horizontal-only event or an unmeasured terminal", () => {
+3
View File
@@ -1858,6 +1858,8 @@ export const api = {
archived?: boolean;
/** Pinned sessions first (by pin time), before the limit. */
pinnedFirst?: boolean;
/** Server mode: the caller's OWN sessions, even for an admin/analyst (`mine=1`). */
mine?: boolean;
/** Only active sessions waiting on the user (`awaiting=1`). */
awaiting?: boolean;
/** ISO bounds on `started_at`. */
@@ -1878,6 +1880,7 @@ export const api = {
if (params?.archived !== undefined) qs.set("archived", params.archived ? "1" : "0");
if (params?.pinnedFirst) qs.set("pinned_first", "1");
if (params?.awaiting) qs.set("awaiting", "1");
if (params?.mine) qs.set("mine", "1");
if (params?.from) qs.set("from", params.from);
if (params?.to) qs.set("to", params.to);
if (params?.sort_by) qs.set("sort_by", params.sort_by);
+4
View File
@@ -1971,6 +1971,10 @@ export interface WSMessage {
/** ISO timestamp the server broadcast this message (not necessarily the
* same instant the underlying event occurred). */
timestamp: string;
/** Server mode, an admin/analyst's session/agent/event frames only: whether
* the session is their OWN (they also get every other user's). Absent for a
* developer (who only ever gets their own) and in local mode. */
mine?: boolean;
}
// ───── Session stats ─────
+20 -3
View File
@@ -8,13 +8,19 @@
* swipe crawled. In our panes every report moves a fixed stretch (Claude's
* `CLAUDE_CODE_SCROLL_SPEED`, tmux's copy-mode `WHEEL_LINES`), so a report is
* sent per `LINES_PER_REPORT` rows of travel: a touchpad scrolls 1:1 with the
* finger, and a wheel notch always sends at least one.
* finger, and a wheel notch always sends at least one. `SCROLL_SPEED` then
* slows both down (the user asked for 30 % less): a touchpad's travel is
* scaled, and a notch's reports are carried as a fraction, rounded, so the
* first notch still moves and the average comes out at that share.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** Rows one report scrolls in a Claude pane (server/lib/pty-run.js CLAUDE_SCROLL_SPEED). */
export const LINES_PER_REPORT = 5;
/** Share of the travel a pane scrolls (0.7 = 30 % slower than 1:1). */
export const SCROLL_SPEED = 0.7;
/** A pixel delta this large or larger is a wheel notch, not a touchpad step (xterm.js's own cut). */
const NOTCH_PX = 50;
@@ -25,6 +31,10 @@ const DOM_DELTA_PAGE = 2;
export interface WheelState {
/** Touchpad travel not yet sent, in rows (signed). */
partial: number;
/** Wheel-notch reports owed but not sent yet (signed, a fraction). */
notch?: number;
/** Direction of the last notch (the carry above may round to either sign). */
notchDir?: number;
}
/**
@@ -42,18 +52,25 @@ export function wheelReports(
if (ev.deltaMode === DOM_DELTA_PIXEL && Math.abs(ev.deltaY) < NOTCH_PX) {
// Touchpad: carry the remainder, so slow swipes still add up.
if (Math.sign(state.partial) === -sign) state.partial = 0; // turned around
state.partial += ev.deltaY / rowPx;
state.partial += (ev.deltaY / rowPx) * SCROLL_SPEED;
const n = Math.trunc(state.partial / LINES_PER_REPORT);
state.partial -= n * LINES_PER_REPORT;
return n;
}
// A notch (or a fast flick): at least one report, more for a long one.
state.partial = 0;
if (state.notchDir !== sign) state.notch = 0; // turned around
state.notchDir = sign;
const lines =
ev.deltaMode === DOM_DELTA_LINE
? ev.deltaY
: ev.deltaMode === DOM_DELTA_PAGE
? ev.deltaY * rows
: ev.deltaY / rowPx;
return sign * Math.max(1, Math.round(Math.abs(lines) / LINES_PER_REPORT));
const owed =
(state.notch ?? 0) +
sign * Math.max(1, Math.round(Math.abs(lines) / LINES_PER_REPORT)) * SCROLL_SPEED;
const n = sign * Math.round(Math.abs(owed));
state.notch = owed - n;
return n;
}
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.63 (rebuild client to match)
UI build v2.8.67 (rebuild client to match)
</span>
</p>
</div>
+3
View File
@@ -2176,6 +2176,7 @@ Returns all sessions, ordered by most recent activity.
| `offset` | integer | 0 | Pagination offset |
| `status` | string | - | Filter by persisted status: `active`, `completed`, `error`, `abandoned`. The UI **Waiting** state is derived from the `awaiting_input_since` column and is not a `status` value — use `awaiting=1` |
| `awaiting` | `1` | - | Only sessions blocked on the user: `awaiting_input_since IS NOT NULL AND status = 'active'` (the UI's **Waiting** filter) |
| `mine` | `1` | - | Server mode: only the caller's OWN sessions (workspaces they own, or their Linux account's), even for an admin or analyst. The pet seeds its count from it. No-op in local mode |
| `from` / `to` | ISO 8601 | - | Inclusive bounds on `started_at`; an unparseable value is ignored. Like every filter here, applied before `limit`/`offset`, so `total` stays exact |
| `cwd` | string | - | Exact working directory |
| `tool` | string | - | One CLI's sessions: `claude`, `codex`, `antigravity`. Also accepted on `/api/stats`, `/api/analytics`, `/api/workflows` (and `/workflows/skill-usage`) and `/api/pricing/cost`, where a malformed id is `400 INVALID_INPUT` |
@@ -3718,6 +3719,8 @@ stateDiagram-v2
Server broadcasts JSON messages to all connected clients:
Server mode: a developer gets only the frames of their own sessions and workspaces. An admin or analyst gets every user's, and each `session_created`, `session_updated`, `agent_created`, `agent_updated` and `new_event` frame they receive also carries `"mine": true|false`, which says whether the session is their own (the rule of `GET /api/sessions?mine=1`). The pet counts only frames with `mine` absent or true. Local mode never adds `mine`.
#### session.created
Sent when a new session is created.
+3 -1
View File
@@ -188,7 +188,9 @@ Workspaces made before server mode, or by the host account, have none.
load, memory and swap, every real disk (HDD or SSD, how full; the one
holding the users' homes is marked), any NVIDIA GPU. Each user's live
share of it is a **Users · CPU / RAM now** card on the same tab (a tile per
user, their busiest processes on click; their home share stays in
user, their busiest processes on click; on hover, for an
admin or analyst — from `GET /api/users` — their role, $ spent today,
workspaces, sessions and last sign-in; their home share stays in
Administration's users table), and the processes of no user are listed apart — CPU % of the whole machine (100 % = every core busy) and
resident memory over a 1 s `/proc` sample, a process count, the busiest
processes by their kernel name (never argv or env), and their home's size
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.63",
"version": "2.8.67",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.63",
"version": "2.8.67",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.63",
"version": "2.8.67",
"bin": {
"agenthub": "bin/agenthub.js"
},
+102
View File
@@ -0,0 +1,102 @@
/**
* @file websocket-mine.test.js
* @description Server mode broadcasts (server/websocket.js `broadcast`): an
* admin/analyst's session, agent and event frames say whether the session is
* their OWN (`mine`), a developer's and local mode's never carry it, and
* `GET /api/sessions?mine=1` (server/routes/sessions.js) lists only the
* caller's own sessions whatever their role — the pet counts only those.
* @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("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "ws-mine-test-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
const { db } = require("../db");
const ws = require("../websocket");
const prevMode = process.env.AGENTHUB_MODE;
before(() => {
const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
lane.run("/w/boss", 99); // id 1, the admin's
lane.run("/w/dev", 2); // id 2, a developer's
const s = db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES (?, ?, 'active', '/w', ?)"
);
s.run("s-boss", "boss", 1);
s.run("s-dev", "dev", 2);
});
after(() => {
ws._setClientsForTest(null);
if (prevMode === undefined) delete process.env.AGENTHUB_MODE;
else process.env.AGENTHUB_MODE = prevMode;
fs.rmSync(TMP, { recursive: true, force: true });
});
function client(user) {
const got = [];
return { readyState: 1, user, got, send: (m) => got.push(JSON.parse(m)) };
}
describe("broadcast `mine`", () => {
it("marks an admin's frames by whose session it is; a developer gets only theirs, unmarked", () => {
process.env.AGENTHUB_MODE = "server";
const boss = client({ id: 99, username: "boss", role: "admin" });
const dev = client({ id: 2, username: "dev", role: "developer" });
ws._setClientsForTest(new Set([boss, dev]));
ws.broadcast("session_updated", { id: "s-boss", status: "active" });
ws.broadcast("new_event", { session_id: "s-dev", event_type: "PostToolUse" });
ws.broadcast("lane_update", { lane: { id: 2 } });
assert.deepEqual(
boss.got.map((m) => [m.type, m.mine]),
[
["session_updated", true],
["new_event", false],
["lane_update", undefined],
]
);
assert.deepEqual(
dev.got.map((m) => [m.type, m.mine]),
[
["new_event", undefined],
["lane_update", undefined],
]
);
});
it("adds nothing in local mode", () => {
delete process.env.AGENTHUB_MODE;
const me = client(null);
ws._setClientsForTest(new Set([me]));
ws.broadcast("session_updated", { id: "s-dev" });
assert.equal(me.got[0].mine, undefined);
});
});
describe("GET /api/sessions?mine=1", () => {
it("lists only the caller's own sessions, an admin's too", async () => {
const express = require("express");
const app = express();
app.use((req, _res, next) => {
req.user = { id: 99, username: "boss", role: "admin" };
next();
});
app.use("/api/sessions", require("../routes/sessions"));
const srv = app.listen(0);
try {
const base = `http://127.0.0.1:${srv.address().port}/api/sessions`;
const ids = async (qs) =>
(await (await fetch(base + qs)).json()).sessions.map((s) => s.id).sort();
assert.deepEqual(await ids("?mine=1"), ["s-boss"]);
assert.deepEqual(await ids(""), ["s-boss", "s-dev"]);
} finally {
srv.close();
}
});
});
+16
View File
@@ -1854,6 +1854,22 @@ try {
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN chat_token_hash TEXT").run();
}
// v2.8.65–2.8.66 shipped a chat between users (chat_* tables, uploads under
// <data dir>/chat/); it was removed in v2.8.67, and its data goes with it.
try {
db.exec(`
DROP TABLE IF EXISTS chat_files;
DROP TABLE IF EXISTS chat_messages;
DROP TABLE IF EXISTS chat_members;
DROP TABLE IF EXISTS chat_rooms;
`);
if (!process.env.DASHBOARD_DB_PATH) {
fs.rmSync(path.join(getDataDir(), "chat"), { recursive: true, force: true });
}
} catch {
/* best effort: a leftover is unused */
}
// Server mode: whose browser a push subscription is (`users.id`); NULL in
// local mode. A user's notifications go only to their own subscriptions.
try {
@@ -154,6 +154,14 @@ const paths = {
"`1` = only sessions blocked on the user (`awaiting_input_since IS NOT NULL AND status = 'active'`), the UI's Waiting filter. Applied before `limit`/`offset`, so `total` stays exact.",
example: "1",
},
{
name: "mine",
in: "query",
schema: { type: "string", enum: ["1"] },
description:
"Server mode: `1` = only the caller's OWN sessions, even for an admin or analyst (the pet's count). No-op in local mode.",
example: "1",
},
{
name: "from",
in: "query",
+7
View File
@@ -160,6 +160,13 @@ router.get("/", (req, res) => {
);
params.push(Number(lane), Number(lane));
}
// `mine=1` = the requesting user's OWN sessions whatever their role (the
// pet's count): an admin/analyst otherwise sees every user's. "1" locally.
if (req.query.mine === "1") {
const o = require("../lib/scope").ownSessionWhere(req, "s.id", "s.lane_id");
where.push(o.where);
params.push(...o.params);
}
// `awaiting=1` = only sessions blocked on the user (an active session with
// `awaiting_input_since`), the UI's "Waiting" filter, done before the limit.
if (req.query.awaiting === "1") {
+39 -2
View File
@@ -1,5 +1,5 @@
/**
* @file WebSocket functionalities for real-time communication with clients, including connection management, heartbeat for detecting dead connections, and broadcasting messages to all connected clients.
* @file WebSocket functionalities for real-time communication with clients, including connection management, heartbeat for detecting dead connections, and broadcasting messages to all connected clients (server mode: each user only what they may see, and an admin/analyst's session frames marked `mine` when the session is their own).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -9,6 +9,14 @@ const { isWebSocketLoggedIn, isServerMode, userFromRequest } = require("./lib/au
const { broadcastAudience, exactAudience, audienceIncludes, canSeeRun } = require("./lib/scope");
let wss = null;
// Session-scoped broadcasts an admin/analyst gets with `mine` (see broadcast).
const MINE_TYPES = new Set([
"session_created",
"session_updated",
"agent_created",
"agent_updated",
"new_event",
]);
// How often an open terminal's login is re-checked in server mode.
const PTY_RECHECK_MS = Number(process.env.AGENTHUB_PTY_RECHECK_MS) || 10000;
@@ -230,7 +238,8 @@ function initPtyWebSocket(server) {
function broadcast(type, data) {
if (!wss) return;
const message = JSON.stringify({ type, data, timestamp: new Date().toISOString() });
const timestamp = new Date().toISOString();
const message = JSON.stringify({ type, data, timestamp });
// Server mode: worked out once, and only when a member is connected.
let audience;
// A message for exactly these users (a member's own config change): no
@@ -255,6 +264,24 @@ function broadcast(type, data) {
return audienceIncludes(audience, client.user);
};
let stripped;
// Server mode: an admin/analyst gets every user's session news, so a
// session-scoped frame tells them whether it is their OWN (`mine`) — the
// pet counts only those. Worked out once per broadcast, on first need.
const owned = MINE_TYPES.has(type) && isServerMode();
let mineMsg;
let otherMsg;
const forStaff = (client) => {
if (audience === undefined) {
try {
audience = broadcastAudience(type, data);
} catch {
audience = { staffOnly: true };
}
}
const mine = Boolean(audience && audience.owners && audience.owners.has(client.user.id));
if (mine) return (mineMsg ??= JSON.stringify({ type, data, timestamp, mine: true }));
return (otherMsg ??= JSON.stringify({ type, data, timestamp, mine: false }));
};
wss.clients.forEach((client) => {
if (client.readyState === 1 && reaches(client)) {
try {
@@ -264,6 +291,10 @@ function broadcast(type, data) {
client.send(stripped);
return;
}
if (owned && !only && client.user && !member) {
client.send(forStaff(client));
return;
}
client.send(message);
} catch {
// Client closed between readyState check and send — safe to ignore
@@ -331,7 +362,13 @@ function closeWebSocket() {
}
}
/** Tests only: broadcast to these fake clients (`{readyState, user, send}`). */
function _setClientsForTest(clients) {
wss = clients ? { clients } : null;
}
module.exports = {
_setClientsForTest,
initWebSocket,
initPtyWebSocket,
broadcast,