Compare commits

..

23 Commits

Author SHA1 Message Date
nntrivi2001 2a83551eee chore: v2.8.69 — pet window width 2026-11-03 13:05:49 +07:00
nntrivi2001 d0889d5250 fix(pet): the pet window no longer scrolls sideways
The status chips' row needs ~340 px; the window was 320 and its body's
overflow-y:auto made x scrollable too, so a horizontal scrollbar showed
and Clear alerts was cut. 22rem wide (capped to the viewport), x hidden.
2026-11-03 13:05:49 +07:00
nntrivi2001 6958a7b4f6 chore: v2.8.68 — server for coding only (OOM kills the command, no tests from Claude, 4 AI sessions), pet without chat, branch search 2026-11-03 12:08:26 +07:00
nntrivi2001 aa7f1b1c60 feat(pet): remove the pet's chat
Each question started a full AI session (a heavy process on a shared
server, and one of a member's 4). A click on the pet now opens its window
(PetPanel): its name (rename), a pat, and the status strip. PetChat, its
off-screen terminal, its AI hand-off and its strings are gone; the window
is where the pet's own care comes next.
2026-11-03 12:08:14 +07:00
nntrivi2001 70d7cf30c2 feat(git): search box for the branch switcher (#88)
A Search branches… box beside Switch branch narrows the local and remote
lists to names containing the text (case-insensitive). One match is
picked at once, Enter picks the first, Esc clears. The picked branch
stays listed, so the select never loses its value.

Closes #88
2026-11-03 12:00:10 +07:00
nntrivi2001 5912e9e613 fix(pet): a member's pet chat starts in <their folder>/sagi
POST /api/lanes/ensure without a cwd defaulted to the dashboard's own
home, outside a member's folder, so every member's pet chat failed with
"a workspace must be inside your own folder" (EFOLDER). A member now
gets <their folder>/sagi, made as them (write-as mkdir) on first use.
2026-11-03 11:53:21 +07:00
nntrivi2001 497faae3bf feat(settings): admin switch to allow or block members' test runs
Settings → AI tools → Tests on this server (admin, S3 only). Stored in
/opt/agenthub/app/member-policy.json, which member-guard.js reads as the
member at each Bash call — no restart; no file = blocked. GET/PUT
/api/ai-tools/member-policy, audited, 409 ENOS3 outside S3.
2026-11-03 11:37:13 +07:00
nntrivi2001 e6ce8009b1 feat(server): S3 box is for coding — OOM kills the command not the session, no tests from Claude, 4 AI sessions per member
- tool-run (CLAUDE_CODE_SHELL_PREFIX in member panes) runs each command
  Claude runs at oom_score_adj 1000 (session 300, Terminal tab 800,
  dashboard/tmux -100), so an OOM inside the capped unit kills the test
  run and tells Claude why, instead of killing the conversation.
- member-guard.js, a synchronous managed hook for ah-* accounts: denies
  test runners and container commands from Claude's Bash, and refuses
  the prompts of a session started by hand past MAX_AI_SESSIONS.
- ai-procs.js counts a member's top-level AI processes from /proc;
  spawnRun refuses a new pane past the limit (429 ESESSIONLIMIT).
Needs `agenthub server install-system` once, then a restart.
2026-11-03 10:50:53 +07:00
nntrivi2001 705353b0da fix(server): a member's test run can no longer freeze the box
On 11-02 open sessions plus test runs filled RAM and the 4 GB swap on a
slow disk; the box hung until a hard reset. Member panes now start at
nice 10, IO priority 7 and oom_score_adj 500 with Vitest capped at 2
workers by default, and the system unit caps its cgroup (server + every
pane) at 85% of RAM with 1G swap and OOMPolicy=continue, so the kernel
kills the biggest pane process instead of hanging the machine. Needs
`agenthub server install-system` once.
2026-11-03 10:27:58 +07:00
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
55 changed files with 1463 additions and 956 deletions
+3 -2
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -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);
-595
View File
@@ -1,595 +0,0 @@
/**
* @file PetChat.tsx
* @description The pet's chat window — its whole UI: clicking the pet opens
* it. Above the conversation sits what the pet knows (`info`: live / waiting /
* errored, the sessions that need you, Mute, Clear alerts — `SagiInfo.tsx`).
* Before a chat runs, a question the pet can answer itself (`onLocal`: status,
* who are you…) is answered right here; anything else — and every message once
* a chat runs — goes to the AI: the first message starts a
* session of the user's DEFAULT AI tool (`agenthub.defaultTool`, Claude or
* Codex — the two with a readable transcript) in the "Sagi" workspace, in its
* first free tab (`startInFreeTabDetailed`), and the conversation is shown as
* chat bubbles read from the session's transcript. Follow-ups are typed into
* that session's pane through a terminal attached OFF-SCREEN (`TerminalView`
* in a fixed box left of the viewport — `submitToRun` needs a mounted,
* attached pane, exactly like the workspace's own Chat view), so nothing new
* reaches the server. A menu the CLI asks with (a permission or trust prompt)
* is read off that pane and answered with `PaneMenuCard`. The chat is kept in
* `localStorage` (`agenthub.pet.chat`) while its pane lives, so closing and
* reopening the window continues it; **New chat** STOPS that session (kills
* its pane and archives it, asking first only mid-reply), **Open in workspace**
* opens its pane there. Opening never resumes an ended session: once the pane
* is gone, the next message starts a new one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { ArrowUp, ExternalLink, Loader2, SquarePen, Wrench, X } from "lucide-react";
import { api } from "../../lib/api";
import type { TranscriptMessage } from "../../lib/types";
import { readDefaultMode, readDefaultTool } from "../../lib/sessionDefaults";
import {
answerRunMenu,
chooseRunOption,
interruptRun,
readRunMenu,
stepRunMenu,
submitToRun,
type PaneMenu,
} from "../../lib/panePaste";
import { isSubmitEnter } from "../../lib/keys";
import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast";
import { startInFreeTabDetailed } from "../lanes/startSession";
import { TerminalView } from "../run/TerminalView";
import { PaneMenuCard } from "../run/PaneChat";
import { MarkdownContent } from "../conversation/MarkdownContent";
/** Tools whose session the chat can read back (a transcript AgentHub parses). */
export const PET_CHAT_TOOLS = new Set(["claude", "codex"]);
const CHAT_KEY = "agenthub.pet.chat";
const POLL_MS = 1500;
const MENU_POLL_MS = 700;
// A follow-up waits this long for the off-screen pane to attach.
const SEND_TRIES = 20;
const SEND_RETRY_MS = 250;
/** The session the chat is talking to. */
export interface PetChatRef {
laneId: number;
runId: string;
tool: string;
}
export function readPetChat(): PetChatRef | null {
try {
const v = JSON.parse(localStorage.getItem(CHAT_KEY) ?? "null") as Partial<PetChatRef> | null;
if (
v &&
typeof v.laneId === "number" &&
typeof v.runId === "string" &&
typeof v.tool === "string"
)
return { laneId: v.laneId, runId: v.runId, tool: v.tool };
} catch {
/* unreadable: no chat */
}
return null;
}
function writePetChat(ref: PetChatRef | null) {
try {
if (ref) localStorage.setItem(CHAT_KEY, JSON.stringify(ref));
else localStorage.removeItem(CHAT_KEY);
} catch {
/* best-effort */
}
}
/** The tool a new chat starts: the user's default, when the chat can show it. */
export function petChatTool(): string | null {
const tool = readDefaultTool() ?? "claude";
return PET_CHAT_TOOLS.has(tool) ? tool : null;
}
type Line =
| { key: string; role: "user" | "assistant"; text: string }
| { key: string; role: "tools"; names: string[] };
/** A human line's text: Claude Code's own injected lines (`<command-…>`,
* `<system-reminder>`…) are not something the user said. */
const humanText = (m: TranscriptMessage) =>
m.content
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text!.trim())
.filter((t) => t && !t.startsWith("<"))
.join("\n\n");
/** The transcript as chat lines: the user's words, the assistant's prose, and
* each run of tool calls between them folded into one chip. */
export function chatLines(messages: TranscriptMessage[]): Line[] {
const out: Line[] = [];
messages.forEach((m, i) => {
const sender = m.sender ?? m.type;
if (sender === "user") {
const text = humanText(m);
if (text) out.push({ key: `u${i}`, role: "user", text });
return;
}
if (m.type !== "assistant") return;
m.content.forEach((c, j) => {
if (c.type === "text" && c.text?.trim()) {
out.push({ key: `a${i}-${j}`, role: "assistant", text: c.text.trim() });
} else if (c.type === "tool_use" && c.name) {
const last = out[out.length - 1];
if (last?.role === "tools") last.names.push(c.name);
else out.push({ key: `t${i}-${j}`, role: "tools", names: [c.name] });
}
});
});
return out;
}
/** Still working: the last line is the user's, or an assistant turn not ended. */
function isBusy(messages: TranscriptMessage[]): boolean {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!;
const sender = m.sender ?? m.type;
if (m.type === "assistant") return m.stop_reason !== undefined && m.stop_reason !== "end_turn";
if (sender === "user" && humanText(m)) return true;
}
return false;
}
/** "Read ×2 · Bash" */
function toolSummary(names: string[]): string {
const counts = new Map<string, number>();
names.forEach((n) => counts.set(n, (counts.get(n) ?? 0) + 1));
return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(" · ");
}
/** What the container made of a message: an answer the pet gives itself, or
* `handled` (it took the message elsewhere); null sends it to the AI. */
export type PetLocalReply = { answer: string } | { handled: true } | null;
interface PetChatProps {
/** The pet's shown name, for the header and greeting. */
name: string;
/** Shown in place of the plain name (the rename button). */
title?: ReactNode;
/** Extra header buttons, before Open in workspace (the pat heart). */
headerActions?: ReactNode;
/** Under the header: what the pet knows (status, sessions that need you). */
info?: ReactNode;
/** Asked first while no chat runs. */
onLocal?: (text: string) => PetLocalReply;
onClose: () => void;
/** Opens the chat's pane in its workspace. */
onOpenWorkspace: (laneId: number, runId: string) => void;
/** The thinking mood while a reply is being written. */
onBusy?: (busy: boolean) => void;
}
export function PetChat({
name,
title,
headerActions,
info,
onLocal,
onClose,
onOpenWorkspace,
onBusy,
}: PetChatProps) {
const { t } = useTranslation("settings");
const [chat, setChat] = useState<PetChatRef | null>(readPetChat);
const [sessionId, setSessionId] = useState<string | null>(null);
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
// Sent but not in the transcript yet.
const [pending, setPending] = useState<string | null>(null);
const [starting, setStarting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [menu, setMenu] = useState<PaneMenu | null>(null);
// The pet's own answers, given before any chat ran.
const [local, setLocal] = useState<Line[]>([]);
const listRef = useRef<HTMLDivElement>(null);
const remember = (ref: PetChatRef | null) => {
writePetChat(ref);
setChat(ref);
setSessionId(null);
setMessages([]);
setMenu(null);
};
// A remembered chat whose pane is gone is over: forget it, the next message starts anew.
useEffect(() => {
if (!chat) return;
let live = true;
const check = () =>
api.run
.get(chat.runId)
.then((h) => {
if (!live) return;
if (h.status !== "running") remember(null);
else if (h.sessionId) setSessionId(h.sessionId);
})
.catch(() => live && remember(null));
void check();
// Codex learns its session only after a while; Claude's is known at start.
const timer = window.setInterval(() => {
if (!sessionId) void check();
}, POLL_MS);
return () => {
live = false;
window.clearInterval(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chat?.runId, sessionId]);
// The conversation, polled while the window is open.
useEffect(() => {
if (!sessionId) return;
let live = true;
const load = () =>
api.sessions
.transcript(sessionId, { limit: 60 })
.then((r) => {
if (!live) return;
setMessages((prev) =>
prev.length === r.messages.length &&
JSON.stringify(prev[prev.length - 1]) ===
JSON.stringify(r.messages[r.messages.length - 1])
? prev
: r.messages
);
})
.catch(() => undefined); // not started yet: the next poll asks again
void load();
const timer = window.setInterval(load, POLL_MS);
return () => {
live = false;
window.clearInterval(timer);
};
}, [sessionId]);
// A menu the CLI shows in the pane (permission, trust, a question).
useEffect(() => {
if (!chat) return;
const read = () =>
setMenu((prev) => {
const next = readRunMenu(chat.runId);
return JSON.stringify(next) === JSON.stringify(prev) ? prev : next;
});
const timer = window.setInterval(read, MENU_POLL_MS);
return () => window.clearInterval(timer);
}, [chat]);
const lines = [...local, ...chatLines(messages)];
// The pending line shows until the transcript has it.
useEffect(() => {
if (pending && lines.some((l) => l.role === "user" && l.text === pending.trim()))
setPending(null);
}, [lines, pending]);
const busy = starting || !!pending || isBusy(messages);
useEffect(() => onBusy?.(busy), [busy, onBusy]);
useEffect(() => () => onBusy?.(false), [onBusy]);
// Keep the newest in sight.
useEffect(() => {
const el = listRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [lines.length, pending, busy, menu]);
const send = useCallback(
async (text: string) => {
const prompt = text.trim();
if (!prompt || starting) return;
setError(null);
if (!chat && onLocal) {
const reply = onLocal(prompt);
if (reply && "handled" in reply) return;
if (reply) {
const n = Date.now();
setLocal((prev) => [
...prev,
{ key: `lu${n}`, role: "user", text: prompt },
{ key: `la${n}`, role: "assistant", text: reply.answer },
]);
return;
}
}
setPending(prompt);
if (chat) {
// Typed into the attached pane; it may still be attaching.
for (let i = 0; i < SEND_TRIES; i++) {
if (submitToRun(chat.runId, prompt)) return;
await new Promise((r) => setTimeout(r, SEND_RETRY_MS));
}
setPending(null);
setDraft(prompt);
setError(t("sagi.chat.notSent"));
return;
}
const tool = petChatTool() ?? "claude";
setStarting(true);
try {
const { lane } = await api.lanes.ensure({ title: "Sagi" });
const r = await startInFreeTabDetailed(lane.id, {
prompt,
...(tool !== lane.tool ? { tool } : {}),
...(tool === "claude" ? { permissionMode: readDefaultMode({ safe: true }) } : {}),
});
remember({ laneId: r.laneId, runId: r.runId, tool });
} catch (err) {
setPending(null);
setDraft(prompt);
setError(
t("sagi.startFailed", { error: err instanceof Error ? err.message : String(err) })
);
} finally {
setStarting(false);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[chat, starting, t, onLocal]
);
// New chat: the old one's session is stopped, not left running unseen —
// asking first only while it is still writing a reply.
const newChat = async () => {
if (chat) {
if (
busy &&
!(await confirmDialog({
title: t("sagi.chat.endTitle"),
message: t("sagi.chat.endBody"),
confirmLabel: t("sagi.chat.endConfirm"),
destructive: true,
}))
)
return;
try {
await api.run.kill(chat.runId, { archive: true });
} catch (err) {
toast.error(err);
return;
}
}
remember(null);
setLocal([]);
setPending(null);
setError(null);
};
const submit = (e?: FormEvent) => {
e?.preventDefault();
if (!draft.trim()) return;
const text = draft;
setDraft("");
void send(text);
};
const toolLabel = chat?.tool ?? petChatTool() ?? "claude";
const empty = lines.length === 0 && !pending;
return (
<div
className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-80 flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
role="dialog"
aria-label={t("sagi.chat.label", { name })}
>
{/* header */}
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
<span className="pl-1.5 text-base leading-none" aria-hidden>
🐾
</span>
<div className="min-w-0 flex-1 px-1">
{title ?? <div className="truncate text-sm font-semibold text-fg-primary">{name}</div>}
<div className="flex items-center gap-1 text-[10px] uppercase tracking-wider text-fg-muted">
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${
busy
? "animate-pulse bg-status-warning"
: chat
? "bg-status-success"
: "bg-fg-muted"
}`}
aria-hidden
/>
{busy ? t("sagi.chat.typing") : toolLabel}
</div>
</div>
{headerActions}
{chat && (
<IconButton
label={t("sagi.chat.openWorkspace")}
onClick={() => onOpenWorkspace(chat.laneId, chat.runId)}
>
<ExternalLink size={15} />
</IconButton>
)}
<IconButton
label={t("sagi.chat.new")}
onClick={() => void newChat()}
disabled={(!chat && !local.length) || starting}
>
<SquarePen size={15} />
</IconButton>
<IconButton label={t("sagi.chat.close", { name })} onClick={onClose}>
<X size={16} />
</IconButton>
</div>
{info}
{/* conversation */}
<div ref={listRef} className="flex-1 space-y-2 overflow-y-auto px-3 py-3">
{empty && (
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center">
<span className="text-3xl" aria-hidden>
🐾
</span>
<p className="text-sm font-medium text-fg-primary">
{t("sagi.chat.emptyTitle", { name })}
</p>
<p className="text-xs text-fg-muted">{t("sagi.chat.emptyHint", { tool: toolLabel })}</p>
</div>
)}
{lines.map((l) =>
l.role === "tools" ? (
<div key={l.key} className="flex justify-start">
<span className="pet-chat-tools inline-flex max-w-[85%] items-center gap-1 truncate rounded-full px-2 py-0.5 text-[10px] text-fg-muted">
<Wrench size={10} className="shrink-0" aria-hidden />
<span className="truncate">{toolSummary(l.names)}</span>
</span>
</div>
) : l.role === "user" ? (
<div key={l.key} className="flex justify-end">
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white shadow-sm">
{l.text}
</div>
</div>
) : (
<div key={l.key} className="flex justify-start">
<div className="pet-chat-reply max-w-[90%] break-words rounded-2xl rounded-bl-md px-3 py-2 text-sm text-fg-primary shadow-sm">
<MarkdownContent text={l.text} dense prose />
</div>
</div>
)
)}
{pending && (
<div className="flex justify-end">
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white opacity-70 shadow-sm">
{pending}
</div>
</div>
)}
{busy && !menu && (
<div className="flex justify-start" aria-label={t("sagi.chat.typing")}>
<div className="pet-chat-reply flex gap-1 rounded-2xl rounded-bl-md px-3 py-3 shadow-sm">
<span className="pet-chat-dot" />
<span className="pet-chat-dot" />
<span className="pet-chat-dot" />
</div>
</div>
)}
{starting && (
<p className="flex items-center justify-center gap-1.5 text-[11px] text-fg-muted">
<Loader2 size={12} className="animate-spin" aria-hidden />
{t("sagi.chat.starting", { tool: toolLabel })}
</p>
)}
{chat && !sessionId && !starting && (
<p className="text-center text-[11px] text-fg-muted">{t("sagi.chat.notStarted")}</p>
)}
</div>
{error && (
<p className="mx-3 mb-1 rounded-lg bg-status-danger/10 px-2 py-1 text-[11px] text-status-danger">
{error}
</p>
)}
{/* prompt, or the question the CLI asks */}
<div className="pet-chat-foot px-2.5 py-2.5">
{menu && chat ? (
<div className="max-h-56 overflow-y-auto">
<PaneMenuCard
menu={menu}
onChoose={(i) => {
if (chooseRunOption(chat.runId, menu, i)) setMenu(null);
else setError(t("sagi.chat.notSent"));
}}
onAnswer={(text) => {
if (answerRunMenu(chat.runId, menu, text)) setMenu(null);
else setError(t("sagi.chat.notSent"));
}}
onStep={(dir) => {
if (!stepRunMenu(chat.runId, dir)) setError(t("sagi.chat.notSent"));
}}
onCancel={() => {
if (interruptRun(chat.runId)) setMenu(null);
else setError(t("sagi.chat.notSent"));
}}
/>
</div>
) : (
<form onSubmit={submit} className="flex items-end gap-1.5">
<textarea
rows={1}
autoFocus
className="max-h-28 min-h-[2.25rem] flex-1 resize-none rounded-2xl border border-border bg-surface-1 px-3.5 py-2 text-sm text-fg-primary placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
placeholder={t("sagi.chat.placeholder", { name })}
aria-label={t("sagi.chat.placeholder", { name })}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e) && !e.shiftKey) {
e.preventDefault();
submit();
} else if (e.key === "Escape") {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
onClose();
}
}}
/>
<button
type="submit"
disabled={!draft.trim() || starting}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent-solid text-white shadow-sm transition-opacity hover:opacity-90 disabled:opacity-40"
aria-label={t("sagi.panel.send")}
>
<ArrowUp size={16} />
</button>
</form>
)}
</div>
{/* The chat's pane, attached off-screen: the only way a follow-up or a
menu answer reaches it. A real size, so tmux does not shrink the pane. */}
{chat && (
<div className="pet-chat-term" aria-hidden>
<TerminalView
runId={chat.runId}
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
autoFocus={false}
// The pane ended: the conversation stays on screen, the next
// message starts a new session.
onExit={() => {
writePetChat(null);
setChat(null);
}}
/>
</div>
)}
</div>
);
}
function IconButton({
label,
onClick,
disabled,
children,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
>
{children}
</button>
);
}
+62
View File
@@ -0,0 +1,62 @@
/**
* @file PetPanel.tsx
* @description The pet's window — clicking the pet opens it: its name (which
* the user can rename), a pat button, and what the pet knows (`info`: live /
* waiting / errored, the sessions that need you, Mute, Clear alerts —
* `SagiInfo.tsx`). The pet no longer chats: it started a full AI session per
* question, a heavy process on a shared server, so the chat was removed
* (v2.8.68); this window is where the pet's own care comes next.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
export function PetPanel({
name,
title,
headerActions,
info,
onClose,
}: {
name: string;
/** The name line (a rename button); plain text without it. */
title?: ReactNode;
/** Buttons before Close (the pat). */
headerActions?: ReactNode;
/** What the pet knows (`SagiInfo`). */
info: ReactNode;
onClose: () => void;
}) {
const { t } = useTranslation("settings");
return (
<div
className="pet-chat flex max-h-[calc(100vh-6rem)] w-[22rem] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
role="dialog"
aria-label={t("sagi.panel.label", { name })}
>
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
<span className="pl-1.5 text-base leading-none" aria-hidden>
🐾
</span>
<div className="min-w-0 flex-1 px-1">
{title ?? <div className="truncate text-sm font-semibold text-fg-primary">{name}</div>}
</div>
{headerActions}
<button
type="button"
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
onClick={onClose}
aria-label={t("sagi.panel.close", { name })}
title={t("sagi.panel.close", { name })}
>
<X size={16} />
</button>
</div>
{/* Vertical only: an auto overflow-y makes x scrollable too, and the
status chips' row then showed a horizontal scrollbar. */}
<div className="min-w-0 overflow-y-auto overflow-x-hidden">{info}</div>
</div>
);
}
+7 -60
View File
@@ -5,9 +5,8 @@
* enabled; `Sagi` gates on the same preference itself, and the body (brain, * enabled; `Sagi` gates on the same preference itself, and the body (brain,
* tick, WS reducer) runs only while it is on. Persists across routes and * tick, WS reducer) runs only while it is on. Persists across routes and
* shares the single WebSocket. * shares the single WebSocket.
* Clicking the pet opens its chat window (`PetChat`), which is its whole UI: * Clicking the pet opens its window (`PetPanel`): its name, a pat, and the
* the status strip (`SagiInfo`) sits on top, a question the pet can answer * status strip (`SagiInfo`). The pet no longer chats (v2.8.68).
* itself is answered there, anything else goes to the AI.
* Owns the open/closed chat state, the ⌘B / Esc shortcuts, reduced-motion * Owns the open/closed chat state, the ⌘B / Esc shortcuts, reduced-motion
* detection, and route navigation. Reactive personality + status/Ask come * detection, and route navigation. Reactive personality + status/Ask come
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via * from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
@@ -15,10 +14,7 @@
* bubble/panel render in a self-clamping flyout so * bubble/panel render in a self-clamping flyout so
* they never spill off any screen edge regardless of where the mascot is docked. * they never spill off any screen edge regardless of where the mascot is docked.
* *
* The "do the job" path: an unmatched question is the chat's — it starts a * ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
* session of the default AI tool in the "Sagi" workspace; a default tool the
* chat cannot read back starts there and its session view opens
* (`/workspace?lane=<id>&run=<runId>`) — no new LLM backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
* in an editable field (a terminal's Ctrl+B is tmux's prefix). * in an editable field (a terminal's Ctrl+B is tmux's prefix).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -91,15 +87,12 @@ import { PetNameButton, SagiInfo } from "./SagiInfo";
import { useSagiBrain } from "./useSagiBrain"; import { useSagiBrain } from "./useSagiBrain";
import { useSagiPosition } from "./useSagiPosition"; import { useSagiPosition } from "./useSagiPosition";
import { useSagiEnabled } from "./SagiMount"; import { useSagiEnabled } from "./SagiMount";
import { matchIntent } from "./intents";
import { petName, sagiPrefs } from "./prefs"; import { petName, sagiPrefs } from "./prefs";
import type { SagiSubject } from "./brain"; import type { SagiSubject } from "./brain";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { readDefaultTool } from "../../lib/sessionDefaults";
import { startInFreeTabDetailed } from "../lanes/startSession";
import { workspaceHref } from "../SessionRowMenu"; import { workspaceHref } from "../SessionRowMenu";
import { Heart } from "lucide-react"; import { Heart } from "lucide-react";
import { PetChat, petChatTool, type PetLocalReply } from "./PetChat"; import { PetPanel } from "./PetPanel";
// Stroking: this many left/right turns of the pointer over the pet, within // Stroking: this many left/right turns of the pointer over the pet, within
// STROKE_MS and with no button held, is a pat; then a pause before the next. // STROKE_MS and with no button held, is a pat; then a pause before the next.
@@ -229,7 +222,7 @@ function SagiBody() {
// The name it goes by: the user's nickname, the pet's own, else Sagi. // The name it goes by: the user's nickname, the pet's own, else Sagi.
const [name, setName] = useState(() => petName(sagiPrefs.getPet())); const [name, setName] = useState(() => petName(sagiPrefs.getPet()));
const [size, setSize] = useState(() => sagiPrefs.getSize()); const [size, setSize] = useState(() => sagiPrefs.getSize());
// The chat window (PetChat.tsx): the pet's whole UI. // The pet's window (PetPanel.tsx).
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// The right-click pet menu (`PetMenu.tsx`). // The right-click pet menu (`PetMenu.tsx`).
const [menu, setMenu] = useState(false); const [menu, setMenu] = useState(false);
@@ -340,43 +333,6 @@ function SagiBody() {
[brain, navigate] [brain, navigate]
); );
// Asked by the chat before any session runs.
const onLocal = useCallback(
(query: string): PetLocalReply => {
const result = matchIntent(query, brain.status, { name, attention: brain.attention });
if (result.kind === "answer") {
if (result.moment) brain.playMoment(result.moment);
return { answer: result.text };
}
// A question the pet cannot answer: the chat starts a session of the
// default AI tool and shows it as chat.
if (petChatTool()) return null;
// A default tool the chat cannot read back (not Claude/Codex): start it
// in the "Sagi" workspace and open its pane there, as before.
const prompt = result.prompt;
brain.setThinking(true);
(async () => {
try {
const { lane } = await api.lanes.ensure({ title: "Sagi" });
const tool = readDefaultTool();
const r = await startInFreeTabDetailed(lane.id, {
prompt,
...(tool && tool !== lane.tool ? { tool } : {}),
});
navigate(`/workspace?lane=${r.laneId}&run=${encodeURIComponent(r.runId)}`);
setOpen(false);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
brain.setBubble(t("sagi.startFailed", { error: msg }));
} finally {
brain.setThinking(false);
}
})();
return { handled: true };
},
[brain, navigate, t, name]
);
// Stroke the pet (move the pointer back and forth over it) to pat it. // Stroke the pet (move the pointer back and forth over it) to pat it.
const stroke = useRef({ dir: 0, x: 0, turns: [] as number[], until: 0 }); const stroke = useRef({ dir: 0, x: 0, turns: [] as number[], until: 0 });
const onStroke = (e: React.PointerEvent) => { const onStroke = (e: React.PointerEvent) => {
@@ -398,8 +354,6 @@ function SagiBody() {
s.dir = dir; s.dir = dir;
}; };
const onBusy = useCallback((b: boolean) => brain.setThinking(b), [brain.setThinking]);
const anchor: Anchor = { const anchor: Anchor = {
left: place.left, left: place.left,
top: place.top, top: place.top,
@@ -412,11 +366,10 @@ function SagiBody() {
return ( return (
<> <>
{/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows {/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows
it 1:1). Closing unmounts it; its chat is remembered, so reopening it 1:1). */}
re-attaches the same session. */}
{open && ( {open && (
<SagiFlyout anchor={anchor} beside> <SagiFlyout anchor={anchor} beside>
<PetChat <PetPanel
name={name} name={name}
title={ title={
<PetNameButton <PetNameButton
@@ -449,13 +402,7 @@ function SagiBody() {
defaultOpen={brain.attention.length > 0} defaultOpen={brain.attention.length > 0}
/> />
} }
onLocal={onLocal}
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
onOpenWorkspace={(laneId, runId) => {
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
setOpen(false);
}}
onBusy={onBusy}
/> />
</SagiFlyout> </SagiFlyout>
)} )}
+3 -3
View File
@@ -1,6 +1,6 @@
/** /**
* @file SagiInfo.tsx * @file SagiInfo.tsx
* @description What the pet knows, shown inside its chat window (`PetChat`): * @description What the pet knows, shown inside its window (`PetPanel`):
* the name you rename by clicking it (`PetNameButton`), and a status strip * the name you rename by clicking it (`PetNameButton`), and a status strip
* (`SagiInfo`) — live / waiting / errored counts and the connection state * (`SagiInfo`) — live / waiting / errored counts and the connection state
* always in sight, and on expanding, the sessions that need the user, by * always in sight, and on expanding, the sessions that need the user, by
@@ -118,8 +118,8 @@ export function SagiInfo({
className="flex w-full items-center gap-1.5 rounded-lg text-left" className="flex w-full items-center gap-1.5 rounded-lg text-left"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
aria-expanded={open} aria-expanded={open}
aria-label={t("sagi.chat.status")} aria-label={t("sagi.panel.status")}
title={t("sagi.chat.status")} title={t("sagi.panel.status")}
> >
<StatChip <StatChip
icon={Radio} icon={Radio}
@@ -1,46 +0,0 @@
/**
* @file PetChat.test.ts
* @description Unit tests for the pet chat's transcript-to-bubbles folding (`chatLines`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { chatLines } from "../PetChat";
import type { TranscriptMessage } from "../../../lib/types";
const msg = (m: Partial<TranscriptMessage>): TranscriptMessage =>
({ type: "user", timestamp: null, content: [], ...m }) as TranscriptMessage;
describe("chatLines", () => {
it("keeps the user's words and the assistant's prose, folding tool calls into one chip", () => {
const lines = chatLines([
msg({ type: "user", sender: "user", content: [{ type: "text", text: "hi" }] }),
msg({
type: "assistant",
content: [
{ type: "thinking", text: "hmm" },
{ type: "tool_use", name: "Read" },
],
}),
msg({ type: "user", sender: "tool", content: [{ type: "tool_result", output: "x" }] }),
msg({
type: "assistant",
content: [
{ type: "tool_use", name: "Read" },
{ type: "text", text: "hello!" },
],
}),
]);
expect(lines.map((l) => (l.role === "tools" ? l.names : l.text))).toEqual([
"hi",
["Read", "Read"],
"hello!",
]);
});
it("drops Claude Code's own injected lines", () => {
const lines = chatLines([
msg({ sender: "user", content: [{ type: "text", text: "<command-name>/clear</command-name>" }] }),
]);
expect(lines).toEqual([]);
});
});
@@ -1,6 +1,6 @@
/** /**
* @file Sagi.test.tsx * @file Sagi.test.tsx
* @description Render tests for the Sagi companion component — mounting, mood rendering, the ⌘B chat toggle (the chat window is the pet's whole UI: status strip, local answers, New chat stopping the old session), and accessibility attributes. * @description Render tests for the Sagi companion component — mounting, mood rendering, the ⌘B window toggle (the pet's window: name, pat, status strip — the pet no longer chats), and accessibility attributes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -18,7 +18,6 @@ import { useSagiPosition } from "../useSagiPosition";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { Sagi } from "../Sagi"; import { Sagi } from "../Sagi";
import { eventBus } from "../../../lib/eventBus"; import { eventBus } from "../../../lib/eventBus";
import { api } from "../../../lib/api";
import type { WSMessage, Session } from "../../../lib/types"; import type { WSMessage, Session } from "../../../lib/types";
function renderSagi() { function renderSagi() {
@@ -55,16 +54,15 @@ describe("Sagi widget", () => {
expect(screen.getByRole("img", { name: /sagi/i })).toBeInTheDocument(); expect(screen.getByRole("img", { name: /sagi/i })).toBeInTheDocument();
}); });
it("opens the chat on click and answers a local status question there", () => { it("opens its window on click — status, no chat box", () => {
renderSagi(); renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i })); fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
const panel = screen.getByRole("dialog", { name: /chat with sagi/i }); const panel = screen.getByRole("dialog", { name: /pet sagi/i });
expect(panel).toBeInTheDocument(); expect(within(panel).getByText("live")).toBeInTheDocument();
expect(within(panel).queryByRole("textbox")).toBeNull();
const input = within(panel).getByLabelText(/ask sagi anything/i); expect(within(panel).queryByRole("button", { name: /new chat/i })).toBeNull();
fireEvent.change(input, { target: { value: "status" } }); fireEvent.click(within(panel).getByRole("button", { name: /close sagi/i }));
fireEvent.submit(input.closest("form")!); expect(screen.queryByRole("dialog")).toBeNull();
expect(within(panel).getByText(/live ·/i)).toBeInTheDocument();
}); });
it("toggles open/closed with Cmd/Ctrl+B and closes with Esc", () => { it("toggles open/closed with Cmd/Ctrl+B and closes with Esc", () => {
@@ -89,7 +87,7 @@ describe("Sagi widget", () => {
expect(within(btn).getByText("1")).toBeInTheDocument(); expect(within(btn).getByText("1")).toBeInTheDocument();
}); });
it("names the errored session in its bubble and in the chat", () => { it("names the errored session in its bubble and in its window", () => {
renderSagi(); renderSagi();
act(() => { act(() => {
eventBus.publish({ eventBus.publish({
@@ -102,7 +100,7 @@ describe("Sagi widget", () => {
expect(within(bubble).getByText("Fix login")).toBeInTheDocument(); expect(within(bubble).getByText("Fix login")).toBeInTheDocument();
expect(within(bubble).getByRole("button", { name: "Open" })).toBeInTheDocument(); expect(within(bubble).getByRole("button", { name: "Open" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i })); fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
const panel = screen.getByRole("dialog", { name: /chat with sagi/i }); const panel = screen.getByRole("dialog", { name: /pet sagi/i });
expect(within(panel).getByText("Needs you")).toBeInTheDocument(); expect(within(panel).getByText("Needs you")).toBeInTheDocument();
expect(within(panel).getByRole("button", { name: /Fix login/ })).toBeInTheDocument(); expect(within(panel).getByRole("button", { name: /Fix login/ })).toBeInTheDocument();
}); });
@@ -122,54 +120,17 @@ describe("Sagi widget", () => {
expect(JSON.parse(localStorage.getItem("agenthub.pet.dismissedErrors")!)).toEqual(["a"]); expect(JSON.parse(localStorage.getItem("agenthub.pet.dismissedErrors")!)).toEqual(["a"]);
}); });
it("renames the pet from the chat header", () => { it("renames the pet from its window header", () => {
renderSagi(); renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i })); fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
fireEvent.click(screen.getByRole("button", { name: /rename sagi/i })); fireEvent.click(screen.getByRole("button", { name: /rename sagi/i }));
const input = screen.getByLabelText("Pet name"); const input = screen.getByLabelText("Pet name");
fireEvent.change(input, { target: { value: "Mochi" } }); fireEvent.change(input, { target: { value: "Mochi" } });
fireEvent.keyDown(input, { key: "Enter" }); fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByRole("dialog", { name: /chat with mochi/i })).toBeInTheDocument(); expect(screen.getByRole("dialog", { name: /pet mochi/i })).toBeInTheDocument();
expect(screen.getByLabelText(/ask mochi anything/i)).toBeInTheDocument();
}); });
it("hands a question it cannot answer to the AI", () => { it("a pat shows its reaction on the pet even while its window is open", () => {
localStorage.setItem("agenthub.defaultTool", "claude");
const ensure = vi.spyOn(api.lanes, "ensure").mockReturnValue(new Promise(() => {}));
renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
const chat = screen.getByRole("dialog", { name: /chat with sagi/i });
const input = within(chat).getByLabelText(/ask sagi anything/i);
fireEvent.change(input, { target: { value: "write me a haiku" } });
fireEvent.submit(input.closest("form")!);
expect(within(chat).getByText("write me a haiku")).toBeInTheDocument();
expect(ensure).toHaveBeenCalledWith({ title: "Sagi" });
ensure.mockRestore();
});
it("reopens the remembered chat, and New chat stops its session", async () => {
localStorage.setItem(
"agenthub.pet.chat",
JSON.stringify({ laneId: 7, runId: "agenthub-lane-7-1", tool: "claude" })
);
const get = vi
.spyOn(api.run, "get")
.mockReturnValue(new Promise(() => {}) as ReturnType<typeof api.run.get>);
const kill = vi.spyOn(api.run, "kill").mockResolvedValue({ ok: true });
renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
expect(screen.getByRole("button", { name: /open in workspace/i })).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /new chat/i }));
});
expect(kill).toHaveBeenCalledWith("agenthub-lane-7-1", { archive: true });
expect(localStorage.getItem("agenthub.pet.chat")).toBeNull();
expect(screen.queryByRole("button", { name: /open in workspace/i })).not.toBeInTheDocument();
get.mockRestore();
kill.mockRestore();
});
it("a pat shows its reaction on the pet even while the chat is open", () => {
renderSagi(); renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i })); fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
fireEvent.click(screen.getByRole("button", { name: /pat sagi/i })); fireEvent.click(screen.getByRole("button", { name: /pat sagi/i }));
@@ -187,26 +148,40 @@ describe("Sagi widget", () => {
expect(pet.querySelector(".sagi-reaction-text")).not.toBeNull(); expect(pet.querySelector(".sagi-reaction-text")).not.toBeNull();
}); });
it("reflects the live count in the chat status", () => { it("reflects the live count in its status", () => {
renderSagi(); renderSagi();
act(() => { act(() => {
eventBus.publish(sessionMsg("a", "active")); eventBus.publish(sessionMsg("a", "active"));
eventBus.publish(sessionMsg("b", "active")); eventBus.publish(sessionMsg("b", "active"));
}); });
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i })); fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
const panel = screen.getByRole("dialog", { name: /chat with sagi/i }); const panel = screen.getByRole("dialog", { name: /pet sagi/i });
// The "live" stat chip shows value 2 next to its label. // The "live" stat chip shows value 2 next to its label.
const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement; const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
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: /pet 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();
expect(screen.queryByRole("button", { name: /open sagi companion/i })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /open sagi companion/i })).not.toBeInTheDocument();
}); });
it("a tap (no movement) still opens the chat", () => { it("a tap (no movement) still opens its window", () => {
renderSagi(); renderSagi();
const btn = screen.getByRole("button", { name: /open sagi companion/i }); const btn = screen.getByRole("button", { name: /open sagi companion/i });
act(() => { act(() => {
@@ -214,7 +189,7 @@ describe("Sagi widget", () => {
fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 }); fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 });
}); });
fireEvent.click(btn); fireEvent.click(btn);
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument(); expect(screen.getByRole("dialog", { name: /pet sagi/i })).toBeInTheDocument();
}); });
it("dragging snaps to an edge, persists position, and does not open the panel", () => { it("dragging snaps to an edge, persists position, and does not open the panel", () => {
@@ -243,7 +218,7 @@ describe("Sagi widget", () => {
fireEvent.pointerUp(btn, { clientX: 992, clientY: 701 }); fireEvent.pointerUp(btn, { clientX: 992, clientY: 701 });
}); });
fireEvent.click(btn); fireEvent.click(btn);
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument(); expect(screen.getByRole("dialog", { name: /pet sagi/i })).toBeInTheDocument();
// No position was persisted because no real drag happened. // No position was persisted because no real drag happened.
expect(localStorage.getItem("agent-dashboard-sagi-pos")).toBeNull(); expect(localStorage.getItem("agent-dashboard-sagi-pos")).toBeNull();
}); });
+3 -61
View File
@@ -529,13 +529,10 @@
z-index: 42; z-index: 42;
} }
/* ════════ the pet's chat window (PetChat.tsx) ════════ */ /* ════════ the pet's window (PetPanel.tsx) ════════ */
/* The reference's soft gradient, in the theme's own accent rather than a fixed /* A tint of the theme's accent at the top fading into the surface, so it
colour: a tint of the accent at the top fading into the surface, so it reads in every light and dark theme. */
reads in every light and dark theme. Replies sit on the plain surface like
the reference's white bubbles; the user's own are accent-solid (white text
is only ever on accent-solid). */
.pet-chat { .pet-chat {
background: linear-gradient( background: linear-gradient(
180deg, 180deg,
@@ -547,61 +544,6 @@
.pet-chat-head { .pet-chat-head {
border-bottom: 1px solid color-mix(in srgb, rgb(var(--accent)) 18%, transparent); border-bottom: 1px solid color-mix(in srgb, rgb(var(--accent)) 18%, transparent);
} }
.pet-chat-reply {
background: rgb(var(--surface-0));
border: 1px solid color-mix(in srgb, rgb(var(--accent)) 12%, transparent);
}
.pet-chat-tools {
background: color-mix(in srgb, rgb(var(--surface-0)) 70%, transparent);
border: 1px dashed color-mix(in srgb, rgb(var(--accent)) 25%, transparent);
}
.pet-chat-foot {
background: rgb(var(--surface-0));
border-top: 1px solid rgb(var(--border) / 0.6);
}
/* Three bouncing dots while a reply is written. */
.pet-chat-dot {
width: 6px;
height: 6px;
border-radius: 9999px;
background: rgb(var(--accent));
opacity: 0.5;
animation: pet-chat-dot 1.2s ease-in-out infinite;
}
.pet-chat-dot:nth-child(2) {
animation-delay: 0.15s;
}
.pet-chat-dot:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes pet-chat-dot {
0%,
60%,
100% {
transform: translateY(0);
opacity: 0.35;
}
30% {
transform: translateY(-4px);
opacity: 0.9;
}
}
/* The chat's terminal, attached but off-screen. It keeps a real size (so tmux
does not shrink the pane to fit it) and a non-zero box (so the paste
registry counts it as on screen). */
.pet-chat-term {
position: fixed;
left: -10000px;
top: 0;
width: 760px;
height: 460px;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.pet-chat-dot {
animation: none;
}
}
/* ════════ a pat's reaction, drawn on the pet itself ════════ */ /* ════════ a pat's reaction, drawn on the pet itself ════════ */
+6 -2
View File
@@ -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 -3
View File
@@ -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.
+32 -29
View File
@@ -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();
}); });
+52 -4
View File
@@ -132,6 +132,8 @@ export function LaneGitBar({
const [remoteBranches, setRemoteBranches] = useState<string[]>([]); const [remoteBranches, setRemoteBranches] = useState<string[]>([]);
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]); const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
const [picked, setPicked] = useState(""); const [picked, setPicked] = useState("");
// Issue #88: typing narrows the switcher to the branches whose name holds it.
const [branchQuery, setBranchQuery] = useState("");
const [newBranch, setNewBranch] = useState(""); const [newBranch, setNewBranch] = useState("");
// Set when git needed a login for `action`; the form resends that action. // Set when git needed a login for `action`; the form resends that action.
const [login, setLogin] = useState<{ action: Action; auth: GitAuthNeeded } | null>(null); const [login, setLogin] = useState<{ action: Action; auth: GitAuthNeeded } | null>(null);
@@ -160,6 +162,7 @@ export function LaneGitBar({
setRemoteBranches([]); setRemoteBranches([]);
setWorktrees([]); setWorktrees([]);
setPicked(""); setPicked("");
setBranchQuery("");
setNewBranch(""); setNewBranch("");
setLogin(null); setLogin(null);
setPassword(""); setPassword("");
@@ -176,6 +179,26 @@ export function LaneGitBar({
// A remote branch: switching creates its local tracking branch; it cannot // A remote branch: switching creates its local tracking branch; it cannot
// be deleted here or given a sibling worktree before that. // be deleted here or given a sibling worktree before that.
const pickedRemote = remoteBranches.includes(picked); const pickedRemote = remoteBranches.includes(picked);
// The switcher's lists, narrowed by the search box (case-insensitive, any
// part of the name). The picked branch always stays in its list, so the
// select never shows a value it has no option for.
const query = branchQuery.trim().toLowerCase();
const matches = (b: string) => !query || b.toLowerCase().includes(query) || b === picked;
const shownBranches = (branches || []).filter(matches);
const shownRemote = remoteBranches.filter(matches);
const matchCount = query
? [...(branches || []), ...remoteBranches].filter((b) => b.toLowerCase().includes(query))
.length
: null;
const searchBranches = (text: string) => {
setBranchQuery(text);
const q = text.trim().toLowerCase();
if (!q) return;
const hits = [...(branches || []), ...remoteBranches].filter((b) =>
b.toLowerCase().includes(q)
);
if (hits.length === 1) setPicked(hits[0]!);
};
const merging = !!git.merging; const merging = !!git.merging;
const run = async (action: Action, body?: RunBody) => { const run = async (action: Action, body?: RunBody) => {
@@ -526,6 +549,27 @@ export function LaneGitBar({
{switching && ( {switching && (
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
<input
type="search"
aria-label={t("gitBar.branchSearch")}
placeholder={t("gitBar.branchSearch")}
value={branchQuery}
disabled={!!busy}
onChange={(e) => searchBranches(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape" && branchQuery) {
e.stopPropagation();
setBranchQuery("");
} else if (isSubmitEnter(e)) {
e.preventDefault();
const first = [...shownBranches, ...shownRemote].find(
(b) => b.toLowerCase().includes(query) && b !== git.branch
);
if (first) setPicked(first);
}
}}
className="w-36 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<select <select
aria-label={t("gitBar.switchLabel")} aria-label={t("gitBar.switchLabel")}
value={picked} value={picked}
@@ -533,8 +577,12 @@ export function LaneGitBar({
onChange={(e) => setPicked(e.target.value)} onChange={(e) => setPicked(e.target.value)}
className="select min-w-[8rem] flex-1 !rounded !py-0.5 !pl-2 !pr-6 font-mono !text-[11px]" className="select min-w-[8rem] flex-1 !rounded !py-0.5 !pl-2 !pr-6 font-mono !text-[11px]"
> >
<option value="">{t("gitBar.switchLabel")}</option> <option value="">
{(branches || []).map((b) => { {matchCount === null
? t("gitBar.switchLabel")
: t("gitBar.branchMatches", { count: matchCount })}
</option>
{shownBranches.map((b) => {
const held = heldBy.get(b); const held = heldBy.get(b);
return ( return (
<option key={b} value={b}> <option key={b} value={b}>
@@ -543,9 +591,9 @@ export function LaneGitBar({
</option> </option>
); );
})} })}
{remoteBranches.length > 0 && ( {shownRemote.length > 0 && (
<optgroup label={t("gitBar.remoteBranches")}> <optgroup label={t("gitBar.remoteBranches")}>
{remoteBranches.map((b) => ( {shownRemote.map((b) => (
<option key={`remote:${b}`} value={b}> <option key={`remote:${b}`} value={b}>
{b} {b}
</option> </option>
@@ -337,6 +337,47 @@ describe("LaneGitBar", () => {
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2)); await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
}); });
it("narrows the branch list as you type, picks a single match and Enter's first one (#88)", async () => {
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
available: true,
branches: ["development", "feat/campaign-pause", "fix/campaign-step", "main"],
remotes: ["origin/feat/campaign-statistic", "origin/feature/thienpt/unit_test"],
current: "development",
worktrees: [],
});
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
const search = screen.getByLabelText("Search branches…");
const options = () =>
Array.from(select.querySelectorAll("option"))
.map((o) => o.value)
.filter(Boolean);
await user.type(search, "CAMPAIGN");
expect(options()).toEqual([
"feat/campaign-pause",
"fix/campaign-step",
"origin/feat/campaign-statistic",
]);
expect(select.querySelector("option")).toHaveTextContent("3 matching branches");
expect(select).toHaveValue("");
await user.keyboard("{Enter}");
expect(select).toHaveValue("feat/campaign-pause");
await user.clear(search);
await user.type(search, "unit_t");
// One match: picked at once (the earlier pick stays listed until replaced).
expect(select).toHaveValue("origin/feature/thienpt/unit_test");
expect(options()).toEqual(["origin/feature/thienpt/unit_test"]);
await user.keyboard("{Escape}");
expect(search).toHaveValue("");
expect(options()).toHaveLength(6);
});
it("lists branches only the remote has, switches to one, and re-reads the list after a fetch", async () => { it("lists branches only the remote has, switches to one, and re-reads the list after a fetch", async () => {
vi.mocked(api.lanes.gitBranches).mockResolvedValue({ vi.mocked(api.lanes.gitBranches).mockResolvedValue({
available: true, available: true,
+11 -3
View File
@@ -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", () => {
@@ -0,0 +1,53 @@
/**
* @file MemberPolicySettings — Settings → AI tools → Tests on this server:
* the admin's server-wide switch that allows or blocks members' Claude from
* running test runners and container commands on this box
* (`GET/PUT /api/ai-tools/member-policy`, `server/lib/member-policy.js`,
* enforced by `scripts/member-guard.js`). Renders nothing outside server mode
* with per-user accounts (S3), or for anyone but an admin.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type MemberPolicyState } from "../../lib/api";
import { toast } from "../../lib/toast";
import { usePermissions } from "../AuthGate";
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
export function MemberPolicySettings() {
const { t } = useTranslation("settings");
const { isAdmin, local } = usePermissions();
const [state, setState] = useState<MemberPolicyState | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (local || !isAdmin) return;
api.aiTools
.memberPolicy()
.then(setState)
.catch(() => setState(null));
}, [local, isAdmin]);
if (local || !isAdmin || !state?.available) return null;
return (
<SettingsSection id="member-policy" title={t("memberPolicy.title")}>
<SettingsSwitchRow
label={t("memberPolicy.switch")}
description={t("memberPolicy.hint")}
checked={state.allowTests}
disabled={busy}
onChange={async (on) => {
setBusy(true);
try {
setState(await api.aiTools.setMemberPolicy(on));
} catch (err) {
toast.error(err);
} finally {
setBusy(false);
}
}}
/>
</SettingsSection>
);
}
@@ -0,0 +1,57 @@
/**
* @file Tests for MemberPolicySettings — the admin's "allow members to run
* tests" switch in Settings → AI tools: hidden in local mode, for a non-admin
* and outside S3, and a toggle saves through PUT /api/ai-tools/member-policy.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import "../../../i18n";
import { MemberPolicySettings } from "../MemberPolicySettings";
import { api } from "../../../lib/api";
const perms = vi.hoisted(() => ({ isAdmin: true, local: false }));
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
vi.mock("../../../lib/api", () => ({
api: { aiTools: { memberPolicy: vi.fn(), setMemberPolicy: vi.fn() } },
}));
const get = vi.mocked(api.aiTools.memberPolicy);
const set = vi.mocked(api.aiTools.setMemberPolicy);
describe("MemberPolicySettings", () => {
beforeEach(() => {
vi.clearAllMocks();
perms.isAdmin = true;
perms.local = false;
});
it("lets an admin allow tests, off by default", async () => {
get.mockResolvedValue({ available: true, allowTests: false });
set.mockResolvedValue({ available: true, allowTests: true });
render(<MemberPolicySettings />);
const sw = await screen.findByRole("switch", { name: "Allow members to run tests" });
expect(sw).toHaveAttribute("aria-checked", "false");
fireEvent.click(sw);
await waitFor(() => expect(set).toHaveBeenCalledWith(true));
await waitFor(() => expect(sw).toHaveAttribute("aria-checked", "true"));
});
it("hides outside S3", async () => {
get.mockResolvedValue({ available: false, allowTests: false });
const { container } = render(<MemberPolicySettings />);
await waitFor(() => expect(get).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
it("asks nothing for a non-admin or in local mode", () => {
perms.isAdmin = false;
const { container } = render(<MemberPolicySettings />);
expect(container).toBeEmptyDOMElement();
perms.isAdmin = true;
perms.local = true;
render(<MemberPolicySettings />);
expect(get).not.toHaveBeenCalled();
});
});
@@ -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",
+3
View File
@@ -195,6 +195,9 @@
"gitBar.pushHint": "git push -u origin HEAD", "gitBar.pushHint": "git push -u origin HEAD",
"gitBar.switch": "Branch", "gitBar.switch": "Branch",
"gitBar.switchApply": "Switch branch", "gitBar.switchApply": "Switch branch",
"gitBar.branchSearch": "Search branches…",
"gitBar.branchMatches_one": "{{count}} matching branch",
"gitBar.branchMatches_other": "{{count}} matching branches",
"gitBar.switchCurrent": "current", "gitBar.switchCurrent": "current",
"gitBar.remoteBranches": "On the remote — switching creates the local branch", "gitBar.remoteBranches": "On the remote — switching creates the local branch",
"gitBar.switchDirty": "Commit, discard or shelve the uncommitted changes first", "gitBar.switchDirty": "Commit, discard or shelve the uncommitted changes first",
+9 -20
View File
@@ -45,7 +45,6 @@
"mute": "Mute", "mute": "Mute",
"unmute": "Unmute", "unmute": "Unmute",
"clearAlerts": "Clear alerts", "clearAlerts": "Clear alerts",
"send": "Send",
"rename": "Click to rename {{name}}", "rename": "Click to rename {{name}}",
"renameLabel": "Pet name", "renameLabel": "Pet name",
"renameHint": "Enter to save · empty = default name", "renameHint": "Enter to save · empty = default name",
@@ -53,13 +52,15 @@
"attention": "Needs you", "attention": "Needs you",
"attentionError": "errored", "attentionError": "errored",
"attentionWaiting": "waiting on you", "attentionWaiting": "waiting on you",
"attentionMore": "+{{count}} more in Sessions" "attentionMore": "+{{count}} more in Sessions",
"label": "Pet {{name}}",
"close": "Close {{name}}",
"status": "Status — click to show the sessions that need you"
}, },
"bubbleDismiss": "Dismiss", "bubbleDismiss": "Dismiss",
"open": "Open {{name}} companion", "open": "Open {{name}} companion",
"closeAvatar": "Close {{name}}", "closeAvatar": "Close {{name}}",
"avatarTitle": "{{name}} — Ctrl/⌘+B · drag or arrow keys to move · stroke to pat · right-click for pets", "avatarTitle": "{{name}} — Ctrl/⌘+B · drag or arrow keys to move · stroke to pat · right-click for pets",
"startFailed": "Couldn't start that session: {{error}}",
"bubbleOpen": "Open", "bubbleOpen": "Open",
"bubbleMore": "{{list}} +{{count}} more", "bubbleMore": "{{list}} +{{count}} more",
"notify": { "notify": {
@@ -174,23 +175,6 @@
"watching": ["on the prowl 👀", "watching your sessions 😼", "eyes peeled 🐾"], "watching": ["on the prowl 👀", "watching your sessions 😼", "eyes peeled 🐾"],
"sleeping": ["zzz… 💤", "wake me if something happens 😴", "curled up, all calm 🐈"], "sleeping": ["zzz… 💤", "wake me if something happens 😴", "curled up, all calm 🐈"],
"idle": ["all quiet 😺", "ready when you are 🐾", "just vibing ✨"] "idle": ["all quiet 😺", "ready when you are 🐾", "just vibing ✨"]
},
"chat": {
"label": "Chat with {{name}}",
"close": "Close {{name}}",
"new": "New chat",
"status": "Status — click to show the sessions that need you",
"endTitle": "End this chat?",
"endBody": "It is still writing a reply. A new chat stops this session.",
"endConfirm": "Stop and start new",
"openWorkspace": "Open in workspace",
"typing": "typing…",
"placeholder": "Ask {{name}} anything…",
"emptyTitle": "Hi! I'm {{name}} 🐾",
"emptyHint": "Ask me anything — I'll get {{tool}} to answer right here.",
"starting": "Starting {{tool}}…",
"notStarted": "Waiting for the session to start — if it asks something, open it in the workspace.",
"notSent": "Couldn't reach the session's terminal — try again, or open it in the workspace."
} }
}, },
"pricing": { "pricing": {
@@ -1169,6 +1153,11 @@
"vi": "Tiếng Việt" "vi": "Tiếng Việt"
} }
}, },
"memberPolicy": {
"title": "Tests on this server",
"switch": "Allow members to run tests",
"hint": "Off: members' Claude may not run test runners or container commands here (vitest, jest, phpunit, pytest, npm test, docker, …) — a few at once freeze this shared server, so they test on their own machine. On: they may. Commands typed by hand in a terminal and Codex are not checked either way."
},
"ompSharing": { "ompSharing": {
"title": "Oh My Pi login sharing", "title": "Oh My Pi login sharing",
"switch": "Share this server's Oh My Pi login", "switch": "Share this server's Oh My Pi login",
@@ -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",
+3
View File
@@ -195,6 +195,9 @@
"gitBar.pushHint": "git push -u origin HEAD", "gitBar.pushHint": "git push -u origin HEAD",
"gitBar.switch": "Nhánh", "gitBar.switch": "Nhánh",
"gitBar.switchApply": "Chuyển nhánh", "gitBar.switchApply": "Chuyển nhánh",
"gitBar.branchSearch": "Tìm nhánh…",
"gitBar.branchMatches_one": "{{count}} nhánh khớp",
"gitBar.branchMatches_other": "{{count}} nhánh khớp",
"gitBar.switchCurrent": "hiện tại", "gitBar.switchCurrent": "hiện tại",
"gitBar.remoteBranches": "Trên remote — switch sẽ tạo nhánh local", "gitBar.remoteBranches": "Trên remote — switch sẽ tạo nhánh local",
"gitBar.switchDirty": "Hãy commit, bỏ hoặc cất (shelve) các thay đổi chưa commit trước", "gitBar.switchDirty": "Hãy commit, bỏ hoặc cất (shelve) các thay đổi chưa commit trước",
+9 -20
View File
@@ -45,7 +45,6 @@
"mute": "Tắt tiếng", "mute": "Tắt tiếng",
"unmute": "Bật tiếng", "unmute": "Bật tiếng",
"clearAlerts": "Xoá cảnh báo", "clearAlerts": "Xoá cảnh báo",
"send": "Gửi",
"rename": "Bấm để đổi tên {{name}}", "rename": "Bấm để đổi tên {{name}}",
"renameLabel": "Tên pet", "renameLabel": "Tên pet",
"renameHint": "Enter để lưu · để trống = tên mặc định", "renameHint": "Enter để lưu · để trống = tên mặc định",
@@ -53,13 +52,15 @@
"attention": "Cần bạn xem", "attention": "Cần bạn xem",
"attentionError": "bị lỗi", "attentionError": "bị lỗi",
"attentionWaiting": "đang chờ bạn", "attentionWaiting": "đang chờ bạn",
"attentionMore": "+{{count}} phiên khác trong Phiên" "attentionMore": "+{{count}} phiên khác trong Phiên",
"label": "Pet {{name}}",
"close": "Đóng {{name}}",
"status": "Trạng thái — bấm để xem các phiên cần bạn"
}, },
"bubbleDismiss": "Đóng", "bubbleDismiss": "Đóng",
"open": "Mở trợ lý {{name}}", "open": "Mở trợ lý {{name}}",
"closeAvatar": "Đóng {{name}}", "closeAvatar": "Đóng {{name}}",
"avatarTitle": "{{name}} — Ctrl/⌘+B · kéo hoặc dùng phím mũi tên để di chuyển · vuốt qua lại để xoa đầu · chuột phải để chọn pet", "avatarTitle": "{{name}} — Ctrl/⌘+B · kéo hoặc dùng phím mũi tên để di chuyển · vuốt qua lại để xoa đầu · chuột phải để chọn pet",
"startFailed": "Không khởi động được phiên: {{error}}",
"bubbleOpen": "Mở", "bubbleOpen": "Mở",
"bubbleMore": "{{list}} và {{count}} phiên khác", "bubbleMore": "{{list}} và {{count}} phiên khác",
"notify": { "notify": {
@@ -162,23 +163,6 @@
"watching": ["đang tuần tra 👀", "đang canh các phiên của bạn 😼", "mắt mở to 🐾"], "watching": ["đang tuần tra 👀", "đang canh các phiên của bạn 😼", "mắt mở to 🐾"],
"sleeping": ["zzz… 💤", "có gì thì gọi mình dậy nhé 😴", "cuộn tròn, mọi thứ yên ổn 🐈"], "sleeping": ["zzz… 💤", "có gì thì gọi mình dậy nhé 😴", "cuộn tròn, mọi thứ yên ổn 🐈"],
"idle": ["yên ắng quá 😺", "sẵn sàng khi bạn cần 🐾", "chill thôi ✨"] "idle": ["yên ắng quá 😺", "sẵn sàng khi bạn cần 🐾", "chill thôi ✨"]
},
"chat": {
"label": "Trò chuyện với {{name}}",
"close": "Đóng {{name}}",
"new": "Cuộc trò chuyện mới",
"status": "Trạng thái — bấm để xem các phiên cần bạn",
"endTitle": "Kết thúc chat này?",
"endBody": "Nó vẫn đang trả lời. Chat mới sẽ dừng phiên này.",
"endConfirm": "Dừng và bắt đầu mới",
"openWorkspace": "Mở trong workspace",
"typing": "đang trả lời…",
"placeholder": "Hỏi {{name}} bất cứ điều gì…",
"emptyTitle": "Chào bạn! Mình là {{name}} 🐾",
"emptyHint": "Hỏi gì cũng được — mình sẽ nhờ {{tool}} trả lời ngay tại đây.",
"starting": "Đang khởi động {{tool}}…",
"notStarted": "Đang chờ phiên khởi động — nếu nó hỏi gì, hãy mở trong workspace.",
"notSent": "Không gửi được tới terminal của phiên — thử lại, hoặc mở trong workspace."
} }
}, },
"pricing": { "pricing": {
@@ -1157,6 +1141,11 @@
"vi": "Tiếng Việt" "vi": "Tiếng Việt"
} }
}, },
"memberPolicy": {
"title": "Chạy test trên server này",
"switch": "Cho phép thành viên chạy test",
"hint": "Tắt: Claude của thành viên không được chạy test hay lệnh container ở đây (vitest, jest, phpunit, pytest, npm test, docker, …) — vài lượt chạy cùng lúc là server dùng chung bị đứng, nên họ test trên máy của mình. Bật: được phép chạy. Lệnh tự gõ trong terminal và Codex thì không bị kiểm tra trong cả hai trường hợp."
},
"ompSharing": { "ompSharing": {
"title": "Dùng chung đăng nhập Oh My Pi", "title": "Dùng chung đăng nhập Oh My Pi",
"switch": "Dùng chung đăng nhập Oh My Pi của máy chủ", "switch": "Dùng chung đăng nhập Oh My Pi của máy chủ",
+16 -6
View File
@@ -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", () => {
+18
View File
@@ -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);
@@ -3779,6 +3782,14 @@ export const api = {
method: "PUT", method: "PUT",
body: JSON.stringify({ enabled }), body: JSON.stringify({ enabled }),
}), }),
/** GET /api/ai-tools/member-policy - what members' Claude may run (server mode, S3). */
memberPolicy: () => request<MemberPolicyState>("/ai-tools/member-policy"),
/** PUT /api/ai-tools/member-policy - an admin's switch. */
setMemberPolicy: (allowTests: boolean) =>
request<MemberPolicyState>("/ai-tools/member-policy", {
method: "PUT",
body: JSON.stringify({ allowTests }),
}),
/** POST /api/ai-tools/refresh - re-read the login shell's PATH and re-detect. */ /** POST /api/ai-tools/refresh - re-read the login shell's PATH and re-detect. */
refresh: () => request<AiToolsView>("/ai-tools/refresh", { method: "POST" }), refresh: () => request<AiToolsView>("/ai-tools/refresh", { method: "POST" }),
/** PUT /api/ai-tools/:id - enable/disable a tool, make it the default, or set its default model ("" clears). */ /** PUT /api/ai-tools/:id - enable/disable a tool, make it the default, or set its default model ("" clears). */
@@ -5271,6 +5282,13 @@ export interface ArtifactCandidate {
} }
/** `GET /api/ai-tools/omp-sharing` — never a token. */ /** `GET /api/ai-tools/omp-sharing` — never a token. */
export interface MemberPolicyState {
/** Server mode with per-user Linux accounts (S3): the only place it applies. */
available: boolean;
/** May members' Claude run test runners and container commands? Off by default. */
allowTests: boolean;
}
export interface OmpSharingState { export interface OmpSharingState {
/** Server mode with per-user Linux accounts (S3): the only place it applies. */ /** Server mode with per-user Linux accounts (S3): the only place it applies. */
available: boolean; available: boolean;
+4
View File
@@ -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 ─────
+20 -3
View File
@@ -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;
} }
+3
View File
@@ -72,6 +72,7 @@ import { AiToolsSettings } from "../components/settings/AiToolsSettings";
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings"; import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsSettings"; import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsSettings";
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings"; import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
import { MemberPolicySettings } from "../components/settings/MemberPolicySettings";
import { ToolProfilesSettings } from "../components/settings/ToolProfilesSettings"; import { ToolProfilesSettings } from "../components/settings/ToolProfilesSettings";
import { PoolAccountSettings } from "../components/settings/PoolAccountSettings"; import { PoolAccountSettings } from "../components/settings/PoolAccountSettings";
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings"; import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
@@ -317,6 +318,8 @@ export function SettingsHub({ section }: { section?: string } = {}) {
<ArtifactsHubSettings /> <ArtifactsHubSettings />
{/* The host's omp login for members: server mode + S3, an admin's. */} {/* The host's omp login for members: server mode + S3, an admin's. */}
<OmpSharingSettings /> <OmpSharingSettings />
{/* Members' test runs on this box: server mode + S3, an admin's. */}
<MemberPolicySettings />
</div> </div>
), ),
}, },
@@ -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.69 (rebuild client to match)
</span> </span>
</p> </p>
</div> </div>
+10 -5
View File
@@ -89,7 +89,7 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode | | `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared | | `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, displayName?, avatarAt?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees). In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, displayName?, avatarAt?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. Under one Linux user per account, a member already running `MAX_AI_SESSIONS` AI sessions (default 4) gets `429 ESESSIONLIMIT` from the same two routes. An admin allows or blocks members' test runs with `GET`/`PUT /api/ai-tools/member-policy` (`{available, allowTests}`, `409 ENOS3` outside it). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups and System Health — the one `/api/users` read EVERY signed-in account may make, a developer included, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `409 ENAMETAKEN` for a `displayName` that reads as another account's (the same rule as `PATCH /api/me/account`), `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log. An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups and System Health — the one `/api/users` read EVERY signed-in account may make, a developer included, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `409 ENAMETAKEN` for a `displayName` that reads as another account's (the same rule as `PATCH /api/me/account`), `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
@@ -197,9 +197,11 @@ POST /api/lanes/ensure
``` ```
`cwd` is optional — when missing or empty the server falls back to `cwd` is optional — when missing or empty the server falls back to
`os.homedir()` (the user's home directory). This is how the Sagi mascot `os.homedir()` (the user's home directory) — or, for a server-mode member
adopts its dedicated workspace without the client needing to know an absolute with an own folder, `<their folder>/sagi`, made as them on first use (the
path. Existing cwd values still pass through unchanged. `pipeline` is host's home lies outside their folder and would be `403 EFOLDER`). This is
how the Sagi mascot adopts its dedicated workspace without the client
needing to know an absolute path. Existing cwd values still pass through unchanged. `pipeline` is
optional and, like `title`, applies **only when a workspace is actually created** optional and, like `title`, applies **only when a workspace is actually created**
— an existing workspace is returned untouched. Omitted, the workspace gets `default`. — an existing workspace is returned untouched. Omitted, the workspace gets `default`.
`POST /api/lanes/worktree` and `POST /api/lanes` take the same optional `POST /api/lanes/worktree` and `POST /api/lanes` take the same optional
@@ -2176,6 +2178,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` |
@@ -3490,7 +3493,7 @@ POST /api/pets/featured/:id Download a FEATURED pet from codex-pets.ne
DELETE /api/pets/:id Delete a pet (its folder under the data dir) DELETE /api/pets/:id Delete a pet (its folder under the data dir)
``` ```
The mascot (Sagi) can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…**, which opens the pet block of **Settings → Appearance** (`/settings/appearance#pet`, `client/src/components/settings/PetSettings.tsx`; the old `/settings/pet` redirects there): show/hide, speech bubbles, size with a live preview, and the same choose / download / upload / import / delete as a grid. The menu and the page share `components/Sagi/usePets.ts`. Which pet is worn (with its display name, which the mascot goes by) and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); a name the user types by clicking it in the mascot's chat window header is `agenthub.pet.names` (`{ [petId]: name }`, empty = the pet's own); the pets live on the server, one folder each under `<data dir>/pets/<id>/` with a `meta.json` AgentHub writes. The mascot (Sagi) no longer chats (v2.8.68): a click opens its window — its name, a pat and the status of your sessions. It can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…**, which opens the pet block of **Settings → Appearance** (`/settings/appearance#pet`, `client/src/components/settings/PetSettings.tsx`; the old `/settings/pet` redirects there): show/hide, speech bubbles, size with a live preview, and the same choose / download / upload / import / delete as a grid. The menu and the page share `components/Sagi/usePets.ts`. Which pet is worn (with its display name, which the mascot goes by) and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); a name the user types by clicking it in the mascot's window header is `agenthub.pet.names` (`{ [petId]: name }`, empty = the pet's own); the pets live on the server, one folder each under `<data dir>/pets/<id>/` with a `meta.json` AgentHub writes.
A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles shared on codex-pets.net and in `mileson/codex-pets`): `pet.json` (`id`, `displayName`, `description`, `spritesheetPath`) and `spritesheet.webp`, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames — or a v2 atlas (`spriteVersionNumber: 2`, 1536×2288, 11 rows) whose rows 9–10 add 16 gaze poses, 22.5° apart clockwise from straight up. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, a live session at rest → review, idle/asleep → idle), and on an event it plays a moment over the mood (a new session → waving, a finish or a pat → jumping, a new subagent → review, a session waiting on you → waiting, an error → failed); dragged it runs left or right with the drag (and along the glide to its edge), hovered it waves, and a v2 pet at idle turns toward the pointer (back to idle 2 s after the pointer rests). Frames play at Codex's own timings (`codex-rs/tui/src/pets/model.rs`: e.g. running 120 ms a frame, its last 220 ms); idle plays at the drawn pace (280/110/110/140/140/320 ms), a sleeping pet at Codex's 6× slower one. Waving, jumping, failed and review are moments — they play three times and settle into idle; running, waiting and the run rows loop. Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly one of those sizes (`400 EBADPET` otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script. A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles shared on codex-pets.net and in `mileson/codex-pets`): `pet.json` (`id`, `displayName`, `description`, `spritesheetPath`) and `spritesheet.webp`, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames — or a v2 atlas (`spriteVersionNumber: 2`, 1536×2288, 11 rows) whose rows 9–10 add 16 gaze poses, 22.5° apart clockwise from straight up. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, a live session at rest → review, idle/asleep → idle), and on an event it plays a moment over the mood (a new session → waving, a finish or a pat → jumping, a new subagent → review, a session waiting on you → waiting, an error → failed); dragged it runs left or right with the drag (and along the glide to its edge), hovered it waves, and a v2 pet at idle turns toward the pointer (back to idle 2 s after the pointer rests). Frames play at Codex's own timings (`codex-rs/tui/src/pets/model.rs`: e.g. running 120 ms a frame, its last 220 ms); idle plays at the drawn pace (280/110/110/140/140/320 ms), a sleeping pet at Codex's 6× slower one. Waving, jumping, failed and review are moments — they play three times and settle into idle; running, waiting and the run rows loop. Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly one of those sizes (`400 EBADPET` otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script.
@@ -3718,6 +3721,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
View File
@@ -531,7 +531,7 @@ the Changes panel carries the per-repo git **writes** the dashboard offers (`POS
| **Pull** | `git pull --ff-only` | the branch has diverged (it reports, never merges) | | **Pull** | `git pull --ff-only` | the branch has diverged (it reports, never merges) |
| **Push** (labelled **Publish branch** while the branch has no upstream) | `git push -u origin HEAD` | the remote rejects it | | **Push** (labelled **Publish branch** while the branch has no upstream) | `git push -u origin HEAD` | the remote rejects it |
| **Publish…** (only when the repo has NO remote) | `git remote add origin -- <url>`, then `git push -u origin HEAD`; `origin` is removed again if the push fails | the repo has no commit yet, already has a remote, or the URL is not `https://` (no `user:token@`), `ssh://` or `user@host:path` | | **Publish…** (only when the repo has NO remote) | `git remote add origin -- <url>`, then `git push -u origin HEAD`; `origin` is removed again if the push fails | the repo has no commit yet, already has a remote, or the URL is not `https://` (no `user:token@`), `ssh://` or `user@host:path` |
| **Branch** → **Switch branch** | `git switch …`; with uncommitted changes it first asks: **Bring my changes** (`changes: "carry"`, a plain `git switch`) or **Shelve and switch** (`changes: "shelve"`) | another worktree holds that branch; **Bring my changes** when git would overwrite a carried file (the files are listed and **Shelve and switch** offered instead) | | **Branch** → **Switch branch** | the **Search branches…** box beside the list narrows it, local and remote, to names containing what you type (one match is picked at once, Enter picks the first, Esc clears — #88); `git switch …`; with uncommitted changes it first asks: **Bring my changes** (`changes: "carry"`, a plain `git switch`) or **Shelve and switch** (`changes: "shelve"`) | another worktree holds that branch; **Bring my changes** when git would overwrite a carried file (the files are listed and **Shelve and switch** offered instead) |
| **Create & switch** | `git switch -c <name>` — uncommitted changes come along (the default on Enter while the tree is dirty) | the branch already exists | | **Create & switch** | `git switch -c <name>` — uncommitted changes come along (the default on Enter while the tree is dirty) | the branch already exists |
| **Create** | `git branch <name>` — never switches | the branch already exists | | **Create** | `git branch <name>` — never switches | the branch already exists |
| **Delete** | `git branch -d <name>` | it is protected (`main`, `master`, `development`, `develop`), current, held by another worktree, or has unmerged commits | | **Delete** | `git branch -d <name>` | it is protected (`main`, `master`, `development`, `develop`), current, held by another worktree, or has unmerged commits |
+47 -1
View File
@@ -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
@@ -1156,6 +1156,52 @@ The same re-run installs `agenthub-provision delete`, behind the Users page's
**Delete user**: until then a delete answers `502 EPROVISION` ("unknown **Delete user**: until then a delete answers `502 EPROVISION` ("unknown
action: delete") and changes nothing. action: delete") and changes nothing.
The same re-run (v2.8.68) keeps members from freezing the box — on 11-02
some 36 open sessions plus test runs filled RAM and the 4 GB swap on a slow
disk, and only a hard reset brought it back. The box is for writing code;
tests run on each member's own machine:
- **Memory.** `agenthub.service` gets `MemoryMax=85%`, `MemorySwapMax=1G`,
`OOMPolicy=continue` and `OOMScoreAdjust=-100`. The cgroup holds the server
AND every member pane, so memory runs out inside the unit first and the
kernel kills one process there by `oom_score_adj`: every command a member's
Claude runs is 1000 (`CLAUDE_CODE_SHELL_PREFIX=/opt/agenthub/libexec/tool-run`,
which also tells Claude why its command died), a Terminal tab 800, an AI
session 300, the dashboard and tmux -100. A test run dies; the session
holding the conversation does not. Codex runs its commands without that
wrapper, so its commands sit at 300 beside it.
- **CPU and disk.** `pane-launch` starts every member pane at `nice 10` and
best-effort IO priority 7 (the nice cannot be taken back without root), and
defaults `VITEST_MAX_THREADS` / `VITEST_MAX_FORKS` to 2.
- **No tests or containers from Claude.** A synchronous `PreToolUse` hook
(`/opt/agenthub/app/scripts/member-guard.js`, Bash only, `ah-*` accounts
only) denies `vitest`, `jest`, `phpunit`, `pest`, `pytest`, `npm test`,
`npm run test:*`, `php artisan test`, `go test`, `cargo test`, `docker`,
`podman`, … and tells Claude to leave testing to the member's machine.
A person typing a command in a Terminal tab, and Codex, are not covered.
An admin can allow them again with **Settings → AI tools → Tests on this
server** (`PUT /api/ai-tools/member-policy` `{allowTests}`, kept in
`/opt/agenthub/app/member-policy.json`, no file = blocked); it takes effect
at members' next Bash call, no restart.
- **At most 4 AI sessions per member at once** (`MAX_AI_SESSIONS=<n>` in
`/etc/agenthub/server.conf`, `0` = no limit). Counted from the account's
running processes (`server/lib/ai-procs.js`: `claude`, `codex`, `agy`,
`cursor-agent`, `omp`, a nested one counted once), so a `claude` typed into
a Terminal tab counts and the tab itself does not. A new pane past the
limit answers `429 ESESSIONLIMIT`; a session started by hand past it has
every prompt refused by the guard's `UserPromptSubmit` until another one
ends. The host account (admins) is not limited.
The memory caps apply to the running unit at once (the re-run does
`daemon-reload`); `OOMScoreAdjust`, the session limit on new panes and the
guard script in `/opt/agenthub/app` arrive with the next restart
(`sudo -n /usr/bin/systemctl restart --no-block agenthub.service` — panes
survive, `KillMode=process`). Panes started before the re-run keep their old
priority. Check with
`systemctl show agenthub.service -p MemoryMax,MemorySwapMax,OOMPolicy`
and, in a new member pane, `ps -o ni= -p $$; cat /proc/self/oom_score_adj;
echo $CLAUDE_CODE_SHELL_PREFIX`.
### Deleting a user ### Deleting a user
Users page → Edit → **Delete user** (the admin's own password, then a red Users page → Edit → **Delete user** (the admin's own password, then a red
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.8.64", "version": "2.8.69",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "agenthub", "name": "agenthub",
"version": "2.8.64", "version": "2.8.69",
"hasInstallScript": true, "hasInstallScript": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.8.64", "version": "2.8.69",
"bin": { "bin": {
"agenthub": "bin/agenthub.js" "agenthub": "bin/agenthub.js"
}, },
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env node
/**
* @file member-guard.js
* @description Server mode, one Linux user per account (S3): the members'
* SYNCHRONOUS Claude Code hook, from /etc/claude-code/managed-settings.json
* (install-system.sh), run AS the member from /opt/agenthub/app. The box is
* shared and small, so it is for writing code only:
*
* - `PreToolUse` (matcher Bash): denies a test run or a container command
* (`isHeavy`) — the member commits, pulls the branch on their own machine
* and runs it there. Claude gets the reason and stops trying. An admin
* can allow them again (Settings → AI tools, server/lib/member-policy.js).
* - `UserPromptSubmit`: a member runs at most `MAX_AI_SESSIONS` AI sessions
* at once (server/lib/ai-procs.js). The dashboard refuses a pane past the
* limit itself (ESESSIONLIMIT); this catches a `claude` typed into a
* Terminal tab, by refusing every prompt of a session that is not among
* the account's oldest `limit` ones.
*
* Only `ah-*` accounts are guarded (the host is an admin's). Fail-open like
* every AgentHub hook: any error lets the call through. A person's own
* terminal commands and Codex (whose hooks are opt-in) are not covered.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const path = require("node:path");
const procs = require(path.join(__dirname, "..", "server", "lib", "ai-procs.js"));
const policy = require(path.join(__dirname, "..", "server", "lib", "member-policy.js"));
/** Test runners and container tools, by the program a command segment runs. */
const HEAVY_BINS = new Set([
"vitest",
"jest",
"mocha",
"ava",
"karma",
"playwright",
"cypress",
"phpunit",
"pest",
"paratest",
"pytest",
"py.test",
"tox",
"nox",
"rspec",
"ctest",
"nextest",
"docker",
"docker-compose",
"podman",
"podman-compose",
"nerdctl",
"buildah",
"kind",
"minikube",
"k3d",
]);
/** Wrappers that run the command after them. */
const PASS_THROUGH = new Set([
"sudo",
"env",
"time",
"nice",
"ionice",
"timeout",
"npx",
"bunx",
"pnpx",
"exec",
"command",
"xvfb-run",
"stdbuf",
]);
const TEST_SCRIPT = /^(test|tests|e2e|spec|unit|integration)([:._-].*)?$/;
/** The words of one command segment, wrappers and env assignments dropped. */
function words(segment) {
const w = segment
.trim()
.split(/\s+/)
.map((x) => x.replace(/^['"]|['"]$/g, ""))
.filter(Boolean);
for (;;) {
while (w.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[0])) w.shift();
if (!w.length || !PASS_THROUGH.has(path.basename(w[0]))) break;
w.shift();
// a wrapper's own options and arguments (`timeout 60`, `nice -n 5`, `npx -y`)
while (w.length && (w[0].startsWith("-") || /^\d+[smhd]?$/.test(w[0]))) w.shift();
}
return w;
}
/** Does this one segment run tests or containers? */
function segmentIsHeavy(segment) {
const w = words(segment);
if (!w.length) return false;
const bin = path.basename(w[0]);
const [a, b] = [w[1] || "", w[2] || ""];
if (HEAVY_BINS.has(bin)) return true;
if (["npm", "pnpm", "yarn", "bun", "composer"].includes(bin)) {
if (a === "t" || TEST_SCRIPT.test(a)) return true; // npm test, yarn test:unit
if (["run", "run-script", "exec", "x", "dlx"].includes(a)) {
return TEST_SCRIPT.test(b) || HEAVY_BINS.has(path.basename(b));
}
return false;
}
if (["go", "deno", "dotnet", "mvn", "mvnw", "gradle", "gradlew"].includes(bin)) {
return ["test", "verify", "integrationTest"].includes(a);
}
if (bin === "cargo") return a === "test" || a === "nextest";
if (bin === "make") return w.slice(1).some((x) => TEST_SCRIPT.test(x) || x === "check");
if (bin === "php" || bin === "artisan") {
const rest = bin === "php" ? w.slice(1) : w;
const i = rest.findIndex((x) => /(^|\/)artisan$/.test(x));
if (i !== -1 && TEST_SCRIPT.test(rest[i + 1] || "")) return true;
return rest.some((x) => HEAVY_BINS.has(path.basename(x)));
}
if (/^python[0-9.]*$/.test(bin)) {
const i = w.indexOf("-m");
return i !== -1 && ["pytest", "unittest", "tox", "nox"].includes(w[i + 1]);
}
return false;
}
/** Does a Bash command run tests or containers anywhere in it? */
function isHeavy(command) {
if (typeof command !== "string") return false;
// Inner `bash -c "..."` / `sh -c '...'` bodies are checked too.
const inner = [...command.matchAll(/\b(?:ba|z|da)?sh\s+-l?c\s+(["'])([\s\S]*?)\1/g)].map(
(m) => m[2]
);
return [command, ...inner].some((c) => c.split(/&&|\|\||[;|&\n()]|\$\(|`/).some(segmentIsHeavy));
}
const DENY_REASON =
"AgentHub: this shared server is for writing code only — tests and containers " +
"(vitest, jest, phpunit, pytest, npm test, docker, …) are not run here, because " +
"a few of them at once freeze it for the whole team. Do not try another way to " +
"run them. Finish the change, tell the user which tests to run, and let them " +
"commit, pull the branch on their own machine and test there.";
/** The admin's switch (Settings → AI tools), beside this script in the app root. */
const testsAllowed = () =>
policy.read(path.join(__dirname, "..", policy.FILE)).allowTests === true;
function guardTool(input, allowed = testsAllowed()) {
if (allowed || input.tool_name !== "Bash" || !isHeavy(input.tool_input?.command)) return;
process.stdout.write(
JSON.stringify({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: DENY_REASON,
},
}) + "\n"
);
}
function guardPrompt() {
const limit = procs.sessionLimit();
if (!limit) return;
const mine = procs.sessionOf(process.pid);
if (mine == null) return;
const all = procs.sessionsOf(process.getuid());
const at = all.findIndex((s) => s.pid === mine);
if (at < limit) return; // among the oldest `limit` (or not found: let it be)
process.stderr.write(
`AgentHub: you are running ${all.length} AI sessions — at most ${limit} at once on this ` +
"server, and this one started past the limit. Close another session (or this one) first.\n"
);
process.exit(2);
}
function main() {
const event = process.argv[2];
let raw = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (c) => (raw += c));
process.stdin.on("end", () => {
try {
if (!os.userInfo().username.startsWith("ah-")) return;
if (event === "PreToolUse") guardTool(JSON.parse(raw || "{}"));
else if (event === "UserPromptSubmit") guardPrompt();
} catch {
/* fail-open */
}
});
}
if (require.main === module) main();
module.exports = { isHeavy, segmentIsHeavy, words, guardTool };
+20 -2
View File
@@ -16,11 +16,13 @@
# /opt/agenthub/libexec/member-hook Claude hook forwarder for members # /opt/agenthub/libexec/member-hook Claude hook forwarder for members
# /opt/agenthub/libexec/write-as writes in a member's home AS the member (sudo -u) # /opt/agenthub/libexec/write-as writes in a member's home AS the member (sudo -u)
# /opt/agenthub/libexec/pane-launch starts a member's pane from its launch file # /opt/agenthub/libexec/pane-launch starts a member's pane from its launch file
# /opt/agenthub/libexec/tool-run wraps each command a member's Claude runs (OOM: it dies first)
# /opt/agenthub/libexec/tmux-member a member's tmux, with their running server's own binary # /opt/agenthub/libexec/tmux-member a member's tmux, with their running server's own binary
# /etc/sudoers.d/agenthub checked with visudo before it is installed # /etc/sudoers.d/agenthub checked with visudo before it is installed
# /etc/claude-code/managed-settings.json the member hooks (never over a foreign file) # /etc/claude-code/managed-settings.json the member hooks (never over a foreign file)
# /proc hidepid=invisible,gid=agenthub-proc (now and in /etc/fstab) # /proc hidepid=invisible,gid=agenthub-proc (now and in /etc/fstab)
# /etc/systemd/system/agenthub.service reads the host-owned env file # /etc/systemd/system/agenthub.service reads the host-owned env file; caps
# the cgroup (server + every pane) at 85% of RAM, 1G swap
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> # @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
set -euo pipefail set -euo pipefail
@@ -106,6 +108,7 @@ chmod 0644 /etc/agenthub/server.conf
install -m 0755 -o root -g root "$ROOT/scripts/server/agenthub-provision.sh" /opt/agenthub/sbin/agenthub-provision install -m 0755 -o root -g root "$ROOT/scripts/server/agenthub-provision.sh" /opt/agenthub/sbin/agenthub-provision
install -m 0755 -o root -g root "$ROOT/scripts/server/write-as.sh" /opt/agenthub/libexec/write-as install -m 0755 -o root -g root "$ROOT/scripts/server/write-as.sh" /opt/agenthub/libexec/write-as
install -m 0755 -o root -g root "$ROOT/scripts/server/pane-launch.sh" /opt/agenthub/libexec/pane-launch install -m 0755 -o root -g root "$ROOT/scripts/server/pane-launch.sh" /opt/agenthub/libexec/pane-launch
install -m 0755 -o root -g root "$ROOT/scripts/server/tool-run.sh" /opt/agenthub/libexec/tool-run
install -m 0755 -o root -g root "$ROOT/scripts/server/tmux-member.sh" /opt/agenthub/libexec/tmux-member install -m 0755 -o root -g root "$ROOT/scripts/server/tmux-member.sh" /opt/agenthub/libexec/tmux-member
# ── node + the hook handler, for members (host-owned: the dashboard refreshes them) ── # ── node + the hook handler, for members (host-owned: the dashboard refreshes them) ──
@@ -163,7 +166,14 @@ else
const hook = (e) => [{ ...(["PreToolUse","PostToolUse"].includes(e) ? { matcher: "*" } : {}), const hook = (e) => [{ ...(["PreToolUse","PostToolUse"].includes(e) ? { matcher: "*" } : {}),
hooks: [{ type: "command", command: `/opt/agenthub/libexec/member-hook ${e}`, timeout: 5, hooks: [{ type: "command", command: `/opt/agenthub/libexec/member-hook ${e}`, timeout: 5,
...(["PreToolUse","PostToolUse"].includes(e) ? { async: true } : {}) }] }]; ...(["PreToolUse","PostToolUse"].includes(e) ? { async: true } : {}) }] }];
process.stdout.write(JSON.stringify({ hooks: Object.fromEntries(ev.map((e) => [e, hook(e)])) }, null, 2) + "\n"); const hooks = Object.fromEntries(ev.map((e) => [e, hook(e)]));
// The guard is synchronous (it decides): no test runs or containers from
// Claude, and at most MAX_AI_SESSIONS AI sessions per member (member-guard.js).
const guard = (e) => ({ type: "command", timeout: 5,
command: `/opt/agenthub/tools/bin/node /opt/agenthub/app/scripts/member-guard.js ${e}` });
hooks.PreToolUse.push({ matcher: "Bash", hooks: [guard("PreToolUse")] });
hooks.UserPromptSubmit.push({ hooks: [guard("UserPromptSubmit")] });
process.stdout.write(JSON.stringify({ hooks }, null, 2) + "\n");
' >"$MANAGED" ' >"$MANAGED"
chmod 0644 "$MANAGED" chmod 0644 "$MANAGED"
sha256sum "$MANAGED" >"$MANAGED_MARK" sha256sum "$MANAGED" >"$MANAGED_MARK"
@@ -203,6 +213,14 @@ ExecStart=/usr/bin/env \${AGENTHUB_NODE} \${AGENTHUB_ROOT}/server/index.js
Restart=always Restart=always
RestartSec=3 RestartSec=3
KillMode=process KillMode=process
# Every member pane lives in this cgroup. Capped below RAM with little swap
# (swap on a slow disk froze the whole box on 11-02), a runaway test run is
# OOM-killed inside the unit — pane-launch makes panes the first pick — and
# "continue" keeps the dashboard up when that happens.
MemoryMax=85%
MemorySwapMax=1G
OOMPolicy=continue
OOMScoreAdjust=-100
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+24 -2
View File
@@ -10,7 +10,8 @@
# the environment and execs the command; no shell ever parses a value. # the environment and execs the command; no shell ever parses a value.
# #
# The file is NUL-separated: the number of environment entries, the # The file is NUL-separated: the number of environment entries, the
# NAME=value entries, then the argv (none = a login shell). # NAME=value entries, then the argv (none = a login shell). Every pane starts
# at a lower CPU/IO priority, Claude's commands as the kernel's first OOM pick.
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> # @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
set -eu set -eu
f=${1:?usage: pane-launch <launch file>} f=${1:?usage: pane-launch <launch file>}
@@ -22,6 +23,27 @@ for kv in "${parts[@]:1:n}"; do
[[ "$kv" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || { echo "pane-launch: bad variable" >&2; exit 2; } [[ "$kv" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || { echo "pane-launch: bad variable" >&2; exit 2; }
export "$kv" export "$kv"
done done
# A member's pane shares the 4-core box with the dashboard and every other
# member, so it runs at a lower CPU/IO priority (a member cannot take the
# nice back: that needs root). Memory (11-02: 36 sessions plus test runs
# filled RAM and swap, and the box froze until a hard reset): the unit caps
# its cgroup below RAM (install-system.sh), and inside it the kernel kills
# by oom_score_adj — the dashboard and tmux at the unit's -100, an AI session
# at 300, a Terminal tab at 800, and every command Claude runs at 1000
# through CLAUDE_CODE_SHELL_PREFIX=tool-run. So a runaway command dies and
# the session that holds the conversation is told why; a member who lowers
# their own value can go no lower than -100.
renice -n 10 -p $$ >/dev/null 2>&1 || true
ionice -c 2 -n 7 -p $$ >/dev/null 2>&1 || true
TOOL_RUN=/opt/agenthub/libexec/tool-run
[ -x "$TOOL_RUN" ] && export CLAUDE_CODE_SHELL_PREFIX="${CLAUDE_CODE_SHELL_PREFIX:-$TOOL_RUN}"
# Vitest ≤ 3 starts one worker per core by default and these override the
# config; a member may set their own in the pane.
export VITEST_MAX_THREADS="${VITEST_MAX_THREADS:-2}" VITEST_MAX_FORKS="${VITEST_MAX_FORKS:-2}"
argv=("${parts[@]:n+1}") argv=("${parts[@]:n+1}")
if [ ${#argv[@]} -eq 0 ]; then exec -l "${SHELL:-/bin/bash}"; fi if [ ${#argv[@]} -eq 0 ]; then
echo 800 >/proc/self/oom_score_adj 2>/dev/null || true
exec -l "${SHELL:-/bin/bash}"
fi
echo 300 >/proc/self/oom_score_adj 2>/dev/null || true
exec "${argv[@]}" exec "${argv[@]}"
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
# @file tool-run.sh
# @description Server mode, one Linux user per account (S3): installed as
# /opt/agenthub/libexec/tool-run and named by CLAUDE_CODE_SHELL_PREFIX in
# every member pane (pane-launch). Claude Code runs each Bash command (and
# hook) as `tool-run '<command string>'`. The command runs at the kernel's
# highest OOM preference, so when memory runs out it is the command — a test
# run, a build — that is killed, never the Claude session above it that holds
# the conversation (pane-launch puts that one at 300). When the cgroup's OOM
# count moved while the command ran, Claude is told why it died.
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
[ $# -gt 0 ] || exec /bin/bash
echo 1000 >/proc/self/oom_score_adj 2>/dev/null
cg=$(sed -n 's/^0:://p' /proc/self/cgroup 2>/dev/null)
events=/sys/fs/cgroup${cg}/memory.events
kills() { sed -n 's/^oom_kill //p' "$events" 2>/dev/null; }
before=$(kills)
/bin/bash -c "$1"
rc=$?
if [ "$rc" -ne 0 ] && [ -n "$before" ] && [ "$(kills)" != "$before" ]; then
printf '\n%s\n' "AgentHub: the server ran out of memory, so a process of this command was stopped (exit $rc). This shared server is for writing code only: do not run tests, containers or other heavy jobs here — tell the user to run them on their own machine." >&2
fi
exit "$rc"
+160
View File
@@ -0,0 +1,160 @@
/**
* @file Tests for the S3 members' synchronous hook (scripts/member-guard.js)
* and the AI-session counter it shares with spawnRun (server/lib/ai-procs.js):
* which Bash commands count as a test run or a container command, and which
* processes count as one top-level AI session, read from a fake /proc.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { isHeavy, guardTool } = require("../../scripts/member-guard.js");
const procs = require("../lib/ai-procs.js");
describe("member-guard: a test run or a container command", () => {
const heavy = [
"npx vitest run",
"vitest",
"npm test",
"npm t",
"npm run test:unit",
"pnpm test",
"yarn test --watch=false",
"bun test",
"cd client && NODE_ENV=test npx vitest related src/a.ts --run",
"timeout 300 npx jest",
"./vendor/bin/phpunit --filter Foo",
"php artisan test",
"vendor/bin/pest",
"python -m pytest -q",
"pytest tests/",
"go test ./...",
"cargo test",
"docker compose up -d",
"docker-compose up",
"podman run alpine",
"node_modules/.bin/playwright test",
'bash -c "npm run e2e"',
"make test",
"npm run build && npm test 2>&1 | tail -20",
];
const fine = [
"npm run build",
"npm install -D vitest",
"git commit -m 'add vitest tests'",
"rg -n docker src/",
"cat tests/a.test.js",
"ls node_modules/.bin/vitest",
"echo run npm test later",
"npx tsc -b",
"npm run lint",
"php artisan migrate:status",
"go build ./...",
"git log --grep test",
"composer install",
];
for (const c of heavy) it(`refuses: ${c}`, () => assert.equal(isHeavy(c), true));
for (const c of fine) it(`allows: ${c}`, () => assert.equal(isHeavy(c), false));
it("answers Claude Code with a PreToolUse deny and its reason, and nothing for the rest", () => {
const out = [];
const write = process.stdout.write;
process.stdout.write = (s) => out.push(String(s));
try {
guardTool({ tool_name: "Bash", tool_input: { command: "npm test" } });
guardTool({ tool_name: "Bash", tool_input: { command: "npm run build" } });
guardTool({ tool_name: "Read", tool_input: { file_path: "/x" } });
} finally {
process.stdout.write = write;
}
assert.equal(out.length, 1);
const o = JSON.parse(out[0]).hookSpecificOutput;
assert.equal(o.hookEventName, "PreToolUse");
assert.equal(o.permissionDecision, "deny");
assert.match(o.permissionDecisionReason, /own machine/);
});
it("lets everything through once an admin allows tests", () => {
const out = [];
const write = process.stdout.write;
process.stdout.write = (s) => out.push(String(s));
try {
guardTool({ tool_name: "Bash", tool_input: { command: "npx vitest run" } }, true);
} finally {
process.stdout.write = write;
}
assert.equal(out.length, 0);
});
});
/** A fake /proc: one dir per pid with stat, cmdline; owner is this process (uid). */
function fakeProc(list) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ah-proc-"));
for (const p of list) {
const d = path.join(root, String(p.pid));
fs.mkdirSync(d);
// pid (comm) state ppid ... field 22 = starttime (index 19 after the ')')
const fields = ["S", String(p.ppid), ...Array(17).fill("0"), String(p.start)];
fs.writeFileSync(path.join(d, "stat"), `${p.pid} (x y) ${fields.join(" ")}`);
fs.writeFileSync(path.join(d, "cmdline"), p.argv.join("\0") + "\0");
}
return root;
}
describe("member-policy: the admin's switch, read by the guard", () => {
it("blocks tests without a file, and writes a world-readable one", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ah-app-"));
const prev = process.env.AGENTHUB_APP_ROOT;
process.env.AGENTHUB_APP_ROOT = dir;
try {
const policy = require("../lib/member-policy.js");
assert.equal(policy.read().allowTests, false);
fs.writeFileSync(path.join(dir, policy.FILE), "not json");
assert.equal(policy.read().allowTests, false);
assert.equal(policy.setAllowTests(true).allowTests, true);
const f = path.join(dir, policy.FILE);
assert.equal(fs.statSync(f).mode & 0o777, 0o644);
assert.equal(policy.read(f).allowTests, true);
assert.equal(policy.setAllowTests(false).allowTests, false);
} finally {
if (prev === undefined) delete process.env.AGENTHUB_APP_ROOT;
else process.env.AGENTHUB_APP_ROOT = prev;
}
});
});
describe("ai-procs: a member's top-level AI sessions", () => {
const uid = process.getuid();
const root = fakeProc([
{ pid: 10, ppid: 1, start: 1, argv: ["tmux"] },
{ pid: 11, ppid: 10, start: 5, argv: ["/opt/agenthub/tools/bin/claude", "--resume", "x"] },
{ pid: 12, ppid: 11, start: 6, argv: ["/bin/bash", "-c", "claude -p hi"] },
{ pid: 13, ppid: 12, start: 7, argv: ["claude", "-p", "hi"] }, // nested: not counted
{ pid: 20, ppid: 10, start: 3, argv: ["bash"] }, // a Terminal tab
{ pid: 21, ppid: 20, start: 4, argv: ["node", "/x/@openai/codex/bin/codex.js"] },
{ pid: 22, ppid: 21, start: 4, argv: ["/x/codex", "--yolo"] }, // Codex's native child
{ pid: 30, ppid: 10, start: 9, argv: ["omp", "auth-gateway", "serve"] }, // a server
{ pid: 31, ppid: 10, start: 8, argv: ["vitest"] },
]);
it("counts each session once, oldest first, and no tool server", () => {
assert.deepEqual(
procs.sessionsOf(uid, root).map((s) => s.pid),
[21, 11]
);
});
it("finds the session a hook process runs under", () => {
assert.equal(procs.sessionOf(13, root), 11);
assert.equal(procs.sessionOf(31, root), null);
});
it("reads the limit from server.conf, 4 without it, 0 = off", () => {
const conf = path.join(root, "server.conf");
assert.equal(procs.sessionLimit(conf), 4);
fs.writeFileSync(conf, "HOST_USER=x\nMAX_AI_SESSIONS=0\n");
assert.equal(procs.sessionLimit(conf), 0);
});
});
+28
View File
@@ -192,3 +192,31 @@ describe("a member's new project folder", () => {
assert.equal(fs.existsSync(path.join(TMP, "host-new")), false); assert.equal(fs.existsSync(path.join(TMP, "host-new")), false);
}); });
}); });
describe("the pet chat's workspace for a member", () => {
it("is <their folder>/sagi, made as them — never the host's home (EFOLDER)", async () => {
const { fakeSudo } = require("./fixtures/fake-sudo");
const sudo = fakeSudo();
runAs.__setExecImpl(sudo.exec);
const login = await fetch(`${BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "mia", password: "member-pass-1" }),
});
const cm = login.headers.get("set-cookie").split(";")[0];
const ensure = () =>
fetch(`${BASE}/api/lanes/ensure`, {
method: "POST",
headers: { "Content-Type": "application/json", cookie: cm },
body: JSON.stringify({ title: "Sagi" }),
}).then(async (r) => ({ status: r.status, body: await r.json() }));
const first = await ensure();
assert.equal(first.status, 201, JSON.stringify(first.body));
const want = fs.realpathSync(path.join(USERS, "mia", "sagi"));
assert.equal(first.body.lane.cwd, want);
const mk = sudo.calls.find((c) => c.includes("/opt/agenthub/libexec/write-as"));
assert.ok(mk && mk.includes("ah-mia") && mk.includes("mkdir"));
const again = await ensure();
assert.equal(again.body.lane.id, first.body.lane.id, "the same workspace next time");
});
});
+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(); 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 {
+116
View File
@@ -0,0 +1,116 @@
/**
* @file ai-procs.js
* @description Server mode, one Linux user per account (S3): counts the AI
* Tools sessions a Linux account is running RIGHT NOW, from /proc — whoever
* started them (a dashboard pane, or `claude` typed into a terminal tab).
* A session is a process whose program is one of the catalog tools' binaries
* (`claude`, `codex`, `agy`, `cursor-agent`, `omp`) with no such process
* among its ancestors, so a nested `claude -p`, Codex's native child behind
* its npm launcher, or a subagent never counts twice. Used by `spawnRun`
* (ESESSIONLIMIT before a pane starts) and by `scripts/member-guard.js`
* (refuses the prompt of a session over the limit started by hand), which
* runs AS the member and sees only that member's processes (hidepid) — that
* is all it needs. Pure Node built-ins: the guard runs from /opt/agenthub/app.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
/** The catalog tools' binary names (server/data/ai-tools/*.json `binary`). */
const AI_BINS = new Set(["claude", "codex", "agy", "cursor-agent", "omp"]);
/** Interpreters whose first argument names the tool (an npm launcher). */
const INTERPRETERS = new Set(["node", "nodejs", "bun", "deno"]);
/** A tool's server, not a session (the host's omp share). */
const NOT_A_SESSION = new Set(["auth-gateway", "auth-broker", "mcp", "daemon"]);
const DEFAULT_LIMIT = 4;
const CONF = "/etc/agenthub/server.conf";
const base = (p) => path.basename(String(p || "")).replace(/\.(c|m)?js$/, "");
/** `{pid, ppid, uid, start, ai}` for one /proc entry, or null once it is gone. */
function readProc(pid, procRoot = "/proc") {
try {
const dir = path.join(procRoot, String(pid));
const stat = fs.readFileSync(path.join(dir, "stat"), "utf8");
// comm may hold spaces and parentheses: the fields start after the LAST ')'.
const f = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
const argv = fs.readFileSync(path.join(dir, "cmdline"), "utf8").split("\0").filter(Boolean);
const uid = fs.statSync(dir).uid;
let name = base(argv[0]);
let rest = argv.slice(1);
if (INTERPRETERS.has(name)) {
const i = rest.findIndex((a) => !a.startsWith("-"));
name = i === -1 ? "" : base(rest[i]);
rest = i === -1 ? [] : rest.slice(i + 1);
}
const ai = AI_BINS.has(name) && !rest.some((a) => NOT_A_SESSION.has(a));
return { pid: Number(pid), ppid: Number(f[1]), start: Number(f[19]), uid, ai };
} catch {
return null; // exited while we looked, or not ours to read
}
}
/**
* The account's top-level AI sessions, oldest first.
* @param {number} uid
* @returns {{pid:number, start:number}[]}
*/
function sessionsOf(uid, procRoot = "/proc") {
const procs = new Map();
let names = [];
try {
names = fs.readdirSync(procRoot);
} catch {
return [];
}
for (const n of names) {
if (!/^\d+$/.test(n)) continue;
const p = readProc(n, procRoot);
if (p) procs.set(p.pid, p);
}
const out = [];
for (const p of procs.values()) {
if (!p.ai || p.uid !== uid) continue;
let nested = false;
const seen = new Set();
for (let a = procs.get(p.ppid); a && !seen.has(a.pid); a = procs.get(a.ppid)) {
seen.add(a.pid);
if (a.ai) {
nested = true;
break;
}
}
if (!nested) out.push({ pid: p.pid, start: p.start });
}
return out.sort((a, b) => a.start - b.start || a.pid - b.pid);
}
/** The pid of the top-level AI session `pid` runs under (itself included), or null. */
function sessionOf(pid, procRoot = "/proc") {
let top = null;
const seen = new Set();
for (let p = readProc(pid, procRoot); p && !seen.has(p.pid); p = readProc(p.ppid, procRoot)) {
seen.add(p.pid);
if (p.ai) top = p.pid;
if (p.ppid <= 1) break;
}
return top;
}
/**
* The most AI sessions one member may run at once: `MAX_AI_SESSIONS` in
* server.conf (0 = no limit), else 4.
*/
function sessionLimit(confFile = CONF) {
try {
const m = fs.readFileSync(confFile, "utf8").match(/^MAX_AI_SESSIONS=(\d+)\s*$/m);
if (m) return Number(m[1]);
} catch {
/* no server.conf: the default */
}
return DEFAULT_LIMIT;
}
module.exports = { AI_BINS, DEFAULT_LIMIT, readProc, sessionsOf, sessionOf, sessionLimit };
+56
View File
@@ -0,0 +1,56 @@
/**
* @file member-policy.js
* @description Server mode, one Linux user per account (S3): the admin's
* switch for what members' Claude may run on this box — today whether test
* runners and container commands are allowed (`allowTests`, off by default).
* Kept in `<app root>/member-policy.json` (/opt/agenthub/app, host-owned,
* world-readable), because `scripts/member-guard.js` runs AS the member and
* reads it from there; a missing or unreadable file means the default, so
* tests stay blocked unless an admin turned them on.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const FILE = "member-policy.json";
const DEFAULTS = Object.freeze({ allowTests: false });
const appRoot = () => process.env.AGENTHUB_APP_ROOT || "/opt/agenthub/app";
const file = () => path.join(appRoot(), FILE);
/** The policy in force (defaults for anything missing or unreadable). */
function read(p = file()) {
try {
const j = JSON.parse(fs.readFileSync(p, "utf8"));
return { allowTests: j?.allowTests === true };
} catch {
return { ...DEFAULTS };
}
}
/** Does it apply here? Only S3 runs members' panes as their own accounts. */
function available() {
try {
return require("./linux-accounts").s3Enabled();
} catch {
return false;
}
}
function state() {
return { available: available(), ...read() };
}
/** Writes the switch (atomically, 0644: members' hooks read it). */
function setAllowTests(on) {
const next = { ...read(), allowTests: on === true };
fs.mkdirSync(appRoot(), { recursive: true });
const tmp = `${file()}.${process.pid}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + "\n", { mode: 0o644 });
fs.chmodSync(tmp, 0o644);
fs.renameSync(tmp, file());
return state();
}
module.exports = { FILE, DEFAULTS, read, available, state, setAllowTests };
+25
View File
@@ -126,6 +126,30 @@ function laneIdFromRunId(id) {
return m ? Number(m[1]) : null; return m ? Number(m[1]) : null;
} }
/**
* S3: a member runs at most `MAX_AI_SESSIONS` AI sessions at once (default 4,
* server.conf), counted from the processes their account runs right now —
* a `claude` typed into a Terminal tab counts, the tab itself does not
* (ai-procs.js; scripts/member-guard.js refuses the prompts of one started
* past the limit by hand). The host account is an admin's and not limited.
* @throws ESESSIONLIMIT
*/
function assertSessionRoom(laneId) {
const account = require("./run-as").accountForLane(laneId);
if (!account) return;
const procs = require("./ai-procs");
const limit = procs.sessionLimit();
if (!limit) return;
const running = procs.sessionsOf(account.uid).length;
if (running >= limit) {
throw makeErr(
"ESESSIONLIMIT",
`AgentHub: you are running ${running} AI sessions — at most ${limit} at once on this server. ` +
"Close one (end its pane) and start again."
);
}
}
function makeErr(code, message) { function makeErr(code, message) {
const err = new Error(message); const err = new Error(message);
err.code = code; err.code = code;
@@ -475,6 +499,7 @@ function spawnRun(args) {
assertCwdAccount(laneId, cwd); // ECWDACCOUNT assertCwdAccount(laneId, cwd); // ECWDACCOUNT
// Server mode: an owner over their spend quota starts nothing new (user-quota.js). // Server mode: an owner over their spend quota starts nothing new (user-quota.js).
require("./user-quota").assertSpawnAllowed(laneOwner); // EQUOTA require("./user-quota").assertSpawnAllowed(laneOwner); // EQUOTA
assertSessionRoom(laneId); // ESESSIONLIMIT
const proxyProfile = resolveProxy(tool, proxy, laneOwner); // EBADPROXY, EUNSUPPORTED const proxyProfile = resolveProxy(tool, proxy, laneOwner); // EBADPROXY, EUNSUPPORTED
// A Claude session on the vendor's own API runs on the least-loaded pool // A Claude session on the vendor's own API runs on the least-loaded pool
// account, if the admin added any (claude-pool.js) — or the one the // account, if the admin added any (claude-pool.js) — or the one the
+37
View File
@@ -173,6 +173,43 @@ const paths = {
}, },
}, },
}, },
"/api/ai-tools/member-policy": {
get: {
tags: ["AI Tools"],
summary: "What members' Claude may run on this server",
description:
"Server mode with per-user accounts (S3): whether members' Claude may run test runners and container commands (`allowTests`, off by default). Kept in `/opt/agenthub/app/member-policy.json` and enforced by the members' synchronous PreToolUse hook (scripts/member-guard.js).",
operationId: "aiToolsMemberPolicy",
responses: {
200: { description: "`{available, allowTests}`." },
},
},
put: {
tags: ["AI Tools"],
summary: "Allow or block members' test runs",
description:
"An admin's switch. Takes effect at the next Bash call of every member's Claude, no restart. Same-origin only; audited.",
operationId: "aiToolsSetMemberPolicy",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["allowTests"],
properties: { allowTests: { type: "boolean" } },
},
},
},
},
responses: {
200: { description: "The new state, as GET." },
400: { description: "EBADFIELD" },
403: { description: "Not an admin" },
409: { description: "ENOS3 — not server mode with per-user accounts" },
},
},
},
"/api/ai-tools/{id}": { "/api/ai-tools/{id}": {
put: { put: {
tags: ["AI Tools"], tags: ["AI Tools"],
@@ -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",
+29
View File
@@ -66,6 +66,35 @@ router.put("/omp-sharing", sameOriginGuard, async (req, res) => {
res.json(state); res.json(state);
}); });
// What members' Claude may run on this box (server/lib/member-policy.js,
// read by scripts/member-guard.js): S3 only, an admin's switch.
router.get("/member-policy", (_req, res) => {
res.json(require("../lib/member-policy").state());
});
router.put("/member-policy", sameOriginGuard, (req, res) => {
const on = req.body?.allowTests;
if (typeof on !== "boolean") {
return res.status(400).json({ error: { code: "EBADFIELD", message: "allowTests: boolean" } });
}
const policy = require("../lib/member-policy");
if (!policy.available()) {
return res.status(409).json({
error: { code: "ENOS3", message: "this needs server mode with per-user accounts" },
});
}
const state = policy.setAllowTests(on);
if (req.user) {
require("../lib/audit").recordAccountChange(
req.user.id,
null,
"PUT",
`members' test runs ${on ? "allowed" : "blocked"}`
);
}
res.json(state);
});
router.put("/:id", sameOriginGuard, (req, res) => { router.put("/:id", sameOriginGuard, (req, res) => {
const { enabled, default: makeDefault, defaultModel } = req.body || {}; const { enabled, default: makeDefault, defaultModel } = req.body || {};
// Every field checked before anything is written: a bad one saves nothing. // Every field checked before anything is written: a bad one saves nothing.
+36 -4
View File
@@ -436,6 +436,23 @@ function refuseFolder(res) {
}); });
} }
/**
* A member's folder for a workspace no caller picked a directory for (the
* pet chat): `<own folder>/sagi`, made AS the member under S3 (`mkdirAs`,
* never a host write in their home). The caller still runs it through
* `folderAllowed`, so a link planted there cannot lead outside.
*/
function ownDefaultFolder(own) {
const target = path.join(own, "sagi");
if (!fs.existsSync(target)) {
const runAs = require("../lib/run-as");
const acct = runAs.accountForPath(target);
if (acct) runAs.mkdirAs(acct, target);
else fs.mkdirSync(target, { recursive: true });
}
return target;
}
/** /**
* Idempotent "which lane owns this directory?" — the Workspace page opens on a * Idempotent "which lane owns this directory?" — the Workspace page opens on a
* cwd, not on a lane id, so it needs one lane to exist for that cwd without * cwd, not on a lane id, so it needs one lane to exist for that cwd without
@@ -447,10 +464,24 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
// specific directory (e.g. Sagi's mascot companion) get a real, existing // specific directory (e.g. Sagi's mascot companion) get a real, existing
// path and lane uniqueness stays meaningful. Callers that do care pass one // path and lane uniqueness stays meaningful. Callers that do care pass one
// through unchanged. // through unchanged.
// A member's default is NOT the host's home (outside their folder: EFOLDER,
// the pet chat could never start) nor their own home (no workspace under
// S3), but `<their folder>/sagi`, made as them on first use.
// Canonical before the lookup, so the lookup and createLane's insert agree. // Canonical before the lookup, so the lookup and createLane's insert agree.
const cwd = lanesLib.canonicalCwd( let cwd;
typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : os.homedir() if (typeof body.cwd === "string" && body.cwd.trim()) {
); cwd = lanesLib.canonicalCwd(body.cwd.trim());
} else if (scope.ownFolder(req)) {
try {
cwd = lanesLib.canonicalCwd(ownDefaultFolder(scope.ownFolder(req)));
} catch (err) {
return res.status(err.code === "EMEMBERGONE" ? 409 : 500).json({
error: { code: err.code || "EMKDIR", message: err.message },
});
}
} else {
cwd = lanesLib.canonicalCwd(os.homedir());
}
if (body.tool !== undefined && !aiTools.has(body.tool)) { if (body.tool !== undefined && !aiTools.has(body.tool)) {
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } }); return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
@@ -3533,11 +3564,12 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
// cannot do (EUNSUPPORTED: a fork on a non-Claude lane) is the request's fault. // cannot do (EUNSUPPORTED: a fork on a non-Claude lane) is the request's fault.
// ENOTSHARED (S3): the tool is not under /opt/agenthub/tools yet — a setup state. // ENOTSHARED (S3): the tool is not under /opt/agenthub/tools yet — a setup state.
// EQUOTA (server mode): the workspace owner is over their spend quota. // EQUOTA (server mode): the workspace owner is over their spend quota.
// ESESSIONLIMIT (S3): the member already runs the most AI sessions allowed.
const status = /^(EBAD|EUNSUPPORTED$)/.test(err.code || "") const status = /^(EBAD|EUNSUPPORTED$)/.test(err.code || "")
? 400 ? 400
: ["ENOTSHARED", "EMEMBERGONE", "EOWNERFOLDER", "ECWDACCOUNT"].includes(err.code) : ["ENOTSHARED", "EMEMBERGONE", "EOWNERFOLDER", "ECWDACCOUNT"].includes(err.code)
? 409 ? 409
: err.code === "EQUOTA" : err.code === "EQUOTA" || err.code === "ESESSIONLIMIT"
? 429 ? 429
: 500; : 500;
return res.status(status).json({ error: { code: err.code, message: err.message } }); return res.status(status).json({ error: { code: err.code, message: err.message } });
+1 -1
View File
@@ -673,7 +673,7 @@ router.post("/", (req, res) => {
return res.status(409).json({ error: { code: err.code, message: err.message } }); return res.status(409).json({ error: { code: err.code, message: err.message } });
} }
// Server mode: the workspace owner is over their spend quota (user-quota.js). // Server mode: the workspace owner is over their spend quota (user-quota.js).
if (err.code === "EQUOTA") { if (err.code === "EQUOTA" || err.code === "ESESSIONLIMIT") {
return res.status(429).json({ error: { code: err.code, message: err.message } }); return res.status(429).json({ error: { code: err.code, message: err.message } });
} }
// Nor is a host missing tmux: the request was fine, the dependency is not // Nor is a host missing tmux: the request was fine, the dependency is not
+7
View File
@@ -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
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> * @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,