Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0792c88612 | |||
| a3d1dce2ca | |||
| db2e61a74b | |||
| 03ebea1d92 | |||
| c797e9868e | |||
| 0016b6a427 | |||
| 0d3e92cfda | |||
| 1dd2521183 | |||
| 21a042dc2b | |||
| 85edc88a6d | |||
| c229b4b79b | |||
| 9721941962 | |||
| b777e08371 | |||
| d0b4f8aaf6 |
@@ -1,6 +1,6 @@
|
|||||||
# Claude Code Working Guide
|
# 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
|
## Project mission
|
||||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* @file NeedsYouStrip.tsx
|
* @file NeedsYouStrip.tsx
|
||||||
* @description The Dashboard's pinned "Needs you" strip: every active session
|
* @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
|
* workspace pane (or replay), else its analysis page. Renders nothing while no
|
||||||
* session is waiting. `compact` is the one-line form shown above Analytics.
|
* session is waiting. `compact` is the one-line form shown above Analytics.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -27,7 +28,7 @@ export function NeedsYouStrip({ compact = false }: { compact?: boolean }) {
|
|||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
api.sessions
|
api.sessions
|
||||||
.list({ awaiting: true, limit: 20 })
|
.list({ awaiting: true, limit: 20, mine: true })
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
setSessions(r.sessions);
|
setSessions(r.sessions);
|
||||||
setTotal(r.total);
|
setTotal(r.total);
|
||||||
|
|||||||
@@ -200,6 +200,20 @@ describe("Sagi widget", () => {
|
|||||||
expect(within(liveChip).getByText("2")).toBeInTheDocument();
|
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", () => {
|
it("respects the enabled preference", () => {
|
||||||
localStorage.setItem("agent-dashboard-sagi-enabled", "false");
|
localStorage.setItem("agent-dashboard-sagi-enabled", "false");
|
||||||
renderSagi();
|
renderSagi();
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
* burst of the same event into one count), the pet's one-shot animation
|
* burst of the same event into one count), the pet's one-shot animation
|
||||||
* "moment", and imperative controls (mute, clear alerts, pat, set thinking)
|
* "moment", and imperative controls (mute, clear alerts, pat, set thinking)
|
||||||
* for the UI shell. It also greets once per browser session and makes an
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
@@ -278,7 +279,7 @@ export function useSagiBrain(): SagiBrain {
|
|||||||
}, GREET_DELAY_MS);
|
}, GREET_DELAY_MS);
|
||||||
};
|
};
|
||||||
api.sessions
|
api.sessions
|
||||||
.list({ status: "active", limit: 100 })
|
.list({ status: "active", limit: 100, mine: true })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
update((prev) => seedSessions(prev, res.sessions, Date.now()));
|
update((prev) => seedSessions(prev, res.sessions, Date.now()));
|
||||||
@@ -294,6 +295,9 @@ export function useSagiBrain(): SagiBrain {
|
|||||||
// Subscribe to the live stream and connection status.
|
// Subscribe to the live stream and connection status.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
|
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 prev = stateRef.current;
|
||||||
const { state: next, pulse, subject } = reduceSagi(prev, msg, Date.now());
|
const { state: next, pulse, subject } = reduceSagi(prev, msg, Date.now());
|
||||||
// Most frames only move lastActivityAt, which the moods read at a 1 s
|
// Most frames only move lastActivityAt, which the moods read at a 1 s
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
* which the sidebar owns and which also carries the update check), the version
|
* 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
|
* — 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
|
* 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
|
* 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
|
* user's OWN spend even for an admin — and, on the
|
||||||
* Workspace page, the open workspace's total (`?lane=` on the same call: the
|
* Workspace page, the open workspace's total (`?lane=` on the same call: the
|
||||||
@@ -388,11 +389,11 @@ export function StatusBar({
|
|||||||
|
|
||||||
const loadCounts = useCallback(() => {
|
const loadCounts = useCallback(() => {
|
||||||
api.sessions
|
api.sessions
|
||||||
.list({ status: "active", limit: 1 })
|
.list({ status: "active", limit: 1, mine: true })
|
||||||
.then((r) => setActive(r.total))
|
.then((r) => setActive(r.total))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
api.sessions
|
api.sessions
|
||||||
.list({ awaiting: true, limit: 1 })
|
.list({ awaiting: true, limit: 1, mine: true })
|
||||||
.then((r) => setNeedYou(r.total))
|
.then((r) => setNeedYou(r.total))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -183,6 +183,9 @@ describe("StatusBar", () => {
|
|||||||
);
|
);
|
||||||
await screen.findByText("1 need you");
|
await screen.findByText("1 need you");
|
||||||
expect(screen.getByTestId("status-bar-counts").textContent).toBe("2 running · 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);
|
await user.click(conn);
|
||||||
expect(onConnectionClick).toHaveBeenCalled();
|
expect(onConnectionClick).toHaveBeenCalled();
|
||||||
// Polite live region: the connection and who is waiting, nothing that ticks.
|
// Polite live region: the connection and who is waiting, nothing that ticks.
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
* busiest processes with who each counts for (history, CPU threads and
|
* 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
|
* 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; hover
|
* live CPU / RAM share (a tile per user; click for their busiest processes; hover
|
||||||
* for their login name and — admin/analyst, from `GET /api/users` — role, $ spent
|
* for — admin/analyst, from `GET /api/users` — their role, $ spent today,
|
||||||
* today, workspaces, sessions and last sign-in). Also exports `useHostStats` (a poll every `pollMs` while
|
* 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
|
* 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.
|
* Administration users table shares for each home's share of the homes disk.
|
||||||
* Read-only.
|
* Read-only.
|
||||||
@@ -330,7 +330,7 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
|
|||||||
// The user tile whose busiest processes are shown.
|
// The user tile whose busiest processes are shown.
|
||||||
const [open, setOpen] = useState<string | null>(null);
|
const [open, setOpen] = useState<string | null>(null);
|
||||||
// Each account's role and spend for the tiles' hover card. `GET /api/users`
|
// Each account's role and spend for the tiles' hover card. `GET /api/users`
|
||||||
// is admin/analyst only, so a developer's card shows the login name alone.
|
// is admin/analyst only, so a developer gets no card.
|
||||||
const { seesAll, local } = usePermissions();
|
const { seesAll, local } = usePermissions();
|
||||||
const { t: tu } = useTranslation("users");
|
const { t: tu } = useTranslation("users");
|
||||||
const [accounts, setAccounts] = useState<Map<number, ServerUserUsage>>(new Map());
|
const [accounts, setAccounts] = useState<Map<number, ServerUserUsage>>(new Map());
|
||||||
@@ -390,10 +390,11 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
|
|||||||
return key;
|
return key;
|
||||||
};
|
};
|
||||||
const userTip = (g: HostUsageGroup) => {
|
const userTip = (g: HostUsageGroup) => {
|
||||||
const lines = [groupName(g)];
|
|
||||||
if (g.username) lines.push(t("host.userTipLogin", { v: g.username }));
|
|
||||||
const u = g.userId != null ? accounts.get(g.userId) : undefined;
|
const u = g.userId != null ? accounts.get(g.userId) : undefined;
|
||||||
if (u) {
|
// No account row (a developer, or not read yet): no card at all.
|
||||||
|
if (!u) return "";
|
||||||
|
const lines = [groupName(g)];
|
||||||
|
{
|
||||||
lines.push(
|
lines.push(
|
||||||
t("host.userTipRole", { v: tu(`roles.${u.role}`) }) +
|
t("host.userTipRole", { v: tu(`roles.${u.role}`) }) +
|
||||||
(u.disabled ? ` · ${t("host.userTipDisabled")}` : "")
|
(u.disabled ? ` · ${t("host.userTipDisabled")}` : "")
|
||||||
@@ -721,8 +722,10 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
|
|||||||
const name = groupName(g);
|
const name = groupName(g);
|
||||||
const isOpen = open === g.key;
|
const isOpen = open === g.key;
|
||||||
return (
|
return (
|
||||||
<Tip key={g.key} raw={userTip(g)} block>
|
<div key={g.key} className="space-y-2" data-testid="server-user">
|
||||||
<div className="space-y-2" data-testid="server-user">
|
{/* 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(isOpen ? null : g.key)}
|
onClick={() => setOpen(isOpen ? null : g.key)}
|
||||||
@@ -743,27 +746,27 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
|
|||||||
{t("host.processCount", { n: g.processes })}
|
{t("host.processCount", { n: g.processes })}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<HealthBar label="CPU" value={g.cpu} tip={t("host.cpuHelp")} />
|
</Tip>
|
||||||
<HealthBar label={`RAM · ${formatBytes(g.memory)}`} value={g.memoryPercent} />
|
<HealthBar label="CPU" value={g.cpu} tip={t("host.cpuHelp")} />
|
||||||
{isOpen && (
|
<HealthBar label={`RAM · ${formatBytes(g.memory)}`} value={g.memoryPercent} />
|
||||||
<ul className="space-y-1 rounded-md bg-surface-2/60 px-2 py-1.5">
|
{isOpen && (
|
||||||
{g.top.map((p) => (
|
<ul className="space-y-1 rounded-md bg-surface-2/60 px-2 py-1.5">
|
||||||
<li
|
{g.top.map((p) => (
|
||||||
key={p.pid}
|
<li
|
||||||
className="flex justify-between gap-2 text-[10px] font-mono"
|
key={p.pid}
|
||||||
>
|
className="flex justify-between gap-2 text-[10px] font-mono"
|
||||||
<span className="truncate text-fg-secondary">
|
>
|
||||||
{p.name} <span className="text-fg-muted">#{p.pid}</span>
|
<span className="truncate text-fg-secondary">
|
||||||
</span>
|
{p.name} <span className="text-fg-muted">#{p.pid}</span>
|
||||||
<span className="flex-shrink-0 text-fg-muted">
|
</span>
|
||||||
{fmtPct(p.cpu)} · {formatBytes(p.memory)}
|
<span className="flex-shrink-0 text-fg-muted">
|
||||||
</span>
|
{fmtPct(p.cpu)} · {formatBytes(p.memory)}
|
||||||
</li>
|
</span>
|
||||||
))}
|
</li>
|
||||||
</ul>
|
))}
|
||||||
)}
|
</ul>
|
||||||
</div>
|
)}
|
||||||
</Tip>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* switch (`GET /api/host/history`), and the busiest processes with who each
|
* 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
|
* 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; on
|
* each user's live CPU / RAM as a tile (their busiest processes on click; on
|
||||||
* hover their login name, and for staff their role and $ spent today);
|
* hover, for staff, their role and $ spent today);
|
||||||
* off Linux the cards it can never fill are left out.
|
* off Linux the cards it can never fill are left out.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -186,7 +186,7 @@ describe("HostHealth", () => {
|
|||||||
expect(within(tile).getByText("claude")).toBeTruthy();
|
expect(within(tile).getByText("claude")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("names the login, role and today's spend when a user tile is hovered", async () => {
|
it("names the role and today's spend when a user tile is hovered", async () => {
|
||||||
Object.assign(perms, { local: false });
|
Object.assign(perms, { local: false });
|
||||||
api.users.list.mockResolvedValue({
|
api.users.list.mockResolvedValue({
|
||||||
users: [
|
users: [
|
||||||
@@ -206,21 +206,27 @@ describe("HostHealth", () => {
|
|||||||
});
|
});
|
||||||
render(<Tab />);
|
render(<Tab />);
|
||||||
const tile = await screen.findByTestId("server-user");
|
const tile = await screen.findByTestId("server-user");
|
||||||
await vi.waitFor(() => expect(api.users.list).toHaveBeenCalled());
|
// The card exists only once the users list has arrived.
|
||||||
fireEvent.mouseEnter(tile.parentElement!);
|
const nameRow = () => within(tile).getByRole("button").parentElement!;
|
||||||
const tip = await screen.findByText(/Login: mia/);
|
const tip = await vi.waitFor(() => {
|
||||||
expect(tip.textContent).toContain("Role: Developer");
|
fireEvent.mouseEnter(nameRow());
|
||||||
|
return screen.getByText(/Role: Developer/);
|
||||||
|
});
|
||||||
|
expect(tip.textContent).not.toContain("Login");
|
||||||
expect(tip.textContent).toMatch(/Today: \$1\.5/);
|
expect(tip.textContent).toMatch(/Today: \$1\.5/);
|
||||||
expect(tip.textContent).toContain("2 workspaces · 5 sessions");
|
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("shows a developer only the login name, without reading the users list", async () => {
|
it("gives a developer no hover card, without reading the users list", async () => {
|
||||||
Object.assign(perms, { seesAll: false, local: false, isAdmin: false });
|
Object.assign(perms, { seesAll: false, local: false, isAdmin: false });
|
||||||
render(<Tab />);
|
render(<Tab />);
|
||||||
const tile = await screen.findByTestId("server-user");
|
const tile = await screen.findByTestId("server-user");
|
||||||
fireEvent.mouseEnter(tile.parentElement!);
|
fireEvent.mouseEnter(within(tile).getByRole("button").parentElement!);
|
||||||
const tip = await screen.findByText(/Login: mia/);
|
expect(screen.queryByText(/Today:/)).toBeNull();
|
||||||
expect(tip.textContent).not.toContain("Today");
|
|
||||||
expect(api.users.list).not.toHaveBeenCalled();
|
expect(api.users.list).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ import {
|
|||||||
sameFilesHere,
|
sameFilesHere,
|
||||||
} from "../../lib/promptAttach";
|
} from "../../lib/promptAttach";
|
||||||
import { attachImeInput } from "../../lib/imeInput";
|
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 { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nativePaste";
|
||||||
import { usePermissions } from "../AuthGate";
|
import { usePermissions } from "../AuthGate";
|
||||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||||
@@ -455,6 +455,8 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
fontWeightBold: 700,
|
fontWeightBold: 700,
|
||||||
theme: paletteRef.current,
|
theme: paletteRef.current,
|
||||||
screenReaderMode: readFlag(SCREEN_READER_KEY),
|
screenReaderMode: readFlag(SCREEN_READER_KEY),
|
||||||
|
// xterm's own scrollback scrolls at the same share as the reports below.
|
||||||
|
scrollSensitivity: SCROLL_SPEED,
|
||||||
});
|
});
|
||||||
termRef.current = term;
|
termRef.current = term;
|
||||||
const fit = new FitAddon();
|
const fit = new FitAddon();
|
||||||
@@ -538,8 +540,14 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
const ws = wsRef.current;
|
const ws = wsRef.current;
|
||||||
if (!n || !ws || ws.readyState !== WebSocket.OPEN) return;
|
if (!n || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
const clamp = (v: number, max: number) => Math.min(max, Math.max(1, v));
|
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 col = clamp(
|
||||||
const row = clamp(Math.floor(((ev.clientY - box.top) / box.height) * term.rows) + 1, term.rows);
|
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);
|
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)));
|
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. */
|
/** Gives the mocked terminal an on-screen box: 10px per column and per row. */
|
||||||
const screenBox = () => {
|
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");
|
const screen = document.createElement("div");
|
||||||
screen.className = "xterm-screen";
|
screen.className = "xterm-screen";
|
||||||
screen.getBoundingClientRect = () =>
|
screen.getBoundingClientRect = () =>
|
||||||
@@ -350,9 +351,11 @@ describe("TerminalView", () => {
|
|||||||
// A notch of 6 rows: one report, at the cell under the pointer.
|
// 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(wheel(new WheelEvent("wheel", { deltaY: -60, clientX: 45, clientY: 72 }))).toBe(false);
|
||||||
expect(ws.sent).toEqual(["\x1b[<64;5;8M"]);
|
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;
|
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));
|
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
|
// wrong. The WebGL renderer draws each cell at a fixed position instead
|
||||||
// (xterm 6 removed the canvas one).
|
// (xterm 6 removed the canvas one).
|
||||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe(
|
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe(true);
|
||||||
true
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to the DOM renderer when the browser takes the WebGL context back", () => {
|
it("falls back to the DOM renderer when the browser takes the WebGL context back", () => {
|
||||||
|
|||||||
@@ -183,7 +183,6 @@
|
|||||||
"agenthub": "AgentHub",
|
"agenthub": "AgentHub",
|
||||||
"usersTitle": "Users · CPU / RAM now",
|
"usersTitle": "Users · CPU / RAM now",
|
||||||
"usersCount": "{{n}} users",
|
"usersCount": "{{n}} users",
|
||||||
"userTipLogin": "Login: {{v}}",
|
|
||||||
"userTipRole": "Role: {{v}}",
|
"userTipRole": "Role: {{v}}",
|
||||||
"userTipToday": "Today: {{cost}} · {{tokens}} tokens",
|
"userTipToday": "Today: {{cost}} · {{tokens}} tokens",
|
||||||
"userTipWork": "{{w}} workspaces · {{s}} sessions",
|
"userTipWork": "{{w}} workspaces · {{s}} sessions",
|
||||||
|
|||||||
@@ -183,7 +183,6 @@
|
|||||||
"agenthub": "AgentHub",
|
"agenthub": "AgentHub",
|
||||||
"usersTitle": "Người dùng · CPU / RAM hiện tại",
|
"usersTitle": "Người dùng · CPU / RAM hiện tại",
|
||||||
"usersCount": "{{n}} người dùng",
|
"usersCount": "{{n}} người dùng",
|
||||||
"userTipLogin": "Tên đăng nhập: {{v}}",
|
|
||||||
"userTipRole": "Vai trò: {{v}}",
|
"userTipRole": "Vai trò: {{v}}",
|
||||||
"userTipToday": "Hôm nay: {{cost}} · {{tokens}} token",
|
"userTipToday": "Hôm nay: {{cost}} · {{tokens}} token",
|
||||||
"userTipWork": "{{w}} workspace · {{s}} phiên",
|
"userTipWork": "{{w}} workspace · {{s}} phiên",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
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 ROW = 20;
|
||||||
const px = (deltaY: number) => ({ deltaY, deltaMode: 0 });
|
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", () => {
|
it("adds small touchpad steps up to one report per LINES_PER_REPORT rows", () => {
|
||||||
const s: WheelState = { partial: 0 };
|
const s: WheelState = { partial: 0 };
|
||||||
let sent = 0;
|
let sent = 0;
|
||||||
// 30 steps of 10px = 300px = 15 rows.
|
// 110 steps of 10px = 1100px = 55 rows, scrolled at SCROLL_SPEED.
|
||||||
for (let i = 0; i < 30; i++) sent += wheelReports(s, px(10), ROW, 40);
|
for (let i = 0; i < 110; i++) sent += wheelReports(s, px(10), ROW, 40);
|
||||||
expect(sent).toBe(15 / LINES_PER_REPORT);
|
expect(sent).toBe(Math.trunc((55 * SCROLL_SPEED) / LINES_PER_REPORT));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops the carried remainder when the swipe turns around", () => {
|
it("drops the carried remainder when the swipe turns around", () => {
|
||||||
const s: WheelState = { partial: 0 };
|
const s: WheelState = { partial: 0 };
|
||||||
wheelReports(s, px(40), ROW, 40); // 2 rows down, nothing sent yet
|
wheelReports(s, px(40), ROW, 40); // 2 rows down, nothing sent yet
|
||||||
let sent = 0;
|
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);
|
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 }, 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: 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", () => {
|
it("sends nothing for a horizontal-only event or an unmeasured terminal", () => {
|
||||||
|
|||||||
@@ -1858,6 +1858,8 @@ export const api = {
|
|||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
/** Pinned sessions first (by pin time), before the limit. */
|
/** Pinned sessions first (by pin time), before the limit. */
|
||||||
pinnedFirst?: boolean;
|
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`). */
|
/** Only active sessions waiting on the user (`awaiting=1`). */
|
||||||
awaiting?: boolean;
|
awaiting?: boolean;
|
||||||
/** ISO bounds on `started_at`. */
|
/** 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?.archived !== undefined) qs.set("archived", params.archived ? "1" : "0");
|
||||||
if (params?.pinnedFirst) qs.set("pinned_first", "1");
|
if (params?.pinnedFirst) qs.set("pinned_first", "1");
|
||||||
if (params?.awaiting) qs.set("awaiting", "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?.from) qs.set("from", params.from);
|
||||||
if (params?.to) qs.set("to", params.to);
|
if (params?.to) qs.set("to", params.to);
|
||||||
if (params?.sort_by) qs.set("sort_by", params.sort_by);
|
if (params?.sort_by) qs.set("sort_by", params.sort_by);
|
||||||
|
|||||||
@@ -1971,6 +1971,10 @@ export interface WSMessage {
|
|||||||
/** ISO timestamp the server broadcast this message (not necessarily the
|
/** ISO timestamp the server broadcast this message (not necessarily the
|
||||||
* same instant the underlying event occurred). */
|
* same instant the underlying event occurred). */
|
||||||
timestamp: string;
|
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 ─────
|
// ───── Session stats ─────
|
||||||
|
|||||||
@@ -8,13 +8,19 @@
|
|||||||
* swipe crawled. In our panes every report moves a fixed stretch (Claude's
|
* 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
|
* `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
|
* 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>
|
* @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). */
|
/** Rows one report scrolls in a Claude pane (server/lib/pty-run.js CLAUDE_SCROLL_SPEED). */
|
||||||
export const LINES_PER_REPORT = 5;
|
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). */
|
/** A pixel delta this large or larger is a wheel notch, not a touchpad step (xterm.js's own cut). */
|
||||||
const NOTCH_PX = 50;
|
const NOTCH_PX = 50;
|
||||||
|
|
||||||
@@ -25,6 +31,10 @@ const DOM_DELTA_PAGE = 2;
|
|||||||
export interface WheelState {
|
export interface WheelState {
|
||||||
/** Touchpad travel not yet sent, in rows (signed). */
|
/** Touchpad travel not yet sent, in rows (signed). */
|
||||||
partial: number;
|
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) {
|
if (ev.deltaMode === DOM_DELTA_PIXEL && Math.abs(ev.deltaY) < NOTCH_PX) {
|
||||||
// Touchpad: carry the remainder, so slow swipes still add up.
|
// Touchpad: carry the remainder, so slow swipes still add up.
|
||||||
if (Math.sign(state.partial) === -sign) state.partial = 0; // turned around
|
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);
|
const n = Math.trunc(state.partial / LINES_PER_REPORT);
|
||||||
state.partial -= n * LINES_PER_REPORT;
|
state.partial -= n * LINES_PER_REPORT;
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
// A notch (or a fast flick): at least one report, more for a long one.
|
// A notch (or a fast flick): at least one report, more for a long one.
|
||||||
state.partial = 0;
|
state.partial = 0;
|
||||||
|
if (state.notchDir !== sign) state.notch = 0; // turned around
|
||||||
|
state.notchDir = sign;
|
||||||
const lines =
|
const lines =
|
||||||
ev.deltaMode === DOM_DELTA_LINE
|
ev.deltaMode === DOM_DELTA_LINE
|
||||||
? ev.deltaY
|
? ev.deltaY
|
||||||
: ev.deltaMode === DOM_DELTA_PAGE
|
: ev.deltaMode === DOM_DELTA_PAGE
|
||||||
? ev.deltaY * rows
|
? ev.deltaY * rows
|
||||||
: ev.deltaY / rowPx;
|
: 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
|
<span
|
||||||
class="text-status-warning/90"
|
class="text-status-warning/90"
|
||||||
>
|
>
|
||||||
UI build v2.8.64 (rebuild client to match)
|
UI build v2.8.67 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2176,6 +2176,7 @@ Returns all sessions, ordered by most recent activity.
|
|||||||
| `offset` | integer | 0 | Pagination offset |
|
| `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` |
|
| `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) |
|
| `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 |
|
| `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 |
|
| `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` |
|
| `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 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
|
#### session.created
|
||||||
|
|
||||||
Sent when a new session is created.
|
Sent when a new session is created.
|
||||||
|
|||||||
+1
-1
@@ -188,7 +188,7 @@ 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
|
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
|
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
|
share of it is a **Users · CPU / RAM now** card on the same tab (a tile per
|
||||||
user, their busiest processes on click; on hover their login name, and for an
|
user, their busiest processes on click; on hover, for an
|
||||||
admin or analyst — from `GET /api/users` — their role, $ spent today,
|
admin or analyst — from `GET /api/users` — their role, $ spent today,
|
||||||
workspaces, sessions and last sign-in; their home share stays in
|
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
|
Administration's users table), and the processes of no user are listed apart — CPU % of the whole machine (100 % = every core busy) and
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.64",
|
"version": "2.8.67",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.64",
|
"version": "2.8.67",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.64",
|
"version": "2.8.67",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1854,6 +1854,22 @@ try {
|
|||||||
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN chat_token_hash TEXT").run();
|
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
|
// Server mode: whose browser a push subscription is (`users.id`); NULL in
|
||||||
// local mode. A user's notifications go only to their own subscriptions.
|
// local mode. A user's notifications go only to their own subscriptions.
|
||||||
try {
|
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.",
|
"`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",
|
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",
|
name: "from",
|
||||||
in: "query",
|
in: "query",
|
||||||
|
|||||||
@@ -160,6 +160,13 @@ router.get("/", (req, res) => {
|
|||||||
);
|
);
|
||||||
params.push(Number(lane), Number(lane));
|
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=1` = only sessions blocked on the user (an active session with
|
||||||
// `awaiting_input_since`), the UI's "Waiting" filter, done before the limit.
|
// `awaiting_input_since`), the UI's "Waiting" filter, done before the limit.
|
||||||
if (req.query.awaiting === "1") {
|
if (req.query.awaiting === "1") {
|
||||||
|
|||||||
+39
-2
@@ -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>
|
* @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");
|
const { broadcastAudience, exactAudience, audienceIncludes, canSeeRun } = require("./lib/scope");
|
||||||
|
|
||||||
let wss = null;
|
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.
|
// 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;
|
const PTY_RECHECK_MS = Number(process.env.AGENTHUB_PTY_RECHECK_MS) || 10000;
|
||||||
|
|
||||||
@@ -230,7 +238,8 @@ function initPtyWebSocket(server) {
|
|||||||
|
|
||||||
function broadcast(type, data) {
|
function broadcast(type, data) {
|
||||||
if (!wss) return;
|
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.
|
// Server mode: worked out once, and only when a member is connected.
|
||||||
let audience;
|
let audience;
|
||||||
// A message for exactly these users (a member's own config change): no
|
// 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);
|
return audienceIncludes(audience, client.user);
|
||||||
};
|
};
|
||||||
let stripped;
|
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) => {
|
wss.clients.forEach((client) => {
|
||||||
if (client.readyState === 1 && reaches(client)) {
|
if (client.readyState === 1 && reaches(client)) {
|
||||||
try {
|
try {
|
||||||
@@ -264,6 +291,10 @@ function broadcast(type, data) {
|
|||||||
client.send(stripped);
|
client.send(stripped);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (owned && !only && client.user && !member) {
|
||||||
|
client.send(forStaff(client));
|
||||||
|
return;
|
||||||
|
}
|
||||||
client.send(message);
|
client.send(message);
|
||||||
} catch {
|
} catch {
|
||||||
// Client closed between readyState check and send — safe to ignore
|
// 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 = {
|
module.exports = {
|
||||||
|
_setClientsForTest,
|
||||||
initWebSocket,
|
initWebSocket,
|
||||||
initPtyWebSocket,
|
initPtyWebSocket,
|
||||||
broadcast,
|
broadcast,
|
||||||
|
|||||||
Reference in New Issue
Block a user